diff --git a/.changeset/allow-third-party-plugins.md b/.changeset/allow-third-party-plugins.md new file mode 100644 index 00000000..67cd4a30 --- /dev/null +++ b/.changeset/allow-third-party-plugins.md @@ -0,0 +1,10 @@ +--- +"varlock": patch +--- + +feat: allow 3rd party plugins + +Third-party (non-`@varlock/*`) plugins are now supported: + +- **JavaScript projects**: Any plugin installed in `node_modules` via `package.json` is automatically trusted and can be used without restriction. +- **Standalone binary**: When downloading a third-party plugin from npm for the first time, Varlock will prompt for interactive confirmation. Once confirmed and cached, subsequent runs skip the prompt. Non-interactive environments (CI/piped) will receive a clear error message instructing the user to confirm interactively or install via `package.json`. diff --git a/packages/varlock-website/src/content/docs/guides/plugins.mdx b/packages/varlock-website/src/content/docs/guides/plugins.mdx index da53e72f..ae9948c9 100644 --- a/packages/varlock-website/src/content/docs/guides/plugins.mdx +++ b/packages/varlock-website/src/content/docs/guides/plugins.mdx @@ -22,12 +22,6 @@ This unlocks use cases like: Plugins are authored in TypeScript and can be loaded via local files, or from package registries like npm. Varlock will handle downloading and caching plugins automatically. -:::caution[Plugin authoring SDKs coming soon] -Plugin authoring SDKs are still in development. For now, only official Varlock plugins are available for use. - -Please reach out on [Discord](https://chat.dmno.dev) if you are interested in developing your own plugins. -::: - ## Plugin installation ||installation|| @@ -55,8 +49,8 @@ When using the standalone binary (no `package.json` present), you must specify a # @plugin(@varlock/a-plugin@1.2.3) # downloads and caches v1.2.3 from npm ``` -:::caution[Only `@varlock/*` plugins supported for now] -For now, only official Varlock plugins under the `@varlock` npm scope are supported. We plan to support third-party plugins in the future, along with additional plugin source types (e.g., jsr, git, http, etc.). +:::caution[Third-party plugin confirmation] +When downloading a third-party (non-`@varlock/*`) plugin for the first time, Varlock will prompt you to confirm before downloading. Once confirmed and cached, subsequent runs will not re-prompt. You can also install the plugin via your `package.json` to skip the prompt entirely. ::: {/* diff --git a/packages/varlock/src/cli/cli-executable.ts b/packages/varlock/src/cli/cli-executable.ts index 9e53bc73..47439f99 100644 --- a/packages/varlock/src/cli/cli-executable.ts +++ b/packages/varlock/src/cli/cli-executable.ts @@ -20,6 +20,7 @@ import { commandSpec as helpCommandSpec } from './commands/help.command'; import { commandSpec as telemetryCommandSpec } from './commands/telemetry.command'; import { commandSpec as scanCommandSpec } from './commands/scan.command'; import { commandSpec as typegenCommandSpec } from './commands/typegen.command'; +import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command'; // import { commandSpec as loginCommandSpec } from './commands/login.command'; // import { commandSpec as pluginCommandSpec } from './commands/plugin.command'; @@ -55,6 +56,7 @@ subCommands.set('help', buildLazyCommand(helpCommandSpec, async () => await impo subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => await import('./commands/telemetry.command'))); subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command'))); subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command'))); +subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command'))); // subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command'))); // subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command'))); diff --git a/packages/varlock/src/cli/commands/install-plugin.command.ts b/packages/varlock/src/cli/commands/install-plugin.command.ts new file mode 100644 index 00000000..251a92c7 --- /dev/null +++ b/packages/varlock/src/cli/commands/install-plugin.command.ts @@ -0,0 +1,84 @@ +import { define } from 'gunshi'; +import ansis from 'ansis'; +import semver from 'semver'; + +import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils'; +import { CliExitError } from '../helpers/exit-error'; +import { isBundledSEA } from '../helpers/install-detection'; +import { fmt } from '../helpers/pretty-format'; +import { downloadPluginToCache } from '../../env-graph/lib/plugins'; + +export const commandSpec = define({ + name: 'install-plugin', + description: 'Download and cache a plugin from npm for use with the standalone binary', + args: { + plugin: { + type: 'positional', + description: 'Plugin to install, in the format name@version (e.g. my-plugin@1.2.3)', + }, + }, + examples: ` +Pre-downloads a plugin into the local varlock plugin cache so it is available without +needing an interactive confirmation prompt. This is useful in CI environments or any +other non-interactive workflow where the standalone binary is used. + +The plugin must be specified with an exact version number. + +Examples: + varlock install-plugin my-plugin@1.2.3 + varlock install-plugin @my-scope/my-plugin@2.0.0 +`.trim(), +}); + +export const commandFn: TypedGunshiCommandFn = async (ctx) => { + if (!isBundledSEA()) { + throw new CliExitError('This command is only available when using the standalone varlock binary', { + suggestion: 'In a JS project, install plugins as regular dependencies using your package manager.\n' + + `For example: ${fmt.command('add my-plugin', { jsPackageManager: true })}`, + }); + } + + const pluginDescriptor = ctx.values.plugin as string | undefined; + + if (!pluginDescriptor) { + throw new CliExitError('No plugin specified', { + suggestion: 'Usage: varlock install-plugin (e.g. my-plugin@1.2.3)', + }); + } + + // Parse module name and version from descriptor like `some-plugin@1.2.3` or `@scope/pkg@1.2.3`. + // Use lastIndexOf to correctly handle scoped packages (e.g. @scope/pkg@1.2.3). + const atLocation = pluginDescriptor.lastIndexOf('@'); + if (atLocation === -1) { + throw new CliExitError(`Missing version in "${pluginDescriptor}"`, { + suggestion: `Specify an exact version, e.g. \`varlock install-plugin ${pluginDescriptor}@1.2.3\``, + }); + } + + const moduleName = pluginDescriptor.slice(0, atLocation); + const versionDescriptor = pluginDescriptor.slice(atLocation + 1); + + if (!versionDescriptor) { + throw new CliExitError(`Missing version in "${pluginDescriptor}"`, { + suggestion: `Specify an exact version, e.g. \`varlock install-plugin ${moduleName}@1.2.3\``, + }); + } + + if (!semver.valid(versionDescriptor)) { + throw new CliExitError(`"${versionDescriptor}" is not an exact version`, { + suggestion: `Use a fixed version number (e.g. 1.2.3), not a range. Example: \`varlock install-plugin ${moduleName}@1.2.3\``, + }); + } + + console.log(`\nšŸ“¦ Installing plugin ${ansis.bold(`${moduleName}@${versionDescriptor}`)} into local cache...\n`); + + try { + const cachedPath = await downloadPluginToCache(moduleName, versionDescriptor); + console.log(`āœ… Plugin ${ansis.bold(`${moduleName}@${versionDescriptor}`)} installed successfully`); + console.log(ansis.dim(` Cached at: ${cachedPath}\n`)); + } catch (err) { + throw new CliExitError(`Failed to install plugin "${moduleName}@${versionDescriptor}"`, { + details: (err as Error).message, + }); + } +}; diff --git a/packages/varlock/src/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index 1895a9d4..6c79ffb7 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -10,9 +10,11 @@ import crypto from 'node:crypto'; import https from 'node:https'; import ansis from 'ansis'; import semver from 'semver'; +import { isCancel } from '@clack/prompts'; import _ from '@env-spec/utils/my-dash'; import { pathExists } from '@env-spec/utils/fs-utils'; import { getUserVarlockDir } from '../../lib/user-config-dir'; +import { confirm } from '../../cli/helpers/prompts'; import { FileBasedDataSource, type EnvGraphDataSource } from './data-source'; @@ -449,6 +451,22 @@ async function registerPluginInGraph(graph: EnvGraph, plugin: VarlockPlugin, plu } } +async function isPluginCached(url: string): Promise { + const cacheDir = path.join(getUserVarlockDir(), 'plugins-cache'); + const indexPath = path.join(cacheDir, 'index.json'); + try { + const indexRaw = await fs.readFile(indexPath, 'utf-8'); + const index = JSON.parse(indexRaw) as Record; + if (index[url]) { + const pluginDir = path.join(cacheDir, index[url]); + return fs.stat(pluginDir).then(() => true, () => false); + } + } catch { + // ignore + } + return false; +} + async function downloadPlugin(url: string) { const exec = promisify(execCb); const cacheDir = path.join(getUserVarlockDir(), 'plugins-cache'); @@ -522,6 +540,32 @@ async function downloadPlugin(url: string) { return finalDir; } +/** + * Fetches plugin metadata from npm and downloads the tarball into the local cache. + * The caller is responsible for any user confirmation — this function downloads unconditionally. + * + * @param moduleName e.g. `@varlock/1password-plugin` or `my-plugin` + * @param versionDescriptor must be a fixed semver version e.g. `1.2.3` + * @returns the local cache directory the plugin was extracted into + */ +export async function downloadPluginToCache(moduleName: string, versionDescriptor: string): Promise { + if (!semver.valid(versionDescriptor)) { + throw new Error(`"${versionDescriptor}" is not a fixed version — use an exact version like 1.2.3`); + } + + const npmInfoUrl = `https://registry.npmjs.org/${moduleName}/${versionDescriptor}`; + const npmInfoReq = await fetch(npmInfoUrl); + if (!npmInfoReq.ok) { + throw new Error(`Failed to fetch plugin "${moduleName}@${versionDescriptor}" from npm: ${npmInfoReq.status} ${npmInfoReq.statusText}`); + } + const npmInfo = await npmInfoReq.json() as { dist?: { tarball?: string } }; + const tarballUrl = npmInfo?.dist?.tarball; + if (!tarballUrl) { + throw new Error(`Failed to find tarball URL for plugin "${moduleName}@${versionDescriptor}" from npm`); + } + + return downloadPlugin(tarballUrl); +} export async function processPluginInstallDecorators(dataSource: EnvGraphDataSource) { @@ -572,10 +616,6 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou versionDescriptor = pluginSourceDescriptor.slice(atLocation + 1); } - if (!moduleName.startsWith('@varlock/')) { - throw new SchemaError(`Plugin "${moduleName}" blocked - only official @varlock/* plugins are supported for now, third-party plugins will be supported in future releases`); - } - const semverRange = semver.validRange(versionDescriptor); if (versionDescriptor && !semverRange) { throw new SchemaError(`Bad @plugin version descriptor: ${versionDescriptor}`); @@ -644,12 +684,43 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou // TODO: new error type? check for 404 vs others and give better message throw new Error(`Failed to fetch plugin "${moduleName}@${versionDescriptor}" from npm: ${npmInfoReq.status} ${npmInfoReq.statusText}`); } - const npmInfo = await npmInfoReq.json() as any; + const npmInfo = await npmInfoReq.json() as { dist?: { tarball?: string } }; const tarballUrl = npmInfo?.dist?.tarball; if (!tarballUrl) { throw new Error(`Failed to find tarball URL for plugin "${moduleName}@${versionDescriptor}" from npm`); } + // Third-party plugins (non-@varlock) require user confirmation before downloading. + // Official @varlock plugins are always trusted. If already cached (previously confirmed), + // skip the prompt — the user has already blessed this specific version. + if (!moduleName.startsWith('@varlock/') && !(await isPluginCached(tarballUrl))) { + if (!process.stdout.isTTY || !process.stdin.isTTY) { + throw new SchemaError( + `Third-party plugin "${moduleName}@${versionDescriptor}" must be confirmed before downloading, ` + + 'but no interactive terminal (TTY) is available. ' + + 'Run `varlock install-plugin` to pre-cache the plugin, or install it via your package.json.', + ); + } + + process.stdout.write( + `\n${ansis.yellow('⚠')} Third-party plugin download requested\n` + + ` Package: ${ansis.bold(`${moduleName}@${versionDescriptor}`)}\n` + + ' Source: npm registry (https://registry.npmjs.org)\n\n' + + ` ${ansis.italic('Only install plugins from sources you trust.')}\n\n`, + ); + + const confirmed = await confirm({ + message: `Allow downloading "${moduleName}@${versionDescriptor}" from npm?`, + active: 'Yes, download it', + inactive: 'No, cancel', + initialValue: false, + }); + + if (isCancel(confirmed) || !confirmed) { + throw new SchemaError(`Third-party plugin "${moduleName}" download cancelled`); + } + } + // downloads into local cache folder (user varlock config dir / plugins-cache/) const downloadedPluginPath = await downloadPlugin(tarballUrl); pluginSrcPath = downloadedPluginPath;