From 33090489ca2d3133ee10c6609e5c54c30ce2d961 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:12:54 +0000 Subject: [PATCH 1/5] Initial plan From a8aab1c8e0a11a6d6e88ebbad5572a6daafb4db3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 2 Apr 2026 19:23:00 +0000 Subject: [PATCH 2/5] feat: allow 3rd party plugins with user confirmation for downloads Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/91d9e940-484b-442c-a323-2174ad181320 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .changeset/allow-third-party-plugins.md | 10 ++++ .../src/content/docs/guides/plugins.mdx | 10 +--- packages/varlock/src/env-graph/lib/plugins.ts | 53 +++++++++++++++++-- 3 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 .changeset/allow-third-party-plugins.md diff --git a/.changeset/allow-third-party-plugins.md b/.changeset/allow-third-party-plugins.md new file mode 100644 index 00000000..0b375ed3 --- /dev/null +++ b/.changeset/allow-third-party-plugins.md @@ -0,0 +1,10 @@ +--- +"varlock": minor +--- + +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/env-graph/lib/plugins.ts b/packages/varlock/src/env-graph/lib/plugins.ts index 1895a9d4..3d5bb962 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'); @@ -572,10 +590,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}`); @@ -650,6 +664,37 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou 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 interactively to confirm the download, or install the plugin 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; From 3e9c20805f497838cfbafecd12737dd65335b9e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:13:41 +0000 Subject: [PATCH 3/5] feat: add varlock install-plugin command for pre-caching plugins in CI Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/97033cd1-efde-4a99-b521-35008faec5fc Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- packages/varlock/src/cli/cli-executable.ts | 2 + .../cli/commands/install-plugin.command.ts | 75 +++++++++++++++++++ packages/varlock/src/env-graph/lib/plugins.ts | 30 +++++++- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 packages/varlock/src/cli/commands/install-plugin.command.ts 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..ec02c852 --- /dev/null +++ b/packages/varlock/src/cli/commands/install-plugin.command.ts @@ -0,0 +1,75 @@ +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 { 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) => { + 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 3d5bb962..6c79ffb7 100644 --- a/packages/varlock/src/env-graph/lib/plugins.ts +++ b/packages/varlock/src/env-graph/lib/plugins.ts @@ -540,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) { @@ -658,7 +684,7 @@ 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`); @@ -672,7 +698,7 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou throw new SchemaError( `Third-party plugin "${moduleName}@${versionDescriptor}" must be confirmed before downloading, ` + 'but no interactive terminal (TTY) is available. ' - + 'Run varlock interactively to confirm the download, or install the plugin via your package.json.', + + 'Run `varlock install-plugin` to pre-cache the plugin, or install it via your package.json.', ); } From c8eb295fd6851890642cde3f53222d4f8ef123e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 7 Apr 2026 05:46:20 +0000 Subject: [PATCH 4/5] feat: guard install-plugin command to standalone binary only Agent-Logs-Url: https://github.com/dmno-dev/varlock/sessions/9864be8b-0363-4168-a52f-28bcd6d18784 Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> --- .../varlock/src/cli/commands/install-plugin.command.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/varlock/src/cli/commands/install-plugin.command.ts b/packages/varlock/src/cli/commands/install-plugin.command.ts index ec02c852..251a92c7 100644 --- a/packages/varlock/src/cli/commands/install-plugin.command.ts +++ b/packages/varlock/src/cli/commands/install-plugin.command.ts @@ -4,6 +4,8 @@ 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({ @@ -29,6 +31,13 @@ Examples: }); 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) { From 95e70ed39333fbadeb50c5cefdbd54f40a63a4b6 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 7 Apr 2026 10:48:51 -0700 Subject: [PATCH 5/5] Change varlock version from minor to patch --- .changeset/allow-third-party-plugins.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/allow-third-party-plugins.md b/.changeset/allow-third-party-plugins.md index 0b375ed3..67cd4a30 100644 --- a/.changeset/allow-third-party-plugins.md +++ b/.changeset/allow-third-party-plugins.md @@ -1,5 +1,5 @@ --- -"varlock": minor +"varlock": patch --- feat: allow 3rd party plugins