Skip to content
Open
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
14 changes: 14 additions & 0 deletions .changeset/remote-imports-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"varlock": minor
---

Add remote import support for `@import()` decorator with two new protocols:

- `public-schemas:` - Import pre-built schemas for popular platforms (Vercel, Netlify, Cloudflare) from the varlock repository, with local caching
- `plugin-schema:` - Import schema files from installed plugin packages, with support for loading specific files (e.g., `plugin-schema:@varlock/1password-plugin/.env.connect`)

Also adds:
- `varlock cache clear` CLI command to manage cached schemas and plugins
- Security restrictions for remote imports (no plugin installation, no local file access)
- Public schemas for Vercel, Netlify, Cloudflare Pages, and Cloudflare Wrangler
- Graceful fallback to temp directory when user home directory is unavailable
51 changes: 50 additions & 1 deletion packages/varlock-website/src/content/docs/guides/import.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ The [`@import()` root decorator](/reference/root-decorators/#import) allows you
# @import(./env-dir/) # import directory
# @import(./.env.partial, KEY1, KEY2) # import specific keys
# @import(~/.env.shared) # import from home directory
# @import(public-schemas:platforms/vercel) # import public schema
```

## Import source types

The first argument to `@import()` specifies where to look for file(s) to import.
Currently only local file imports are supported, but we plan to support importing over http in a style similar to Deno's http imports.

For now, all imported files must be `.env` files (and may contain @env-spec decorators), but in the future, we may also support other formats (e.g., JSON, YAML, etc.) or even JS/TS files.

Expand Down Expand Up @@ -68,6 +68,55 @@ A registry (npm, jsr, etc.), package name, version, and path can be used to impo
```
*/}

### Public schemas

Varlock maintains a collection of pre-built schemas for popular platforms and services. Import them using the `public-schemas:` protocol.

- Schema is fetched from GitHub and cached locally (in `~/.config/varlock/schemas-cache/`)
- Cache is refreshed automatically after 24 hours
- Use `varlock cache clear schemas` to force a refresh

**Available platform schemas:**

| Schema | Protocol path | Description |
|--------|--------------|-------------|
| Vercel | `public-schemas:platforms/vercel` | Vercel system environment variables |
| Netlify | `public-schemas:platforms/netlify` | Netlify build environment variables |
| Cloudflare Pages | `public-schemas:platforms/cloudflare-pages` | Cloudflare Pages build variables |
| Cloudflare Wrangler | `public-schemas:platforms/cloudflare-wrangler` | Cloudflare Wrangler system variables |

```env-spec
# @import(public-schemas:platforms/vercel)
```

:::tip[Partial imports]
You can import only specific variables from a public schema:

```env-spec
# @import(public-schemas:platforms/vercel, VERCEL_ENV, VERCEL_URL)
```
:::

### Plugin schemas

If you have a Varlock plugin installed that provides a schema, you can import it using the `plugin-schema:` protocol.

- Looks for a `.env.schema` export in the plugin's `package.json` exports map, or falls back to a `.env.schema` file in the package root
- The plugin must be installed in your project's `node_modules`
- You can specify a different file path if the plugin exposes multiple schema files

```env-spec
# @import(plugin-schema:@varlock/1password-plugin)
# @import(plugin-schema:@varlock/1password-plugin/.env.connect)
```

:::caution[Security]
Remotely imported schemas (via `public-schemas:` or `plugin-schema:`) have security restrictions:
- They **cannot** install plugins (`@plugin` is not allowed)
- They **cannot** import local files (`./`, `../`, `~/`, or absolute paths)
- They can only define schema information (types, descriptions, validation rules)
:::

## Partial imports

By default, all items will be imported, but you may add a list of specific keys to import as additional args after the first.
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 cacheCommandSpec } from './commands/cache.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('cache', buildLazyCommand(cacheCommandSpec, async () => await import('./commands/cache.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
60 changes: 60 additions & 0 deletions packages/varlock/src/cli/commands/cache.command.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { define } from 'gunshi';
import { type TypedGunshiCommandFn } from '../helpers/gunshi-type-utils';
import { CliExitError } from '../helpers/exit-error';
import { clearSchemasCache, clearPluginsCache, clearAllCaches } from '../../lib/schema-cache';

export const commandSpec = define({
name: 'cache',
description: 'Manage cached schemas and plugins',
args: {
action: {
type: 'positional',
description: '"clear" to clear all caches',
},
target: {
type: 'positional',
description: '"schemas", "plugins", or "all" (default: all)',
},
},
examples: `
Manage cached data for remote schemas and downloaded plugins.

Examples:
varlock cache clear # Clear all caches (schemas + plugins)
varlock cache clear schemas # Clear only the schemas cache
varlock cache clear plugins # Clear only the plugins cache
`.trim(),
});

export const commandFn: TypedGunshiCommandFn<typeof commandSpec> = async (ctx) => {
const { action, target } = ctx.values;

if (action !== 'clear') {
throw new CliExitError('First argument must be "clear"', {
forceExit: true,
});
}

const cacheTarget = target || 'all';
if (!['schemas', 'plugins', 'all'].includes(cacheTarget)) {
throw new CliExitError('Cache target must be "schemas", "plugins", or "all"', {
forceExit: true,
});
}

try {
if (cacheTarget === 'schemas') {
await clearSchemasCache();
console.log('✅ Schemas cache cleared');
} else if (cacheTarget === 'plugins') {
await clearPluginsCache();
console.log('✅ Plugins cache cleared');
} else {
await clearAllCaches();
console.log('✅ All caches cleared (schemas + plugins)');
}
} catch (error) {
console.error('Failed to clear cache:', error);
throw new CliExitError(`Failed to clear ${cacheTarget} cache`, { forceExit: true });
}
};
79 changes: 78 additions & 1 deletion packages/varlock/src/env-graph/lib/data-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import { pathExists } from '@env-spec/utils/fs-utils';
import { processPluginInstallDecorators } from './plugins';
import { RootDecoratorInstance } from './decorators';
import { isBuiltinVar } from './builtin-vars';
import { fetchPublicSchema } from '../../lib/schema-cache';
import { resolvePluginSchema } from './plugin-schema';

