Skip to content

Commit 2022ef7

Browse files
Copilottheoephraim
andauthored
feat: allow 3rd party plugins with user confirmation and CI pre-caching (#538)
* Initial plan * 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> * 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> * 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> * Change varlock version from minor to patch --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: theoephraim <1158956+theoephraim@users.noreply.github.com> Co-authored-by: Theo Ephraim <theo@dmno.dev>
1 parent 530cfe7 commit 2022ef7

5 files changed

Lines changed: 174 additions & 13 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"varlock": patch
3+
---
4+
5+
feat: allow 3rd party plugins
6+
7+
Third-party (non-`@varlock/*`) plugins are now supported:
8+
9+
- **JavaScript projects**: Any plugin installed in `node_modules` via `package.json` is automatically trusted and can be used without restriction.
10+
- **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`.

packages/varlock-website/src/content/docs/guides/plugins.mdx

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,6 @@ This unlocks use cases like:
2222

2323
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.
2424

25-
:::caution[Plugin authoring SDKs coming soon]
26-
Plugin authoring SDKs are still in development. For now, only official Varlock plugins are available for use.
27-
28-
Please reach out on [Discord](https://chat.dmno.dev) if you are interested in developing your own plugins.
29-
:::
30-
3125

3226
## Plugin installation ||installation||
3327

@@ -55,8 +49,8 @@ When using the standalone binary (no `package.json` present), you must specify a
5549
# @plugin(@varlock/a-plugin@1.2.3) # downloads and caches v1.2.3 from npm
5650
```
5751

58-
:::caution[Only `@varlock/*` plugins supported for now]
59-
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.).
52+
:::caution[Third-party plugin confirmation]
53+
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.
6054
:::
6155

