Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/allow-third-party-plugins.md
Original file line number Diff line number Diff line change
@@ -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`.
10 changes: 2 additions & 8 deletions packages/varlock-website/src/content/docs/guides/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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||

Expand Down Expand Up @@ -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.
:::

{/*
Expand Down
2 changes: 2 additions & 0 deletions packages/varlock/src/cli/cli-executable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')));

Expand Down
84 changes: 84 additions & 0 deletions packages/varlock/src/cli/commands/install-plugin.command.ts
Original file line number Diff line number Diff line change
@@ -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<typeof commandSpec> = 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 <name@version> (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,
});
}
};
81 changes: 76 additions & 5 deletions packages/varlock/src/env-graph/lib/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -449,6 +451,22 @@ async function registerPluginInGraph(graph: EnvGraph, plugin: VarlockPlugin, plu
}
}

async function isPluginCached(url: string): Promise<boolean> {
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<string, string>;
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');
Expand Down Expand Up @@ -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<string> {
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) {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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;
Expand Down
Loading