const DATA_SOURCE_TYPES = Object.freeze({
schema: {
Expand Down Expand Up @@ -56,10 +58,16 @@ export abstract class EnvGraphDataSource {
importKeys?: Array<string>,
/** true when the @import had a non-static `enabled` parameter (e.g. `enabled=forEnv("dev")`) */
isConditionallyEnabled?: boolean,
/** true when the source was imported from a remote protocol (public-schemas:, plugin-schema:) */
isRemoteImport?: boolean,
};
get isImport(): boolean {
return !!this.importMeta?.isImport || !!this.parent?.isImport;
}
/** true if this data source (or any ancestor) was imported from a remote protocol */
get isRemoteImport(): boolean {
return !!this.importMeta?.isRemoteImport || !!this.parent?.isRemoteImport;
}
get isPartialImport() {
return (this.importKeys || []).length > 0;
}
Expand Down Expand Up @@ -302,7 +310,16 @@ export abstract class EnvGraphDataSource {
const defaultRequiredDec = this.getRootDec('defaultRequired');
await defaultRequiredDec?.process();

await processPluginInstallDecorators(this);
// Security: remotely imported files cannot install plugins
if (this.isRemoteImport) {
const pluginDecs = this.getRootDecFns('plugin');
if (pluginDecs.length) {
this._loadingError = new Error('Remotely imported schemas cannot install plugins (@plugin is not allowed)');
return;
}
} else {
await processPluginInstallDecorators(this);
}
}

/**
Expand Down Expand Up @@ -347,15 +364,27 @@ export abstract class EnvGraphDataSource {
// determine the full import path based on path type
let fullImportPath: string | undefined;
if (importPath.startsWith('./') || importPath.startsWith('../')) {
// Security: remote imports cannot access local files
if (this.isRemoteImport) {
throw new Error('Remotely imported schemas cannot use local file imports');
}
// eslint-disable-next-line no-use-before-define
if (!(this instanceof FileBasedDataSource)) {
throw new Error('@import of files can only be used from a file-based data source');
}
fullImportPath = path.resolve(this.fullPath, '..', importPath);
} else if (importPath.startsWith('~/') || importPath === '~') {
// Security: remote imports cannot access local files
if (this.isRemoteImport) {
throw new Error('Remotely imported schemas cannot use local file imports');
}
// expand ~ to home directory (treat like absolute path)
fullImportPath = path.join(os.homedir(), importPath.slice(1));
} else if (importPath.startsWith('/')) {
// Security: remote imports cannot access local files
if (this.isRemoteImport) {
throw new Error('Remotely imported schemas cannot use local file imports');
}
// absolute path
fullImportPath = importPath;
}
Expand Down Expand Up @@ -447,6 +476,54 @@ export abstract class EnvGraphDataSource {
});
}
}
} else if (importPath.startsWith('public-schemas:')) {
// Remote import from official varlock public schemas
const schemaPath = importPath.slice('public-schemas:'.length);
if (!schemaPath || schemaPath.includes('..')) {
this._loadingError = new Error(`Invalid public schema path: ${schemaPath}`);
return;
}
try {
const contents = await fetchPublicSchema(schemaPath);
// Sanitize the schema path for use as a synthetic filename
const safeName = schemaPath.replace(/[^a-zA-Z0-9_-]/g, '-');
const syntheticPath = `.env.public-schema-${safeName}`;
// eslint-disable-next-line no-use-before-define
const source = new DotEnvFileDataSource(syntheticPath, { overrideContents: contents });
await this.addChild(source, {
isImport: true, importKeys, isConditionallyEnabled, isRemoteImport: true,
});
} catch (fetchErr) {
if (allowMissing) continue;
this._loadingError = new Error(`Failed to fetch public schema "${schemaPath}": ${(fetchErr as Error).message}`);
return;
}
} else if (importPath.startsWith('plugin-schema:')) {
// Import schema from an installed plugin package
// Supports: plugin-schema:@scope/name (defaults to .env.schema)
// plugin-schema:@scope/name/.env.custom (specific file)
const pluginDescriptor = importPath.slice('plugin-schema:'.length);
if (!pluginDescriptor) {
this._loadingError = new Error('plugin-schema: import must specify a plugin name');
return;
}
try {
// eslint-disable-next-line no-use-before-define
const fileSource = this instanceof FileBasedDataSource ? this : undefined;
const schemaSource = await resolvePluginSchema(pluginDescriptor, fileSource);
if (!schemaSource) {
if (allowMissing) continue;
this._loadingError = new Error(`Plugin "${pluginDescriptor}" does not expose the requested schema file`);
return;
}
await this.addChild(schemaSource, {
isImport: true, importKeys, isConditionallyEnabled, isRemoteImport: true,
});
} catch (pluginErr) {
if (allowMissing) continue;
this._loadingError = new Error(`Failed to resolve plugin schema "${pluginDescriptor}": ${(pluginErr as Error).message}`);
return;
}
} else if (importPath.startsWith('http://') || importPath.startsWith('https://')) {
this._loadingError = new Error('http imports not supported yet');
return;
Expand Down
Loading
Loading