diff --git a/packages/varlock-website/astro.config.ts b/packages/varlock-website/astro.config.ts index a7011174..015128ad 100644 --- a/packages/varlock-website/astro.config.ts +++ b/packages/varlock-website/astro.config.ts @@ -204,6 +204,7 @@ export default defineConfig({ { label: '> @type data types', slug: 'reference/data-types' }, { label: 'Value functions', slug: 'reference/functions' }, { label: 'Builtin variables', slug: 'reference/builtin-variables', badge: 'new' }, + { 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 ae9948c9..1b78e797 100644 --- a/packages/varlock-website/src/content/docs/guides/plugins.mdx +++ b/packages/varlock-website/src/content/docs/guides/plugins.mdx @@ -111,3 +111,245 @@ Once installed, all decorators, data types, and resolver functions provided by t Some decorators or resolver functions may require the plugin to be initialized and will throw an error if not set up properly. Please refer to the specific plugin's documentation for details on usage. + +## Plugin authoring best practices + +Plugins are TypeScript modules that import from `varlock/plugin-lib` and register their functionality at module load time. Everything below is drawn from real first-party plugins. + +### 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 (only active when VARLOCK_DEBUG is set) +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(); + 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'); + + const instance = instances[instanceId]; + // ... call your SDK / API and return the secret value + return `fetched:${ref}`; + }, +}); +``` + +### 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: +- `"./plugin"` exports to a **CJS** file — this is required for runtime plugin loading. +- 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` so that `instanceof` checks on error classes work correctly. + +```ts title="tsup.config.ts" +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/plugin.ts'], + format: ['cjs'], // CJS required for plugin loading + 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/reference/plugin-api.mdx b/packages/varlock-website/src/content/docs/reference/plugin-api.mdx new file mode 100644 index 00000000..cac25777 --- /dev/null +++ b/packages/varlock-website/src/content/docs/reference/plugin-api.mdx @@ -0,0 +1,408 @@ +--- +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 [plugins guide](/guides/plugins/#plugin-authoring-best-practices) 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` (read-only) + +The version string from the plugin package's `package.json`. Populated automatically — do not assign. + +```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. Messages are only printed when the `VARLOCK_DEBUG` environment variable is set. Namespaced to `varlock:plugin:` — set `plugin.name` before using it. + +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 }) { + 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; + 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. + +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()); + 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) +``` + +
+ +