@visualautomate/plugin-sdk 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,418 @@
1
+ /**
2
+ * What a plugin is handed, and what it must hand back.
3
+ *
4
+ * This file is the contract between a plugin and the VisualAutomate runtime:
5
+ * the arguments `execute` is called with, every helper on `context`, the shape
6
+ * of the result, and the manifest that declares the step. When a helper is only
7
+ * present under some condition, that is said here rather than left for somebody
8
+ * to discover from a TypeError.
9
+ *
10
+ * context-api.ts describes the same runtime as data, for tools that cannot read
11
+ * a TypeScript interface, such as an editor's autocomplete.
12
+ */
13
+ /**
14
+ * The output of the steps that led here, keyed by step name and node id.
15
+ *
16
+ * A step reads `input.download_clips.file` where `download_clips` is what the
17
+ * step before it is called on the canvas. Every route into this step is merged,
18
+ * so a step at the join of two branches sees both.
19
+ */
20
+ export interface PluginInput {
21
+ [key: string]: unknown;
22
+ }
23
+ /** The fields somebody filled in on the canvas, as the manifest declared them. */
24
+ export interface PluginConfig {
25
+ [key: string]: unknown;
26
+ }
27
+ /** What `storage.put` reports about the file it wrote. */
28
+ export interface StorageResult {
29
+ url: string;
30
+ size: number;
31
+ contentType: string;
32
+ uploadedAt: string;
33
+ expiresAt: string;
34
+ }
35
+ /** What `storage.list` reports. */
36
+ export interface StorageListResult {
37
+ files: Array<{
38
+ url: string;
39
+ pathname: string;
40
+ size: number;
41
+ uploadedAt: string;
42
+ }>;
43
+ totalSize: number;
44
+ }
45
+ /**
46
+ * The account's own files. Paths are scoped to the user; you cannot name
47
+ * anybody else's, and you do not have to prefix your own.
48
+ *
49
+ * The account's plan caps what it may hold at once, and a `put` past that
50
+ * ceiling is refused rather than silently dropped.
51
+ */
52
+ export interface PluginStorage {
53
+ /** Write a file. Bytes may be a string, a Buffer or any typed array. */
54
+ put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>;
55
+ /** Read a file back. `null` when it is not there. */
56
+ get(path: string): Promise<Uint8Array | null>;
57
+ delete(path: string): Promise<void>;
58
+ /** Everything under a prefix, or everything when no prefix is given. */
59
+ list(prefix?: string): Promise<StorageListResult>;
60
+ /** Size and content type without moving the bytes. `null` when absent. */
61
+ head(path: string): Promise<{
62
+ size: number;
63
+ contentType: string;
64
+ } | null>;
65
+ /**
66
+ * A URL somebody else can fetch this file from, for a few minutes.
67
+ *
68
+ * For the common API that takes a link and pulls the file itself. Clamped
69
+ * to between one minute and one hour; the default is fifteen minutes.
70
+ */
71
+ signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>;
72
+ /**
73
+ * Pull a URL straight into storage without the bytes passing through this
74
+ * step. A hundred-megabyte file costs the run no memory and no egress,
75
+ * because a Worker fetches it and writes it to storage directly.
76
+ *
77
+ * ONLY PRESENT when the download Worker is configured on the server. Guard
78
+ * with `if (context.storage.download)` if you want to degrade gracefully.
79
+ */
80
+ download?(url: string, path: string, contentType?: string): Promise<StorageResult>;
81
+ }
82
+ /**
83
+ * Values that outlive one run, scoped to this workflow and this account.
84
+ *
85
+ * For a cursor, a last-seen id, a counter — the things a workflow needs to
86
+ * remember between firings. Not for large data: that is what storage is for.
87
+ * The account's plan caps how many keys it may hold.
88
+ */
89
+ export interface PluginState {
90
+ /** The stored value, or `null` when the key has never been set. */
91
+ get(key: string): Promise<unknown>;
92
+ set(key: string, value: unknown, options?: {
93
+ ttlSeconds?: number;
94
+ }): Promise<void>;
95
+ delete(key: string): Promise<void>;
96
+ list(prefix?: string): Promise<{
97
+ keys: string[];
98
+ }>;
99
+ }
100
+ /**
101
+ * ffmpeg, for a step that has a file in storage and wants a different one.
102
+ *
103
+ * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an
104
+ * operation and supply values; the command line is assembled on the server, one
105
+ * operation at a time, and the only paths in it are placeholders the container
106
+ * fills from a list the server wrote. That is deliberate: `-i` takes any path
107
+ * and several filters open files of their own, so an ffmpeg taking its flags
108
+ * from plugin config would be a plugin reading the container's filesystem —
109
+ * including the file holding that run's own credentials.
110
+ *
111
+ * The job runs in a container of its own and talks to storage directly, so a
112
+ * two hundred megabyte clip never crosses this step.
113
+ *
114
+ * ONLY PRESENT when the sandbox and storage are both configured.
115
+ */
116
+ export interface PluginMedia {
117
+ run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>;
118
+ }
119
+ /** One request through the integration proxy. */
120
+ export interface ConnectionRequest {
121
+ path: string;
122
+ method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
123
+ query?: Record<string, string | number | boolean | undefined>;
124
+ headers?: Record<string, string>;
125
+ body?: unknown;
126
+ }
127
+ /** What the proxy answers with. The provider's own reply, unwrapped. */
128
+ export interface ConnectionResponse {
129
+ ok: boolean;
130
+ status: number;
131
+ headers?: Record<string, string>;
132
+ body: unknown;
133
+ }
134
+ /**
135
+ * One of the accounts the user connected.
136
+ *
137
+ * THERE IS NO getToken(), deliberately. A token that can be read can be logged
138
+ * or posted somewhere, and this is where third-party code runs. You name a path
139
+ * and the proxy attaches the credential on the way past.
140
+ *
141
+ * You also never name a host. The base URL belongs to the provider
142
+ * configuration, which is what stops a connection being pointed at an internal
143
+ * address or at somebody's collection server.
144
+ */
145
+ export interface PluginConnection {
146
+ /** This platform's id for the connection, not the provider's. */
147
+ id: string;
148
+ request(options: ConnectionRequest): Promise<ConnectionResponse>;
149
+ }
150
+ /**
151
+ * The accounts this step was configured with.
152
+ *
153
+ * `get` takes the name of one of your own `connection` properties — not a
154
+ * connection id — so a step can only reach the accounts it was set up with.
155
+ *
156
+ * ONLY PRESENT when the step declares at least one `connection` property and
157
+ * the user has chosen an account for it.
158
+ */
159
+ export interface PluginConnections {
160
+ get(propertyName: string): Promise<PluginConnection>;
161
+ }
162
+ /**
163
+ * Everything a step is told about the run it is part of.
164
+ *
165
+ * The optional members are optional for a reason and not out of caution: each
166
+ * is absent when the thing behind it is not configured or not applicable, and
167
+ * a plugin that needs one should say so plainly rather than crash on a dot.
168
+ */
169
+ export interface PluginContext {
170
+ /** The workflow this run belongs to. */
171
+ workflowId: string;
172
+ /** This step's id on the canvas. */
173
+ nodeId: string;
174
+ /** Whose run it is. */
175
+ userId: string;
176
+ /** This run, distinct from every other run of the same workflow. */
177
+ executionId: string;
178
+ /** When the run started, as an ISO string. */
179
+ startedAt: string;
180
+ /** Values the plugin was published with. Not the user's credentials. */
181
+ secrets: Record<string, string>;
182
+ /** The account's files. See PluginStorage. */
183
+ storage: PluginStorage;
184
+ /** Values that outlive one run. See PluginState. */
185
+ state: PluginState;
186
+ /** ffmpeg. Absent when the server has no sandbox or storage configured. */
187
+ media?: PluginMedia;
188
+ /** The accounts this step was configured with. Absent when it has none. */
189
+ connections?: PluginConnections;
190
+ }
191
+ /**
192
+ * The result of a step.
193
+ *
194
+ * `output` names which port the run leaves by, and therefore which lines on the
195
+ * canvas are followed. It has to be one of the `outputs` in the manifest —
196
+ * usually `"success"` or `"error"`.
197
+ *
198
+ * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and
199
+ * named for what it is; those names are what somebody picks from a dropdown.
200
+ */
201
+ export interface PluginOutput {
202
+ output: string;
203
+ data: Record<string, unknown>;
204
+ metadata?: {
205
+ durationMs?: number;
206
+ memoryUsedMb?: number;
207
+ logs?: string[];
208
+ };
209
+ }
210
+ /**
211
+ * A plugin.
212
+ *
213
+ * Export `execute`, either as `module.exports.execute` or as the default export
214
+ * of a module with an `execute` on it. Both are accepted, and so is a bare
215
+ * function as the default export.
216
+ *
217
+ * @example
218
+ * export default {
219
+ * async execute(input, config, context) {
220
+ * const file = await context.storage.get(config.path as string)
221
+ * return { output: "success", data: { bytes: file?.length ?? 0 } }
222
+ * }
223
+ * }
224
+ */
225
+ export interface PluginModule {
226
+ execute(input: PluginInput, config: PluginConfig, context: PluginContext): Promise<PluginOutput> | PluginOutput;
227
+ }
228
+ /** The bare function form, for a plugin that is only an execute. */
229
+ export type PluginExecute = PluginModule["execute"];
230
+ /**
231
+ * `skipped` is a decision rather than a fault: the filters on every line into
232
+ * the step said no, so it never ran. Counting it as an error turns a branch
233
+ * that was simply not chosen into a broken workflow.
234
+ */
235
+ export type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled";
236
+ export interface PluginExecutionResult {
237
+ status: ExecutionStatus;
238
+ output: PluginOutput | null;
239
+ error?: string;
240
+ durationMs: number;
241
+ memoryMb?: number;
242
+ outputPort?: string;
243
+ logs?: string[];
244
+ }
245
+ /**
246
+ * The declaration that sits beside the code.
247
+ *
248
+ * It says which fields the canvas renders, which account the step acts as, what
249
+ * it hands downstream, and whether it is a starting point that watches
250
+ * something. One shape wherever it is stored — `manifest.json` next to your
251
+ * code, or an `@schema` comment in the source.
252
+ *
253
+ * Every name below is one the platform keeps. A block it does not recognise is
254
+ * dropped when the plugin is saved, so a field that is not listed here will not
255
+ * survive an upload.
256
+ */
257
+ export type PluginPropertyType = "string" | "number" | "boolean" | "select" | "multiselect" | "json"
258
+ /**
259
+ * A program, not a value.
260
+ *
261
+ * Stored exactly as typed and never parsed, which is the difference from
262
+ * `json` above: that one keeps its value structured, so `return 5` would be
263
+ * saved as the number five and the step would run nothing.
264
+ */
265
+ | "code" | "color" | "date"
266
+ /** An account the user connected. Rendered as a picker, not a text box. */
267
+ | "connection"
268
+ /**
269
+ * A file the account already has, read into the config before the step
270
+ * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,
271
+ * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of
272
+ * megabytes of base64 — use `path` for those.
273
+ */
274
+ | "file"
275
+ /** A file in storage, named rather than read. What ffmpeg steps take. */
276
+ | "path"
277
+ /** Several of them, one per line, in the order they were chosen. */
278
+ | "paths";
279
+ export interface PluginProperty {
280
+ /** Shown above the input on the canvas. Defaults to the property name. */
281
+ label: string;
282
+ type: PluginPropertyType;
283
+ required?: boolean;
284
+ default?: unknown;
285
+ /** Helper text under the input. */
286
+ description?: string;
287
+ /** For `select` and `multiselect`. */
288
+ options?: Array<{
289
+ label: string;
290
+ value: string;
291
+ }>;
292
+ /** Puts related fields in a section together. */
293
+ group?: string;
294
+ /** For `number`. */
295
+ min?: number;
296
+ max?: number;
297
+ /** For `string`: a regex the value must match. */
298
+ pattern?: string;
299
+ /**
300
+ * For `connection`: which integration the account must belong to, e.g.
301
+ * `outlook`. A module inherits its app's, so it does not repeat this.
302
+ */
303
+ provider?: string;
304
+ }
305
+ /**
306
+ * How a step reaches the account it acts as.
307
+ *
308
+ * `none` means the plugin holds its own key in a property. That is not the same
309
+ * as leaving the block out, which means "written before auth could be
310
+ * declared".
311
+ *
312
+ * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are
313
+ * refused outright rather than ignored, so nobody writes one and believes it
314
+ * took effect: the base URL belongs to the provider configuration, and that is
315
+ * what stops a connection being pointed at an internal address.
316
+ */
317
+ export interface PluginAuth {
318
+ type: "oauth2" | "api_key" | "basic" | "none";
319
+ provider?: string;
320
+ scopes?: string[];
321
+ }
322
+ /** One request the platform makes on your behalf. Relative paths only. */
323
+ export interface TriggerRequest {
324
+ method?: "GET" | "POST";
325
+ /** Relative to the provider's base URL. The proxy refuses anything else. */
326
+ path: string;
327
+ query?: Record<string, string>;
328
+ headers?: Record<string, string>;
329
+ body?: unknown;
330
+ }
331
+ /** Where the next poll should carry on from. */
332
+ export interface TriggerCursor {
333
+ from: "response" | "item";
334
+ /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */
335
+ path: string;
336
+ into?: string;
337
+ }
338
+ /**
339
+ * A second request per item, for providers that answer with identifiers.
340
+ *
341
+ * Gmail's message list returns `{ id, threadId }` — enough to know a mail
342
+ * arrived, not enough to tell anyone what it says. Without this the trigger
343
+ * polls correctly, dedupes correctly, and hands every workflow an empty
344
+ * message, which is the worst kind of working.
345
+ */
346
+ export interface TriggerHydrate {
347
+ path: string;
348
+ method?: "GET" | "POST";
349
+ query?: Record<string, string>;
350
+ headers?: Record<string, string>;
351
+ }
352
+ /** For providers that can tell us instead of being asked. */
353
+ export interface TriggerPush {
354
+ resource: string;
355
+ changeType: string;
356
+ maxMinutes?: number;
357
+ }
358
+ /**
359
+ * What makes a plugin a starting point.
360
+ *
361
+ * A declaration rather than code, because the runtime is billed by the second
362
+ * and a step blocking on an empty inbox would be paid for every second of doing
363
+ * nothing. The platform polls for you and starts the workflow when something
364
+ * arrives.
365
+ */
366
+ export interface PluginTrigger {
367
+ /** Must name one of your own properties of type `connection`. */
368
+ connection: string;
369
+ /** Minutes between checks. The account's plan sets the floor. */
370
+ poll?: string | number;
371
+ request: TriggerRequest;
372
+ /** Dotted path to the array of items in the response. */
373
+ items: string;
374
+ /** Dotted path, within one item, to what makes it unique. */
375
+ id: string;
376
+ cursor?: TriggerCursor;
377
+ hydrate?: TriggerHydrate;
378
+ push?: TriggerPush;
379
+ /** How many items one poll may start workflows for. */
380
+ maxPerPoll?: number;
381
+ }
382
+ /**
383
+ * A step that is a declaration rather than a program.
384
+ *
385
+ * One request built from the properties somebody filled in, or no request at
386
+ * all for a step that only reshapes what reached it. No container, no code, and
387
+ * nothing for a review to be about — which is why most steps should be one.
388
+ *
389
+ * Left loosely typed here on purpose: writing an action is a different job from
390
+ * writing a plugin, and the platform validates its shape when it is saved.
391
+ */
392
+ export type PluginAction = Record<string, unknown>;
393
+ /**
394
+ * Every block the validator keeps. Anything else is dropped on save.
395
+ *
396
+ * Note what is NOT here: the hosts a plugin may contact are set on the platform
397
+ * rather than declared in the manifest, so a plugin cannot widen its own egress.
398
+ */
399
+ export interface PluginManifest {
400
+ description?: string;
401
+ icon?: string;
402
+ color?: string;
403
+ /** Bumped by you when the declaration changes shape. */
404
+ version?: number;
405
+ /** How long one run may take. Capped by the account's plan. */
406
+ timeoutMs?: number;
407
+ memoryLimitMb?: number;
408
+ auth?: PluginAuth;
409
+ properties?: Record<string, PluginProperty>;
410
+ inputs?: string[];
411
+ outputs?: string[];
412
+ /** What downstream steps can read, described for the picker. */
413
+ outputFields?: Record<string, string>;
414
+ trigger?: PluginTrigger;
415
+ action?: PluginAction;
416
+ }
417
+ /** The older name for the same thing, kept because authors still say it. */
418
+ export type PluginSchema = PluginManifest;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * What the platform checks about a step's code before it will take it.
3
+ *
4
+ * The same checks, in the same order, with the same messages as the platform's
5
+ * upload, against the rules in ./platform-rules. A plugin that passes here is
6
+ * not refused on push for its code; one that fails here would have been.
7
+ *
8
+ * And a `require` that behaves like the sandbox's: a step gets the allowed
9
+ * packages and nothing else — no `fs`, no `http`, not even `path`.
10
+ */
11
+ import { ALLOWED_PACKAGES, RESTRICTED_PATTERNS } from "./platform-rules";
12
+ export { ALLOWED_PACKAGES, RESTRICTED_PATTERNS };
13
+ /** The names in ALLOWED_PACKAGES, for lookups. */
14
+ export declare const ALLOWED_PACKAGE_NAMES: ReadonlySet<string>;
15
+ export type CodeCheck = {
16
+ valid: true;
17
+ packages: string[];
18
+ } | {
19
+ valid: false;
20
+ error: string;
21
+ };
22
+ /**
23
+ * Check a step's code the way the platform's upload does.
24
+ *
25
+ * On success, also says which packages it requires — the ones a local run has to
26
+ * be able to load.
27
+ */
28
+ export declare function validatePluginCode(code: string): CodeCheck;
29
+ /**
30
+ * The `require` a step gets inside the sandbox, backed by a loader of your own.
31
+ *
32
+ * `load` is how this machine finds a package — in the CLI, Node's resolution
33
+ * from the project's directory. The sandbox has every allowed package installed;
34
+ * a laptop has whatever was installed there, so a missing one is reported with
35
+ * the command that installs the version the sandbox has.
36
+ */
37
+ export declare function sandboxRequire(load: (name: string) => unknown): (name: string) => unknown;
38
+ /**
39
+ * Evaluate a step's code the way the sandbox wraps it: as the body of a function
40
+ * given `module`, `exports` and `require`, in strict mode. Returns what it exported.
41
+ *
42
+ * Not a security boundary — this runs your own code on your own machine. It is
43
+ * here so a file the sandbox cannot load (an `export` statement, a top-level
44
+ * `await`) fails here too, rather than passing a looser loader.
45
+ */
46
+ export declare function loadPluginCode(code: string, require: (name: string) => unknown): unknown;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@visualautomate/plugin-sdk",
3
+ "version": "0.3.0",
4
+ "description": "Types and a local runner for building VisualAutomate plugins.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.mjs",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.mjs",
14
+ "require": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md"
20
+ ],
21
+ "scripts": {
22
+ "build": "node build.mjs",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "visualautomate",
27
+ "plugin",
28
+ "sdk",
29
+ "automation",
30
+ "workflow"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "devDependencies": {
36
+ "@types/node": "^22",
37
+ "esbuild": "^0.23.0",
38
+ "typescript": "^5"
39
+ },
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/VisualAutomate/visualautomate-sdk.git",
43
+ "directory": "packages/plugin-sdk"
44
+ },
45
+ "homepage": "https://developers.visualautomate.com",
46
+ "publishConfig": {
47
+ "access": "public"
48
+ }
49
+ }