6256
{/*

packages/varlock/src/cli/cli-executable.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { commandSpec as telemetryCommandSpec } from './commands/telemetry.comman
2222
import { commandSpec as explainCommandSpec } from './commands/explain.command';
2323
import { commandSpec as scanCommandSpec } from './commands/scan.command';
2424
import { commandSpec as typegenCommandSpec } from './commands/typegen.command';
25+
import { commandSpec as installPluginCommandSpec } from './commands/install-plugin.command';
2526
// import { commandSpec as loginCommandSpec } from './commands/login.command';
2627
// import { commandSpec as pluginCommandSpec } from './commands/plugin.command';
2728

@@ -58,6 +59,7 @@ subCommands.set('help', buildLazyCommand(helpCommandSpec, async () => await impo
5859
subCommands.set('telemetry', buildLazyCommand(telemetryCommandSpec, async () => await import('./commands/telemetry.command')));
5960
subCommands.set('scan', buildLazyCommand(scanCommandSpec, async () => await import('./commands/scan.command')));
6061
subCommands.set('typegen', buildLazyCommand(typegenCommandSpec, async () => await import('./commands/typegen.command')));
62+
subCommands.set('install-plugin', buildLazyCommand(installPluginCommandSpec, async () => await import('./commands/install-plugin.command')));
6163
// subCommands.set('login', buildLazyCommand(loginCommandSpec, async () => await import('./commands/login.command')));
6264
// subCommands.set('plugin', buildLazyCommand(pluginCommandSpec, async () => await import('./commands/plugin.command')));
6365

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { define } from 'gunshi';
2+
import ansis from 'ansis';
3+
import semver from 'semver';
4+
5+
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
6+
import { CliExitError } from '../helpers/exit-error';
7+
import { isBundledSEA } from '../helpers/install-detection';
8+
import { fmt } from '../helpers/pretty-format';
9+
import { downloadPluginToCache } from '../../env-graph/lib/plugins';
10+
11+
export const commandSpec = define({
12+
name: 'install-plugin',
13+
description: 'Download and cache a plugin from npm for use with the standalone binary',
14+
args: {
15+
plugin: {
16+
type: 'positional',
17+
description: 'Plugin to install, in the format name@version (e.g. my-plugin@1.2.3)',
18+
},
19+
},
20+
examples: `
21+
Pre-downloads a plugin into the local varlock plugin cache so it is available without
22+
needing an interactive confirmation prompt. This is useful in CI environments or any
23+
other non-interactive workflow where the standalone binary is used.
24+
25+
The plugin must be specified with an exact version number.
26+
27+
Examples:
28+
varlock install-plugin my-plugin@1.2.3
29+
varlock install-plugin @my-scope/my-plugin@2.0.0
30+
`.trim(),
31+
});
32+
33+
export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) => {
34+
if (!isBundledSEA()) {
35+
throw new CliExitError('This command is only available when using the standalone varlock binary', {
36+
suggestion: 'In a JS project, install plugins as regular dependencies using your package manager.\n'
37+
+ `For example: ${fmt.command('add my-plugin', { jsPackageManager: true })}`,
38+
});
39+
}
40+
41+
const pluginDescriptor = ctx.values.plugin as string | undefined;
42+
43+
if (!pluginDescriptor) {
44+
throw new CliExitError('No plugin specified', {
45+
suggestion: 'Usage: varlock install-plugin <name@version> (e.g. my-plugin@1.2.3)',
46+
});
47+
}
48+
49+
// Parse module name and version from descriptor like `some-plugin@1.2.3` or `@scope/pkg@1.2.3`.
50+
// Use lastIndexOf to correctly handle scoped packages (e.g. @scope/pkg@1.2.3).
51+
const atLocation = pluginDescriptor.lastIndexOf('@');
52+
if (atLocation === -1) {
53+
throw new CliExitError(`Missing version in "${pluginDescriptor}"`, {
54+
suggestion: `Specify an exact version, e.g. \`varlock install-plugin ${pluginDescriptor}@1.2.3\``,
55+
});
56+
}
57+
58+
const moduleName = pluginDescriptor.slice(0, atLocation);
59+
const versionDescriptor = pluginDescriptor.slice(atLocation + 1);
60+
61+
if (!versionDescriptor) {
62+
throw new CliExitError(`Missing version in "${pluginDescriptor}"`, {
63+
suggestion: `Specify an exact version, e.g. \`varlock install-plugin ${moduleName}@1.2.3\``,
64+
});
65+
}
66+
67+
if (!semver.valid(versionDescriptor)) {
68+
throw new CliExitError(`"${versionDescriptor}" is not an exact version`, {
69+
suggestion: `Use a fixed version number (e.g. 1.2.3), not a range. Example: \`varlock install-plugin ${moduleName}@1.2.3\``,
70+
});
71+
}
72+
73+
console.log(`\n📦 Installing plugin ${ansis.bold(`${moduleName}@${versionDescriptor}`)} into local cache...\n`);
74+
75+
try {
76+
const cachedPath = await downloadPluginToCache(moduleName, versionDescriptor);
77+
console.log(`✅ Plugin ${ansis.bold(`${moduleName}@${versionDescriptor}`)} installed successfully`);
78+
console.log(ansis.dim(` Cached at: ${cachedPath}\n`));
79+
} catch (err) {
80+
throw new CliExitError(`Failed to install plugin "${moduleName}@${versionDescriptor}"`, {
81+
details: (err as Error).message,
82+
});
83+
}
84+
};

packages/varlock/src/env-graph/lib/plugins.ts

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,11 @@ import crypto from 'node:crypto';
1010
import https from 'node:https';
1111
import ansis from 'ansis';
1212
import semver from 'semver';
13+
import { isCancel } from '@clack/prompts';
1314
import _ from '@env-spec/utils/my-dash';
1415
import { pathExists } from '@env-spec/utils/fs-utils';
1516
import { getUserVarlockDir } from '../../lib/user-config-dir';
17+
import { confirm } from '../../cli/helpers/prompts';
1618

1719

1820
import { FileBasedDataSource, type EnvGraphDataSource } from './data-source';
@@ -449,6 +451,22 @@ async function registerPluginInGraph(graph: EnvGraph, plugin: VarlockPlugin, plu
449451
}
450452
}
451453

454+
async function isPluginCached(url: string): Promise<boolean> {
455+
const cacheDir = path.join(getUserVarlockDir(), 'plugins-cache');
456+
const indexPath = path.join(cacheDir, 'index.json');
457+
try {
458+
const indexRaw = await fs.readFile(indexPath, 'utf-8');
459+
const index = JSON.parse(indexRaw) as Record<string, string>;
460+
if (index[url]) {
461+
const pluginDir = path.join(cacheDir, index[url]);
462+
return fs.stat(pluginDir).then(() => true, () => false);
463+
}
464+
} catch {
465+
// ignore
466+
}
467+
return false;
468+
}
469+
452470
async function downloadPlugin(url: string) {
453471
const exec = promisify(execCb);
454472
const cacheDir = path.join(getUserVarlockDir(), 'plugins-cache');
@@ -522,6 +540,32 @@ async function downloadPlugin(url: string) {
522540
return finalDir;
523541
}
524542

543+
/**
544+
* Fetches plugin metadata from npm and downloads the tarball into the local cache.
545+
* The caller is responsible for any user confirmation — this function downloads unconditionally.
546+
*
547+
* @param moduleName e.g. `@varlock/1password-plugin` or `my-plugin`
548+
* @param versionDescriptor must be a fixed semver version e.g. `1.2.3`
549+
* @returns the local cache directory the plugin was extracted into
550+
*/
551+
export async function downloadPluginToCache(moduleName: string, versionDescriptor: string): Promise<string> {
552+
if (!semver.valid(versionDescriptor)) {
553+
throw new Error(`"${versionDescriptor}" is not a fixed version — use an exact version like 1.2.3`);
554+
}
555+
556+
const npmInfoUrl = `https://registry.npmjs.org/${moduleName}/${versionDescriptor}`;
557+
const npmInfoReq = await fetch(npmInfoUrl);
558+
if (!npmInfoReq.ok) {
559+
throw new Error(`Failed to fetch plugin "${moduleName}@${versionDescriptor}" from npm: ${npmInfoReq.status} ${npmInfoReq.statusText}`);
560+
}
561+
const npmInfo = await npmInfoReq.json() as { dist?: { tarball?: string } };
562+
const tarballUrl = npmInfo?.dist?.tarball;
563+
if (!tarballUrl) {
564+
throw new Error(`Failed to find tarball URL for plugin "${moduleName}@${versionDescriptor}" from npm`);
565+
}
566+
567+
return downloadPlugin(tarballUrl);
568+
}
525569

526570

527571
export async function processPluginInstallDecorators(dataSource: EnvGraphDataSource) {
@@ -572,10 +616,6 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou
572616
versionDescriptor = pluginSourceDescriptor.slice(atLocation + 1);
573617
}
574618

575-
if (!moduleName.startsWith('@varlock/')) {
576-
throw new SchemaError(`Plugin "${moduleName}" blocked - only official @varlock/* plugins are supported for now, third-party plugins will be supported in future releases`);
577-
}
578-
579619
const semverRange = semver.validRange(versionDescriptor);
580620
if (versionDescriptor && !semverRange) {
581621
throw new SchemaError(`Bad @plugin version descriptor: ${versionDescriptor}`);
@@ -644,12 +684,43 @@ export async function processPluginInstallDecorators(dataSource: EnvGraphDataSou
644684
// TODO: new error type? check for 404 vs others and give better message
645685
throw new Error(`Failed to fetch plugin "${moduleName}@${versionDescriptor}" from npm: ${npmInfoReq.status} ${npmInfoReq.statusText}`);
646686
}
647-
const npmInfo = await npmInfoReq.json() as any;
687+
const npmInfo = await npmInfoReq.json() as { dist?: { tarball?: string } };
648688
const tarballUrl = npmInfo?.dist?.tarball;
649689
if (!tarballUrl) {
650690
throw new Error(`Failed to find tarball URL for plugin "${moduleName}@${versionDescriptor}" from npm`);
651691
}
652692

693+
// Third-party plugins (non-@varlock) require user confirmation before downloading.
694+
// Official @varlock plugins are always trusted. If already cached (previously confirmed),
695+
// skip the prompt — the user has already blessed this specific version.
696+
if (!moduleName.startsWith('@varlock/') && !(await isPluginCached(tarballUrl))) {
697+
if (!process.stdout.isTTY || !process.stdin.isTTY) {
698+
throw new SchemaError(
699+
`Third-party plugin "${moduleName}@${versionDescriptor}" must be confirmed before downloading, `
700+
+ 'but no interactive terminal (TTY) is available. '
701+
+ 'Run `varlock install-plugin` to pre-cache the plugin, or install it via your package.json.',
702+
);
703+
}
704+
705+
process.stdout.write(
706+
`\n${ansis.yellow('⚠')} Third-party plugin download requested\n`
707+
+ ` Package: ${ansis.bold(`${moduleName}@${versionDescriptor}`)}\n`
708+
+ ' Source: npm registry (https://registry.npmjs.org)\n\n'
709+
+ ` ${ansis.italic('Only install plugins from sources you trust.')}\n\n`,
710+
);
711+
712+
const confirmed = await confirm({
713+
message: `Allow downloading "${moduleName}@${versionDescriptor}" from npm?`,
714+
active: 'Yes, download it',
715+
inactive: 'No, cancel',
716+
initialValue: false,
717+
});
718+
719+
if (isCancel(confirmed) || !confirmed) {
720+
throw new SchemaError(`Third-party plugin "${moduleName}" download cancelled`);
721+
}
722+
}
723+
653724
// downloads into local cache folder (user varlock config dir / plugins-cache/)
654725
const downloadedPluginPath = await downloadPlugin(tarballUrl);
655726
pluginSrcPath = downloadedPluginPath;

0 commit comments

Comments
 (0)