diff --git a/AGENTS.md b/AGENTS.md index bebbeebec..ebdb90732 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,12 @@ This is a monorepo managed with bun workspaces and Turborepo: - Smoke tests live in `smoke-tests/` and test the CLI end-to-end - Binary-specific tests in `smoke-tests/tests/binary.test.ts` require the SEA binary to be built first +## Plugins + +- First-party plugins live in `packages/plugins/*` +- When adding a new plugin to the monorepo, register a `plugin:` label for it in `.github/config/labeler.yaml` (one entry per plugin, globbing `packages/plugins//**`) +- See the plugin authoring guide (`packages/varlock-website/src/content/docs/plugins/authoring.mdx`) and the Plugin API reference (`.../reference/plugin-api.mdx`) for design conventions and the API surface + ## Versioning & releases - This monorepo uses **bumpy** (`@varlock/bumpy`) for version management diff --git a/packages/varlock-website/astro.config.ts b/packages/varlock-website/astro.config.ts index 3169f5b69..8b7438e4e 100644 --- a/packages/varlock-website/astro.config.ts +++ b/packages/varlock-website/astro.config.ts @@ -185,6 +185,7 @@ export default defineConfig({ collapsed: true, items: [ { label: 'Overview', slug: 'plugins/overview' }, + { label: 'Authoring plugins', slug: 'plugins/authoring' }, { label: '1Password', slug: 'plugins/1password' }, { label: 'Akeyless', slug: 'plugins/akeyless' }, { label: 'AWS SSM/SM', slug: 'plugins/aws-secrets' }, @@ -215,6 +216,7 @@ export default defineConfig({ { label: 'Resolver functions', slug: 'reference/functions' }, { label: 'Builtin variables', slug: 'reference/builtin-variables' }, { label: 'Reserved variables', slug: 'reference/reserved-variables' }, + { label: 'Plugin API', slug: 'reference/plugin-api' }, ], }, { diff --git a/packages/varlock-website/src/content/docs/guides/plugins.mdx b/packages/varlock-website/src/content/docs/guides/plugins.mdx index 8189562bd..0277ea24a 100644 --- a/packages/varlock-website/src/content/docs/guides/plugins.mdx +++ b/packages/varlock-website/src/content/docs/guides/plugins.mdx @@ -112,20 +112,10 @@ Some decorators or resolver functions may require the plugin to be initialized a Please refer to the specific plugin's documentation for details on usage. -## Plugin caching +## Caching plugin values -Plugins can use varlock's built-in [cache](/guides/caching/) to avoid repeated API calls or hold short-lived tokens. Caching is opt-in - it is only active when the user enables it (typically via a `cacheTtl` init option) and the global cache mode allows it. +Some plugins can cache fetched values to avoid repeated API calls or to hold short-lived tokens. This is opt-in — typically enabled per-instance via a `cacheTtl` init option and gated by the global [cache](/guides/caching/) mode. See each plugin's page for its specific caching options. -Plugin authors get a scoped cache accessor on the plugin instance: - -```ts -// primary API - atomic get-or-set -const value = await plugin.cache.getOrSet('some-key', '1h', () => fetchFromApi()); - -// lower-level helpers -await plugin.cache.get('some-key'); -await plugin.cache.set('some-key', value, '1h'); -plugin.cache.delete('some-key'); -``` - -Keys are automatically prefixed with `plugin:{name}:`, so plugins cannot collide with each other's cache entries. +:::tip[Building your own plugin?] +See the [plugin authoring guide](/plugins/authoring/) for design conventions, the `varlock/plugin-lib` API, packaging, and testing. +::: diff --git a/packages/varlock-website/src/content/docs/plugins/authoring.mdx b/packages/varlock-website/src/content/docs/plugins/authoring.mdx new file mode 100644 index 000000000..e622d61e8 --- /dev/null +++ b/packages/varlock-website/src/content/docs/plugins/authoring.mdx @@ -0,0 +1,298 @@ +--- +title: Authoring plugins +description: Build a varlock plugin — design conventions, the plugin-lib API, packaging, and testing +--- + +Most people only ever *use* plugins (see the [plugins guide](/guides/plugins/) and the [plugin catalog](/plugins/overview/)). This page is for the rarer case of *building* one. + +Plugins are TypeScript modules that import from `varlock/plugin-lib` and register their functionality at module load time. This page covers the conventions and mechanics of building a plugin; for the full API surface of `varlock/plugin-lib`, see the [Plugin API reference](/reference/plugin-api/). Everything below is drawn from real first-party plugins. + +## Design conventions + +Before writing any code, a few conventions that make a plugin feel native to the service it wraps and consistent with other varlock plugins. + +**Mirror the service.** Use the service's own terminology throughout, and match its native URL/ID schemes as closely as possible so references look familiar to anyone who already uses that service. When a single native ID can't capture everything you need, it's fine to extend it — e.g. appending `@version` or `#key` to a combined identifier — even if that exact form isn't a native concept. + +**Instances.** The init decorator should create a single default instance with no extra configuration. Support _named_ instances only when it genuinely makes sense to have multiple auth methods or presets in use at the same time — most plugins never need more than the default. + +**Resolver arguments.** +- Accept key/value (named) params where they help, but in most cases positional array args are cleaner — infer meaning from position wherever you can rather than requiring the user to name everything. +- When named instances are supported, give the resolver an optional _first_ positional arg that selects the instance id. +- Many services let you store either a single value or a key/value blob of many values under one path. There, make the key optional and use a `#key` suffix on the path to pull one entry out of a blob. +- If the service commonly stores values under the exact key names they'll be injected as, offer a bulk value resolver — and let the key names be optional, defaulting to each config item's own key. + +**Auth.** +- Do _not_ probe well-known environment variables by default. If a user wants to authenticate that way, let them wire the env var up explicitly in their schema. +- It _is_ fine to check well-known _file_ locations when that's an established pattern for the service (e.g. `~/.aws`). +- Gate more automatic or "magic" auth behind opt-in flags so it can be turned off when it doesn't fit — for example, enabling 1Password desktop-app auth only in dev. + +**Lazy validation.** A plugin may be initialized but never actually used, so an initialized-but-unused plugin with empty config must be a complete no-op — it should never error. Defer "is this usable?" checks to the first time the plugin is used through a resolver. (Config that is _known to be invalid_ — not merely empty — may still fail early.) + +**Data types.** Custom data types are a good place to attach documentation links and service-specific validation. Always feel free to add docs — but don't add a `validate()` function unless there's a real, specific check to make. Validating "is a non-empty string" adds nothing. + +## Scaffold and metadata + +A plugin is a single TypeScript file (`src/plugin.ts`). All registration calls run at the top level when the module is loaded. + +```ts title="src/plugin.ts" +import { type Resolver, plugin } from 'varlock/plugin-lib'; + +// Destructure the error classes you need +const { ValidationError, SchemaError, ResolutionError } = plugin.ERRORS; + +// Short identifier used internally (e.g. for logging) +plugin.name = 'myplugin'; + +// Optional: debug logger — output is enabled by the DEBUG env var, +// e.g. `DEBUG=varlock:plugin:*` (namespaced to `varlock:plugin:`) +const { debug } = plugin; +debug('init - version =', plugin.version); + +// Icon from simple-icons (https://simpleicons.org) or any Iconify set +plugin.icon = 'simple-icons:yourservice'; + +// Optional: declare well-known env var names so users get warnings if they +// forget to wire them up as schema items +plugin.standardVars = { + initDecorator: '@initMyPlugin', + params: { + token: { key: 'MY_SERVICE_TOKEN' }, + url: { key: 'MY_SERVICE_URL' }, + }, +}; + +// registration calls follow… +``` + +## `plugin.registerRootDecorator` + +Root decorators appear as `@decoratorName(...)` comments at the top of a `.env` file. They are used for plugin initialization and run in two phases: + +- **`process`** — runs during schema parsing. Validates static arguments (e.g. `id=`), creates the instance record, and returns a plain serialisable object containing any `Resolver` references for dynamic args. +- **`execute`** — runs during value resolution. Awaits the dynamic resolvers returned by `process` and performs auth/connection setup. + +```ts title="src/plugin.ts" +interface PluginInstance { + token?: string; +} +const instances: Record = {}; + +plugin.registerRootDecorator({ + name: 'initMyPlugin', + description: 'Initialise a MyPlugin instance', + isFunction: true, // required when the decorator accepts arguments + + async process(argsVal) { + const { objArgs } = argsVal; + if (!objArgs) throw new SchemaError('@initMyPlugin requires arguments'); + + // id must be a literal string so we can key the instance map at parse time + if (objArgs.id && !objArgs.id.isStatic) { + throw new SchemaError('id must be a static value'); + } + const id = String(objArgs.id?.staticValue ?? '_default'); + + if (instances[id]) { + throw new SchemaError(`Instance "${id}" is already initialised`); + } + instances[id] = {}; // reserve the slot + + // Return resolvers for dynamic args alongside any static data + return { id, tokenResolver: objArgs.token }; + }, + + async execute({ id, tokenResolver }) { + // Await dynamic values (these may reference other schema items) + const token = await tokenResolver?.resolve(); + // `process` reserved instances[id], so the non-null assertion is safe here + instances[id]!.token = token ? String(token) : undefined; + }, +}); +``` + +## `plugin.registerDataType` + +Data types appear as `@type=myType` on an item. They can mark values as sensitive, add validation, and surface documentation links. + +```ts title="src/plugin.ts" +plugin.registerDataType({ + name: 'myServiceToken', + sensitive: true, // value will be redacted in logs + typeDescription: 'Authentication token for MyService', + icon: 'simple-icons:yourservice', + docs: [ + { + description: 'Creating API tokens', + url: 'https://docs.yourservice.example/tokens', + }, + ], + // Optional: validate the raw string value + async validate(val) { + if (typeof val !== 'string' || !val.startsWith('mst_')) { + throw new ValidationError('Token must start with "mst_"'); + } + }, +}); +``` + +## `plugin.registerResolverFunction` + +Resolver functions appear as values in `.env` files: `MY_SECRET=myPlugin(ref)`. They also run in two phases: + +- **`process`** — runs at parse time. Validate argument shapes and return the resolvers + metadata your `resolve` call needs. +- **`resolve`** — runs at resolution time. Awaits resolvers, contacts the external service, and returns the final string value. + +```ts title="src/plugin.ts" +plugin.registerResolverFunction({ + name: 'myPlugin', + label: 'Fetch secret from MyService', + icon: 'simple-icons:yourservice', + argsSchema: { + type: 'array', + arrayMinLength: 1, + arrayMaxLength: 2, // myPlugin(ref) or myPlugin(instanceId, ref) + }, + + process() { + let instanceId = '_default'; + let refResolver: Resolver; + + if (this.arrArgs!.length === 1) { + refResolver = this.arrArgs![0]; + } else { + // first arg is the instance id – must be a literal + if (!this.arrArgs![0].isStatic) { + throw new SchemaError('Instance id must be a static value'); + } + instanceId = String(this.arrArgs![0].staticValue); + refResolver = this.arrArgs![1]; + } + + if (!instances[instanceId]) { + throw new SchemaError( + `No MyPlugin instance "${instanceId}" found`, + { tip: 'Add @initMyPlugin() to your .env.schema file' }, + ); + } + + return { instanceId, refResolver }; + }, + + async resolve({ instanceId, refResolver }) { + const ref = await refResolver.resolve(); + if (typeof ref !== 'string') throw new SchemaError('Expected a string reference'); + + // `process` verified the instance exists, so the non-null assertion is safe + const instance = instances[instanceId]!; + // ... use `instance` to call your SDK / API and return the secret value + return `fetched:${ref}`; + }, +}); +``` + +## Caching + +Plugins can use varlock's built-in [cache](/guides/caching/) to avoid repeated API calls or hold short-lived tokens. Caching is opt-in — it is only active when the user enables it (typically via a `cacheTtl` init option) and the global cache mode allows it. + +Plugin authors get a scoped cache accessor on the plugin instance: + +```ts +// primary API - atomic get-or-set +const value = await plugin.cache.getOrSet('some-key', '1h', () => fetchFromApi()); + +// lower-level helpers +await plugin.cache.get('some-key'); +await plugin.cache.set('some-key', value, '1h'); +plugin.cache.delete('some-key'); +``` + +Keys are automatically prefixed with `plugin:{name}:`, so plugins cannot collide with each other's cache entries. + +Capture the accessor **once at module load** — `plugin.cache` (like the rest of the `plugin` object) is only valid while the plugin module is initializing, not later inside a resolver: + +```ts +// at module top, alongside plugin.name / plugin.ERRORS +let pluginCache: typeof plugin.cache | undefined; +try { pluginCache = plugin.cache; } catch { /* cache unavailable (no encryption key) */ } +``` + +## Error handling + +Always use error classes from `plugin.ERRORS`: + +| Class | When to use | +|---|---| +| `SchemaError` | Problems detected at parse/schema-build time (bad args, missing config) | +| `ResolutionError` | Problems at value-fetch time (secret not found, network error) | +| `ValidationError` | Value fails a `@type` constraint | +| `CoercionError` | Value cannot be converted to the expected type | + +Pass a `tip` string (or array of strings) to guide users toward a fix: + +```ts +throw new ResolutionError(`Secret "${ref}" not found`, { + tip: [ + 'Verify the secret name is correct in MyService', + 'Check your token has read access', + ], +}); +``` + +## Package setup + +```json title="package.json" +{ + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + "./plugin": "./dist/plugin.cjs" + }, + "files": ["dist"], + "engines": { "node": ">=22" }, + "peerDependencies": { "varlock": "*" }, + "devDependencies": { + "varlock": "...", + "tsup": "...", + "vitest": "..." + } +} +``` + +Key points: +- Published plugins should expose their entry through a `"./plugin"` export pointing at a **CJS** file (`.cjs`). Varlock loads CJS plugins by intercepting `require('varlock/plugin-lib')` so the plugin shares varlock's _own_ module instance — that's what makes the `plugin` proxy and the shared error classes work. (Single-file local plugins may also be `.mjs` or `.ts`, but packaged plugins should ship CJS.) +- SDK/client libraries should go in `devDependencies` and be bundled via tsup; they must **not** be listed as runtime `dependencies` (which would require the user to install them separately). +- `varlock` is a `peerDependency` (not a regular dependency) so the plugin binds to the user's installed varlock rather than pulling in a second copy — keeping the shared module instance, and therefore `instanceof` checks on error classes, intact. + +```ts title="tsup.config.ts" +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/plugin.ts'], + format: ['cjs'], // ship CJS for the ./plugin export (see packaging notes above) + dts: true, + sourcemap: true, + treeshake: true, + external: ['varlock'], // peer – do NOT bundle +}); +``` + +## Testing + +```ts title="vitest.config.ts" +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + resolve: { + // Resolve varlock's `ts-src` condition so tests run against TypeScript source + conditions: ['ts-src'], + }, + define: { + // Required – varlock uses these globals at import time + __VARLOCK_BUILD_TYPE__: JSON.stringify('test'), + __VARLOCK_SEA_BUILD__: 'false', + }, +}); +``` + +Without `conditions: ['ts-src']` and the two `define` entries your tests will fail with a `ReferenceError`. diff --git a/packages/varlock-website/src/content/docs/plugins/overview.mdx b/packages/varlock-website/src/content/docs/plugins/overview.mdx index d4aaad440..45390e574 100644 --- a/packages/varlock-website/src/content/docs/plugins/overview.mdx +++ b/packages/varlock-website/src/content/docs/plugins/overview.mdx @@ -9,6 +9,8 @@ Plugins allow extending the functionality of Varlock. See the [plugins guide](/g 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 loading plugins from different sources (e.g., local files, git, npm/jsr, http, etc.). +Want to build your own? See the [plugin authoring guide](/plugins/authoring/). + ## Official plugins | Plugin | npm package | diff --git a/packages/varlock-website/src/content/docs/reference/plugin-api.mdx b/packages/varlock-website/src/content/docs/reference/plugin-api.mdx new file mode 100644 index 000000000..fbe477499 --- /dev/null +++ b/packages/varlock-website/src/content/docs/reference/plugin-api.mdx @@ -0,0 +1,414 @@ +--- +title: Plugin API +description: Reference for the varlock/plugin-lib module — all properties and methods available to plugin authors +--- + +Plugins import from `varlock/plugin-lib` and register their behaviour at module-load time. + +```ts +import { type Resolver, plugin } from 'varlock/plugin-lib'; +``` + +The `plugin` object is a proxy that delegates to the currently-loading plugin instance. It is valid only during plugin module execution. + +See the [plugin authoring guide](/plugins/authoring/) for authoring patterns, packaging conventions, and testing setup. + +## Metadata properties + +
+ +
+ +### `plugin.name` +**Type:** `string` + +A short internal identifier for the plugin, used in log output and debug messages. Convention is lowercase with hyphens. + +```ts +plugin.name = 'my-service'; +``` + +
+ +
+ +### `plugin.version` +**Type:** `string` + +The plugin's version. For packaged plugins this is read automatically from the package's `package.json` and takes precedence, so you normally don't set it yourself. + +```ts +debug('loaded version', plugin.version); +``` + +
+ +
+ +### `plugin.icon` +**Type:** `string` + +An icon identifier shown in the VS Code extension and website UI. First-party plugins use [Simple Icons](https://simpleicons.org) identifiers (e.g. `simple-icons:vault`). Any [Iconify](https://icones.js.org) identifier is accepted. + +```ts +plugin.icon = 'simple-icons:yourservice'; +``` + +
+ +
+ +### `plugin.standardVars` +**Type:** +```ts +{ + initDecorator: string; + params: Record; + dataType?: string; + }>; +} +``` + +Declares well-known environment variable names that your plugin reads (e.g. `VAULT_TOKEN`). Varlock will warn if those variables are detected in the process environment but are not wired up to the init decorator in the schema. + +- `initDecorator` — the init decorator name including the `@` prefix (e.g. `'@initMyPlugin'`). +- `params` — maps a parameter name to the env var key(s) it corresponds to. If multiple keys are given the first found in the environment is used. +- `dataType` — optional; suggests a custom type name in the warning hint. + +```ts +plugin.standardVars = { + initDecorator: '@initMyPlugin', + params: { + token: { key: 'MY_SERVICE_TOKEN', dataType: 'myServiceToken' }, + url: { key: ['MY_SERVICE_URL', 'MY_SVC_URL'] }, + }, +}; +``` + +
+ +
+ +### `plugin.debug` +**Type:** `(...args: any[]) => void` + +A scoped debug logger, namespaced to `varlock:plugin:` — set `plugin.name` before using it. Output is enabled via the `DEBUG` environment variable using namespace patterns, e.g. `DEBUG=varlock:plugin:*` (all plugins), `DEBUG=varlock:plugin:my-service` (just yours), or `DEBUG=varlock:*` (all of varlock). + +Destructure before use so it can be called without referencing `plugin` each time: + +```ts +plugin.name = 'my-service'; +const { debug } = plugin; + +debug('init - version =', plugin.version); +debug('resolving reference', ref); +``` + +
+ +
+ +### `plugin.ERRORS` +**Type:** +```ts +{ + ValidationError: typeof ValidationError; + CoercionError: typeof CoercionError; + SchemaError: typeof SchemaError; + ResolutionError: typeof ResolutionError; +} +``` + +Always destructure and use these classes instead of plain `Error`. They carry structured metadata and are handled specially by the runtime. + +```ts +const { ValidationError, SchemaError, CoercionError, ResolutionError } = plugin.ERRORS; +``` + +All four share the same constructor signature: + +```ts +throw new ResolutionError('Secret not found', { + tip: 'Check the path and verify your token has read access', + code: 'NOT_FOUND', // optional machine-readable code +}); +``` + +`tip` may be a string or an array of strings (joined with newlines). + +| Class | When to use | +|---|---| +| `SchemaError` | Problems at parse / schema-build time — bad arguments, missing config, duplicate IDs | +| `ResolutionError` | Problems at value-fetch time — secret not found, network error, auth failure | +| `ValidationError` | Value fails a `registerDataType` constraint | +| `CoercionError` | Value cannot be converted to the expected type | + +
+ +
+ +## Registration methods + +
+ +
+ +### `plugin.registerRootDecorator()` ||registerRootDecorator|| + +Registers a decorator that users can place in the _header_ section of a `.env` file. Root decorators are mainly used for plugin initialisation. + +**Argument type:** + +```ts +type RootDecoratorDef = { + name: string; + description?: string; + isFunction?: boolean; // true → must be called as @name(...) + deprecated?: boolean | string; + incompatibleWith?: Array; + process?: (decoratorValue: Resolver) => Processed | Promise; + execute?: (state: Processed) => void | Promise; +}; +``` + +**Two-phase execution:** `process` runs during schema parsing; `execute` runs at resolution time. Return resolvers from `process` so `execute` can await them once dependent schema items have resolved. + +Inside `process`, `decoratorValue.objArgs` is a `Record` (keyword args) and `decoratorValue.arrArgs` is an `Array` (positional args). Each `Resolver` exposes: +- `isStatic` / `staticValue` — available at parse time for literal arguments +- `resolve()` — awaitable at `execute` time for dynamic arguments + +```ts +const instances: Record = {}; + +plugin.registerRootDecorator({ + name: 'initMyPlugin', + description: 'Initialise a MyPlugin connection', + isFunction: true, + + async process(argsVal) { + const { objArgs } = argsVal; + if (!objArgs) throw new SchemaError('@initMyPlugin requires arguments'); + + if (objArgs.id && !objArgs.id.isStatic) { + throw new SchemaError('id must be a static value'); + } + const id = String(objArgs.id?.staticValue ?? '_default'); + + if (instances[id]) throw new SchemaError(`Instance "${id}" already initialised`); + instances[id] = {}; + + return { id, tokenResolver: objArgs.token }; + }, + + async execute({ id, tokenResolver }) { + // `process` reserved instances[id], so the non-null assertion is safe here + instances[id]!.token = tokenResolver + ? String(await tokenResolver.resolve()) + : undefined; + }, +}); +``` + +Usage in a `.env.schema` file: + +```env-spec title=".env.schema" "@initMyPlugin" +# @plugin(my-plugin) +# @initMyPlugin(token=$MY_SERVICE_TOKEN) +# --- +# @sensitive +MY_SERVICE_TOKEN= +``` + +
+ +
+ +### `plugin.registerItemDecorator()` ||registerItemDecorator|| + +Registers a decorator that users can attach to individual config items. + +**Argument type:** + +```ts +type ItemDecoratorDef = { + name: string; + incompatibleWith?: Array; + isFunction?: boolean; + deprecated?: boolean | string; + process?: (decoratorValue: Resolver) => Processed | Promise; + execute?: (state: Processed) => void | Promise; +}; +``` + +Like root decorators, `process` runs at parse time and `execute` at resolution time. If neither is provided the decorator acts as an inert marker. + +```ts +plugin.registerItemDecorator({ + name: 'awsRegion', + + process(decoratorValue) { + if (!decoratorValue.isStatic) { + throw new SchemaError('@awsRegion value must be a static string'); + } + const region = String(decoratorValue.staticValue); + const VALID = ['us-east-1', 'eu-west-1', 'ap-southeast-1']; + if (!VALID.includes(region)) { + throw new SchemaError( + `@awsRegion: "${region}" is not a valid region`, + { tip: `Valid regions: ${VALID.join(', ')}` }, + ); + } + return { region }; + }, +}); +``` + +Usage: + +```env-spec title=".env.schema" "@awsRegion" +# @awsRegion=us-east-1 +AWS_BUCKET=my-prod-bucket +``` + +
+ +
+ +### `plugin.registerDataType()` ||registerDataType|| + +Registers a named data type that users apply with `# @type=name`. Data types add validation, coercion, sensitive-marking, and documentation links to config items. + +**Argument type:** + +```ts +type DataTypeDef = { + name: string; + typeDescription?: string; + icon?: string; + sensitive?: boolean; + coerce?: (value: any) => CoercedType | CoercionError | undefined; + validate?: (value: any) => void | true | ValidationError | Array + | Promise>; + docs?: Array; +}; +``` + +- `coerce` is called first; return the canonical form, `undefined` to skip, or a `CoercionError`. +- `validate` is called after coercion. Throw or return a `ValidationError` to reject the value. +- `sensitive: true` marks all items of this type as sensitive by default (users can override with `# @public`). + +```ts +plugin.registerDataType({ + name: 'myServiceToken', + sensitive: true, + typeDescription: 'API token for MyService', + icon: 'simple-icons:yourservice', + docs: [{ description: 'Generating tokens', url: 'https://docs.example.com/tokens' }], + validate(val) { + if (typeof val !== 'string' || !val.startsWith('mst_')) { + throw new ValidationError('Token must start with "mst_"'); + } + }, +}); +``` + +Usage: + +```env-spec title=".env.schema" "@type" +# @type=myServiceToken +MY_SERVICE_TOKEN= +``` + +
+ +
+ +### `plugin.registerResolverFunction()` ||registerResolverFunction|| + +Registers a function that users can use as a config item value. At resolution time Varlock calls the function to produce the final value — the primary mechanism for fetching secrets from external providers. + +**Argument type:** + +```ts +type ResolverDef = { + name: string; + description?: string; + label?: string; + icon?: string; + inferredType?: string; // hint the resolved value's type (e.g. 'string', 'number') + impliesSensitive?: boolean; // mark items using this resolver as sensitive by default + argsSchema?: { + type: 'array' | 'object' | 'mixed'; + arrayExactLength?: number; + arrayMinLength?: number; + arrayMaxLength?: number; + objKeyMinLength?: number; + }; + process?: (this: Resolver) => Processed; + resolve: (this: Resolver, state: Processed) => ResolvedValue | Promise; +}; +``` + +**Two-phase execution:** `process` runs at schema-build time (`this` is the `Resolver` instance); validate argument shapes and return state for `resolve`. `resolve` runs at value-fetch time; await resolvers and call your SDK/API. + +`argsSchema` constraints are enforced _before_ `process` runs, so validation code can assume the correct number of arguments. + +For resolvers that fetch secrets, set `impliesSensitive: true` so consuming items are treated as sensitive by default (the user can still override per item with `# @public`). + +Access arguments via `this.arrArgs` (positional, `Array`) and `this.objArgs` (keyword, `Record`). Each `Resolver` exposes `isStatic`, `staticValue`, and `resolve()`. + +```ts +plugin.registerResolverFunction({ + name: 'myPlugin', + label: 'Fetch secret from MyService', + icon: 'simple-icons:yourservice', + argsSchema: { + type: 'array', + arrayMinLength: 1, + arrayMaxLength: 2, // myPlugin(ref) or myPlugin(instanceId, ref) + }, + + process() { + let instanceId = '_default'; + let refResolver: Resolver; + + if (this.arrArgs!.length === 1) { + refResolver = this.arrArgs![0]; + } else { + if (!this.arrArgs![0].isStatic) { + throw new SchemaError('Instance id must be a static value'); + } + instanceId = String(this.arrArgs![0].staticValue); + refResolver = this.arrArgs![1]; + } + + if (!instances[instanceId]) { + throw new SchemaError( + `No MyPlugin instance "${instanceId}" found`, + { tip: 'Add @initMyPlugin() to your .env.schema file' }, + ); + } + return { instanceId, refResolver }; + }, + + async resolve({ instanceId, refResolver }) { + const ref = String(await refResolver.resolve()); + // `process` verified the instance exists, so the non-null assertion is safe + return await instances[instanceId]!.fetchSecret(ref); + }, +}); +``` + +Usage: + +```env-spec title=".env.schema" /myPlugin\\(.*\\)/ +# @sensitive +DB_PASSWORD=myPlugin(services/db#password) +# Named instance: +API_KEY=myPlugin(prod, services/api#key) +``` + +
+ +