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
1 change: 1 addition & 0 deletions packages/varlock-website/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
],
},
{
Expand Down
242 changes: 242 additions & 0 deletions packages/varlock-website/src/content/docs/guides/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, PluginInstance> = {};

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`.
Loading
Loading