@visualautomate/cli 0.6.1 → 0.7.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.
Files changed (3) hide show
  1. package/README.md +292 -243
  2. package/dist/index.js +2089 -427
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -2,210 +2,17 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
5
- import { existsSync as existsSync4, watch } from "node:fs";
6
- import { basename as basename3, join as join4, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
5
+ import { existsSync as existsSync5, watch } from "node:fs";
6
+ import { basename as basename3, join as join5, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
7
7
  import { createRequire } from "node:module";
8
+ import { homedir } from "node:os";
8
9
  import { build, stop as stopBundler } from "esbuild";
9
10
 
10
- // ../plugin-sdk/src/simulate.ts
11
- function toBytes(data) {
12
- return typeof data === "string" ? new TextEncoder().encode(data) : data;
13
- }
14
- function createLocalStorage(seed = {}) {
15
- const files = /* @__PURE__ */ new Map();
16
- for (const [path, data] of Object.entries(seed)) {
17
- files.set(path, {
18
- bytes: toBytes(data),
19
- contentType: "application/octet-stream",
20
- uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
21
- });
22
- }
23
- const result = (path, file2) => ({
24
- url: `memory://${path}`,
25
- size: file2.bytes.length,
26
- contentType: file2.contentType,
27
- uploadedAt: file2.uploadedAt,
28
- // The real one expires; this one does not, and says so rather than
29
- // inventing a date a test might come to rely on.
30
- expiresAt: "never (local simulation)"
31
- });
32
- const storage = {
33
- async put(path, data, contentType = "application/octet-stream") {
34
- const file2 = {
35
- bytes: toBytes(data),
36
- contentType,
37
- uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
38
- };
39
- files.set(path, file2);
40
- return result(path, file2);
41
- },
42
- async get(path) {
43
- return files.get(path)?.bytes ?? null;
44
- },
45
- async delete(path) {
46
- files.delete(path);
47
- },
48
- async list(prefix) {
49
- const listed = [];
50
- let totalSize = 0;
51
- for (const [path, file2] of files) {
52
- if (prefix && !path.startsWith(prefix)) continue;
53
- listed.push({
54
- url: `memory://${path}`,
55
- pathname: path,
56
- size: file2.bytes.length,
57
- uploadedAt: file2.uploadedAt
58
- });
59
- totalSize += file2.bytes.length;
60
- }
61
- return { files: listed, totalSize };
62
- },
63
- async head(path) {
64
- const file2 = files.get(path);
65
- return file2 ? { size: file2.bytes.length, contentType: file2.contentType } : null;
66
- },
67
- async signedUrl(path) {
68
- return files.has(path) ? `memory://${path}` : null;
69
- },
70
- /**
71
- * Really fetches, because that is the only way to find out whether the
72
- * URL a plugin builds is the URL it meant. The bytes land in the same
73
- * in-memory store the rest of these read from.
74
- */
75
- async download(url, path, contentType) {
76
- const res = await fetch(url);
77
- if (!res.ok) throw new Error(`Downloading ${url} failed with status ${res.status}`);
78
- const file2 = {
79
- bytes: new Uint8Array(await res.arrayBuffer()),
80
- contentType: contentType ?? res.headers.get("content-type") ?? "application/octet-stream",
81
- uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
82
- };
83
- files.set(path, file2);
84
- return result(path, file2);
85
- }
86
- };
87
- return { storage, files };
88
- }
89
- function createLocalState(seed = {}) {
90
- const values = new Map(Object.entries(seed));
91
- const state = {
92
- async get(key) {
93
- return values.has(key) ? values.get(key) : null;
94
- },
95
- async set(key, value) {
96
- values.set(key, value);
97
- },
98
- async delete(key) {
99
- values.delete(key);
100
- },
101
- async list(prefix) {
102
- const keys = [...values.keys()].filter((k) => !prefix || k.startsWith(prefix));
103
- return { keys };
104
- }
105
- };
106
- return { state, values };
107
- }
108
- function absent(name, what) {
109
- throw new Error(
110
- `context.${name} is not available locally. ${what} Pass a stub to simulate({ ${name}: \u2026 }) to test the code around it, or run the step on a canvas to exercise the real thing.`
111
- );
112
- }
113
- async function simulate(plugin, options = {}) {
114
- const startTime = Date.now();
115
- const execute = typeof plugin === "function" ? plugin : plugin?.execute;
116
- const { storage, files } = createLocalStorage(options.storage);
117
- const { state, values } = createLocalState(options.state);
118
- if (typeof execute !== "function") {
119
- return {
120
- status: "error",
121
- output: null,
122
- error: "This module exports no execute function. A plugin is `export default { execute }`, `module.exports.execute = \u2026`, or a default export that is the function itself.",
123
- durationMs: Date.now() - startTime,
124
- files,
125
- state: values
126
- };
127
- }
128
- const context = {
129
- workflowId: options.workflowId ?? "local-workflow",
130
- nodeId: options.nodeId ?? "local-node",
131
- userId: options.userId ?? "local-user",
132
- executionId: options.executionId ?? `local-${Date.now()}`,
133
- startedAt: new Date(startTime).toISOString(),
134
- secrets: options.secrets ?? {},
135
- storage,
136
- state,
137
- connections: options.connections ?? {
138
- get: async () => absent("connections", "It reaches an account somebody connected on the platform.")
139
- },
140
- media: options.media ?? {
141
- run: async () => absent("media", "It runs ffmpeg in a container of its own.")
142
- }
143
- };
144
- const timeoutMs = options.timeoutMs ?? 3e4;
145
- let timer;
146
- const timeout = new Promise((_, reject) => {
147
- timer = setTimeout(
148
- () => reject(new Error(`Plugin execution timed out after ${timeoutMs}ms`)),
149
- timeoutMs
150
- );
151
- });
152
- try {
153
- const raw = await Promise.race([
154
- Promise.resolve(execute(options.input ?? {}, options.config ?? {}, context)),
155
- timeout
156
- ]);
157
- const durationMs = Date.now() - startTime;
158
- if (!raw || typeof raw !== "object") {
159
- return {
160
- status: "error",
161
- output: null,
162
- error: `A plugin must return an object like { output: "success", data: {} }. This one returned ${raw === void 0 ? "undefined" : JSON.stringify(raw)}.`,
163
- durationMs,
164
- files,
165
- state: values
166
- };
167
- }
168
- const output = raw;
169
- if (typeof output.output !== "string") {
170
- return {
171
- status: "error",
172
- output: null,
173
- error: 'The returned object has no `output`. That field names which port the run leaves by \u2014 usually "success" or "error" \u2014 and it has to be one of the `outputs` in your manifest.',
174
- durationMs,
175
- files,
176
- state: values
177
- };
178
- }
179
- return {
180
- status: "success",
181
- output: { ...output, data: output.data ?? {} },
182
- durationMs,
183
- outputPort: output.output,
184
- logs: output.metadata?.logs,
185
- files,
186
- state: values
187
- };
188
- } catch (error) {
189
- const durationMs = Date.now() - startTime;
190
- const message = error instanceof Error ? error.message : String(error);
191
- return {
192
- status: message.includes("timed out") ? "timeout" : "error",
193
- output: null,
194
- error: message,
195
- durationMs,
196
- files,
197
- state: values
198
- };
199
- } finally {
200
- if (timer) clearTimeout(timer);
201
- }
202
- }
203
-
204
11
  // ../plugin-sdk/src/platform-rules.ts
205
12
  var ALLOWED_PACKAGES = [
206
13
  {
207
14
  "name": "nodemailer",
208
- "version": "^6.10.0"
15
+ "version": "^10.0.16"
209
16
  },
210
17
  {
211
18
  "name": "lodash",
@@ -421,7 +228,7 @@ var ALLOWED_PACKAGES = [
421
228
  },
422
229
  {
423
230
  "name": "uuid",
424
- "version": "^11.0.5"
231
+ "version": "^11.1.1"
425
232
  },
426
233
  {
427
234
  "name": "nanoid",
@@ -709,7 +516,7 @@ var ALLOWED_PACKAGES = [
709
516
  },
710
517
  {
711
518
  "name": "fast-xml-parser",
712
- "version": "^4.5.1"
519
+ "version": "^5.11.2"
713
520
  },
714
521
  {
715
522
  "name": "xml2js",
@@ -881,7 +688,7 @@ var ALLOWED_PACKAGES = [
881
688
  },
882
689
  {
883
690
  "name": "diff",
884
- "version": "^7.0.0"
691
+ "version": "^8.0.4"
885
692
  },
886
693
  {
887
694
  "name": "diff-match-patch",
@@ -1259,155 +1066,1913 @@ var ALLOWED_PACKAGES = [
1259
1066
  "name": "p-settle",
1260
1067
  "version": "^5.2.1"
1261
1068
  },
1262
- {
1263
- "name": "p-throttle",
1264
- "version": "^8.1.0"
1069
+ {
1070
+ "name": "p-throttle",
1071
+ "version": "^8.1.0"
1072
+ },
1073
+ {
1074
+ "name": "p-debounce",
1075
+ "version": "^5.1.0"
1076
+ },
1077
+ {
1078
+ "name": "async",
1079
+ "version": "^3.2.6"
1080
+ },
1081
+ {
1082
+ "name": "bluebird",
1083
+ "version": "^3.7.2"
1084
+ },
1085
+ {
1086
+ "name": "promise-retry",
1087
+ "version": "^2.0.1"
1088
+ },
1089
+ {
1090
+ "name": "retry",
1091
+ "version": "^0.13.1"
1092
+ },
1093
+ {
1094
+ "name": "exponential-backoff",
1095
+ "version": "^3.1.3"
1096
+ },
1097
+ {
1098
+ "name": "bottleneck",
1099
+ "version": "^2.19.5"
1100
+ },
1101
+ {
1102
+ "name": "throttle-debounce",
1103
+ "version": "^5.0.2"
1104
+ },
1105
+ {
1106
+ "name": "delay",
1107
+ "version": "^7.0.0"
1108
+ },
1109
+ {
1110
+ "name": "graphlib",
1111
+ "version": "^2.1.8"
1112
+ }
1113
+ ];
1114
+ var RESTRICTED_PATTERNS = [
1115
+ /\bprocess\s*\.\s*exit/,
1116
+ /\bprocess\s*\.\s*env/,
1117
+ /\bprocess\s*\.\s*binding/,
1118
+ /\bprocess\s*\.\s*mainModule/,
1119
+ /\bprocess\s*\[/,
1120
+ /\bprocess\s*\(/,
1121
+ /\beval\s*\(/,
1122
+ /\bFunction\s*\(/,
1123
+ /\bnew\s+Function\b/,
1124
+ /\bAsyncFunction\b/,
1125
+ /\bGeneratorFunction\b/,
1126
+ /\bglobalThis\b/,
1127
+ /\bself\s*\[/,
1128
+ /\bimport\s+/,
1129
+ /\bimport\s*\(/,
1130
+ /\bReflect\b/,
1131
+ /\bProxy\b/,
1132
+ /\b__proto__\b/,
1133
+ /\bprototype\s*\[/,
1134
+ /\bprototype\s*\.\s*constructor\b/,
1135
+ /\bconstructor\b/,
1136
+ /\bthis\s*\.\s*constructor\b/,
1137
+ /\barguments\s*\.\s*callee\b/,
1138
+ /\bObject\s*\.\s*defineProperty\b/,
1139
+ /\b__defineGetter__\b/,
1140
+ /\b__defineSetter__\b/,
1141
+ /\b__lookupGetter__\b/,
1142
+ /\b__lookupSetter__\b/,
1143
+ /\bmodule\s*\.\s*(?:constructor|extensions|_compile|_path|_sourceMaps)\b/,
1144
+ /\brequire\s*\.\s*cache\b/,
1145
+ /\brequire\s*\.\s*resolve\b/,
1146
+ /\bError\s*\.\s*captureStackTrace\b/,
1147
+ /\bWebAssembly\b/
1148
+ ];
1149
+ var MAX_CODE_LENGTH = 1e5;
1150
+ var PLATFORM_NODE_MAJOR = 24;
1151
+ var PROVIDED_GLOBALS = [
1152
+ "fetch",
1153
+ "Request",
1154
+ "Response",
1155
+ "Headers",
1156
+ "URL",
1157
+ "URLSearchParams",
1158
+ "btoa",
1159
+ "atob",
1160
+ "console",
1161
+ "setTimeout",
1162
+ "clearTimeout",
1163
+ "setInterval",
1164
+ "clearInterval",
1165
+ "crypto",
1166
+ "TextEncoder",
1167
+ "TextDecoder",
1168
+ "ArrayBuffer",
1169
+ "Uint8Array",
1170
+ "Uint16Array",
1171
+ "Uint32Array",
1172
+ "Int8Array",
1173
+ "Int16Array",
1174
+ "Int32Array",
1175
+ "Float32Array",
1176
+ "Float64Array",
1177
+ "DataView",
1178
+ "Blob",
1179
+ "FormData",
1180
+ "AbortController",
1181
+ "AbortSignal",
1182
+ "require"
1183
+ ];
1184
+ var BLOCKED_GLOBAL_NAMES = [
1185
+ "process",
1186
+ "global",
1187
+ "module",
1188
+ "exports",
1189
+ "__dirname",
1190
+ "__filename",
1191
+ "eval",
1192
+ "Function",
1193
+ "child_process",
1194
+ "fs",
1195
+ "os",
1196
+ "cluster",
1197
+ "dgram",
1198
+ "dns",
1199
+ "net",
1200
+ "http",
1201
+ "https",
1202
+ "path",
1203
+ "readline",
1204
+ "stream",
1205
+ "tls",
1206
+ "zlib",
1207
+ "buffer",
1208
+ "Buffer",
1209
+ "globalThis",
1210
+ "self",
1211
+ "window",
1212
+ "SharedArrayBuffer",
1213
+ "Atomics",
1214
+ "WeakRef",
1215
+ "FinalizationRegistry",
1216
+ "AsyncFunction",
1217
+ "GeneratorFunction",
1218
+ "AsyncGeneratorFunction",
1219
+ "WebAssembly"
1220
+ ];
1221
+ var SANDBOX_TIERS = [
1222
+ {
1223
+ "id": "standard",
1224
+ "label": "Standard",
1225
+ "vcpus": 0.5,
1226
+ "memoryGb": 4
1227
+ },
1228
+ {
1229
+ "id": "boosted",
1230
+ "label": "Boosted",
1231
+ "vcpus": 1,
1232
+ "memoryGb": 6
1233
+ },
1234
+ {
1235
+ "id": "high",
1236
+ "label": "High",
1237
+ "vcpus": 2,
1238
+ "memoryGb": 8
1239
+ }
1240
+ ];
1241
+ var RETIRED_TIERS = {
1242
+ "max": "high"
1243
+ };
1244
+ var STATE_LIMITS = {
1245
+ /** One value, once encoded as JSON. */
1246
+ maxValueBytes: 128 * 1024,
1247
+ /** The shortest `ttlSeconds` the store accepts. */
1248
+ minTtlSeconds: 60
1249
+ };
1250
+ var SIGNED_URL_SECONDS = { default: 900, min: 60, max: 3600 };
1251
+ var PUSH_CHECK = {
1252
+ /** When manifest.json names no `timeoutMs`. The test file's is not read. */
1253
+ defaultTimeoutMs: 3e4,
1254
+ /** The longest `timeoutMs` a manifest is given. */
1255
+ maxTimeoutMs: 6e4,
1256
+ /** The port a run must leave by when the test file names none. */
1257
+ defaultExpectOutput: "success",
1258
+ /** A declared step (an `action` block) is tested in the portal's editor, not on a push. */
1259
+ declaredStepsTestedOnPush: false
1260
+ };
1261
+
1262
+ // ../plugin-sdk/src/runtime.ts
1263
+ import vm from "node:vm";
1264
+ function pluginConsole(target = console) {
1265
+ const forward = (level) => (...args) => {
1266
+ const write = target[level] ?? target.log ?? console.log;
1267
+ write.apply(target, args);
1268
+ };
1269
+ return { log: forward("log"), error: forward("error"), warn: forward("warn"), info: forward("info") };
1270
+ }
1271
+ function sandboxGlobals(options = {}) {
1272
+ const host = globalThis;
1273
+ const globals = {};
1274
+ for (const name of PROVIDED_GLOBALS) {
1275
+ if (name === "require") continue;
1276
+ if (name === "console") globals.console = pluginConsole(options.console);
1277
+ else if (name === "fetch") globals.fetch = options.fetch ?? host.fetch;
1278
+ else globals[name] = host[name];
1279
+ }
1280
+ for (const name of BLOCKED_GLOBAL_NAMES) globals[name] = void 0;
1281
+ return globals;
1282
+ }
1283
+ function loadPluginCode(code, require2, options = {}) {
1284
+ const sandbox = sandboxGlobals(options);
1285
+ Object.defineProperty(sandbox, "constructor", { value: void 0, writable: false, configurable: false });
1286
+ const wrapped = '(function(module, exports, context, require) { "use strict"; var console = context.console; ' + code + " })";
1287
+ const script = new vm.Script(wrapped, { filename: "plugin.js" });
1288
+ const context = vm.createContext(sandbox, { name: "plugin-sandbox" });
1289
+ const fn = script.runInContext(context, { timeout: options.timeoutMs ?? 1e4 });
1290
+ const moduleObject = { exports: {} };
1291
+ fn(moduleObject, moduleObject.exports, { ...sandbox }, require2);
1292
+ return moduleObject.exports;
1293
+ }
1294
+ function executeOf(exported) {
1295
+ if (typeof exported === "function") return exported;
1296
+ const object = exported;
1297
+ if (object && typeof object.execute === "function") return object.execute;
1298
+ if (object && typeof object.default === "function") return object.default;
1299
+ return null;
1300
+ }
1301
+
1302
+ // ../plugin-sdk/src/simulate.ts
1303
+ function toBytes(data) {
1304
+ return typeof data === "string" ? new TextEncoder().encode(data) : data;
1305
+ }
1306
+ function createLocalStorage(seed = {}) {
1307
+ const files = /* @__PURE__ */ new Map();
1308
+ for (const [path, data] of Object.entries(seed)) {
1309
+ files.set(path, {
1310
+ bytes: toBytes(data),
1311
+ contentType: "application/octet-stream",
1312
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1313
+ });
1314
+ }
1315
+ const result = (path, file2) => ({
1316
+ url: `memory://${path}`,
1317
+ size: file2.bytes.length,
1318
+ contentType: file2.contentType,
1319
+ uploadedAt: file2.uploadedAt,
1320
+ // The real one expires; this one does not, and says so rather than
1321
+ // inventing a date a test might come to rely on.
1322
+ expiresAt: "never (local simulation)"
1323
+ });
1324
+ const storage = {
1325
+ async put(path, data, contentType = "application/octet-stream") {
1326
+ const file2 = {
1327
+ bytes: toBytes(data),
1328
+ contentType,
1329
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1330
+ };
1331
+ files.set(path, file2);
1332
+ return result(path, file2);
1333
+ },
1334
+ async get(path) {
1335
+ return files.get(path)?.bytes ?? null;
1336
+ },
1337
+ async delete(path) {
1338
+ files.delete(path);
1339
+ },
1340
+ async list(prefix) {
1341
+ const listed = [];
1342
+ let totalSize = 0;
1343
+ for (const [path, file2] of files) {
1344
+ if (prefix && !path.startsWith(prefix)) continue;
1345
+ listed.push({
1346
+ url: `memory://${path}`,
1347
+ pathname: path,
1348
+ size: file2.bytes.length,
1349
+ uploadedAt: file2.uploadedAt
1350
+ });
1351
+ totalSize += file2.bytes.length;
1352
+ }
1353
+ return { files: listed, totalSize };
1354
+ },
1355
+ async head(path) {
1356
+ const file2 = files.get(path);
1357
+ return file2 ? { size: file2.bytes.length, contentType: file2.contentType } : null;
1358
+ },
1359
+ async signedUrl(path, seconds) {
1360
+ const valid = Math.min(Math.max(Number(seconds) || SIGNED_URL_SECONDS.default, SIGNED_URL_SECONDS.min), SIGNED_URL_SECONDS.max);
1361
+ return files.has(path) ? `memory://${path}?expires=${valid}` : null;
1362
+ },
1363
+ async shareUrl(path, seconds) {
1364
+ const valid = Number(seconds) || SIGNED_URL_SECONDS.default;
1365
+ return files.has(path) ? `memory://share/${path}?expires=${valid}` : null;
1366
+ },
1367
+ /**
1368
+ * Really fetches, because that is the only way to find out whether the
1369
+ * URL a plugin builds is the URL it meant. The bytes land in the same
1370
+ * in-memory store the rest of these read from.
1371
+ */
1372
+ async download(url, path, contentType) {
1373
+ const res = await fetch(url);
1374
+ if (!res.ok) throw new Error(`Downloading ${url} failed with status ${res.status}`);
1375
+ const file2 = {
1376
+ bytes: new Uint8Array(await res.arrayBuffer()),
1377
+ contentType: contentType ?? res.headers.get("content-type") ?? "application/octet-stream",
1378
+ uploadedAt: (/* @__PURE__ */ new Date()).toISOString()
1379
+ };
1380
+ files.set(path, file2);
1381
+ return result(path, file2);
1382
+ }
1383
+ };
1384
+ return { storage, files };
1385
+ }
1386
+ function createLocalState(seed = {}) {
1387
+ const values = new Map(Object.entries(seed));
1388
+ const expiries = /* @__PURE__ */ new Map();
1389
+ const expired = (key) => {
1390
+ const at = expiries.get(key);
1391
+ if (at === void 0 || at > Date.now()) return false;
1392
+ values.delete(key);
1393
+ expiries.delete(key);
1394
+ return true;
1395
+ };
1396
+ const state = {
1397
+ async get(key) {
1398
+ if (expired(key)) return null;
1399
+ return values.has(key) ? values.get(key) : null;
1400
+ },
1401
+ async set(key, value, options) {
1402
+ const encoded = JSON.stringify(value);
1403
+ if (encoded === void 0) throw new Error(`state.set("${key}"): the value must be JSON-serialisable.`);
1404
+ const bytes = new TextEncoder().encode(encoded).length;
1405
+ if (bytes > STATE_LIMITS.maxValueBytes) {
1406
+ throw new Error(
1407
+ `state.set("${key}"): the value is ${Math.ceil(bytes / 1024)} KB once encoded, and a value may be at most ${STATE_LIMITS.maxValueBytes / 1024} KB. Put large data in storage and keep its path here.`
1408
+ );
1409
+ }
1410
+ const ttl = options?.ttlSeconds;
1411
+ if (ttl !== void 0 && (typeof ttl !== "number" || ttl < STATE_LIMITS.minTtlSeconds)) {
1412
+ throw new Error(`state.set("${key}"): ttlSeconds must be at least ${STATE_LIMITS.minTtlSeconds}.`);
1413
+ }
1414
+ values.set(key, JSON.parse(encoded));
1415
+ if (ttl !== void 0) expiries.set(key, Date.now() + ttl * 1e3);
1416
+ else expiries.delete(key);
1417
+ },
1418
+ async delete(key) {
1419
+ values.delete(key);
1420
+ expiries.delete(key);
1421
+ },
1422
+ async list(prefix) {
1423
+ const keys = [...values.keys()].filter((key) => !expired(key) && (!prefix || key.startsWith(prefix))).map((key) => {
1424
+ const at = expiries.get(key);
1425
+ return { key, expiresAt: at === void 0 ? null : new Date(at).toISOString() };
1426
+ });
1427
+ return { keys, complete: true };
1428
+ }
1429
+ };
1430
+ return { state, values };
1431
+ }
1432
+ function absent(name, what) {
1433
+ throw new Error(
1434
+ `context.${name} is not available locally. ${what} Pass a stub to simulate({ ${name}: \u2026 }) to test the code around it, or run the step on a canvas to exercise the real thing.`
1435
+ );
1436
+ }
1437
+ async function simulate(plugin, options = {}) {
1438
+ const startTime = Date.now();
1439
+ const execute = executeOf(plugin);
1440
+ const { storage, files } = createLocalStorage(options.storage);
1441
+ const { state, values } = createLocalState(options.state);
1442
+ if (typeof execute !== "function") {
1443
+ return {
1444
+ status: "error",
1445
+ output: null,
1446
+ error: "Plugin must export an execute function: `module.exports = { execute }`, `module.exports.execute = \u2026`, or a function as `module.exports` or `module.exports.default`.",
1447
+ durationMs: Date.now() - startTime,
1448
+ files,
1449
+ state: values
1450
+ };
1451
+ }
1452
+ const context = {
1453
+ workflowId: options.workflowId ?? "local-workflow",
1454
+ nodeId: options.nodeId ?? "local-node",
1455
+ userId: options.userId ?? "local-user",
1456
+ executionId: options.executionId ?? `local-${Date.now()}`,
1457
+ startedAt: new Date(startTime).toISOString(),
1458
+ secrets: options.secrets ?? {},
1459
+ storage,
1460
+ state,
1461
+ // Absent unless a stub is given, as on the platform when no account was
1462
+ // chosen for the step: the push check runs a step with only the accounts
1463
+ // its test file names, and none can be named here.
1464
+ connections: options.connections,
1465
+ media: options.media ?? {
1466
+ run: async () => absent("media", "It runs ffmpeg in a container of its own.")
1467
+ }
1468
+ };
1469
+ const timeoutMs = options.timeoutMs ?? PUSH_CHECK.defaultTimeoutMs;
1470
+ let timer;
1471
+ const timeout = new Promise((_, reject) => {
1472
+ timer = setTimeout(
1473
+ () => reject(new Error(`Plugin execution timed out after ${timeoutMs}ms`)),
1474
+ timeoutMs
1475
+ );
1476
+ });
1477
+ try {
1478
+ const raw = await Promise.race([
1479
+ Promise.resolve(execute(options.input ?? {}, options.config ?? {}, context)),
1480
+ timeout
1481
+ ]);
1482
+ const durationMs = Date.now() - startTime;
1483
+ if (!raw || typeof raw !== "object") {
1484
+ return {
1485
+ status: "error",
1486
+ output: null,
1487
+ error: `A plugin must return an object like { output: "success", data: {} }. This one returned ${raw === void 0 ? "undefined" : JSON.stringify(raw)}.`,
1488
+ durationMs,
1489
+ files,
1490
+ state: values
1491
+ };
1492
+ }
1493
+ const output = raw;
1494
+ if (typeof output.output !== "string") {
1495
+ return {
1496
+ status: "error",
1497
+ output: null,
1498
+ error: 'The returned object has no `output`. That field names which port the run leaves by \u2014 usually "success" or "error" \u2014 and it has to be one of the `outputs` in your manifest.',
1499
+ durationMs,
1500
+ files,
1501
+ state: values
1502
+ };
1503
+ }
1504
+ return {
1505
+ status: "success",
1506
+ output: { ...output, data: output.data ?? {} },
1507
+ durationMs,
1508
+ outputPort: output.output,
1509
+ logs: output.metadata?.logs,
1510
+ files,
1511
+ state: values
1512
+ };
1513
+ } catch (error) {
1514
+ const durationMs = Date.now() - startTime;
1515
+ const message = error && typeof error === "object" && typeof error.message === "string" ? error.message : String(error);
1516
+ return {
1517
+ status: message.includes("timed out") ? "timeout" : "error",
1518
+ output: null,
1519
+ error: message,
1520
+ durationMs,
1521
+ files,
1522
+ state: values
1523
+ };
1524
+ } finally {
1525
+ if (timer) clearTimeout(timer);
1526
+ }
1527
+ }
1528
+
1529
+ // ../plugin-sdk/src/validate.ts
1530
+ var ALLOWED_PACKAGE_NAMES = new Set(ALLOWED_PACKAGES.map((p) => p.name));
1531
+ function validatePluginCode(code) {
1532
+ if (code.length > MAX_CODE_LENGTH) {
1533
+ return { valid: false, error: `Plugin code exceeds maximum length of ${Math.round(MAX_CODE_LENGTH / 1e3)}KB.` };
1534
+ }
1535
+ const hasExecute = code.includes("module.exports") || code.includes("exports.execute") || code.includes("export");
1536
+ if (!hasExecute) {
1537
+ return {
1538
+ valid: false,
1539
+ error: "Plugin code must export an execute function. Example: module.exports = { execute: async (input, config, context) => { ... } }"
1540
+ };
1541
+ }
1542
+ for (const pattern of RESTRICTED_PATTERNS) {
1543
+ if (pattern.test(code)) {
1544
+ return {
1545
+ valid: false,
1546
+ error: `Plugin code contains restricted patterns. Plugins cannot access the filesystem, spawn processes, use eval, import modules, or escape the sandbox. (matched ${pattern})`
1547
+ };
1548
+ }
1549
+ }
1550
+ const packages = [];
1551
+ const requireCall = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
1552
+ let match;
1553
+ while ((match = requireCall.exec(code)) !== null) {
1554
+ const name = match[2];
1555
+ if (!ALLOWED_PACKAGE_NAMES.has(name)) {
1556
+ return {
1557
+ valid: false,
1558
+ error: `Plugin code requires "${name}" which is not an allowed package. See ALLOWED_PACKAGES for the list.`
1559
+ };
1560
+ }
1561
+ if (!packages.includes(name)) packages.push(name);
1562
+ }
1563
+ if (/\brequire\s*\(\s*(?!['"][^'"]+['"]\s*\))/.test(code)) {
1564
+ return {
1565
+ valid: false,
1566
+ error: "Plugin code contains a dynamic require() call. Only require() with string literals of allowed packages is permitted."
1567
+ };
1568
+ }
1569
+ return { valid: true, packages };
1570
+ }
1571
+ function sandboxRequire(load) {
1572
+ return (name) => {
1573
+ const allowed = ALLOWED_PACKAGES.find((p) => p.name === name);
1574
+ if (!allowed) throw new Error(`Cannot require "${name}"`);
1575
+ try {
1576
+ return load(name);
1577
+ } catch {
1578
+ throw new Error(
1579
+ `"${name}" is available in the sandbox but not installed here. To run this locally: npm install --save-dev ${name}@${allowed.version}`
1580
+ );
1581
+ }
1582
+ };
1583
+ }
1584
+
1585
+ // ../plugin-sdk/src/manifest-check.ts
1586
+ var KNOWN_BLOCKS = /* @__PURE__ */ new Set([
1587
+ "properties",
1588
+ "inputs",
1589
+ "outputs",
1590
+ "outputFields",
1591
+ "auth",
1592
+ "trigger",
1593
+ "action",
1594
+ "timeoutMs",
1595
+ "memoryLimitMb",
1596
+ "icon",
1597
+ "color",
1598
+ "description",
1599
+ "version"
1600
+ ]);
1601
+ var PROPERTY_TYPES = /* @__PURE__ */ new Set([
1602
+ "string",
1603
+ "number",
1604
+ "boolean",
1605
+ "select",
1606
+ "multiselect",
1607
+ "json",
1608
+ "code",
1609
+ "color",
1610
+ "date",
1611
+ "connection",
1612
+ "file",
1613
+ "path",
1614
+ "paths",
1615
+ "secret"
1616
+ ]);
1617
+ var AUTH_TYPES = /* @__PURE__ */ new Set(["oauth2", "api_key", "basic", "none"]);
1618
+ var HOST_KEYS = ["url", "baseUrl", "base_url", "host"];
1619
+ var METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
1620
+ var BODY_TYPES = /* @__PURE__ */ new Set(["json", "form", "multipart"]);
1621
+ var PAGINATE_FROM = /* @__PURE__ */ new Set(["response", "item", "page", "offset"]);
1622
+ var MAX_ITEM_LIMIT = 1e3;
1623
+ var MAX_PAGES = 50;
1624
+ var MAX_MEMORY_MB = 128;
1625
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1626
+ var isRelativePath = (path) => typeof path === "string" && path.trim() !== "" && !/^[a-z][a-z0-9+.-]*:/i.test(path) && !path.startsWith("//");
1627
+ function checkManifest(raw, hasCode) {
1628
+ const errors = [];
1629
+ const warnings = [];
1630
+ if (!isObject(raw)) {
1631
+ return { ok: false, kind: hasCode ? "code" : "declared", errors: ["manifest.json must be a JSON object."], warnings };
1632
+ }
1633
+ const m = raw;
1634
+ const kind = isObject(m.trigger) ? "trigger" : isObject(m.action) ? isObject(m.action.request) ? "declared" : "outputs-only" : "code";
1635
+ for (const key of Object.keys(m)) {
1636
+ if (!KNOWN_BLOCKS.has(key)) warnings.push(`"${key}" is not something a manifest can declare, and is dropped when the step is saved.`);
1637
+ }
1638
+ const properties = isObject(m.properties) ? m.properties : {};
1639
+ if (m.properties !== void 0 && !isObject(m.properties)) errors.push("properties must be an object of fields.");
1640
+ const typeOf = (name) => typeof name === "string" && isObject(properties[name]) ? properties[name].type : void 0;
1641
+ for (const [key, value] of Object.entries(properties)) {
1642
+ if (!isObject(value)) {
1643
+ errors.push(`properties.${key} must be an object with at least a label and a type.`);
1644
+ continue;
1645
+ }
1646
+ if (typeof value.type !== "string" || !PROPERTY_TYPES.has(value.type)) {
1647
+ warnings.push(`properties.${key} has type ${JSON.stringify(value.type)}, which the platform does not know; it becomes a plain text field.`);
1648
+ }
1649
+ if (typeof value.label !== "string" || !value.label.trim()) {
1650
+ warnings.push(`properties.${key} has no label; the canvas shows "${key}" instead.`);
1651
+ }
1652
+ if ((value.type === "select" || value.type === "multiselect") && value.options === void 0 && value.optionsFrom === void 0) {
1653
+ warnings.push(`properties.${key} is a ${value.type} with no options and no optionsFrom; there is nothing to choose.`);
1654
+ }
1655
+ if (value.options !== void 0) {
1656
+ const options = Array.isArray(value.options) ? value.options : null;
1657
+ if (!options || options.some((o) => !isObject(o) || typeof o.label !== "string" || typeof o.value !== "string")) {
1658
+ errors.push(`properties.${key}.options must be a list of { "label": "\u2026", "value": "\u2026" }, both text.`);
1659
+ }
1660
+ }
1661
+ if (value.optionsFrom !== void 0) checkOptionsFrom(key, value, typeOf, errors);
1662
+ }
1663
+ if (m.auth !== void 0) {
1664
+ if (!isObject(m.auth)) errors.push("auth must be an object.");
1665
+ else {
1666
+ if (typeof m.auth.type !== "string" || !AUTH_TYPES.has(m.auth.type)) {
1667
+ errors.push(`auth.type must be one of ${[...AUTH_TYPES].join(", ")}.`);
1668
+ }
1669
+ for (const key of HOST_KEYS) {
1670
+ if (key in m.auth) errors.push(`auth.${key} is refused: a manifest may not name a host. The provider's base URL belongs to the platform.`);
1671
+ }
1672
+ }
1673
+ }
1674
+ for (const ports of ["inputs", "outputs"]) {
1675
+ if (m[ports] !== void 0 && (!Array.isArray(m[ports]) || m[ports].some((p) => typeof p !== "string"))) {
1676
+ errors.push(`${ports} must be a list of port names.`);
1677
+ }
1678
+ }
1679
+ if (Array.isArray(m.outputs) && m.outputs.length === 0 && kind !== "trigger") {
1680
+ warnings.push("outputs is empty: no line can be drawn from this step.");
1681
+ }
1682
+ if (m.timeoutMs !== void 0) {
1683
+ if (typeof m.timeoutMs !== "number" || m.timeoutMs <= 0) errors.push("timeoutMs must be a number of milliseconds.");
1684
+ else if (m.timeoutMs > PUSH_CHECK.maxTimeoutMs) warnings.push(`timeoutMs ${m.timeoutMs} is cut to ${PUSH_CHECK.maxTimeoutMs}, the most a step is given.`);
1685
+ }
1686
+ if (typeof m.memoryLimitMb === "number" && m.memoryLimitMb > MAX_MEMORY_MB) {
1687
+ warnings.push(`memoryLimitMb ${m.memoryLimitMb} is cut to ${MAX_MEMORY_MB}.`);
1688
+ }
1689
+ if (m.action !== void 0) {
1690
+ if (!isObject(m.action)) errors.push("action must be an object.");
1691
+ else checkAction(m.action, properties, typeOf, errors, warnings);
1692
+ if (hasCode) errors.push("This step has both an action block and code. A step is one or the other: remove index.js, or the action block.");
1693
+ }
1694
+ if (m.trigger !== void 0) {
1695
+ if (!isObject(m.trigger)) errors.push("trigger must be an object.");
1696
+ else checkTrigger(m.trigger, typeOf, errors, warnings);
1697
+ }
1698
+ if (kind === "code" && !hasCode) {
1699
+ errors.push("There is no index.js and no action or trigger block, so this step has nothing to run.");
1700
+ }
1701
+ return { ok: errors.length === 0, kind, errors, warnings };
1702
+ }
1703
+ function checkConnectionName(where, name, typeOf, errors) {
1704
+ if (typeof name !== "string" || !name) {
1705
+ errors.push(`${where} must name one of this step's properties of type "connection".`);
1706
+ } else if (typeOf(name) === void 0) {
1707
+ errors.push(`${where} names "${name}", which is not one of this step's properties.`);
1708
+ } else if (typeOf(name) !== "connection") {
1709
+ errors.push(`${where} names "${name}", which is a ${String(typeOf(name))} property rather than a connection.`);
1710
+ }
1711
+ }
1712
+ function checkAction(a, properties, typeOf, errors, warnings) {
1713
+ const request = a.request;
1714
+ if (request !== void 0) {
1715
+ if (!isObject(request)) {
1716
+ errors.push("action.request must be an object.");
1717
+ return;
1718
+ }
1719
+ const viaConnection = a.connection !== void 0;
1720
+ const viaBaseUrl = a.baseUrl !== void 0;
1721
+ if (viaConnection && viaBaseUrl) errors.push("action has both connection and baseUrl: a request goes as an account or to a named host, never both.");
1722
+ if (!viaConnection && !viaBaseUrl) errors.push("action.request needs either connection (an account to act as) or baseUrl (the https:// host to call).");
1723
+ if (viaConnection) checkConnectionName("action.connection", a.connection, typeOf, errors);
1724
+ if (viaBaseUrl) {
1725
+ if (typeof a.baseUrl !== "string" || !/^https:\/\//i.test(a.baseUrl)) errors.push("action.baseUrl must start with https://.");
1726
+ else if (/\{\{\s*input\./.test(a.baseUrl)) errors.push("action.baseUrl may read the step's fields ({{config.\u2026}}) but never {{input.\u2026}}: what an earlier step hands on must not decide where a key is sent.");
1727
+ }
1728
+ const method = String(request.method ?? "GET").toUpperCase();
1729
+ if (!METHODS.has(method)) errors.push(`action.request.method must be one of ${[...METHODS].join(", ")}.`);
1730
+ if (!isRelativePath(request.path)) errors.push("action.request.path must be a path relative to the base URL, never a full URL.");
1731
+ if (request.bodyType !== void 0 && !BODY_TYPES.has(String(request.bodyType))) {
1732
+ errors.push(`action.request.bodyType must be one of ${[...BODY_TYPES].join(", ")}.`);
1733
+ }
1734
+ if (request.body !== void 0 && method === "GET") warnings.push("action.request has a body on a GET; most APIs ignore it.");
1735
+ if (viaConnection && isObject(request.headers)) {
1736
+ for (const header of Object.keys(request.headers)) {
1737
+ if (header.toLowerCase() === "authorization") warnings.push("action.request sets Authorization while acting as an account; the platform adds the account's credential itself.");
1738
+ }
1739
+ }
1740
+ }
1741
+ if (a.outputs !== void 0 && !isObject(a.outputs)) errors.push("action.outputs must be an object of name to value.");
1742
+ if (a.outputs === void 0) warnings.push("action has no outputs: the step hands nothing on.");
1743
+ for (const key of ["errorPath", "okPath", "okValue"]) {
1744
+ if (a[key] !== void 0 && typeof a[key] !== "string") errors.push(`action.${key} must be text.`);
1745
+ }
1746
+ if (a.items !== void 0) {
1747
+ if (!isObject(a.items) || typeof a.items.path !== "string") errors.push('action.items must be { "path": "\u2026" }, with "" when the answer is the list itself.');
1748
+ else if (a.items.limit !== void 0 && (typeof a.items.limit !== "number" || a.items.limit < 1 || a.items.limit > MAX_ITEM_LIMIT)) {
1749
+ errors.push(`action.items.limit must be a number from 1 to ${MAX_ITEM_LIMIT}.`);
1750
+ }
1751
+ }
1752
+ if (a.paginate !== void 0) {
1753
+ if (!isObject(a.paginate) || !PAGINATE_FROM.has(String(a.paginate.from))) {
1754
+ errors.push(`action.paginate.from must be one of ${[...PAGINATE_FROM].join(", ")}.`);
1755
+ } else if (a.paginate.maxPages !== void 0 && (typeof a.paginate.maxPages !== "number" || a.paginate.maxPages > MAX_PAGES)) {
1756
+ errors.push(`action.paginate.maxPages must be at most ${MAX_PAGES}.`);
1757
+ }
1758
+ if (a.items === void 0) errors.push("action.paginate needs action.items: pages are pages of a list.");
1759
+ }
1760
+ const text = JSON.stringify({ baseUrl: a.baseUrl, request: a.request, outputs: a.outputs });
1761
+ for (const [, key] of text.matchAll(/\{\{\s*config\.([A-Za-z0-9_$]+)/g)) {
1762
+ if (!(key in properties)) warnings.push(`{{config.${key}}} names no property; it is always empty.`);
1763
+ }
1764
+ }
1765
+ function checkTrigger(t, typeOf, errors, warnings) {
1766
+ checkConnectionName("trigger.connection", t.connection, typeOf, errors);
1767
+ if (!isObject(t.request) || !isRelativePath(t.request.path)) errors.push("trigger.request.path must be a path relative to the provider.");
1768
+ else if (t.request.method !== void 0 && !["GET", "POST"].includes(String(t.request.method).toUpperCase())) {
1769
+ errors.push("trigger.request.method must be GET or POST.");
1770
+ }
1771
+ if (typeof t.items !== "string") errors.push('trigger.items must be the dotted path to the list in the answer ("" when the answer is the list).');
1772
+ if (typeof t.id !== "string" || !t.id) errors.push("trigger.id must be the dotted path, within an item, to what makes it unique.");
1773
+ if (t.poll !== void 0 && typeof t.poll !== "number" && (typeof t.poll !== "string" || typeOf(t.poll) === void 0)) {
1774
+ errors.push("trigger.poll must be a number of minutes, or the name of a property that holds one.");
1775
+ }
1776
+ if (t.cursor !== void 0) {
1777
+ if (!isObject(t.cursor) || !["response", "item"].includes(String(t.cursor.from)) || typeof t.cursor.path !== "string") {
1778
+ errors.push('trigger.cursor must be { "from": "response" | "item", "path": "\u2026" }.');
1779
+ } else if (t.cursor.initial !== "now") {
1780
+ warnings.push(`trigger.cursor has no "initial": "now": switching the trigger on starts a run, at the user's cost, for every item already there.`);
1781
+ }
1782
+ }
1783
+ if (t.hydrate !== void 0 && (!isObject(t.hydrate) || !isRelativePath(t.hydrate.path))) {
1784
+ errors.push("trigger.hydrate.path must be a path relative to the provider.");
1785
+ }
1786
+ if (t.maxPerPoll !== void 0 && (typeof t.maxPerPoll !== "number" || t.maxPerPoll < 1)) errors.push("trigger.maxPerPoll must be a positive number.");
1787
+ }
1788
+ function checkOptionsFrom(key, property, typeOf, errors) {
1789
+ const where = `properties.${key}.optionsFrom`;
1790
+ if (property.type !== "select" && property.type !== "multiselect") {
1791
+ errors.push(`${where} only means something on a select or multiselect, and ${key} is a ${String(property.type)}.`);
1792
+ }
1793
+ const o = property.optionsFrom;
1794
+ if (!isObject(o)) {
1795
+ errors.push(`${where} must be an object.`);
1796
+ return;
1797
+ }
1798
+ checkConnectionName(`${where}.connection`, o.connection, typeOf, errors);
1799
+ if (typeof o.label !== "string" || !o.label.trim()) errors.push(`${where}.label must say what each option shows, e.g. {{item.name}}.`);
1800
+ if (typeof o.value !== "string" || !o.value.trim()) errors.push(`${where}.value must say what choosing it stores, e.g. {{item.id}}.`);
1801
+ if (!isObject(o.request) || !isRelativePath(o.request.path)) errors.push(`${where}.request.path must be a path relative to the provider.`);
1802
+ else {
1803
+ if (o.request.method !== void 0 && String(o.request.method).toUpperCase() !== "GET") errors.push(`${where}.request must be a GET: options are read whenever somebody opens the step.`);
1804
+ if (o.request.body !== void 0) errors.push(`${where}.request cannot have a body: it is a GET.`);
1805
+ }
1806
+ }
1807
+
1808
+ // src/declarations.ts
1809
+ var TYPE_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\nexport interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\nexport interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\nexport interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\nexport interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\nexport interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\nexport interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\nexport interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\nexport interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\nexport interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\nexport interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\nexport interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\nexport interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\nexport interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\nexport interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\nexport type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\nexport type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\nexport interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\nexport type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\nexport interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\nexport interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\nexport interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\nexport interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\nexport interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\nexport interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\nexport interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\nexport interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\nexport type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\nexport interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\nexport type PluginSchema = PluginManifest\n';
1810
+
1811
+ // src/ai-files.ts
1812
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1813
+ import { dirname, join } from "node:path";
1814
+
1815
+ // src/skill-files.ts
1816
+ var SKILL_FILES = { "examples/apps/deepl/modules/list-languages/manifest.json": '{\n "description": "List the languages DeepL translates into",\n "icon": "list",\n "color": "#0f2b46",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "none"\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "properties": {\n "apiKey": {\n "label": "DeepL API key",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "From your DeepL account, under API keys"\n },\n "host": {\n "label": "Plan",\n "type": "select",\n "default": "api-free.deepl.com",\n "group": "Account",\n "description": "Free keys end in :fx and use the free API",\n "options": [\n {\n "label": "DeepL API Free",\n "value": "api-free.deepl.com"\n },\n {\n "label": "DeepL API Pro",\n "value": "api.deepl.com"\n }\n ]\n }\n },\n "outputFields": {\n "items": {\n "type": "list",\n "description": "One entry per language: code and name"\n },\n "count": {\n "type": "number",\n "description": "How many there are"\n }\n },\n "action": {\n "baseUrl": "https://{{config.host}}/v2",\n "request": {\n "method": "GET",\n "path": "languages",\n "headers": {\n "Authorization": "DeepL-Auth-Key {{config.apiKey}}"\n },\n "query": {\n "type": "target"\n }\n },\n "errorPath": "message",\n "outputs": {\n "code": "{{item.language}}",\n "name": "{{item.name}}"\n },\n "items": {\n "path": "",\n "limit": 200\n }\n }\n}\n', "examples/apps/deepl/modules/translate-long-text/index.js": '// One DeepL request takes at most 50 texts, so a long document is several\n// requests whose answers are put back together in order. Several calls and\n// stitching their results is what a declared step cannot do.\nconst BATCH = 50\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n if (!config.apiKey) return { output: "error", data: { error: "Paste your DeepL API key into this step." } }\n const host = config.host || "api-free.deepl.com"\n const paragraphs = String(config.text || "")\n .split(/\\n\\s*\\n/)\n .map((paragraph) => paragraph.trim())\n .filter(Boolean)\n if (paragraphs.length === 0) return { output: "error", data: { error: "There is no text to translate." } }\n\n const translated = []\n let calls = 0\n for (let start = 0; start < paragraphs.length; start += BATCH) {\n const res = await fetch(`https://${host}/v2/translate`, {\n method: "POST",\n headers: { Authorization: `DeepL-Auth-Key ${config.apiKey}`, "Content-Type": "application/json" },\n body: JSON.stringify({ text: paragraphs.slice(start, start + BATCH), target_lang: String(config.targetLang || "EN-GB").toUpperCase() }),\n })\n calls++\n const raw = await res.text()\n let body = {}\n try { body = JSON.parse(raw) } catch { /* not JSON: reported below */ }\n if (!res.ok) {\n return { output: "error", data: { error: body.message || `DeepL answered ${res.status}`, status: res.status } }\n }\n for (const item of body.translations || []) translated.push(item.text)\n }\n\n console.log(`translated ${paragraphs.length} paragraph(s) in ${calls} call(s)`)\n return { output: "success", data: { text: translated.join("\\n\\n"), paragraphs: paragraphs.length, calls } }\n },\n}\n', "examples/apps/deepl/modules/translate-long-text/manifest.json": '{\n "description": "Translate a long text paragraph by paragraph, in as many calls as it takes",\n "icon": "file-text",\n "color": "#0f2b46",\n "timeoutMs": 60000,\n "version": 1,\n "auth": {\n "type": "none"\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "properties": {\n "apiKey": {\n "label": "DeepL API key",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "From your DeepL account, under API keys"\n },\n "host": {\n "label": "Plan",\n "type": "select",\n "default": "api-free.deepl.com",\n "group": "Account",\n "description": "Free keys end in :fx and use the free API",\n "options": [\n {\n "label": "DeepL API Free",\n "value": "api-free.deepl.com"\n },\n {\n "label": "DeepL API Pro",\n "value": "api.deepl.com"\n }\n ]\n },\n "text": {\n "label": "Text",\n "type": "string",\n "required": true,\n "group": "Translation",\n "description": "Any length. Paragraphs are separated by an empty line"\n },\n "targetLang": {\n "label": "Translate into",\n "type": "string",\n "required": true,\n "default": "EN-GB",\n "group": "Translation",\n "description": "A language code such as DE, FR, NL or EN-GB. List languages gives every one"\n }\n },\n "outputFields": {\n "text": {\n "type": "text",\n "description": "The whole translation, paragraphs kept"\n },\n "paragraphs": {\n "type": "number",\n "description": "How many paragraphs were translated"\n },\n "calls": {\n "type": "number",\n "description": "How many requests it took"\n }\n }\n}\n', "examples/apps/deepl/modules/translate-long-text/visualautomate.test.json": '{\n "config": {\n "text": "Hello.\\n\\nThis is the second paragraph.",\n "targetLang": "DE"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/apps/deepl/modules/translate-text/manifest.json": '{\n "description": "Translate a piece of text with DeepL",\n "icon": "languages",\n "color": "#0f2b46",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "none"\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "properties": {\n "apiKey": {\n "label": "DeepL API key",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "From your DeepL account, under API keys"\n },\n "host": {\n "label": "Plan",\n "type": "select",\n "default": "api-free.deepl.com",\n "group": "Account",\n "description": "Free keys end in :fx and use the free API",\n "options": [\n {\n "label": "DeepL API Free",\n "value": "api-free.deepl.com"\n },\n {\n "label": "DeepL API Pro",\n "value": "api.deepl.com"\n }\n ]\n },\n "text": {\n "label": "Text",\n "type": "string",\n "required": true,\n "group": "Translation"\n },\n "targetLang": {\n "label": "Translate into",\n "type": "string",\n "required": true,\n "default": "EN-GB",\n "group": "Translation",\n "description": "A language code such as DE, FR, NL or EN-GB. List languages gives every one"\n }\n },\n "outputFields": {\n "text": {\n "type": "text",\n "description": "The translation"\n },\n "detected": {\n "type": "text",\n "description": "The language DeepL found the text was in"\n }\n },\n "action": {\n "baseUrl": "https://{{config.host}}/v2",\n "request": {\n "method": "POST",\n "path": "translate",\n "headers": {\n "Authorization": "DeepL-Auth-Key {{config.apiKey}}"\n },\n "body": {\n "text": [\n "{{config.text}}"\n ],\n "target_lang": "{{config.targetLang | upper}}"\n }\n },\n "errorPath": "message",\n "outputs": {\n "text": "{{response.translations.0.text}}",\n "detected": "{{response.translations.0.detected_source_language}}"\n }\n }\n}\n', "examples/apps/deepl/modules/usage/manifest.json": `{
1817
+ "description": "How much of this month's character allowance is used",
1818
+ "icon": "gauge",
1819
+ "color": "#0f2b46",
1820
+ "timeoutMs": 30000,
1821
+ "version": 1,
1822
+ "auth": {
1823
+ "type": "none"
1824
+ },
1825
+ "inputs": [
1826
+ "input"
1827
+ ],
1828
+ "outputs": [
1829
+ "success",
1830
+ "error"
1831
+ ],
1832
+ "properties": {
1833
+ "apiKey": {
1834
+ "label": "DeepL API key",
1835
+ "type": "secret",
1836
+ "required": true,
1837
+ "group": "Account",
1838
+ "description": "From your DeepL account, under API keys"
1839
+ },
1840
+ "host": {
1841
+ "label": "Plan",
1842
+ "type": "select",
1843
+ "default": "api-free.deepl.com",
1844
+ "group": "Account",
1845
+ "description": "Free keys end in :fx and use the free API",
1846
+ "options": [
1847
+ {
1848
+ "label": "DeepL API Free",
1849
+ "value": "api-free.deepl.com"
1850
+ },
1851
+ {
1852
+ "label": "DeepL API Pro",
1853
+ "value": "api.deepl.com"
1854
+ }
1855
+ ]
1856
+ }
1857
+ },
1858
+ "outputFields": {
1859
+ "used": {
1860
+ "type": "number",
1861
+ "description": "Characters translated this period"
1862
+ },
1863
+ "limit": {
1864
+ "type": "number",
1865
+ "description": "Characters allowed this period"
1866
+ }
1867
+ },
1868
+ "action": {
1869
+ "baseUrl": "https://{{config.host}}/v2",
1870
+ "request": {
1871
+ "method": "GET",
1872
+ "path": "usage",
1873
+ "headers": {
1874
+ "Authorization": "DeepL-Auth-Key {{config.apiKey}}"
1875
+ }
1876
+ },
1877
+ "errorPath": "message",
1878
+ "outputs": {
1879
+ "used": "{{response.character_count}}",
1880
+ "limit": "{{response.character_limit}}"
1881
+ }
1882
+ }
1883
+ }
1884
+ `, "examples/apps/deepl/README.md": '# DeepL: an app with an API key, and a module that needs code\n\nDeepL is reached with a key the user pastes, not an account the platform connects, so every module has a `secret` property for the key and names the host itself with `baseUrl`. The host depends on the user\'s plan, so `baseUrl` reads a field: `https://{{config.host}}/v2`. (`baseUrl` may read `config`, never `input`.)\n\n| | |\n| --- | --- |\n| Name | DeepL |\n| Connection | none: the key is a field on each module |\n| Hosts to allow for the code module | `api-free.deepl.com`, `api.deepl.com` |\n\n| Module | Kind | Notes |\n| --- | --- | --- |\n| `translate-text` | declared | `POST translate`, the key in an `Authorization: DeepL-Auth-Key \u2026` header, `{{response.translations.0.text}}` reads the first result |\n| `list-languages` | declared | The answer is a bare list, so `items.path` is `""` |\n| `usage` | declared | |\n| `translate-long-text` | code | One request takes at most 50 texts, so a long document is several requests stitched back together in order |\n\nThe declared modules are created on the portal (**New module \u2192 Call an API**, paste the manifest, Try it with your key). `translate-long-text` is published from the repository; its push test needs a key, which belongs in `context.secrets` set for the plugin, never in a committed test file.\n\nOptional body fields are left out on purpose: a declared body sends a blank field as `""`, which DeepL refuses. Empty `query` values are dropped, body values are not.\n\n`translate-long-text` runs on your machine without a key: its test checks the refusal when no key is filled in. With a real key, add `"apiKey"` to `config` and `"allowedDomains": ["api-free.deepl.com"]` locally, and never commit the key.\n', "examples/apps/github/modules/comment-on-issue/manifest.json": `{
1885
+ "description": "Add a comment to an issue or a pull request",
1886
+ "icon": "message-square",
1887
+ "color": "#24292f",
1888
+ "timeoutMs": 30000,
1889
+ "version": 1,
1890
+ "auth": {
1891
+ "type": "oauth2",
1892
+ "provider": "github",
1893
+ "scopes": [
1894
+ "repo"
1895
+ ]
1896
+ },
1897
+ "properties": {
1898
+ "account": {
1899
+ "label": "GitHub account",
1900
+ "type": "connection",
1901
+ "provider": "github",
1902
+ "required": true,
1903
+ "description": "Which GitHub account this step acts as",
1904
+ "group": "Account"
1905
+ },
1906
+ "owner": {
1907
+ "label": "Owner",
1908
+ "type": "string",
1909
+ "required": true,
1910
+ "group": "Repository"
1911
+ },
1912
+ "repo": {
1913
+ "label": "Repository",
1914
+ "type": "string",
1915
+ "required": true,
1916
+ "group": "Repository"
1917
+ },
1918
+ "issue_number": {
1919
+ "label": "Issue or pull request number",
1920
+ "type": "number",
1921
+ "required": true,
1922
+ "group": "Comment"
1923
+ },
1924
+ "body": {
1925
+ "label": "Comment",
1926
+ "type": "string",
1927
+ "required": true,
1928
+ "description": "Markdown is understood",
1929
+ "group": "Comment"
1930
+ }
1931
+ },
1932
+ "inputs": [
1933
+ "input"
1934
+ ],
1935
+ "outputs": [
1936
+ "success",
1937
+ "error"
1938
+ ],
1939
+ "outputFields": {
1940
+ "id": {
1941
+ "type": "text",
1942
+ "description": "The comment's id"
1943
+ },
1944
+ "url": {
1945
+ "type": "text",
1946
+ "description": "A link to it"
1947
+ },
1948
+ "body": {
1949
+ "type": "object",
1950
+ "description": "What was written"
1951
+ }
1952
+ },
1953
+ "action": {
1954
+ "connection": "account",
1955
+ "request": {
1956
+ "method": "POST",
1957
+ "path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}/comments",
1958
+ "body": {
1959
+ "body": "{{config.body}}"
1960
+ }
1961
+ },
1962
+ "errorPath": "message",
1963
+ "outputs": {
1964
+ "id": "{{response.id}}",
1965
+ "url": "{{response.html_url}}",
1966
+ "body": "{{config.body}}"
1967
+ }
1968
+ }
1969
+ }
1970
+ `, "examples/apps/github/modules/create-issue/manifest.json": `{
1971
+ "description": "Open an issue on a repository",
1972
+ "icon": "circle-dot",
1973
+ "color": "#24292f",
1974
+ "timeoutMs": 30000,
1975
+ "version": 1,
1976
+ "auth": {
1977
+ "type": "oauth2",
1978
+ "provider": "github",
1979
+ "scopes": [
1980
+ "repo"
1981
+ ]
1982
+ },
1983
+ "properties": {
1984
+ "account": {
1985
+ "label": "GitHub account",
1986
+ "type": "connection",
1987
+ "provider": "github",
1988
+ "required": true,
1989
+ "description": "Which GitHub account this step acts as",
1990
+ "group": "Account"
1991
+ },
1992
+ "owner": {
1993
+ "label": "Owner",
1994
+ "type": "string",
1995
+ "required": true,
1996
+ "description": "The user or organisation",
1997
+ "group": "Repository"
1998
+ },
1999
+ "repo": {
2000
+ "label": "Repository",
2001
+ "type": "string",
2002
+ "required": true,
2003
+ "group": "Repository"
2004
+ },
2005
+ "title": {
2006
+ "label": "Title",
2007
+ "type": "string",
2008
+ "required": true,
2009
+ "group": "Issue"
2010
+ },
2011
+ "body": {
2012
+ "label": "Description",
2013
+ "type": "string",
2014
+ "description": "Markdown is understood",
2015
+ "group": "Issue"
2016
+ },
2017
+ "labels": {
2018
+ "label": "Labels",
2019
+ "type": "string",
2020
+ "description": "Separated by commas",
2021
+ "group": "Issue"
2022
+ },
2023
+ "assignees": {
2024
+ "label": "Assignees",
2025
+ "type": "string",
2026
+ "description": "Usernames, separated by commas",
2027
+ "group": "Issue"
2028
+ }
2029
+ },
2030
+ "inputs": [
2031
+ "input"
2032
+ ],
2033
+ "outputs": [
2034
+ "success",
2035
+ "error"
2036
+ ],
2037
+ "outputFields": {
2038
+ "number": {
2039
+ "type": "number",
2040
+ "description": "The issue number, which every other step wants"
2041
+ },
2042
+ "id": {
2043
+ "type": "text",
2044
+ "description": "GitHub's internal id"
2045
+ },
2046
+ "url": {
2047
+ "type": "text",
2048
+ "description": "A link to it"
2049
+ },
2050
+ "state": {
2051
+ "type": "text",
2052
+ "description": "open or closed"
2053
+ }
2054
+ },
2055
+ "action": {
2056
+ "connection": "account",
2057
+ "request": {
2058
+ "method": "POST",
2059
+ "path": "repos/{{config.owner}}/{{config.repo}}/issues",
2060
+ "body": {
2061
+ "title": "{{config.title}}",
2062
+ "body": "{{config.body}}",
2063
+ "labels": "{{config.labels | list}}",
2064
+ "assignees": "{{config.assignees | list}}"
2065
+ }
2066
+ },
2067
+ "errorPath": "message",
2068
+ "outputs": {
2069
+ "number": "{{response.number}}",
2070
+ "id": "{{response.id}}",
2071
+ "url": "{{response.html_url}}",
2072
+ "state": "{{response.state}}"
2073
+ }
2074
+ }
2075
+ }
2076
+ `, "examples/apps/github/modules/list-issues/manifest.json": `{
2077
+ "description": "List a repository's issues, newest first",
2078
+ "icon": "list",
2079
+ "color": "#24292f",
2080
+ "timeoutMs": 30000,
2081
+ "version": 1,
2082
+ "auth": {
2083
+ "type": "oauth2",
2084
+ "provider": "github",
2085
+ "scopes": [
2086
+ "repo"
2087
+ ]
2088
+ },
2089
+ "properties": {
2090
+ "account": {
2091
+ "label": "GitHub account",
2092
+ "type": "connection",
2093
+ "provider": "github",
2094
+ "required": true,
2095
+ "description": "Which GitHub account this step acts as",
2096
+ "group": "Account"
2097
+ },
2098
+ "owner": {
2099
+ "label": "Owner",
2100
+ "type": "string",
2101
+ "required": true,
2102
+ "group": "Repository"
2103
+ },
2104
+ "repo": {
2105
+ "label": "Repository",
2106
+ "type": "string",
2107
+ "required": true,
2108
+ "group": "Repository"
2109
+ },
2110
+ "state": {
2111
+ "label": "State",
2112
+ "type": "select",
2113
+ "default": "open",
2114
+ "options": [
2115
+ {
2116
+ "label": "Open",
2117
+ "value": "open"
2118
+ },
2119
+ {
2120
+ "label": "Closed",
2121
+ "value": "closed"
2122
+ },
2123
+ {
2124
+ "label": "Both",
2125
+ "value": "all"
2126
+ }
2127
+ ],
2128
+ "group": "Search"
2129
+ },
2130
+ "labels": {
2131
+ "label": "Labels",
2132
+ "type": "string",
2133
+ "description": "Only issues with all of these, separated by commas",
2134
+ "group": "Search"
2135
+ },
2136
+ "per_page": {
2137
+ "label": "How many",
2138
+ "type": "number",
2139
+ "default": 30,
2140
+ "min": 1,
2141
+ "max": 100,
2142
+ "group": "Search"
2143
+ }
2144
+ },
2145
+ "inputs": [
2146
+ "input"
2147
+ ],
2148
+ "outputs": [
2149
+ "success",
2150
+ "error"
2151
+ ],
2152
+ "outputFields": {
2153
+ "issues": {
2154
+ "type": "list",
2155
+ "description": "The issues, each with a number, a title and a state",
2156
+ "fields": {
2157
+ "number": "number",
2158
+ "title": "text",
2159
+ "body": "object",
2160
+ "state": "text",
2161
+ "html_url": "text"
2162
+ }
2163
+ },
2164
+ "count": {
2165
+ "type": "number",
2166
+ "description": "How many came back"
2167
+ }
2168
+ },
2169
+ "action": {
2170
+ "connection": "account",
2171
+ "request": {
2172
+ "method": "GET",
2173
+ "path": "repos/{{config.owner}}/{{config.repo}}/issues",
2174
+ "query": {
2175
+ "state": "{{config.state}}",
2176
+ "labels": "{{config.labels}}",
2177
+ "per_page": "{{config.per_page}}"
2178
+ }
2179
+ },
2180
+ "errorPath": "message",
2181
+ "outputs": {
2182
+ "issues": "{{response}}",
2183
+ "count": "{{response | length}}"
2184
+ }
2185
+ }
2186
+ }
2187
+ `, "examples/apps/github/modules/update-issue/manifest.json": `{
2188
+ "description": "Change an issue's title, body, state or labels",
2189
+ "icon": "pencil",
2190
+ "color": "#24292f",
2191
+ "timeoutMs": 30000,
2192
+ "version": 1,
2193
+ "auth": {
2194
+ "type": "oauth2",
2195
+ "provider": "github",
2196
+ "scopes": [
2197
+ "repo"
2198
+ ]
2199
+ },
2200
+ "properties": {
2201
+ "account": {
2202
+ "label": "GitHub account",
2203
+ "type": "connection",
2204
+ "provider": "github",
2205
+ "required": true,
2206
+ "description": "Which GitHub account this step acts as",
2207
+ "group": "Account"
2208
+ },
2209
+ "owner": {
2210
+ "label": "Owner",
2211
+ "type": "string",
2212
+ "required": true,
2213
+ "group": "Repository"
2214
+ },
2215
+ "repo": {
2216
+ "label": "Repository",
2217
+ "type": "string",
2218
+ "required": true,
2219
+ "group": "Repository"
2220
+ },
2221
+ "issue_number": {
2222
+ "label": "Issue number",
2223
+ "type": "number",
2224
+ "required": true,
2225
+ "group": "Issue"
2226
+ },
2227
+ "title": {
2228
+ "label": "Title",
2229
+ "type": "string",
2230
+ "group": "Issue"
2231
+ },
2232
+ "body": {
2233
+ "label": "Description",
2234
+ "type": "string",
2235
+ "group": "Issue"
2236
+ },
2237
+ "state": {
2238
+ "label": "State",
2239
+ "type": "select",
2240
+ "default": "",
2241
+ "options": [
2242
+ {
2243
+ "label": "Leave as it is",
2244
+ "value": ""
2245
+ },
2246
+ {
2247
+ "label": "Open",
2248
+ "value": "open"
2249
+ },
2250
+ {
2251
+ "label": "Closed",
2252
+ "value": "closed"
2253
+ }
2254
+ ],
2255
+ "group": "Issue"
2256
+ },
2257
+ "labels": {
2258
+ "label": "Labels",
2259
+ "type": "string",
2260
+ "description": "Replaces the labels on the issue",
2261
+ "group": "Issue"
2262
+ }
2263
+ },
2264
+ "inputs": [
2265
+ "input"
2266
+ ],
2267
+ "outputs": [
2268
+ "success",
2269
+ "error"
2270
+ ],
2271
+ "outputFields": {
2272
+ "number": {
2273
+ "type": "number",
2274
+ "description": "The issue that changed"
2275
+ },
2276
+ "url": {
2277
+ "type": "text",
2278
+ "description": "A link to it"
2279
+ },
2280
+ "state": {
2281
+ "type": "text",
2282
+ "description": "Its state afterwards"
2283
+ }
1265
2284
  },
1266
- {
1267
- "name": "p-debounce",
1268
- "version": "^5.1.0"
2285
+ "action": {
2286
+ "connection": "account",
2287
+ "request": {
2288
+ "method": "PATCH",
2289
+ "path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}",
2290
+ "body": {
2291
+ "title": "{{config.title}}",
2292
+ "body": "{{config.body}}",
2293
+ "state": "{{config.state}}",
2294
+ "labels": "{{config.labels | list}}"
2295
+ }
2296
+ },
2297
+ "errorPath": "message",
2298
+ "outputs": {
2299
+ "number": "{{response.number}}",
2300
+ "url": "{{response.html_url}}",
2301
+ "state": "{{response.state}}"
2302
+ }
2303
+ }
2304
+ }
2305
+ `, "examples/apps/github/README.md": '# GitHub: an app without code\n\nFour modules, every one a single request to the GitHub REST API, so every one is declared. These are the platform\'s own GitHub modules, generated from its catalogue.\n\nOn the developer portal, the app is:\n\n| | |\n| --- | --- |\n| Name | GitHub |\n| Connection | GitHub, OAuth, scope `repo` |\n| Base URL (from the provider) | `https://api.github.com` |\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `create-issue` | `POST repos/{owner}/{repo}/issues` | `"{{config.labels \\| list}}"` turns "bug, urgent" into a list |\n| `list-issues` | `GET repos/{owner}/{repo}/issues` | Optional filters go in `query`, where empty values are left out |\n| `update-issue` | `PATCH repos/{owner}/{repo}/issues/{number}` | |\n| `comment-on-issue` | `POST repos/{owner}/{repo}/issues/{number}/comments` | |\n\n`errorPath` is `message`: where GitHub puts the reason it refused. `vsa test` at the app\'s root checks all four manifests. To publish them, create each on the portal with **New module \u2192 Call an API**, paste its `manifest.json` into the module\'s editor, press **Try it**, and publish. There is nothing here for GitHub to publish.\n', "examples/apps/gmail/modules/get-message/manifest.json": `{
2306
+ "description": "Read one message: its headers, its snippet and its parts",
2307
+ "icon": "mail-open",
2308
+ "color": "#ea4335",
2309
+ "timeoutMs": 30000,
2310
+ "version": 1,
2311
+ "auth": {
2312
+ "type": "oauth2",
2313
+ "provider": "google-mail",
2314
+ "scopes": [
2315
+ "https://www.googleapis.com/auth/gmail.modify"
2316
+ ]
2317
+ },
2318
+ "properties": {
2319
+ "account": {
2320
+ "label": "Google account",
2321
+ "type": "connection",
2322
+ "provider": "google-mail",
2323
+ "required": true,
2324
+ "description": "Which Gmail account this step acts as",
2325
+ "group": "Account"
2326
+ },
2327
+ "messageId": {
2328
+ "label": "Message id",
2329
+ "type": "string",
2330
+ "required": true,
2331
+ "group": "Message"
2332
+ },
2333
+ "format": {
2334
+ "label": "How much",
2335
+ "type": "select",
2336
+ "default": "full",
2337
+ "options": [
2338
+ {
2339
+ "label": "Everything",
2340
+ "value": "full"
2341
+ },
2342
+ {
2343
+ "label": "Headers and snippet only",
2344
+ "value": "metadata"
2345
+ },
2346
+ {
2347
+ "label": "The raw message",
2348
+ "value": "raw"
2349
+ }
2350
+ ],
2351
+ "group": "Message"
2352
+ }
1269
2353
  },
1270
- {
1271
- "name": "async",
1272
- "version": "^3.2.6"
2354
+ "inputs": [
2355
+ "input"
2356
+ ],
2357
+ "outputs": [
2358
+ "success",
2359
+ "error"
2360
+ ],
2361
+ "outputFields": {
2362
+ "id": {
2363
+ "type": "text",
2364
+ "description": "The message's id"
2365
+ },
2366
+ "threadId": {
2367
+ "type": "text",
2368
+ "description": "The conversation it belongs to"
2369
+ },
2370
+ "snippet": {
2371
+ "type": "text",
2372
+ "description": "The first line or so, as Gmail shows it"
2373
+ },
2374
+ "headers": {
2375
+ "type": "list",
2376
+ "description": "From, To, Subject and the rest, as a list of name and value"
2377
+ },
2378
+ "labelIds": {
2379
+ "type": "list",
2380
+ "description": "The labels on it"
2381
+ },
2382
+ "payload": {
2383
+ "type": "list",
2384
+ "description": "The body parts, base64url encoded as Gmail stores them"
2385
+ }
1273
2386
  },
1274
- {
1275
- "name": "bluebird",
1276
- "version": "^3.7.2"
2387
+ "action": {
2388
+ "connection": "account",
2389
+ "request": {
2390
+ "method": "GET",
2391
+ "path": "gmail/v1/users/me/messages/{{config.messageId}}",
2392
+ "query": {
2393
+ "format": "{{config.format}}"
2394
+ }
2395
+ },
2396
+ "errorPath": "error.message",
2397
+ "outputs": {
2398
+ "id": "{{response.id}}",
2399
+ "threadId": "{{response.threadId}}",
2400
+ "snippet": "{{response.snippet}}",
2401
+ "headers": "{{response.payload.headers}}",
2402
+ "labelIds": "{{response.labelIds}}",
2403
+ "payload": "{{response.payload}}"
2404
+ }
2405
+ }
2406
+ }
2407
+ `, "examples/apps/gmail/modules/modify-labels/manifest.json": '{\n "description": "Label a message, archive it, or mark it read",\n "icon": "tag",\n "color": "#ea4335",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "google-mail",\n "scopes": [\n "https://www.googleapis.com/auth/gmail.modify"\n ]\n },\n "properties": {\n "account": {\n "label": "Google account",\n "type": "connection",\n "provider": "google-mail",\n "required": true,\n "description": "Which Gmail account this step acts as",\n "group": "Account"\n },\n "messageId": {\n "label": "Message id",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "addLabelIds": {\n "label": "Add labels",\n "type": "string",\n "description": "Label ids, separated by commas",\n "group": "Labels"\n },\n "removeLabelIds": {\n "label": "Remove labels",\n "type": "string",\n "description": "Ids. UNREAD marks it read, INBOX archives it.",\n "group": "Labels"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "id": {\n "type": "text",\n "description": "The message that changed"\n },\n "labelIds": {\n "type": "list",\n "description": "The labels on it afterwards"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "gmail/v1/users/me/messages/{{config.messageId}}/modify",\n "body": {\n "addLabelIds": "{{config.addLabelIds | list}}",\n "removeLabelIds": "{{config.removeLabelIds | list}}"\n }\n },\n "errorPath": "error.message",\n "outputs": {\n "id": "{{response.id}}",\n "labelIds": "{{response.labelIds}}"\n }\n }\n}\n', "examples/apps/gmail/modules/search-messages/manifest.json": `{
2408
+ "description": "Find messages with Gmail's own search syntax",
2409
+ "icon": "search",
2410
+ "color": "#ea4335",
2411
+ "timeoutMs": 30000,
2412
+ "version": 1,
2413
+ "auth": {
2414
+ "type": "oauth2",
2415
+ "provider": "google-mail",
2416
+ "scopes": [
2417
+ "https://www.googleapis.com/auth/gmail.modify"
2418
+ ]
2419
+ },
2420
+ "properties": {
2421
+ "account": {
2422
+ "label": "Google account",
2423
+ "type": "connection",
2424
+ "provider": "google-mail",
2425
+ "required": true,
2426
+ "description": "Which Gmail account this step acts as",
2427
+ "group": "Account"
2428
+ },
2429
+ "q": {
2430
+ "label": "Search",
2431
+ "type": "string",
2432
+ "description": "e.g. from:ada@x.com is:unread newer_than:7d",
2433
+ "group": "Search"
2434
+ },
2435
+ "labelIds": {
2436
+ "label": "Labels",
2437
+ "type": "string",
2438
+ "description": "Label ids, separated by commas",
2439
+ "group": "Search"
2440
+ },
2441
+ "maxResults": {
2442
+ "label": "How many",
2443
+ "type": "number",
2444
+ "default": 25,
2445
+ "min": 1,
2446
+ "max": 500,
2447
+ "group": "Search"
2448
+ }
1277
2449
  },
1278
- {
1279
- "name": "promise-retry",
1280
- "version": "^2.0.1"
2450
+ "inputs": [
2451
+ "input"
2452
+ ],
2453
+ "outputs": [
2454
+ "success",
2455
+ "error"
2456
+ ],
2457
+ "outputFields": {
2458
+ "messages": {
2459
+ "type": "list",
2460
+ "description": "The matches, each with an id and a thread id \u2014 read one to get its content",
2461
+ "fields": {
2462
+ "id": "text",
2463
+ "threadId": "text",
2464
+ "snippet": "text",
2465
+ "labelIds": "list",
2466
+ "payload": "list"
2467
+ }
2468
+ },
2469
+ "count": {
2470
+ "type": "number",
2471
+ "description": "How many came back"
2472
+ },
2473
+ "estimate": {
2474
+ "type": "number",
2475
+ "description": "Gmail's guess at the total"
2476
+ }
1281
2477
  },
1282
- {
1283
- "name": "retry",
1284
- "version": "^0.13.1"
2478
+ "action": {
2479
+ "connection": "account",
2480
+ "request": {
2481
+ "method": "GET",
2482
+ "path": "gmail/v1/users/me/messages",
2483
+ "query": {
2484
+ "q": "{{config.q}}",
2485
+ "labelIds": "{{config.labelIds | list}}",
2486
+ "maxResults": "{{config.maxResults}}"
2487
+ }
2488
+ },
2489
+ "errorPath": "error.message",
2490
+ "outputs": {
2491
+ "messages": "{{response.messages}}",
2492
+ "count": "{{response.messages | length}}",
2493
+ "estimate": "{{response.resultSizeEstimate}}"
2494
+ }
2495
+ }
2496
+ }
2497
+ `, "examples/apps/gmail/modules/send-email/index.js": '// Gmail takes a whole RFC 2822 message, base64url-encoded, in one field. Building\n// that needs code: a declared step can fill in a JSON body, not encode one.\nconst { Base64 } = require("js-base64")\n\n/** "a@x.com, b@y.com" to ["a@x.com", "b@y.com"]. */\nconst addresses = (value) =>\n String(value || "")\n .split(",")\n .map((address) => address.trim())\n .filter(Boolean)\n\n/** A header value with non-ASCII text in it, encoded so every mail client reads it. */\nconst headerText = (text) => (/^[\\x20-\\x7e]*$/.test(text) ? text : `=?UTF-8?B?${Base64.encode(text)}?=`)\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const to = addresses(config.to)\n if (to.length === 0) return { output: "error", data: { error: "Fill in who the email goes to." } }\n if (!context.connections) return { output: "error", data: { error: "Choose a Google account on this step." } }\n\n const cc = addresses(config.cc)\n const message = [\n `To: ${to.join(", ")}`,\n ...(cc.length ? [`Cc: ${cc.join(", ")}`] : []),\n `Subject: ${headerText(config.subject || "")}`,\n "MIME-Version: 1.0",\n `Content-Type: ${config.isHtml ? "text/html" : "text/plain"}; charset=UTF-8`,\n "Content-Transfer-Encoding: base64",\n "",\n Base64.encode(config.body || ""),\n ].join("\\r\\n")\n\n const account = await context.connections.get("account")\n const res = await account.request({\n method: "POST",\n path: "gmail/v1/users/me/messages/send",\n // URL-safe base64 without padding, as Gmail\'s "raw" field wants it.\n body: { raw: Base64.encodeURI(message) },\n })\n\n if (!res.ok) {\n const reason = res.body && res.body.error && res.body.error.message\n return { output: "error", data: { error: reason || `Gmail answered ${res.status}`, status: res.status } }\n }\n console.log(`sent to ${to.length} recipient(s)`)\n return { output: "success", data: { id: res.body.id, threadId: res.body.threadId, to } }\n },\n}\n', "examples/apps/gmail/modules/send-email/manifest.json": `{
2498
+ "description": "Send an email from the connected Gmail account",
2499
+ "icon": "send",
2500
+ "color": "#ea4335",
2501
+ "timeoutMs": 30000,
2502
+ "version": 1,
2503
+ "auth": {
2504
+ "type": "oauth2",
2505
+ "provider": "google-mail",
2506
+ "scopes": [
2507
+ "https://www.googleapis.com/auth/gmail.modify"
2508
+ ]
2509
+ },
2510
+ "properties": {
2511
+ "account": {
2512
+ "label": "Google account",
2513
+ "type": "connection",
2514
+ "provider": "google-mail",
2515
+ "required": true,
2516
+ "description": "Which Gmail account this step acts as",
2517
+ "group": "Account"
2518
+ },
2519
+ "to": {
2520
+ "label": "To",
2521
+ "type": "string",
2522
+ "required": true,
2523
+ "description": "Addresses, separated by commas",
2524
+ "group": "Message"
2525
+ },
2526
+ "cc": {
2527
+ "label": "Cc",
2528
+ "type": "string",
2529
+ "description": "Addresses, separated by commas",
2530
+ "group": "Message"
2531
+ },
2532
+ "subject": {
2533
+ "label": "Subject",
2534
+ "type": "string",
2535
+ "required": true,
2536
+ "group": "Message"
2537
+ },
2538
+ "body": {
2539
+ "label": "Message",
2540
+ "type": "string",
2541
+ "required": true,
2542
+ "group": "Message"
2543
+ },
2544
+ "isHtml": {
2545
+ "label": "Send as HTML",
2546
+ "type": "boolean",
2547
+ "default": false,
2548
+ "group": "Message"
2549
+ }
1285
2550
  },
1286
- {
1287
- "name": "exponential-backoff",
1288
- "version": "^3.1.3"
2551
+ "inputs": [
2552
+ "input"
2553
+ ],
2554
+ "outputs": [
2555
+ "success",
2556
+ "error"
2557
+ ],
2558
+ "outputFields": {
2559
+ "id": {
2560
+ "type": "text",
2561
+ "description": "The sent message's id"
2562
+ },
2563
+ "threadId": {
2564
+ "type": "text",
2565
+ "description": "The conversation it belongs to"
2566
+ },
2567
+ "to": {
2568
+ "type": "list",
2569
+ "description": "Who it went to"
2570
+ }
2571
+ }
2572
+ }
2573
+ `, "examples/apps/gmail/modules/send-email/visualautomate.test.json": '{\n "config": {\n "to": "",\n "subject": "Hello",\n "body": "From a test"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/apps/gmail/README.md": '# Gmail: a mixed app\n\nReading and labelling mail is one request each, so those modules are declared (generated from the platform\'s catalogue). Sending is not: Gmail takes the whole message as an RFC 2822 text, base64url-encoded, in one `raw` field. A declaration can fill in a JSON body but cannot encode one, so `send-email` is code.\n\n| | |\n| --- | --- |\n| Name | Gmail |\n| Connection | Google (`google-mail`), OAuth, scope `gmail.modify` |\n| Base URL (from the provider) | `https://gmail.googleapis.com` |\n\n| Module | Kind | Notes |\n| --- | --- | --- |\n| `search-messages` | declared | Gmail\'s own search syntax in `q` |\n| `get-message` | declared | Headers, snippet and parts of one message |\n| `modify-labels` | declared | Archive, mark read, add or remove labels |\n| `send-email` | code | Builds the MIME message with `js-base64` (an allowed package), sends it with `context.connections` |\n\nThe three declared modules are created on the portal (**New module \u2192 Call an API**, paste the manifest); `send-email` is a **Write code** module, published from the repository after a push test on `success`.\n\n`send-email` acts as a connected account, and there is none on your machine, so its test file checks the clean refusal (no recipient). That passes locally and on push, but **does not count for publishing**, which needs a push test on `success`: before publishing, put your Google account\'s id in `config.account`, a real recipient (yourself) in `config.to`, and `"expectOutput": "success"`. Every push then sends that mail, so keep it to yourself.\n', "examples/apps/README.md": "# App examples\n\nAn **app** is several steps (its **modules**) for one service, under one name and one connection. In a repository it is one folder per module under `modules/`:\n\n```text\nmy-app/\n modules/\n create-issue/\n manifest.json a declared module: no index.js\n send-email/\n manifest.json\n index.js a code module\n visualautomate.test.json\n```\n\nThe app itself has no file: its name, description, logo and **connection** (what a user connects when they add the app: an OAuth sign-in, or a key) are set on the developer portal. Every module that acts as the account declares the same `connection` property, here always called `account`.\n\n**Code modules** are published from the repository: a push tests them, and a commit that passed on `success` is published. **Declared modules** are kept here as `manifest.json` so `vsa test` can check them, but they are published from the portal: **New module \u2192 Call an API**, paste the manifest into the module's editor, **Try it**, publish.\n\n| App | Modules | Connection | Shows |\n| --- | --- | --- | --- |\n| `github/` | 4 declared | GitHub, OAuth | An app without code: create, list, update and comment, as the platform's own catalogue ships them |\n| `slack/` | 4 declared | Slack, OAuth | An API that answers 200 to a failure, caught with `okPath` |\n| `gmail/` | 3 declared, 1 code | Google, OAuth | A mixed app: reads are declared; sending needs code to build a base64url MIME message |\n| `deepl/` | 3 declared, 1 code | none: an API key the user pastes | `baseUrl` that reads a field, a `secret` key in a header, and code for many calls stitched together |\n\nChoose per module, not per app: a module is declared when one request does the job, and code when it needs more.\n", "examples/apps/slack/modules/add-reaction/manifest.json": '{\n "description": "Put an emoji reaction on a message",\n "icon": "smile",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "slack",\n "scopes": [\n "chat:write",\n "channels:read",\n "channels:history",\n "groups:read",\n "users:read",\n "users:read.email",\n "reactions:write",\n "channels:manage"\n ]\n },\n "properties": {\n "account": {\n "label": "Slack workspace",\n "type": "connection",\n "provider": "slack",\n "required": true,\n "description": "Which Slack account this step acts as",\n "group": "Account"\n },\n "channel": {\n "label": "Channel id",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "timestamp": {\n "label": "Message timestamp",\n "type": "string",\n "required": true,\n "group": "Message"\n },\n "name": {\n "label": "Emoji",\n "type": "string",\n "required": true,\n "description": "The name without colons, e.g. thumbsup",\n "group": "Message"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "added": {\n "type": "true/false",\n "description": "Whether Slack accepted it"\n },\n "name": {\n "type": "text",\n "description": "The emoji used"\n },\n "timestamp": {\n "type": "text",\n "description": "The message it went on"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "reactions.add",\n "body": {\n "channel": "{{config.channel}}",\n "timestamp": "{{config.timestamp}}",\n "name": "{{config.name}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "added": true,\n "name": "{{config.name}}",\n "timestamp": "{{config.timestamp}}"\n }\n }\n}\n', "examples/apps/slack/modules/find-user-by-email/manifest.json": '{\n "description": "Look up a member of the workspace by their address",\n "icon": "search",\n "color": "#4a154b",\n "timeoutMs": 30000,\n "version": 1,\n "auth": {\n "type": "oauth2",\n "provider": "slack",\n "scopes": [\n "chat:write",\n "channels:read",\n "channels:history",\n "groups:read",\n "users:read",\n "users:read.email",\n "reactions:write",\n "channels:manage"\n ]\n },\n "properties": {\n "account": {\n "label": "Slack workspace",\n "type": "connection",\n "provider": "slack",\n "required": true,\n "description": "Which Slack account this step acts as",\n "group": "Account"\n },\n "email": {\n "label": "Email",\n "type": "string",\n "required": true,\n "group": "Search"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "id": {\n "type": "text",\n "description": "Their user id, which every other method wants"\n },\n "name": {\n "type": "text",\n "description": "Their display name"\n },\n "email": {\n "type": "text",\n "description": "The address that was looked up"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "GET",\n "path": "users.lookupByEmail",\n "query": {\n "email": "{{config.email}}"\n }\n },\n "okPath": "ok",\n "errorPath": "error",\n "outputs": {\n "id": "{{response.user.id}}",\n "name": "{{response.user.real_name}}",\n "email": "{{config.email}}"\n }\n }\n}\n', "examples/apps/slack/modules/list-channels/manifest.json": `{
2574
+ "description": "List the channels in the workspace, with their ids",
2575
+ "icon": "list",
2576
+ "color": "#4a154b",
2577
+ "timeoutMs": 30000,
2578
+ "version": 1,
2579
+ "auth": {
2580
+ "type": "oauth2",
2581
+ "provider": "slack",
2582
+ "scopes": [
2583
+ "chat:write",
2584
+ "channels:read",
2585
+ "channels:history",
2586
+ "groups:read",
2587
+ "users:read",
2588
+ "users:read.email",
2589
+ "reactions:write",
2590
+ "channels:manage"
2591
+ ]
2592
+ },
2593
+ "properties": {
2594
+ "account": {
2595
+ "label": "Slack workspace",
2596
+ "type": "connection",
2597
+ "provider": "slack",
2598
+ "required": true,
2599
+ "description": "Which Slack account this step acts as",
2600
+ "group": "Account"
2601
+ },
2602
+ "types": {
2603
+ "label": "Kinds",
2604
+ "type": "select",
2605
+ "default": "public_channel",
2606
+ "options": [
2607
+ {
2608
+ "label": "Public channels",
2609
+ "value": "public_channel"
2610
+ },
2611
+ {
2612
+ "label": "Private channels",
2613
+ "value": "private_channel"
2614
+ },
2615
+ {
2616
+ "label": "Public and private",
2617
+ "value": "public_channel,private_channel"
2618
+ },
2619
+ {
2620
+ "label": "Direct messages",
2621
+ "value": "im"
2622
+ }
2623
+ ],
2624
+ "group": "Search"
2625
+ },
2626
+ "limit": {
2627
+ "label": "How many",
2628
+ "type": "number",
2629
+ "default": 100,
2630
+ "min": 1,
2631
+ "max": 1000,
2632
+ "group": "Search"
2633
+ },
2634
+ "exclude_archived": {
2635
+ "label": "Skip archived",
2636
+ "type": "boolean",
2637
+ "default": true,
2638
+ "group": "Search"
2639
+ }
1289
2640
  },
1290
- {
1291
- "name": "bottleneck",
1292
- "version": "^2.19.5"
2641
+ "inputs": [
2642
+ "input"
2643
+ ],
2644
+ "outputs": [
2645
+ "success",
2646
+ "error"
2647
+ ],
2648
+ "outputFields": {
2649
+ "channels": {
2650
+ "type": "list",
2651
+ "description": "The channels, each with an id and a name"
2652
+ },
2653
+ "count": {
2654
+ "type": "number",
2655
+ "description": "How many came back"
2656
+ },
2657
+ "cursor": {
2658
+ "type": "text",
2659
+ "description": "Slack's cursor for the next page, when there is one"
2660
+ }
1293
2661
  },
1294
- {
1295
- "name": "throttle-debounce",
1296
- "version": "^5.0.2"
2662
+ "action": {
2663
+ "connection": "account",
2664
+ "request": {
2665
+ "method": "GET",
2666
+ "path": "conversations.list",
2667
+ "query": {
2668
+ "types": "{{config.types}}",
2669
+ "limit": "{{config.limit}}",
2670
+ "exclude_archived": "{{config.exclude_archived}}"
2671
+ }
2672
+ },
2673
+ "okPath": "ok",
2674
+ "errorPath": "error",
2675
+ "outputs": {
2676
+ "channels": "{{response.channels}}",
2677
+ "count": "{{response.channels | length}}",
2678
+ "cursor": "{{response.response_metadata.next_cursor}}"
2679
+ }
2680
+ }
2681
+ }
2682
+ `, "examples/apps/slack/modules/post-message/manifest.json": `{
2683
+ "description": "Post a message to a channel, a private channel or a direct message",
2684
+ "icon": "send",
2685
+ "color": "#4a154b",
2686
+ "timeoutMs": 30000,
2687
+ "version": 1,
2688
+ "auth": {
2689
+ "type": "oauth2",
2690
+ "provider": "slack",
2691
+ "scopes": [
2692
+ "chat:write",
2693
+ "channels:read",
2694
+ "channels:history",
2695
+ "groups:read",
2696
+ "users:read",
2697
+ "users:read.email",
2698
+ "reactions:write",
2699
+ "channels:manage"
2700
+ ]
2701
+ },
2702
+ "properties": {
2703
+ "account": {
2704
+ "label": "Slack workspace",
2705
+ "type": "connection",
2706
+ "provider": "slack",
2707
+ "required": true,
2708
+ "description": "Which Slack account this step acts as",
2709
+ "group": "Account"
2710
+ },
2711
+ "channel": {
2712
+ "label": "Channel id",
2713
+ "type": "string",
2714
+ "required": true,
2715
+ "description": "The channel to post in, e.g. C0123456789. Use List channels to find one.",
2716
+ "group": "Message"
2717
+ },
2718
+ "text": {
2719
+ "label": "Message",
2720
+ "type": "string",
2721
+ "required": true,
2722
+ "description": "The text to post. Slack's mrkdwn is understood.",
2723
+ "group": "Message"
2724
+ },
2725
+ "thread_ts": {
2726
+ "label": "Reply to",
2727
+ "type": "string",
2728
+ "description": "The ts of a message to reply to, leaving this one in its thread",
2729
+ "group": "Message"
2730
+ },
2731
+ "unfurl_links": {
2732
+ "label": "Show link previews",
2733
+ "type": "boolean",
2734
+ "default": true,
2735
+ "group": "Message"
2736
+ }
1297
2737
  },
1298
- {
1299
- "name": "delay",
1300
- "version": "^7.0.0"
2738
+ "inputs": [
2739
+ "input"
2740
+ ],
2741
+ "outputs": [
2742
+ "success",
2743
+ "error"
2744
+ ],
2745
+ "outputFields": {
2746
+ "ts": {
2747
+ "type": "text",
2748
+ "description": "The message's timestamp, which is its id in every other method"
2749
+ },
2750
+ "channel": {
2751
+ "type": "text",
2752
+ "description": "The channel it was posted in"
2753
+ },
2754
+ "text": {
2755
+ "type": "text",
2756
+ "description": "What was sent"
2757
+ },
2758
+ "permalink": {
2759
+ "type": "text",
2760
+ "description": "A link to the message, when Slack returns one"
2761
+ }
1301
2762
  },
1302
- {
1303
- "name": "graphlib",
1304
- "version": "^2.1.8"
1305
- }
1306
- ];
1307
- var RESTRICTED_PATTERNS = [
1308
- /\bprocess\s*\.\s*exit/,
1309
- /\bprocess\s*\.\s*env/,
1310
- /\bprocess\s*\.\s*binding/,
1311
- /\bprocess\s*\.\s*mainModule/,
1312
- /\bprocess\s*\[/,
1313
- /\bprocess\s*\(/,
1314
- /\beval\s*\(/,
1315
- /\bFunction\s*\(/,
1316
- /\bnew\s+Function\b/,
1317
- /\bAsyncFunction\b/,
1318
- /\bGeneratorFunction\b/,
1319
- /\bglobalThis\b/,
1320
- /\bself\s*\[/,
1321
- /\bimport\s+/,
1322
- /\bimport\s*\(/,
1323
- /\bReflect\b/,
1324
- /\bProxy\b/,
1325
- /\b__proto__\b/,
1326
- /\bprototype\s*\[/,
1327
- /\bprototype\s*\.\s*constructor\b/,
1328
- /\bconstructor\b/,
1329
- /\bthis\s*\.\s*constructor\b/,
1330
- /\barguments\s*\.\s*callee\b/,
1331
- /\bObject\s*\.\s*defineProperty\b/,
1332
- /\b__defineGetter__\b/,
1333
- /\b__defineSetter__\b/,
1334
- /\b__lookupGetter__\b/,
1335
- /\b__lookupSetter__\b/,
1336
- /\bmodule\s*\.\s*(?:constructor|extensions|_compile|_path|_sourceMaps)\b/,
1337
- /\brequire\s*\.\s*cache\b/,
1338
- /\brequire\s*\.\s*resolve\b/,
1339
- /\bError\s*\.\s*captureStackTrace\b/,
1340
- /\bWebAssembly\b/
1341
- ];
1342
-
1343
- // ../plugin-sdk/src/validate.ts
1344
- var ALLOWED_PACKAGE_NAMES = new Set(ALLOWED_PACKAGES.map((p) => p.name));
1345
- function validatePluginCode(code) {
1346
- const hasExecute = code.includes("module.exports") || code.includes("exports.execute") || code.includes("export");
1347
- if (!hasExecute) {
1348
- return {
1349
- valid: false,
1350
- error: "Plugin code must export an execute function. Example: module.exports = { execute: async (input, config, context) => { ... } }"
1351
- };
1352
- }
1353
- for (const pattern of RESTRICTED_PATTERNS) {
1354
- if (pattern.test(code)) {
1355
- return {
1356
- valid: false,
1357
- error: `Plugin code contains restricted patterns. Plugins cannot access the filesystem, spawn processes, use eval, import modules, or escape the sandbox. (matched ${pattern})`
1358
- };
2763
+ "action": {
2764
+ "connection": "account",
2765
+ "request": {
2766
+ "method": "POST",
2767
+ "path": "chat.postMessage",
2768
+ "body": {
2769
+ "channel": "{{config.channel}}",
2770
+ "text": "{{config.text}}",
2771
+ "thread_ts": "{{config.thread_ts}}",
2772
+ "unfurl_links": "{{config.unfurl_links}}"
2773
+ }
2774
+ },
2775
+ "okPath": "ok",
2776
+ "errorPath": "error",
2777
+ "outputs": {
2778
+ "ts": "{{response.ts}}",
2779
+ "channel": "{{response.channel}}",
2780
+ "text": "{{config.text}}",
2781
+ "permalink": "{{response.message.permalink}}"
1359
2782
  }
1360
2783
  }
1361
- const packages = [];
1362
- const requireCall = /\brequire\s*\(\s*(['"])([^'"]+)\1\s*\)/g;
1363
- let match;
1364
- while ((match = requireCall.exec(code)) !== null) {
1365
- const name = match[2];
1366
- if (!ALLOWED_PACKAGE_NAMES.has(name)) {
1367
- return {
1368
- valid: false,
1369
- error: `Plugin code requires "${name}" which is not an allowed package. See ALLOWED_PACKAGES for the list.`
1370
- };
2784
+ }
2785
+ `, "examples/apps/slack/README.md": '# Slack: an app without code, and a 200 that is a failure\n\nFour declared modules, generated from the platform\'s catalogue. Like every declared module, each is created on the portal (**New module \u2192 Call an API**, paste its `manifest.json`, **Try it**) and published there, not from GitHub.\n\n| | |\n| --- | --- |\n| Name | Slack |\n| Connection | Slack, OAuth, scopes for messages, channels, users and reactions |\n| Base URL (from the provider) | `https://slack.com/api` |\n\nSlack answers `200` even when it refuses: `{ "ok": false, "error": "channel_not_found" }`. Every module therefore has `"okPath": "ok"` and `"errorPath": "error"`, so a refusal leaves by the `error` port instead of passing as a success with nothing in it.\n\n| Module | Request | Notes |\n| --- | --- | --- |\n| `post-message` | `POST chat.postMessage` | Hands on the message\'s `ts`, which `add-reaction` and threads need |\n| `list-channels` | `GET conversations.list` | A list, with the ids the other modules ask for |\n| `add-reaction` | `POST reactions.add` | |\n| `find-user-by-email` | `GET users.lookupByEmail` | |\n', "examples/branching-outputs/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const score = Number(input.score);\n if (Number.isNaN(score)) {\n return { output: "error", data: { reason: "input.score is not a number" } };\n }\n const high = config.highFrom ?? 80;\n const medium = config.mediumFrom ?? 50;\n const output = score >= high ? "high" : score >= medium ? "medium" : "low";\n return { output, data: { score } };\n },\n};\n', "examples/branching-outputs/manifest.json": '{\n "description": "Sorts a score into high, medium or low",\n "icon": "split",\n "properties": {\n "highFrom": {\n "label": "High from",\n "type": "number",\n "default": 80\n },\n "mediumFrom": {\n "label": "Medium from",\n "type": "number",\n "default": 50\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "high",\n "medium",\n "low",\n "error"\n ],\n "outputFields": {\n "score": "The score that was sorted"\n }\n}\n', "examples/branching-outputs/visualautomate.test.json": '{\n "config": {},\n "input": {\n "score": 91\n },\n "expectOutput": "high"\n}\n', "examples/connection-account/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!context.connections) {\n return { output: "error", data: { reason: "No account was chosen for this step" } };\n }\n\n // Asked for by the property name in manifest.json, not by an id.\n const account = await context.connections.get("account");\n const res = await account.request({\n path: "user/repos", // relative to the provider: never a full URL\n method: "GET",\n query: { per_page: config.perPage || 10, sort: "pushed" },\n });\n\n if (!res.ok) {\n return { output: "error", data: { status: res.status, body: res.body } };\n }\n return { output: "success", data: { repositories: res.body.map((r) => r.full_name) } };\n },\n};\n', "examples/connection-account/manifest.json": `{
2786
+ "description": "Lists the connected GitHub account's repositories",
2787
+ "icon": "github",
2788
+ "properties": {
2789
+ "account": {
2790
+ "label": "GitHub account",
2791
+ "type": "connection",
2792
+ "provider": "github",
2793
+ "required": true,
2794
+ "description": "Which connected account to act as"
2795
+ },
2796
+ "perPage": {
2797
+ "label": "How many",
2798
+ "type": "number",
2799
+ "default": 10,
2800
+ "min": 1,
2801
+ "max": 100
1371
2802
  }
1372
- if (!packages.includes(name)) packages.push(name);
2803
+ },
2804
+ "inputs": [
2805
+ "input"
2806
+ ],
2807
+ "outputs": [
2808
+ "success",
2809
+ "error"
2810
+ ],
2811
+ "outputFields": {
2812
+ "repositories": "Names of the account's repositories"
1373
2813
  }
1374
- if (/\brequire\s*\(\s*(?!['"][^'"]+['"]\s*\))/.test(code)) {
1375
- return {
1376
- valid: false,
1377
- error: "Plugin code contains a dynamic require() call. Only require() with string literals of allowed packages is permitted."
1378
- };
2814
+ }
2815
+ `, "examples/connection-account/visualautomate.test.json": '{\n "config": {},\n "input": {},\n "expectOutput": "error"\n}\n', "examples/declared-api-key/manifest.json": '{\n "description": "Gets the current weather for a city",\n "icon": "cloud-sun",\n "auth": {\n "type": "none"\n },\n "properties": {\n "apiKey": {\n "label": "API key",\n "type": "secret",\n "required": true,\n "description": "From your account on the weather service"\n },\n "city": {\n "label": "City",\n "type": "string",\n "required": true\n },\n "units": {\n "label": "Units",\n "type": "select",\n "default": "metric",\n "options": [\n {\n "label": "Celsius",\n "value": "metric"\n },\n {\n "label": "Fahrenheit",\n "value": "imperial"\n }\n ]\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "temperature": "The temperature now",\n "summary": "The weather in words",\n "city": "The city it is for"\n },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": {\n "q": "{{config.city}}",\n "units": "{{config.units}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.apiKey}}"\n }\n },\n "errorPath": "error.message",\n "outputs": {\n "temperature": "{{response.current.temp}}",\n "summary": "{{response.current.summary}}",\n "city": "{{config.city}}"\n }\n }\n}\n', "examples/declared-connection-list/manifest.json": '{\n "description": "Lists the repositories of a connected GitHub account",\n "icon": "github",\n "auth": {\n "type": "oauth2",\n "provider": "github"\n },\n "properties": {\n "account": {\n "label": "GitHub account",\n "type": "connection",\n "provider": "github",\n "required": true\n },\n "sort": {\n "label": "Sort by",\n "type": "select",\n "default": "pushed",\n "options": [\n {\n "label": "Last pushed",\n "value": "pushed"\n },\n {\n "label": "Name",\n "value": "full_name"\n }\n ]\n },\n "limit": {\n "label": "At most",\n "type": "number",\n "default": 50,\n "min": 1,\n "max": 300\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "items": {\n "type": "list",\n "description": "One entry per repository: name, url, private"\n },\n "count": {\n "type": "number",\n "description": "How many came back"\n }\n },\n "action": {\n "connection": "account",\n "request": {\n "method": "GET",\n "path": "user/repos",\n "query": {\n "sort": "{{config.sort}}",\n "per_page": "100",\n "page": "{{cursor}}"\n }\n },\n "items": {\n "path": "",\n "limit": 300\n },\n "paginate": {\n "from": "page",\n "start": 1,\n "maxPages": 3\n },\n "errorPath": "message",\n "outputs": {\n "name": "{{item.full_name}}",\n "url": "{{item.html_url}}",\n "private": "{{item.private}}"\n }\n }\n}\n', "examples/declared-reshape/manifest.json": '{\n "description": "Turns a comma-separated list of addresses into a list",\n "icon": "list",\n "auth": {\n "type": "none"\n },\n "properties": {\n "addresses": {\n "label": "Addresses",\n "type": "string",\n "required": true,\n "description": "Separated by commas"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "recipients": "The addresses, as a list",\n "first": "The first address",\n "count": "How many there are"\n },\n "action": {\n "outputs": {\n "recipients": "{{config.addresses | list}}",\n "first": "{{config.addresses | first}}",\n "count": "{{config.addresses | length}}"\n }\n }\n}\n', "examples/hello-world/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.name) {\n return { output: "error", data: { reason: "No name given" } };\n }\n return { output: "success", data: { greeting: `Hello, ${config.name}!` } };\n },\n};\n', "examples/hello-world/manifest.json": '{\n "description": "Greets someone",\n "icon": "sparkles",\n "properties": {\n "name": {\n "label": "Name",\n "type": "string",\n "required": true,\n "description": "Who to greet"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "greeting": "The greeting text"\n }\n}\n', "examples/hello-world/visualautomate.test.json": '{\n "config": {\n "name": "VisualAutomate"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/http-request/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config) => {\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), (config.timeoutSeconds || 10) * 1000);\n\n try {\n const hasBody = config.method === "POST" || config.method === "PUT";\n const res = await fetch(config.url, {\n method: config.method || "GET",\n headers: hasBody ? { "Content-Type": "application/json" } : {},\n body: hasBody ? JSON.stringify(config.body ?? {}) : undefined,\n signal: controller.signal,\n });\n\n const text = await res.text();\n let body = text;\n try { body = JSON.parse(text); } catch { /* not JSON: keep the text */ }\n\n return { output: res.ok ? "success" : "error", data: { status: res.status, body } };\n } catch (err) {\n return { output: "error", data: { status: 0, reason: String(err) } };\n } finally {\n clearTimeout(timer);\n }\n },\n};\n', "examples/http-request/manifest.json": '{\n "description": "Calls any HTTP endpoint",\n "icon": "globe",\n "timeoutMs": 20000,\n "properties": {\n "url": {\n "label": "URL",\n "type": "string",\n "required": true,\n "pattern": "^https://"\n },\n "method": {\n "label": "Method",\n "type": "select",\n "default": "GET",\n "options": [\n {\n "label": "GET",\n "value": "GET"\n },\n {\n "label": "POST",\n "value": "POST"\n },\n {\n "label": "PUT",\n "value": "PUT"\n },\n {\n "label": "DELETE",\n "value": "DELETE"\n }\n ]\n },\n "body": {\n "label": "Body",\n "type": "json",\n "description": "Sent as JSON for POST and PUT"\n },\n "timeoutSeconds": {\n "label": "Give up after (s)",\n "type": "number",\n "default": 10,\n "min": 1,\n "max": 15\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "status": "HTTP status",\n "body": "The parsed response, or text"\n }\n}\n', "examples/http-request/visualautomate.test.json": '{\n "config": {\n "url": "https://httpbin.org/get",\n "method": "GET"\n },\n "input": {},\n "expectOutput": "success"\n}\n', "examples/packages/index.js": 'const _ = require("lodash");\nconst { format } = require("date-fns");\nconst { z } = require("zod");\n\nconst Order = z.object({ id: z.string(), total: z.number(), createdAt: z.string() });\n\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input) => {\n const parsed = z.array(Order).safeParse(input.orders ?? []);\n if (!parsed.success) {\n return { output: "error", data: { reason: parsed.error.issues.map((i) => i.message) } };\n }\n\n const byDay = _.mapValues(\n _.groupBy(parsed.data, (o) => format(new Date(o.createdAt), "yyyy-MM-dd")),\n (orders) => _.sumBy(orders, "total"),\n );\n return { output: "success", data: { byDay } };\n },\n};\n', "examples/packages/manifest.json": '{\n "description": "Validates orders and groups them by day",\n "icon": "package",\n "properties": {},\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "byDay": "Order totals per day"\n }\n}\n', "examples/packages/visualautomate.test.json": '{\n "config": {},\n "input": {\n "orders": [\n {\n "id": "a",\n "total": 10,\n "createdAt": "2026-09-14T10:00:00Z"\n },\n {\n "id": "b",\n "total": 5,\n "createdAt": "2026-09-14T12:00:00Z"\n }\n ]\n },\n "expectOutput": "success"\n}\n', "examples/README.md": "# Examples\n\nWorking steps to start from. Copy the closest one into the repository (a plugin's root, or `modules/<slug>/` in an app) and change it. For a whole app, start from `apps/`.\n\nEvery code example passes the platform's code check, and the ones that need no network, account or extra package are run with `vsa test` by this repository's tests, so they cannot quietly go stale.\n\n| Folder | Kind | Shows |\n| --- | --- | --- |\n| `hello-world/` | code | The smallest working step: one property, success and error ports |\n| `branching-outputs/` | code | More than two ports, so a workflow can branch per result |\n| `state-dedupe/` | code | `context.state`: let each item through once, a short lock with `ttlSeconds` |\n| `storage-files/` | code | `context.storage`: write, read, list, and a signed link |\n| `storage-paths-media/` | code | `path`/`paths` properties and `context.media` to trim a video |\n| `http-request/` | code | `fetch` with a select, a number and a JSON field; failures by the error port |\n| `connection-account/` | code | A `connection` property and `context.connections`; the test checks the clean refusal without an account |\n| `secrets/` | code | `context.secrets` for a key the plugin itself holds |\n| `packages/` | code | `require` of allowed packages: lodash, date-fns, zod |\n| `declared-api-key/` | declared | One request to an API with the user's own key: `baseUrl` and a `secret` property |\n| `declared-connection-list/` | declared | A list from a connected account, with `items` and page-by-page `paginate` |\n| `declared-reshape/` | declared | No request at all: outputs built from the fields with filters |\n| `trigger-new-mail/` | trigger | A starting point that polls a mailbox, from now on |\n| `apps/` | apps | Whole apps in the `modules/` shape: GitHub and Slack without code, Gmail and DeepL with declared and code modules side by side. See `apps/README.md` |\n\nDeclared steps and triggers have no `index.js`: `vsa test` checks their manifests. A declared step is published from the developer portal, not GitHub: **New module \u2192 Call an API**, paste its `manifest.json`, **Try it**, publish.\n\nThe test files here are what passes **locally**, where there is no account. To publish a step, its test must come back on `success` in a push check: for a step that acts as an account, put the id of one of your connected accounts in `config` (the module's page on the portal lists them) and set `expectOutput` to `success`.\n", "examples/secrets/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // Provided for the step at run time. Never put a key in index.js: this repository, a fork\n // and a reviewer can all read it.\n const apiKey = context.secrets.API_KEY;\n if (!apiKey) {\n return { output: "error", data: { reason: "API_KEY is not set for this step" } };\n }\n\n const res = await fetch(`https://api.example.com/search?q=${encodeURIComponent(config.query)}`, {\n headers: { Authorization: `Bearer ${apiKey}` },\n });\n return { output: res.ok ? "success" : "error", data: { status: res.status } };\n },\n};\n', "examples/secrets/manifest.json": '{\n "description": "Calls an API that needs a key",\n "icon": "key",\n "properties": {\n "query": {\n "label": "Search for",\n "type": "string",\n "required": true\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ]\n}\n', "examples/secrets/visualautomate.test.json": '{\n "config": {\n "query": "workflows"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/state-dedupe/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const id = input.id;\n if (id === undefined || id === null) {\n return { output: "error", data: { reason: "input.id is missing" } };\n }\n\n const key = config.key || "seen-ids";\n // Values are JSON and at most 128 KB, so keep a bounded list rather than everything ever seen.\n const seen = (await context.state.get(key)) ?? [];\n if (seen.includes(id)) return { output: "duplicate", data: { id } };\n\n seen.push(id);\n await context.state.set(key, seen.slice(-500));\n\n // A value that expires on its own: a lock for five minutes (the minimum is 60 seconds).\n await context.state.set(`lock:${id}`, true, { ttlSeconds: 300 });\n\n return { output: "new", data: { id } };\n },\n};\n', "examples/state-dedupe/manifest.json": `{
2816
+ "description": "Lets each item through once",
2817
+ "icon": "database",
2818
+ "properties": {
2819
+ "key": {
2820
+ "label": "Remember under",
2821
+ "type": "string",
2822
+ "default": "seen-ids"
2823
+ }
2824
+ },
2825
+ "inputs": [
2826
+ "input"
2827
+ ],
2828
+ "outputs": [
2829
+ "new",
2830
+ "duplicate",
2831
+ "error"
2832
+ ],
2833
+ "outputFields": {
2834
+ "id": "The item's id"
1379
2835
  }
1380
- return { valid: true, packages };
1381
2836
  }
1382
- function sandboxRequire(load) {
1383
- return (name) => {
1384
- const allowed = ALLOWED_PACKAGES.find((p) => p.name === name);
1385
- if (!allowed) throw new Error(`Cannot require "${name}"`);
1386
- try {
1387
- return load(name);
1388
- } catch {
1389
- throw new Error(
1390
- `"${name}" is available in the sandbox but not installed here. To run this locally: npm install --save-dev ${name}@${allowed.version}`
1391
- );
2837
+ `, "examples/state-dedupe/visualautomate.test.json": '{\n "config": {},\n "input": {\n "id": "item-1"\n },\n "expectOutput": "new"\n}\n', "examples/storage-files/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n const folder = config.folder || "reports";\n const path = `${folder}/${new Date().toISOString().slice(0, 10)}.json`;\n\n // Write. Strings, bytes or a Uint8Array; the content type is optional.\n const saved = await context.storage.put(path, JSON.stringify(input, null, 2), "application/json");\n\n // Read it back. get() answers null for a path that is not there.\n const back = await context.storage.get(path);\n if (!back) return { output: "error", data: { reason: "The file did not save" } };\n\n // A link somebody can open without an account, valid for an hour.\n const link = await context.storage.signedUrl(path, 3600);\n\n // Stream a URL straight into storage without it passing through this sandbox.\n if (config.sourceUrl && context.storage.download) {\n await context.storage.download(config.sourceUrl, `${folder}/source`, undefined);\n }\n\n const { files, totalSize } = await context.storage.list(`${folder}/`);\n return {\n output: "success",\n data: { path, size: saved.size, expiresAt: saved.expiresAt, link, files: files.map((f) => f.pathname), totalSize },\n };\n },\n};\n', "examples/storage-files/manifest.json": '{\n "description": "Saves a report and returns a link to it",\n "icon": "file",\n "properties": {\n "folder": {\n "label": "Folder",\n "type": "string",\n "default": "reports"\n },\n "sourceUrl": {\n "label": "Also download from",\n "type": "string",\n "description": "Optional URL to store beside it"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "path": "Where the report was saved",\n "link": "A link valid for an hour",\n "files": "What is in the folder now"\n }\n}\n', "examples/storage-files/visualautomate.test.json": '{\n "config": {\n "folder": "examples"\n },\n "input": {\n "hello": "storage"\n },\n "expectOutput": "success"\n}\n', "examples/storage-paths-media/index.js": '/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n // A path property holds the path; the file is not read into the step.\n const info = await context.storage.head(config.source);\n if (!info) return { output: "error", data: { reason: `${config.source} is not in storage` } };\n\n // media is only present where the server has ffmpeg configured.\n if (!context.media) return { output: "error", data: { reason: "Media is not available here" } };\n\n const output = config.source.replace(/(\\.[a-z0-9]+)?$/i, "-trimmed.mp4");\n const result = await context.media.run("trim", {\n input: config.source,\n start: config.start ?? 0,\n duration: config.duration ?? 10,\n output,\n });\n\n if (!result.ok) return { output: "error", data: { logs: result.logs } };\n return { output: "success", data: { output, files: result.files, extras: config.extras ?? [] } };\n },\n};\n', "examples/storage-paths-media/manifest.json": '{\n "description": "Trims a stored video",\n "icon": "film",\n "timeoutMs": 60000,\n "properties": {\n "source": {\n "label": "Video",\n "type": "path",\n "required": true,\n "description": "A video already in storage"\n },\n "start": {\n "label": "Start (s)",\n "type": "number",\n "default": 0,\n "min": 0\n },\n "duration": {\n "label": "Length (s)",\n "type": "number",\n "default": 10,\n "min": 1\n },\n "extras": {\n "label": "Other files",\n "type": "paths",\n "description": "One per line; only listed, not read"\n }\n },\n "inputs": [\n "input"\n ],\n "outputs": [\n "success",\n "error"\n ],\n "outputFields": {\n "output": "Where the trimmed video was saved"\n }\n}\n', "examples/storage-paths-media/visualautomate.test.json": '{\n "config": {\n "source": "examples/missing.mp4"\n },\n "input": {},\n "expectOutput": "error"\n}\n', "examples/trigger-new-mail/manifest.json": `{
2838
+ "description": "Starts a run for each new email",
2839
+ "icon": "mail",
2840
+ "auth": {
2841
+ "type": "oauth2",
2842
+ "provider": "outlook",
2843
+ "scopes": [
2844
+ "Mail.Read"
2845
+ ]
2846
+ },
2847
+ "properties": {
2848
+ "account": {
2849
+ "label": "Mailbox",
2850
+ "type": "connection",
2851
+ "provider": "outlook",
2852
+ "required": true
2853
+ },
2854
+ "poll_minutes": {
2855
+ "label": "Check every (minutes)",
2856
+ "type": "number",
2857
+ "default": 5,
2858
+ "min": 1
1392
2859
  }
1393
- };
2860
+ },
2861
+ "inputs": [],
2862
+ "outputs": [
2863
+ "success"
2864
+ ],
2865
+ "outputFields": {
2866
+ "subject": "The email's subject",
2867
+ "from": "Who sent it"
2868
+ },
2869
+ "trigger": {
2870
+ "connection": "account",
2871
+ "poll": "poll_minutes",
2872
+ "request": {
2873
+ "path": "/v1.0/me/mailFolders/inbox/messages/delta"
2874
+ },
2875
+ "items": "value",
2876
+ "id": "id",
2877
+ "cursor": {
2878
+ "from": "response",
2879
+ "path": "@odata.deltaLink",
2880
+ "as": "url",
2881
+ "initial": "now"
2882
+ },
2883
+ "maxPerPoll": 10
2884
+ }
1394
2885
  }
1395
- function loadPluginCode(code, require2) {
1396
- const module = { exports: {} };
1397
- const body = new Function("module", "exports", "require", `"use strict"; ${code}`);
1398
- body(module, module.exports, require2);
1399
- return module.exports;
2886
+ `, "references/code-steps.md": '# Code steps: index.js\n\nA code step is one function, `execute(input, config, context)`, run in an isolated sandbox each time the step runs (once per item when the user makes it repeat).\n\n```js\n/** @type {PluginModule} */\nmodule.exports = {\n execute: async (input, config, context) => {\n if (!config.project) {\n return { output: "error", data: { error: "Choose a project on this step." } }\n }\n\n const res = await fetch("https://api.example.com/tasks", {\n method: "POST",\n headers: { Authorization: `Bearer ${config.apiKey}`, "Content-Type": "application/json" },\n body: JSON.stringify({ project: config.project, title: config.title }),\n })\n\n const text = await res.text()\n let body = text\n try { body = JSON.parse(text) } catch { /* not JSON: keep the text */ }\n\n if (!res.ok) {\n return { output: "error", data: { error: `The API answered ${res.status}`, status: res.status, body } }\n }\n return { output: "success", data: { id: body.id, url: body.url } }\n },\n}\n```\n\nRules for the file: CommonJS, loaded as written with no build step. No `import` anywhere (comments included). `require` only allowed packages by bare name. See `sandbox.md`.\n\nAccepted export forms: `module.exports = { execute }`, `module.exports.execute = fn`, `module.exports = fn`, or `module.exports.default = fn` (a function). A `default` that is an object with `execute` on it is **not** found by the runner.\n\nThe file is loaded in a `vm` context with the platform\'s globals only (see `sandbox.md`): no `Buffer`, `process`, `globalThis` or `structuredClone`, and a `console` with `log`, `error`, `warn` and `info`. End the file with a newline.\n\n## `input`\n\nThe output data of every step on the route that led here, keyed by **step name** (and node id). Routes that meet here are merged.\n\n```js\nconst invoices = input.list_invoices.invoices // a step named "list_invoices" returned { invoices }\n```\n\nIn a loop, also `input.item`, `input.index` (from 0) and `input.count`; after a loop, `input.collected` (every turn\'s result) and `input.count`. In a test file, `input` is given directly.\n\nPrefer reading values from `config`: a field lets the user choose the source with a data bubble, so the step does not depend on what the previous step is called.\n\n## `config`\n\nThe fields, by property name, with data bubbles already resolved. Types as in `manifest.md`. Empty fields may be missing: always default.\n\n## `context`\n\n| Member | What | Present |\n| --- | --- | --- |\n| `workflowId`, `nodeId`, `userId`, `executionId`, `startedAt` | facts about the run (`startedAt` is ISO) | always |\n| `secrets` | values set for the plugin itself, e.g. `context.secrets.API_KEY`. Not the user\'s credentials | always, may be empty |\n| `storage` | the account\'s files | always |\n| `state` | values that outlive one run, per workflow | always |\n| `connections` | the accounts chosen on this step | only with a `connection` property and a chosen account |\n| `media` | ffmpeg | only where the platform has it configured |\n\n### `context.storage`\n\nPaths are scoped to the account; never prefix them.\n\n| Method | Returns |\n| --- | --- |\n| `put(path, data, contentType?)` | `{ url, size, contentType, uploadedAt, expiresAt }`. `data`: a string or a `Uint8Array` (`Buffer` is not available) |\n| `get(path)` | `Uint8Array`, or `null` when missing |\n| `delete(path)` | nothing |\n| `list(prefix?)` | `{ files: [{ url, pathname, size, uploadedAt }], totalSize }` |\n| `head(path)` | `{ size, contentType }` or `null` |\n| `signedUrl(path, seconds?)` | the storage bucket\'s link, for an API that fetches the file: 60 to 3600 seconds (default 900) |\n| `shareUrl(path, seconds?)` | a link on VisualAutomate\'s own domain, for a person: a mail, a message (default 900 seconds) |\n| `download(url, path, contentType?)` | pulls a URL straight into storage. Optional: check `if (context.storage.download)` |\n\nDecode text with `new TextDecoder().decode(bytes)`.\n\n### `context.state`\n\n| Method | Notes |\n| --- | --- |\n| `get(key)` | the value, or `null` if never set |\n| `set(key, value, { ttlSeconds? })` | any JSON value, at most 128 KB. `ttlSeconds` at least 60 |\n| `delete(key)` | |\n| `list(prefix?)` | `{ keys: [{ key, expiresAt }], complete }`: each key with when it expires (`null` for never); `complete` is false when there were more |\n\nFor cursors, seen-ids (keep them bounded: `seen.slice(-500)`), counters, short locks. Large data goes in storage, with its path in state.\n\n### `context.connections`\n\n```js\nif (!context.connections) {\n return { output: "error", data: { error: "No account was chosen for this step." } }\n}\nconst account = await context.connections.get("account") // YOUR property\'s name, not an id\nconst res = await account.request({\n method: "GET", // GET, POST, PUT, PATCH, DELETE\n path: "user/repos", // relative to the provider: never a full URL\n query: { per_page: 10 },\n headers: {}, // never an Authorization header: the platform adds it\n body: undefined,\n})\n// res: { ok, status, headers?, body } (the provider\'s own reply, body parsed when JSON)\n```\n\nThere is no way to read the token, and no way to choose the host. `context.connections` is absent when no account was chosen, which is also how `vsa test` runs a step locally: check for it. For the push check, and for publishing, name an account in the test file\'s `config` (see `test-and-publish.md`).\n\n### `context.media`\n\n```js\nif (!context.media) return { output: "error", data: { error: "Media is not available here." } }\nconst result = await context.media.run("trim", { input: config.source, start: 0, duration: 10, output: "clips/out.mp4" })\nif (!result.ok) return { output: "error", data: { error: "The trim failed", logs: result.logs } }\n```\n\nName an operation and pass values; never build an ffmpeg command line. Operations: `probe`, `convert`, `trim`, `resize`, `framerate`, `extract-audio`, `remove-audio`, `replace-audio`, `thumbnail`, `frames`, `concat`, `watermark`, `text`, `crop`, `rotate`, `speed`, `volume`, `fade`, `gif`, `compress`, `reframe`, `subtitles`, `metadata`, `loop`. Their values match the fields of the Media steps on the canvas (`trim`: `input`, `start`, `end` or `duration`, `output`). Files are storage paths (`path`/`paths` properties). Not available locally: test only the code around it.\n\n## Return value\n\n```js\nreturn { output: "success", data: { id: "t_42", url: "https://..." } }\n```\n\n- `output`: one of the manifest\'s `outputs`.\n- `data`: a flat object; its keys are what `outputFields` describes and what the next steps read.\n- Optional `metadata: { logs?: string[] }`.\n\nExpected failure: `{ output: "error", data: { error: "<a sentence saying what to do>" } }`. Throw only for what should never happen: a thrown error fails the step with its message.\n\n`console.log`/`warn`/`error` write to the step\'s log. Never log secrets or whole customer records.\n\n## In a loop\n\n`execute` runs once per item. Build storage paths and state keys from `input.item` or `input.index` so turns do not overwrite each other; keep per-item work small (a loop is at most 200 items).\n', "references/declared-steps.md": '# Declared steps: the `action` block\n\nOne HTTP request described in the manifest, made by the platform. No `index.js`, no sandbox, one credit per run for the user, and reviewable at a glance. Use it whenever a step is a single call (or no call at all). It cannot transform: no base64, no combining two calls, no arithmetic. That needs code.\n\n**Where it lives.** A declared module is not published from GitHub: a push publishes only code. It is created on the developer portal with **New module \u2192 Call an API**, and its `manifest.json` is edited on the module\'s action page, as a form or as JSON (both edit the same file). So write the manifest, keep a copy in the repository if you like (`modules/<slug>/manifest.json`), check it with `vsa test`, and paste it into the portal.\n\n`vsa test` checks the manifest (connection or `baseUrl`, a relative path, `https://`, no `{{input}}` in `baseUrl`, items and pages) but does not run it. The action page runs it with **Try it**, against an account the user picks. Publishing needs a passing Try it, on the success port, on the saved version. `$map` and `$if` cannot be edited in the form; paste them in the JSON.\n\n## Shape\n\n```json\n"action": {\n "connection": "account",\n "request": {\n "method": "POST",\n "path": "v1.0/me/sendMail",\n "query": {},\n "headers": {},\n "body": {\n "message": {\n "subject": "{{config.subject}}",\n "body": {\n "contentType": { "$if": "{{config.isHtml}}", "then": "HTML", "else": "Text" },\n "content": "{{config.body}}"\n },\n "toRecipients": { "$map": "{{config.to | list}}", "to": { "emailAddress": { "address": "{{item}}" } } }\n }\n }\n },\n "errorPath": "error.message",\n "outputs": { "sent": true, "to": "{{config.to | list}}", "statusCode": "{{status}}" }\n}\n```\n\n| Key | Notes |\n| --- | --- |\n| `connection` | Name of your `connection` property. The request goes as that account, under the provider\'s base URL |\n| `baseUrl` | For an API without a connection: `"https://api.example.com/v1"`. `https://` only. May use `{{config.\u2026}}`, never `{{input.\u2026}}`. Exactly one of `connection`/`baseUrl` when there is a request |\n| `request.method` | `GET`, `POST`, `PUT`, `PATCH`, `DELETE` |\n| `request.path` | Relative to the base URL. Never a full URL |\n| `request.query`, `request.headers`, `request.body` | Values may contain tokens |\n| `request.bodyType` | `json` (default), `form` (urlencoded, nested as `a[b]=c`: Stripe, Twilio), `multipart` (a `file` property becomes a file part) |\n| `outputs` | What the step hands on. Literals stay literal (`"sent": true` is a boolean) |\n| `errorPath` | Where the provider puts its refusal reason, e.g. `error.message`. Absent: the whole body |\n| `okPath` / `okValue` | For APIs that answer 200 to a failure. `"okPath": "ok"` (Slack); `"okPath": "status", "okValue": "OK"` (Google Places) |\n| `items` | `{ "path": "data", "limit": 50 }`: the answer is a list at `path`; each item goes through `outputs` reading `{{item.\u2026}}`, and the step hands on `{ items, count }`. `limit` default 100, max 1000 |\n| `paginate` | See below |\n\nLeave out `request` for a step that only reshapes its fields:\n\n```json\n"action": { "outputs": { "recipients": "{{config.to | list}}", "howMany": "{{config.to | length}}" } }\n```\n\n## Tokens\n\n| Token | Is |\n| --- | --- |\n| `{{config.x}}` | a field |\n| `{{input.step_name.field}}` | data from an earlier step |\n| `{{response.a.b}}` | the answer (in `outputs`) |\n| `{{status}}` | HTTP status |\n| `{{item}}`, `{{item.id}}` | the current item in `$map` or with `items` |\n| `{{cursor}}` | the next page\'s cursor, with `paginate` |\n\nA string that is exactly one token keeps its type (`"{{config.isHtml}}"` sends a boolean). Text around a token makes it a string.\n\n**Empty values:** a `query` parameter that resolves to nothing (empty, `null`, missing) is left out of the request. A `body` field is sent as it resolves, so a field the user left blank can arrive as `""`. So put optional values in `query` where the API allows it; for an optional body field that an API refuses when empty, leave the field out of the step, or write code. Dotted paths try a key containing dots first, so `{{response.@odata.nextLink}}` works.\n\nFilters after a bar: `list` (value or comma-separated text to a list), `length`, `first`, `last`, `upper`, `lower`, `trim`, `number`, `json`.\n\nOperators: `{ "$map": "<list token>", "to": <template using {{item}}> }` builds one entry per item; `{ "$if": "<token>", "then": a, "else": b }` picks by truthiness.\n\n## An API with the user\'s own key\n\n```json\n{\n "description": "Get the current weather for a city",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true },\n "city": { "label": "City", "type": "string", "required": true }\n },\n "outputs": ["success", "error"],\n "outputFields": { "temperature": "Degrees Celsius", "summary": "In words" },\n "action": {\n "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "GET",\n "path": "weather",\n "query": { "q": "{{config.city}}" },\n "headers": { "Authorization": "Bearer {{config.apiKey}}" }\n },\n "errorPath": "error.message",\n "outputs": { "temperature": "{{response.current.temp}}", "summary": "{{response.current.summary}}" }\n }\n}\n```\n\n## Pagination\n\n```json\n"request": { "method": "GET", "path": "v1/customers", "query": { "limit": "100", "starting_after": "{{cursor}}" } },\n"items": { "path": "data", "limit": 500 },\n"paginate": { "from": "item", "path": "id", "more": "has_more" }\n```\n\n| `from` | Next page from |\n| --- | --- |\n| `response` | a value in the answer (`response_metadata.next_cursor`, `paging.next.after`). With `"as": "url"` a next link becomes the next request (`@odata.nextLink`) |\n| `item` | a value on the last item (Stripe `id` for `starting_after`) |\n| `page` | a page number from `start` |\n| `offset` | items so far |\n\nStops at `limit`, an empty page, `more` false, no next value, or `maxPages` (default 10, max 50).\n\n## Checklist (nothing runs it for you)\n\n- The JSON parses, and every `{{config.x}}` names a property that exists.\n- `connection` names a property of type `connection`; or `baseUrl` is `https://` and reads no `input`.\n- `path` is relative; no Authorization header for a connection.\n- `outputs` covers every key in `outputFields`, and `errorPath`/`okPath` match the provider\'s documented error shape.\n- Lists have `items` with a `limit`.\n', "references/manifest.md": '# manifest.json\n\nDeclares a step: the fields the canvas draws, the ports it leaves by, what it hands on, the account it acts as, and its limits. Strict JSON on the platform (the CLI strips `//` comments in local files). Keys not listed here are dropped when the step is saved.\n\n```json\n{\n "description": "Create a task in a project",\n "icon": "check-square",\n "color": "#5e6ad2",\n "auth": { "type": "none" },\n "properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true, "group": "Account" },\n "project": { "label": "Project", "type": "string", "required": true },\n "title": { "label": "Title", "type": "string", "required": true },\n "priority": {\n "label": "Priority",\n "type": "select",\n "default": "normal",\n "options": [{ "label": "Normal", "value": "normal" }, { "label": "Urgent", "value": "urgent" }]\n }\n },\n "inputs": ["input"],\n "outputs": ["success", "error"],\n "outputFields": { "id": "The new task\'s id", "url": "A link to it" },\n "timeoutMs": 20000\n}\n```\n\n## Top-level keys\n\n| Key | Type | Notes |\n| --- | --- | --- |\n| `description` | string | One line, shown in the step picker. Start with a verb |\n| `icon` | string | A Lucide icon name: `globe`, `database`, `mail`, `file-text`, `sparkles`, ... |\n| `color` | string | Hex ring colour on the canvas |\n| `version` | number | Bump when the declaration changes shape. Default 1 |\n| `properties` | object | The fields. See below |\n| `inputs` | string[] | Default `["input"]`. A trigger has `[]` |\n| `outputs` | string[] | Ports. Default `["success", "error"]`. Each is a line a user can draw from |\n| `outputFields` | object | Describes the keys of `data` for the next step\'s picker: `{ "id": "The new id" }`, or `{ "items": { "type": "list", "description": "..." } }` |\n| `auth` | object | `{ "type": "oauth2" \\| "api_key" \\| "basic" \\| "none", "provider"?: "...", "scopes"?: [...] }` |\n| `timeoutMs` | number | Default 10000 on a canvas and 30000 in the push check, max 60000, and never more than the user\'s plan allows. The push check reads it here, not from the test file |\n| `memoryLimitMb` | number | Default 32, max 128 |\n| `action` | object | A declared step. See `declared-steps.md` |\n| `trigger` | object | A trigger. See `triggers.md` |\n\n`auth` may not contain `url`, `baseUrl`, `base_url` or `host`: refused outright.\n\n## Properties\n\nThe key is the name in code: property `title` is `config.title`.\n\n| Key | Notes |\n| --- | --- |\n| `label` | Shown above the input. Always set it, in words |\n| `type` | See the table below |\n| `required` | The workflow will not run while it is empty; the step shows a red dot |\n| `default` | Starting value |\n| `description` | Helper text under the input |\n| `group` | Fields with the same group are shown together |\n| `options` | For `select`/`multiselect`: `[{ "label": "Fast", "value": "fast" }]` |\n| `min`, `max` | For `number` |\n| `pattern` | For `string`: a regex the value must match, e.g. `"^https://"` |\n| `help` | Longer help, shown on request |\n| `disabled` | Shown but not editable |\n| `optionsFrom` | For `select`/`multiselect`: read the options from the account, `{ "connection": "account", "request": { "path": "\u2026" }, "items": { "path": "\u2026" }, "label": "{{item.name}}", "value": "{{item.id}}" }`. GET only |\n| `sentTo` | For `secret`: the properties that say where it is sent (a server URL); the stored key is kept only while they are unchanged |\n| `verbatim` | For `secret`: sent exactly as typed, never computed with |\n| `provider` | For `connection`: the provider id, e.g. `outlook`, `github`. A module inherits its app\'s |\n\n### Types\n\n| Type | Canvas | Arrives in `config` as |\n| --- | --- | --- |\n| `string` | text box that takes data bubbles | text, with bubbles already filled in |\n| `number` | number input | number |\n| `boolean` | switch | `true`/`false` |\n| `select` | dropdown | the chosen `value` |\n| `multiselect` | several choices | array of values |\n| `json` | JSON editor | the parsed value |\n| `code` | code editor | the text exactly as typed, never parsed |\n| `secret` | masked input for a key the user pastes | text; masked wherever shown |\n| `color` | colour picker | hex string |\n| `date` | date picker | ISO date |\n| `connection` | picker of the user\'s connected accounts | do not read it; use `context.connections.get("<name>")` |\n| `file` | picker over the account\'s storage | the file read: `{ name, contentType, size, contentBytes }`. For a PDF attachment, never a video |\n| `path` | the same picker | the path only, nothing read. What media steps take |\n| `paths` | the same picker, appending | several paths, one per line |\n\nA field the user left empty may be missing: default it in code (`config.limit ?? 10`).\n\n## Ports\n\n```json\n"outputs": ["success", "error", "empty"]\n```\n\nReturn the name of one as `output`. `success` and `error` are what users expect; an error route on the canvas catches `error`. Add a port only for a third outcome a workflow should branch on (`duplicate`, `not_found`, `high`/`medium`/`low`).\n\n## Connections\n\n```json\n"auth": { "type": "oauth2", "provider": "outlook" },\n"properties": {\n "account": { "label": "Outlook account", "type": "connection", "provider": "outlook", "required": true }\n}\n```\n\nIn an app, the app holds the connection; each module still declares its `connection` property (it may omit `provider`, which it inherits).\n', "references/sandbox.md": "# The sandbox\n\nCode steps run in an isolated sandbox created for the run and destroyed afterwards, on <!-- node:start -->**Node.js 24**<!-- node:end -->. The runner compiles `index.js` into a `vm` context of its own, as the body of a function given `module`, `exports` and a `require` that hands out only the allowed packages. `vsa test` loads code exactly that way, and `vsa dev` and `vsa test --docker` also run on the platform's Node version, in the sandbox image (`ghcr.io/visualautomate/plugin-sandbox`).\n\nA step's context has the language's own objects (`Object`, `Array`, `JSON`, `Math`, `Promise`, `Map`, `Date`, `RegExp`, \u2026) and these globals, nothing more: <!-- provided:start -->`fetch`, `Request`, `Response`, `Headers`, `URL`, `URLSearchParams`, `btoa`, `atob`, `console`, `setTimeout`, `clearTimeout`, `setInterval`, `clearInterval`, `crypto`, `TextEncoder`, `TextDecoder`, `ArrayBuffer`, `Uint8Array`, `Uint16Array`, `Uint32Array`, `Int8Array`, `Int16Array`, `Int32Array`, `Float32Array`, `Float64Array`, `DataView`, `Blob`, `FormData`, `AbortController`, `AbortSignal`<!-- provided:end -->.\n\nThese are set to undefined: <!-- blocked:start -->`process`, `global`, `module`, `exports`, `__dirname`, `__filename`, `eval`, `Function`, `child_process`, `fs`, `os`, `cluster`, `dgram`, `dns`, `net`, `http`, `https`, `path`, `readline`, `stream`, `tls`, `zlib`, `buffer`, `Buffer`, `globalThis`, `self`, `window`, `SharedArrayBuffer`, `Atomics`, `WeakRef`, `FinalizationRegistry`, `AsyncFunction`, `GeneratorFunction`, `AsyncGeneratorFunction`, `WebAssembly`<!-- blocked:end -->. Anything else Node has (`structuredClone`, `setImmediate`, `queueMicrotask`, `performance`) is simply not there.\n\n`console` has four methods: `log`, `error`, `warn` and `info`. `console.debug` and `console.table` do not exist.\n\nThe code is wrapped on one line with nothing added after it, so **end `index.js` with a newline**: a last line that is a `//` comment with no newline after it comments out the wrapper's closing bracket and the step does not load.\n\n## Not available\n\n<!-- globals:start -->\n| Name | Instead |\n| --- | --- |\n| `process` | Ids and secrets are on `context`. |\n| `fs` | Use context.storage. |\n| `child_process` | Not available. For ffmpeg, use context.media.run. |\n| `os` | Not available. |\n| `net` | Use fetch. |\n| `http` | Use fetch. |\n| `https` | Use fetch. |\n| `eval` | Not available. |\n| `Function` | Not available as a constructor. |\n| `Buffer` | Use Uint8Array and TextEncoder/TextDecoder. |\n| `__dirname` | There is no meaningful filesystem. |\n<!-- globals:end -->\n\nNo environment variables, no filesystem, no child processes. Files go through `context.storage`; keys through a `secret` property or `context.secrets`. Bytes are `Uint8Array`: decode with `new TextDecoder().decode(bytes)`, encode with `new TextEncoder().encode(text)`.\n\n## npm packages\n\n`require` with a literal string and a bare package name: `require(\"lodash\")`, never `require(\"lodash/get\")` or `require(name)`. They are pre-installed in the sandbox, so there is nothing to install or bundle for the platform. Locally, `vsa test` without Docker loads them from the project and prints the `npm install` line for a missing one.\n\n<!-- packages:start -->\n`nodemailer`, `lodash`, `ramda`, `underscore`, `immer`, `immutable`, `deepmerge`, `defu`, `rfdc`, `klona`, `fast-copy`, `fast-deep-equal`, `dequal`, `object-hash`, `hash-sum`, `flat`, `dot-prop`, `object-path`, `sort-array`, `natural-orderby`, `deep-object-diff`, `microdiff`, `fast-json-patch`, `rfc6902`, `just-clone`, `just-diff`, `just-diff-apply`, `just-omit`, `just-pick`, `just-group-by`, `just-order-by`, `just-unique`, `just-flatten-it`, `just-safe-get`, `just-safe-set`, `just-typeof`, `just-range`, `just-zip-it`, `just-compare`, `just-intersect`, `just-split`, `just-truncate`, `semver`, `compare-versions`, `tiny-invariant`, `json-stringify-safe`, `fast-safe-stringify`, `flatted`, `superjson`, `devalue`, `lru-cache`, `quick-lru`, `memoize-one`, `fast-memoize`, `uuid`, `nanoid`, `ulid`, `@paralleldrive/cuid2`, `slugify`, `slug`, `sanitize-filename`, `filenamify`, `seedrandom`, `random-js`, `chance`, `@faker-js/faker`, `lorem-ipsum`, `randomcolor`, `date-fns`, `date-fns-tz`, `dayjs`, `luxon`, `moment`, `moment-timezone`, `ms`, `pretty-ms`, `parse-duration`, `humanize-duration`, `timeago.js`, `chrono-node`, `rrule`, `cron-parser`, `cronstrue`, `date-and-time`, `fecha`, `dateformat`, `iso8601-duration`, `ics`, `zod`, `yup`, `joi`, `ajv`, `ajv-formats`, `ajv-errors`, `superstruct`, `valibot`, `jsonschema`, `@sinclair/typebox`, `validator`, `email-validator`, `libphonenumber-js`, `iban`, `credit-card-type`, `card-validator`, `psl`, `tldts`, `normalize-url`, `valid-url`, `is-ip`, `ipaddr.js`, `ip-address`, `netmask`, `ua-parser-js`, `bowser`, `papaparse`, `csv-parse`, `csv-stringify`, `json-2-csv`, `js-yaml`, `yaml`, `json5`, `jsonc-parser`, `hjson`, `toml`, `@iarna/toml`, `ini`, `fast-xml-parser`, `xml2js`, `xml-js`, `xmlbuilder2`, `@xmldom/xmldom`, `xpath`, `cheerio`, `htmlparser2`, `node-html-parser`, `parse5`, `sanitize-html`, `marked`, `markdown-it`, `showdown`, `turndown`, `html-to-text`, `he`, `entities`, `striptags`, `front-matter`, `qs`, `query-string`, `url-parse`, `mime-types`, `mime`, `content-type`, `cookie`, `set-cookie-parser`, `jsonpath-plus`, `jmespath`, `jsonata`, `exceljs`, `change-case`, `camelcase`, `decamelize`, `title-case`, `pluralize`, `inflection`, `voca`, `underscore.string`, `string-similarity`, `fastest-levenshtein`, `leven`, `diff`, `diff-match-patch`, `remove-accents`, `transliteration`, `latinize`, `franc`, `stopword`, `emoji-regex`, `node-emoji`, `string-width`, `word-wrap`, `wrap-ansi`, `linkify-it`, `linkifyjs`, `fuse.js`, `minisearch`, `lunr`, `flexsearch`, `mustache`, `liquidjs`, `intl-messageformat`, `gpt-tokenizer`, `js-tiktoken`, `decimal.js`, `big.js`, `bignumber.js`, `currency.js`, `dinero.js`, `numeral`, `accounting`, `fraction.js`, `convert-units`, `js-quantities`, `mathjs`, `simple-statistics`, `ml-matrix`, `d3-array`, `d3-format`, `d3-time-format`, `geolib`, `haversine-distance`, `filesize`, `bytes`, `human-format`, `jsonwebtoken`, `jose`, `jws`, `jwt-decode`, `crypto-js`, `bcryptjs`, `otplib`, `otpauth`, `tweetnacl`, `tweetnacl-util`, `node-forge`, `elliptic`, `bn.js`, `js-base64`, `base64-js`, `base-64`, `hi-base32`, `thirty-two`, `js-sha256`, `js-sha512`, `js-sha1`, `js-md5`, `hash.js`, `spark-md5`, `blueimp-md5`, `crc-32`, `crc`, `utf8`, `iconv-lite`, `punycode`, `pako`, `fflate`, `lz-string`, `color`, `tinycolor2`, `chroma-js`, `color-convert`, `colord`, `i18n-iso-countries`, `iso-3166-1`, `iso-639-1`, `currency-codes`, `qrcode-generator`, `p-limit`, `p-retry`, `p-map`, `p-queue`, `p-timeout`, `p-all`, `p-props`, `p-settle`, `p-throttle`, `p-debounce`, `async`, `bluebird`, `promise-retry`, `retry`, `exponential-backoff`, `bottleneck`, `throttle-debounce`, `delay`, `graphlib`\n<!-- packages:end -->\n\nUseful picks: dates `date-fns`, `dayjs`, `luxon`; validation `zod`; CSV `papaparse`, `csv-parse`; XML `fast-xml-parser`; HTML `cheerio`; Markdown `marked`; money `decimal.js`, `currency.js`; JWT `jose`; retries `p-retry`; concurrency `p-limit`; ids `nanoid`, `uuid`; spreadsheets `exceljs`; templates `mustache`, `liquidjs`.\n\n## Refused code\n\nThe platform refuses `index.js` when `require` is called with anything but a string literal naming an allowed package, or when any of these regular expressions matches **anywhere in the text, comments and strings included**:\n\n<!-- patterns:start -->\n```text\n\\bprocess\\s*\\.\\s*exit\n\\bprocess\\s*\\.\\s*env\n\\bprocess\\s*\\.\\s*binding\n\\bprocess\\s*\\.\\s*mainModule\n\\bprocess\\s*\\[\n\\bprocess\\s*\\(\n\\beval\\s*\\(\n\\bFunction\\s*\\(\n\\bnew\\s+Function\\b\n\\bAsyncFunction\\b\n\\bGeneratorFunction\\b\n\\bglobalThis\\b\n\\bself\\s*\\[\n\\bimport\\s+\n\\bimport\\s*\\(\n\\bReflect\\b\n\\bProxy\\b\n\\b__proto__\\b\n\\bprototype\\s*\\[\n\\bprototype\\s*\\.\\s*constructor\\b\n\\bconstructor\\b\n\\bthis\\s*\\.\\s*constructor\\b\n\\barguments\\s*\\.\\s*callee\\b\n\\bObject\\s*\\.\\s*defineProperty\\b\n\\b__defineGetter__\\b\n\\b__defineSetter__\\b\n\\b__lookupGetter__\\b\n\\b__lookupSetter__\\b\n\\bmodule\\s*\\.\\s*(?:constructor|extensions|_compile|_path|_sourceMaps)\\b\n\\brequire\\s*\\.\\s*cache\\b\n\\brequire\\s*\\.\\s*resolve\\b\n\\bError\\s*\\.\\s*captureStackTrace\\b\n\\bWebAssembly\\b\n```\n<!-- patterns:end -->\n\nSo: no `import` (not even `import type`, or the word \"import\" followed by a space in a comment), no `.constructor` and no variable called `constructor` (`constructorName` is fine: not a whole word), no `globalThis`, `Reflect`, `Proxy`, `eval(` or `process.env`. Rephrase comments that would contain them.\n\n## Network\n\n- `fetch` may reach the hosts allowed for the plugin on the platform (a domain covers its subdomains), plus a host in the step's own configuration, such as a URL field. The manifest cannot widen this.\n- Never localhost, private networks or cloud metadata addresses.\n- A connected account is reached only through `context.connections`, under the provider's base URL.\n- Locally, `allowedDomains` in the test file applies the same rule; without it, `vsa test` lists every host the platform would refuse.\n\n## Limits\n\n| | Default | Max |\n| --- | --- | --- |\n| `timeoutMs`, one run of the step | 10000 on a canvas, 30000 in the push check | 60000, and the user's plan |\n| `index.js` | | 100,000 characters |\n| `memoryLimitMb` | 32 | 128 |\n| One state value | | 128 KB |\n| `ttlSeconds` on a state value | at least 60 | |\n| A loop the user puts the step in | | 200 items |\n\nMachine tiers (`vsa dev --tier`), the first being the default: <!-- tiers:start -->`standard` 0.5 vCPU and 4 GB, `boosted` 1 vCPU and 6 GB, `high` 2 vCPU and 8 GB<!-- tiers:end -->.\n", "references/test-and-publish.md": '# Testing and publishing\n\n## The CLI\n\n```bash\nnpm install -g @visualautomate/cli # vsa, va and visualautomate are the same command\n```\n\nThe CLI runs on Node 20 or newer; steps run on the platform\'s Node 24, which `vsa dev` and `vsa test --docker` use.\n\n| Command | Does |\n| --- | --- |\n| `vsa test [<module>]` | The push check, here: the manifest is checked; a code step\'s code is checked with the upload\'s rules, loaded the way the runner loads it, and run. A declared step or a trigger is checked, not run |\n| `vsa test --docker` | The same, inside the sandbox image, on Node 24 |\n| `vsa dev [<module>]` | Runs in the sandbox image and again on every save. `--local` without Docker, `--tier standard\\|boosted\\|high` |\n| `vsa types` | Writes `visualautomate.d.ts` and `jsconfig.json` so `/** @type {PluginModule} */` gives completion |\n| `vsa add:property <type> <name> [description] [default]` | Adds a field to `manifest.json` and a value to the test file. `--required`, `--label`, `--options "A:a,B:b"`, `--provider <p>`, `--module <m>` |\n| `vsa init <name> --template blank\\|http-request\\|connection\\|file` | A TypeScript playground (`plugin.ts`). Not the publishing shape |\n| `vsa skill [--global]` | Installs this skill for Claude Code, and writes AGENTS.md / CLAUDE.md |\n\nAt the root of an app\'s repository, `vsa test` runs every folder in `modules/`, code or not; `vsa test send` runs the one whose name matches best.\n\nNothing in the CLI signs in or uploads. Publishing goes through GitHub and the developer portal.\n\n## The test file: visualautomate.test.json\n\n```json\n{\n "config": { "account": "<connection id>", "project": "Website", "title": "Check the forms" },\n "input": {},\n "expectOutput": "success",\n "storage": { "notes/hello.txt": "hello from a file" },\n "state": { "runs": 0 },\n "secrets": { "API_KEY": "test-key" },\n "allowedDomains": ["api.example.com"]\n}\n```\n\n| Key | Read by | Is |\n| --- | --- | --- |\n| `config` | push check and `vsa test` | The fields, as if filled in. For an account property, the **id of one of the developer\'s connected accounts**: the push check then acts as that account |\n| `input` | push check and `vsa test` | What earlier steps handed on |\n| `expectOutput` | push check and `vsa test` | The port the run must leave by. **Default `success`** when it is left out |\n| `storage`, `state`, `secrets` | `vsa test` only | Files, state and `context.secrets` to start with |\n| `allowedDomains` | `vsa test` only | Hosts `fetch` may reach; anything else fails, as on the platform |\n\nThe run\'s time limit is **`timeoutMs` in manifest.json** (30000 when it names none, at most 60000). The push check does not read a `timeoutMs` in the test file, and `vsa test` warns about one.\n\nA run passes when it succeeds and leaves by `expectOutput`. Invalid JSON stops the run.\n\n## Publishing needs a run on success\n\nThe portal refuses to publish code that has not passed a push test **on the `success` port**: once, on that exact code, within the last 30 days. A test that expects `error` and gets it passes the check, but proves nothing, and publishing stays refused. So every code step needs a test file that reaches `success` on the platform:\n\n- **A step that acts as an account:** put the account\'s id in `config`, under the connection property\'s name (`"account": "\u2026"`). The ids are listed on the module\'s page in the developer portal; ask the user for one. The push check then makes real calls as that account **on every push**, so choose a test that is safe to repeat: a read, a draft, a message to a test channel.\n- **A step that needs the user\'s key:** a `secret` field in `config` would be committed to the repository, so never put a real key there. Use a key the plugin holds in `context.secrets`, a public endpoint the plugin may reach, or ask the user how they want to prove it.\n- **A pure step:** realistic `input` and `config`, `expectOutput` `success`.\n\nLocally there is no account: `context.connections` is absent, as on the platform when no account is chosen, and `vsa test` says when an account property has none named for the push check. `context.media` is not available locally.\n\nA **declared step** is not published from GitHub. It is created on the portal with **New module \u2192 Call an API**, its `manifest.json` pasted into the module\'s editor, and it needs a passing **Try it** on its saved version, on the success port, before it can be published there.\n\nOutput of a passing run:\n\n```text\n\u2713 passed success, 3ms\noutput success (expected success, the default)\ndata { "id": "t_42" }\n```\n\n## Publishing (tell the user; you cannot do it for them)\n\n1. Push the repository to GitHub.\n2. On https://developers.visualautomate.com create a **plugin**, or an **app** (name, description, PNG logo up to 2 MB, and the connection users will connect) with a **New module** per step: **Write code** for a code module, **Call an API** for a declared one.\n3. Open **GitHub repository** and import the repository. A repository belongs to one plugin or app.\n4. Every push is tested against the code modules it touched; the portal shows each commit\'s result. Declared modules are pasted into their action page, tried there with **Try it**, and published there.\n5. Publish a commit whose test came back on `success`. **Private**: only the user, always free. **Public**: reviewed first (editing is locked while in review), then listed in the marketplace; can be priced per run and/or per month in credits.\n6. A published version is fixed. A change is a new commit, a new check and a new version.\n', "references/triggers.md": '# Triggers\n\nA trigger starts a workflow for each new item somewhere: a mail, a row, an order. It is **never code that waits**: a sandbox bills by the second. It is a `trigger` block in the manifest; the platform\'s scheduler polls the provider without starting a machine, and starts one run per new item, with the item as the step\'s output. A trigger has no `index.js`, `inputs: []`, and usually `outputs: ["success"]`.\n\n`vsa test` checks it (and warns when `"initial": "now"` is missing) but does not run it. A push publishes only modules with an `index.js` that passes the push check, so a manifest-only trigger cannot be published from GitHub. Ask the user how their app is meant to add one before building it.\n\n## Example: Outlook\'s "New email", as it ships\n\n```json\n{\n "description": "Starts the workflow when new mail arrives",\n "auth": { "type": "oauth2", "provider": "outlook" },\n "properties": {\n "account": { "label": "Outlook account", "type": "connection", "required": true },\n "poll_minutes": {\n "label": "Double-check every",\n "type": "select",\n "default": 30,\n "options": [{ "label": "Every 30 minutes", "value": "30" }, { "label": "Every hour", "value": "60" }]\n }\n },\n "inputs": [],\n "outputs": ["success"],\n "outputFields": { "subject": "The subject line", "from": "Who sent it" },\n "trigger": {\n "connection": "account",\n "poll": "poll_minutes",\n "push": { "resource": "/me/mailFolders(\'inbox\')/messages", "changeType": "created", "maxMinutes": 4230 },\n "request": {\n "path": "/v1.0/me/mailFolders/inbox/messages",\n "query": { "$filter": "receivedDateTime gt {{cursor}}", "$orderby": "receivedDateTime asc", "$top": "25" }\n },\n "items": "value",\n "id": "id",\n "cursor": { "from": "item", "path": "receivedDateTime", "as": "value", "initial": "now" },\n "maxPerPoll": 10\n }\n}\n```\n\n## Keys\n\n| Key | Notes |\n| --- | --- |\n| `connection` | Required. Name of your `connection` property: the account to watch |\n| `request` | `{ method?: "GET" \\| "POST", path, query?, headers?, body? }`. `path` relative to the provider |\n| `items` | Dotted path to the list in the answer (`value`, `data`, `messages`). Empty when the answer is the list |\n| `id` | Dotted path within an item to what makes it unique. Seen ids never start a second run |\n| `poll` | Minutes between checks: a number, or the name of a property so the user picks it. Default 5; the user\'s plan sets the minimum |\n| `cursor` | Where the next poll continues: `{ from: "response" \\| "item", path, as: "url" \\| "value", initial?: "now" }` |\n| `hydrate` | A second request per item, when the list returns only ids: `{ path: ".../messages/{{id}}", query? }`. The result replaces the item |\n| `push` | For providers that notify (Microsoft Graph): `{ resource, changeType, maxMinutes? }`. Still polled underneath as a safety net |\n| `maxPerPoll` | Items one poll may start runs for. Default 25; the rest wait for the next poll |\n\n### cursor\n\n- `from: "response"` reads it from the envelope (a delta link); `from: "item"` from the newest item (a timestamp, a history id).\n- `as: "url"`: the value is the next request outright (delta links carry their own query). `as: "value"`: it is substituted wherever `{{cursor}}` appears in `request`.\n- `initial: "now"`: the first poll only marks the position and starts nothing. **Use it** unless starting from the past is the point: without it, switching the trigger on starts one run, and costs the user credits, for every item already there.\n\n## Checklist\n\n- The provider\'s list endpoint sorts oldest first, or the cursor reads the newest item correctly.\n- `id` is stable and unique per item.\n- `initial: "now"` is set.\n- `maxPerPoll` is modest (10 to 25).\n- `outputFields` describes the item\'s useful fields, since the item is what the next steps read.\n', "SKILL.md": '---\nname: visualautomate-steps\ndescription: Build, fix or extend VisualAutomate steps (plugins, apps and their modules, triggers) end to end - manifest.json, index.js, declared action blocks, visualautomate.test.json, running `vsa test`, and getting the step published on the developer portal. Use whenever someone wants a new VisualAutomate step or integration ("make a VisualAutomate step that...", "add a module to my VisualAutomate app", "connect X to VisualAutomate"), mentions the `vsa`/`va`/`visualautomate` CLI or @visualautomate/plugin-sdk, or is working in a repository with manifest.json + index.js + visualautomate.test.json or a modules/ folder of them.\n---\n\n# Building VisualAutomate steps\n\nVisualAutomate is a workflow automation platform: people drag **steps** onto a canvas and join them with lines. You are building one or more of those steps. Done well, the user describes what the step should do and gets back a repository that passes the platform\'s checks and is ready to push.\n\nRead this file first. Open a file in `references/` when you reach the part it covers; copy from `examples/`, which are held to the platform\'s checks by this repository\'s tests.\n\n| File | Read it when |\n| --- | --- |\n| `references/manifest.md` | writing any `manifest.json` |\n| `references/code-steps.md` | writing `index.js`: `input`, `config`, `context`, return values |\n| `references/declared-steps.md` | the step is one HTTP request, or only reshapes data |\n| `references/triggers.md` | the step should *start* workflows when something new appears |\n| `references/sandbox.md` | requiring an npm package, calling `fetch`, or `vsa test` refuses code |\n| `references/test-and-publish.md` | writing the test file, running the CLI, handing over |\n| `examples/` | before writing a step of the same kind: start from the closest one |\n| `examples/apps/` | building an app with several modules: GitHub and Slack (declared only), Gmail and DeepL (declared and code mixed) |\n\n## The workflow\n\n### 1. Understand the step\n\nGet these straight before writing anything. Ask only for what you cannot sensibly decide yourself, in one short message.\n\n- **What it does**, in one sentence a user would read in the step picker: "Create an invoice in Moneybird".\n- **Which service and how it is reached:**\n - an account the user connects on VisualAutomate (OAuth or a key held by the platform) \u2192 a `connection` property;\n - an API key the user pastes into the step \u2192 a `secret` property;\n - nothing external \u2192 neither.\n- **The fields** a user fills in on the canvas, which are required, and sensible defaults.\n- **What it hands on**: the few named values the next steps need (an id, a URL, a list and its count).\n- **How it can fail**, and whether there is a third outcome worth its own port ("not found", "duplicate", "empty").\n\nIf the user names an API, read its documentation for the exact endpoint, method, body and response shape. Never guess field names of a real API.\n\n### 2. Choose the kind of step\n\nPick the first one that can do the job:\n\n1. **Declared step, no request**: only reshapes what reached it (pick, rename, count, join). An `action` block with `outputs` only.\n2. **Declared step, one request**: one HTTP call built from the fields, with parts of the answer handed on. An `action` block with `request`. No code, no sandbox, one credit per run for the user, and a reviewer can read exactly what it does. Most app modules are this.\n3. **Code step**: anything a single request cannot express: two calls combined, arithmetic, parsing, files, ffmpeg, npm packages, state. An `execute` function in `index.js`, run in a sandbox.\n4. **Trigger**: starts a workflow for every new item somewhere (a mail, a row, an order). Always a `trigger` block in the manifest, never code that waits.\n\nSay which you chose and why in one line when you hand over.\n\n### 3. Choose the repository shape\n\n| Building | Shape |\n| --- | --- |\n| One step that stands alone (a **plugin**) | `manifest.json`, `index.js`, `visualautomate.test.json` at the repository root |\n| Several steps for one service (an **app** with **modules**) | `modules/<module-slug>/` with those three files in each folder. The app itself has no code |\n| A **declared** module (an `action` block, no code) | Not published from GitHub. It is created on the developer portal (**New module \u2192 Call an API**), with its `manifest.json` pasted into the module\'s manifest editor. Write it as `modules/<slug>/manifest.json` all the same, check it with `vsa test`, and tell the user to paste it |\n\nA push publishes only modules with an `index.js` that passes the push check; a declared module or a manifest-only trigger in the repository is checked by the portal and skipped. Module folder names are short kebab-case slugs: `send-message`, `list-invoices`.\n\nIn an app, decide per module: the reads and simple writes are usually declared, and only the modules that need more are code (see `examples/apps/gmail`, where sending needs code and the rest does not). Every module that acts as the account declares the same `connection` property (`account`) with the same provider and `auth`; the app\'s name, logo and connection are set on the portal, not in the repository.\n\nIf the user is already in a repository, follow its shape. If they ran `vsa init`, they have a TypeScript playground (`plugin.ts`); the repository shape above is what gets published.\n\n### 4. Write the files\n\nStart from the closest example. Then:\n\n- `manifest.json`: see `references/manifest.md`. Every field has a `label`; required fields first; `outputFields` describes every key of `data`.\n- `index.js`: see `references/code-steps.md`. CommonJS, `module.exports`, `/** @type {PluginModule} */` above it.\n- `visualautomate.test.json`: see `references/test-and-publish.md`. A realistic `config` and `input` that reach `success` on the platform: publishing needs one push whose test comes back on `success`. For an account property, that means the id of a connected account in `config` (ask the user).\n\n### 5. Check it\n\nFor a **code step**, run the platform\'s check and fix until it passes:\n\n```bash\nvsa test # in the plugin\'s folder, or at an app\'s root (every module)\nvsa test send-message # one module of an app\n```\n\n`vsa test` judges as the push check does: the manifest is checked, the code is loaded the way the platform\'s runner loads it (its globals and no others, a four-method console), the run gets manifest.json\'s `timeoutMs`, and it must leave by `expectOutput`, which is `success` when the test file names none.\n\nIf `vsa` is not installed: `npm install -g @visualautomate/cli` (Node 20+ for the CLI). With Docker available, `vsa test --docker` runs inside the real sandbox image on the platform\'s Node 24.\n\nWhat a red result can mean, and what to do:\n\n- **refused**: the code breaks a rule below. Fix the code, never the check.\n- **`"x" is available in the sandbox but not installed here`**: an allowed package is missing locally. Run the `npm install --save-dev` line it prints (it is only for local runs), or skip the local run and say so.\n- **`\u2717 manifest`**: the manifest breaks a rule the portal enforces. Fix the manifest.\n- **`Cannot read properties of undefined (reading \'get\')`**: the step uses `context.connections` without checking it. Locally there is no account, as on the platform when none is chosen: check for it and leave by `error` with a sentence. To prove the step works, name an account in the test file\'s `config` for the push check.\n- **`\u2026 is not defined` / `undefined`** for `Buffer`, `process`, `structuredClone` and the like: the platform does not have them either. See `references/sandbox.md`.\n- **`context.media is not available locally`**: expected for a step that uses ffmpeg; it runs on the platform.\n- **a network failure**: the API needs the user\'s key or a host this machine cannot reach. Make sure the step fails cleanly by its `error` port, and say what you could not verify.\n\nA **declared step** is checked by `vsa test` (`\u2713 checked \u2026 the manifest is sound`) but not run, here or by the push check. Fix every `\u2717 manifest` line, read the warnings, and check the request against the API\'s documentation. Then it goes to the developer portal: **New module \u2192 Call an API**, paste the `manifest.json` into the module\'s manifest editor (the form above it edits the same file), press **Try it** with an account, and publish there. Publishing needs a Try it that came back on `success`, on the saved version.\n\n### 6. Hand over\n\nTell the user, briefly:\n\n- what you built and which kind of step it is;\n- the files, and what each field and output is;\n- what `vsa test` said (or what you could not test, and why);\n- the next steps, for code modules: push to GitHub; on https://developers.visualautomate.com create the plugin or app (and a **Write code** module), import the repository under **GitHub repository**, wait for the commit\'s check to pass **on success**, then publish. Private steps are usable at once; public ones are reviewed first.\n- for declared modules: on the portal, **New module \u2192 Call an API**, paste the `manifest.json`, **Try it**, publish. They are never published from GitHub.\n- if an account property has no account named in the test file: that the user must add one (the module\'s page lists the ids) before the step can be published.\n\n## Hard rules\n\nThese are enforced by the platform. Breaking one means the step is refused or fails for every user.\n\n1. **No `import`, anywhere in `index.js`**, not even in a comment or a type import. CommonJS only: `module.exports`, `require`.\n2. **`require` only the allowed npm packages**, by bare name (`lodash`, not `lodash/get`), with a literal string. No `fs`, `http`, `child_process`, `os`, `path`. The list is in `references/sandbox.md`.\n3. **None of the refused words**, even in comments or strings: `eval(`, `Function(`, `new Function`, `process.env`, `process.exit`, `globalThis`, `Reflect`, `Proxy`, `constructor`, `__proto__`, `require.cache`, `WebAssembly` and the others in `references/sandbox.md`. Name variables so they do not contain these as whole words (`ctorName`, not `constructor`).\n4. **Never hold or read a credential for a connected account.** Use `context.connections.get("<your connection property>")` and `request({ path })`. There is no token to read.\n5. **Never name a host for a connected account.** Request paths are relative to the provider. A manifest may not contain `url`, `baseUrl`, `base_url` or `host` in `auth`; a declared `action` uses either `connection` or `baseUrl`, never both, and `baseUrl` may read `config` but never `input`.\n6. **No secrets in code or in the repository.** A user\'s own key is a `secret` property; a key the plugin itself uses on everybody\'s behalf comes from `context.secrets`.\n7. **Return `{ output, data }`** where `output` is one of the manifest\'s `outputs`. Expected failures go out by `"error"` with a readable `data.error`; do not throw for them.\n8. **A trigger never waits in code.** It is a `trigger` block; the platform polls. Use `"initial": "now"` on its cursor unless starting from the past is the point.\n9. **Stay inside the limits**: `timeoutMs` at most 60000 (default 10000 on a canvas, 30000 in the push check), `memoryLimitMb` at most 128 (default 32), state values at most 128 KB each with `ttlSeconds` at least 60, `index.js` at most 100,000 characters.\n10. **Only the platform\'s globals.** No `Buffer`, `process`, `globalThis`, `structuredClone`, `setImmediate`; `console` has `log`, `error`, `warn` and `info` only. End `index.js` with a newline.\n11. **Export what the runner accepts**: `module.exports = { execute }`, `module.exports.execute = fn`, or a function. A `default` that is an object is not found.\n\n## What makes a good step\n\n- **The description is a sentence a user picks from**, starting with a verb: "Create an invoice", "List open tickets".\n- **Labels are words, not keys**: "Customer email", not `customerEmail`. Add a `description` where a field needs explaining, and a `group` when there are more than five.\n- **Defaults make the first run work**: a sensible `limit`, a default `select` option.\n- **Outputs are flat and few**: `id`, `url`, `items` and `count` beat the whole raw response. Describe each one in `outputFields`; that is what the next step\'s user sees.\n- **Error messages say what to do**: "Choose a project on this step." beats "Bad request".\n- **Read the body as text before parsing it.** An API having a bad day answers with HTML.\n- **Lists stay small**: a declared step with `items` has a `limit`; a code step that pages through results stops at a sensible maximum.\n- **Logs explain, they do not dump**: `console.log("found 12 invoices")`, never a customer\'s record or a key.\n' };
2887
+
2888
+ // src/ai-files.ts
2889
+ var SKILL_NAME = "visualautomate-steps";
2890
+ var AGENTS_FILE = "AGENTS.md";
2891
+ var CLAUDE_FILE = "CLAUDE.md";
2892
+ var AGENTS_MARKER = "<!-- Written by the VisualAutomate CLI: `vsa skill` keeps it current. Replace this line to keep your own edits. -->";
2893
+ function agentsFor(version) {
2894
+ return `${AGENTS_MARKER}
2895
+
2896
+ # Building VisualAutomate steps here
2897
+
2898
+ This folder holds VisualAutomate steps: modules that run on the VisualAutomate workflow canvas. (CLI ${version})
2899
+
2900
+ Before writing or changing a step, read the full guide: \`.claude/skills/${SKILL_NAME}/SKILL.md\` if it is here (\`vsa skill\` puts it there), otherwise https://docs.visualautomate.com/developers/overview or, all in one file, https://docs.visualautomate.com/llms-full.txt.
2901
+
2902
+ ## Shape
2903
+
2904
+ - A plugin: \`manifest.json\`, \`index.js\` and \`visualautomate.test.json\` at the root.
2905
+ - An app: one folder per module under \`modules/<slug>/\`, each with those three files.
2906
+ - A step that is one HTTP request (an \`action\` block in the manifest) has no \`index.js\`, and is not published from GitHub: it is created on the developer portal as New module \u2192 Call an API, with its manifest.json pasted into the module's editor. Keep a copy here and check it with \`vsa test\`.
2907
+ - A \`plugin.ts\` made by \`vsa init\` is a local playground; the files above are what gets published.
2908
+
2909
+ ## Rules the platform enforces
2910
+
2911
+ 1. \`index.js\` is CommonJS, loaded as written: \`module.exports = { execute: async (input, config, context) => ... }\`, with \`/** @type {PluginModule} */\` above it.
2912
+ 2. The word \`import\` may not appear anywhere in \`index.js\`, comments included. Neither may \`eval(\`, \`Function(\`, \`process.env\`, \`globalThis\`, \`Reflect\`, \`Proxy\`, \`constructor\`, \`__proto__\` or \`WebAssembly\`.
2913
+ 3. \`require\` only allowed npm packages, by bare name and with a string literal. No \`fs\`, \`http\`, \`os\`, \`child_process\` or \`Buffer\`; use \`context.storage\`, \`fetch\` and \`Uint8Array\`.
2914
+ Steps run on Node ${PLATFORM_NODE_MAJOR} in a context with only the platform's globals: no \`process\`, \`globalThis\`, \`structuredClone\` or \`setImmediate\`, and \`console\` has \`log\`, \`error\`, \`warn\` and \`info\` only. End \`index.js\` with a newline.
2915
+ 4. Never read or hold a credential. A connected account is \`context.connections.get("<connection property>")\` and \`request({ path })\` with a path relative to the provider.
2916
+ 5. A user's own key is a \`secret\` property; a key the plugin holds is \`context.secrets\`. Nothing secret goes in the repository.
2917
+ 6. Return \`{ output, data }\`, where \`output\` is one of the manifest's \`outputs\`. Expected failures leave by \`"error"\` with a readable \`data.error\`.
2918
+ 7. Limits: \`timeoutMs\` at most 60000 (the push check gives 30000 when the manifest names none), \`memoryLimitMb\` at most 128, a state value at most 128 KB, \`index.js\` at most 100,000 characters.
2919
+
2920
+ ## Checking
2921
+
2922
+ - \`vsa test\` is the push check, here (\`vsa test <module>\` for one module of an app): it checks every manifest, and loads and runs every code step the way the platform does. Fix the code until it passes; never weaken the check.
2923
+ - A run must leave by the test file's \`expectOutput\`, which is \`success\` when it names none.
2924
+ - Locally there is no connected account: \`context.connections\` is absent, so check for it. \`context.media\` only runs on the platform.
2925
+ - Declared steps are checked by \`vsa test\`, then pasted into the portal, run with Try it, and published there.
2926
+
2927
+ Publishing is done by the developer, from GitHub, on https://developers.visualautomate.com, and needs one push whose test came back on \`success\`. For a step that acts as an account, that means the id of a connected account under \`config\` in visualautomate.test.json (the module's page on the portal lists them).
2928
+ `;
1400
2929
  }
2930
+ var CLAUDE_TEXT = `${AGENTS_MARKER}
1401
2931
 
1402
- // src/declarations.ts
1403
- var TYPE_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\nexport interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\nexport interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\nexport interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\nexport interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\nexport interface PluginStorage {\n /** Write a file. Bytes may be a string, a Buffer or any typed array. */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\nexport interface PluginState {\n /** The stored value, or `null` when the key has never been set. */\n get(key: string): Promise<unknown>\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n list(prefix?: string): Promise<{ keys: string[] }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\nexport interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\nexport interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\nexport interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\nexport interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\nexport interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\nexport interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\nexport interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\nexport interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\nexport type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\nexport type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\nexport interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\nexport type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\nexport interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\nexport interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\nexport interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\nexport interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\nexport interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\nexport interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\nexport interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\nexport type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\nexport interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\nexport type PluginSchema = PluginManifest\n';
2932
+ @${AGENTS_FILE}
2933
+ `;
2934
+ function ensureAgentFiles(root, version) {
2935
+ const written = [];
2936
+ for (const [name, wanted2] of [
2937
+ [AGENTS_FILE, agentsFor(version)],
2938
+ [CLAUDE_FILE, CLAUDE_TEXT]
2939
+ ]) {
2940
+ const path = join(root, name);
2941
+ const current = existsSync(path) ? readFileSync(path, "utf8") : null;
2942
+ if (current === null || current.startsWith(AGENTS_MARKER) && current !== wanted2) {
2943
+ writeFileSync(path, wanted2);
2944
+ written.push(name);
2945
+ }
2946
+ }
2947
+ return written;
2948
+ }
2949
+ function foreignAgentFiles(root) {
2950
+ return [AGENTS_FILE, CLAUDE_FILE].filter((name) => {
2951
+ const path = join(root, name);
2952
+ return existsSync(path) && !readFileSync(path, "utf8").startsWith(AGENTS_MARKER);
2953
+ });
2954
+ }
2955
+ function installSkill(dir) {
2956
+ let changed = 0;
2957
+ for (const [rel, text] of Object.entries(SKILL_FILES)) {
2958
+ const path = join(dir, rel);
2959
+ if (existsSync(path) && readFileSync(path, "utf8") === text) continue;
2960
+ mkdirSync(dirname(path), { recursive: true });
2961
+ writeFileSync(path, text);
2962
+ changed++;
2963
+ }
2964
+ return changed;
2965
+ }
2966
+ function skillFileCount() {
2967
+ return Object.keys(SKILL_FILES).length;
2968
+ }
1404
2969
 
1405
2970
  // src/repo-types.ts
1406
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
1407
- import { basename, dirname, join } from "node:path";
2971
+ import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
2972
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
1408
2973
 
1409
2974
  // src/ambient-declarations.ts
1410
- var AMBIENT_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\ndeclare interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\ndeclare interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\ndeclare interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\ndeclare interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\ndeclare interface PluginStorage {\n /** Write a file. Bytes may be a string, a Buffer or any typed array. */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\ndeclare interface PluginState {\n /** The stored value, or `null` when the key has never been set. */\n get(key: string): Promise<unknown>\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n list(prefix?: string): Promise<{ keys: string[] }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\ndeclare interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\ndeclare interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\ndeclare interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\ndeclare interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\ndeclare interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\ndeclare interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\ndeclare interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\ndeclare interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\ndeclare type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\ndeclare type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\ndeclare interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\ndeclare type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\ndeclare interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\ndeclare interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\ndeclare interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\ndeclare interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\ndeclare interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\ndeclare interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\ndeclare interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\ndeclare type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\ndeclare interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\ndeclare type PluginSchema = PluginManifest\n';
2975
+ var AMBIENT_DECLARATIONS = '/**\n * What a plugin is handed, and what it must hand back.\n *\n * This file is the contract between a plugin and the VisualAutomate runtime:\n * the arguments `execute` is called with, every helper on `context`, the shape\n * of the result, and the manifest that declares the step. When a helper is only\n * present under some condition, that is said here rather than left for somebody\n * to discover from a TypeError.\n *\n * context-api.ts describes the same runtime as data, for tools that cannot read\n * a TypeScript interface, such as an editor\'s autocomplete.\n */\n\n// \u2500\u2500\u2500 What a step is called with \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The output of the steps that led here, keyed by step name and node id.\n *\n * A step reads `input.download_clips.file` where `download_clips` is what the\n * step before it is called on the canvas. Every route into this step is merged,\n * so a step at the join of two branches sees both.\n */\ndeclare interface PluginInput {\n [key: string]: unknown\n}\n\n/** The fields somebody filled in on the canvas, as the manifest declared them. */\ndeclare interface PluginConfig {\n [key: string]: unknown\n}\n\n// \u2500\u2500\u2500 context.storage \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** What `storage.put` reports about the file it wrote. */\ndeclare interface StorageResult {\n url: string\n size: number\n contentType: string\n uploadedAt: string\n expiresAt: string\n}\n\n/** What `storage.list` reports. */\ndeclare interface StorageListResult {\n files: Array<{\n url: string\n pathname: string\n size: number\n uploadedAt: string\n }>\n totalSize: number\n}\n\n/**\n * The account\'s own files. Paths are scoped to the user; you cannot name\n * anybody else\'s, and you do not have to prefix your own.\n *\n * The account\'s plan caps what it may hold at once, and a `put` past that\n * ceiling is refused rather than silently dropped.\n */\ndeclare interface PluginStorage {\n /**\n * Write a file. Bytes are a string or a Uint8Array: there is no `Buffer` in a\n * step\'s context.\n */\n put(path: string, data: Uint8Array | string, contentType?: string): Promise<StorageResult>\n /** Read a file back. `null` when it is not there. */\n get(path: string): Promise<Uint8Array | null>\n delete(path: string): Promise<void>\n /** Everything under a prefix, or everything when no prefix is given. */\n list(prefix?: string): Promise<StorageListResult>\n /** Size and content type without moving the bytes. `null` when absent. */\n head(path: string): Promise<{ size: number; contentType: string } | null>\n /**\n * A URL somebody else can fetch this file from, for a few minutes.\n *\n * For the common API that takes a link and pulls the file itself. Clamped\n * to between one minute and one hour; the default is fifteen minutes.\n */\n signedUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * A link on VisualAutomate\'s own domain that opens the file, for somebody\n * with no account: a mail, a message, a page. The storage bucket\'s own\n * address (signedUrl) is right for an API that fetches the file; this one is\n * right for a person. Fifteen minutes unless `expiresInSeconds` says otherwise.\n */\n shareUrl(path: string, expiresInSeconds?: number): Promise<string | null>\n /**\n * Pull a URL straight into storage without the bytes passing through this\n * step. A hundred-megabyte file costs the run no memory and no egress,\n * because a Worker fetches it and writes it to storage directly.\n *\n * ONLY PRESENT when the download Worker is configured on the server. Guard\n * with `if (context.storage.download)` if you want to degrade gracefully.\n */\n download?(url: string, path: string, contentType?: string): Promise<StorageResult>\n}\n\n// \u2500\u2500\u2500 context.state \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Values that outlive one run, scoped to this workflow and this account.\n *\n * For a cursor, a last-seen id, a counter \u2014 the things a workflow needs to\n * remember between firings. Not for large data: that is what storage is for.\n * The account\'s plan caps how many keys it may hold.\n */\ndeclare interface PluginState {\n /** The stored value, or `null` when the key has never been set or has expired. */\n get(key: string): Promise<unknown>\n /**\n * Store a JSON value, at most 128 KB once encoded. With `ttlSeconds`, which\n * must be at least 60, it is forgotten after that many seconds.\n */\n set(key: string, value: unknown, options?: { ttlSeconds?: number }): Promise<void>\n delete(key: string): Promise<void>\n /**\n * The keys under a prefix, each with when it expires (`null` for never).\n * `complete` is false when there were more than one call returns.\n */\n list(prefix?: string): Promise<{ keys: Array<{ key: string; expiresAt: string | null }>; complete: boolean }>\n}\n\n// \u2500\u2500\u2500 context.media \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * ffmpeg, for a step that has a file in storage and wants a different one.\n *\n * A PLUGIN NEVER BUILDS AN FFMPEG COMMAND LINE, and cannot. You name an\n * operation and supply values; the command line is assembled on the server, one\n * operation at a time, and the only paths in it are placeholders the container\n * fills from a list the server wrote. That is deliberate: `-i` takes any path\n * and several filters open files of their own, so an ffmpeg taking its flags\n * from plugin config would be a plugin reading the container\'s filesystem \u2014\n * including the file holding that run\'s own credentials.\n *\n * The job runs in a container of its own and talks to storage directly, so a\n * two hundred megabyte clip never crosses this step.\n *\n * ONLY PRESENT when the sandbox and storage are both configured.\n */\ndeclare interface PluginMedia {\n run(operation: string, params: Record<string, unknown>): Promise<Record<string, unknown>>\n}\n\n// \u2500\u2500\u2500 context.connections \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/** One request through the integration proxy. */\ndeclare interface ConnectionRequest {\n path: string\n method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"\n query?: Record<string, string | number | boolean | undefined>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** What the proxy answers with. The provider\'s own reply, unwrapped. */\ndeclare interface ConnectionResponse {\n ok: boolean\n status: number\n headers?: Record<string, string>\n body: unknown\n}\n\n/**\n * One of the accounts the user connected.\n *\n * THERE IS NO getToken(), deliberately. A token that can be read can be logged\n * or posted somewhere, and this is where third-party code runs. You name a path\n * and the proxy attaches the credential on the way past.\n *\n * You also never name a host. The base URL belongs to the provider\n * configuration, which is what stops a connection being pointed at an internal\n * address or at somebody\'s collection server.\n */\ndeclare interface PluginConnection {\n /** This platform\'s id for the connection, not the provider\'s. */\n id: string\n request(options: ConnectionRequest): Promise<ConnectionResponse>\n}\n\n/**\n * The accounts this step was configured with.\n *\n * `get` takes the name of one of your own `connection` properties \u2014 not a\n * connection id \u2014 so a step can only reach the accounts it was set up with.\n *\n * ONLY PRESENT when the step declares at least one `connection` property and\n * the user has chosen an account for it.\n */\ndeclare interface PluginConnections {\n get(propertyName: string): Promise<PluginConnection>\n}\n\n// \u2500\u2500\u2500 The context itself \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * Everything a step is told about the run it is part of.\n *\n * The optional members are optional for a reason and not out of caution: each\n * is absent when the thing behind it is not configured or not applicable, and\n * a plugin that needs one should say so plainly rather than crash on a dot.\n */\ndeclare interface PluginContext {\n /** The workflow this run belongs to. */\n workflowId: string\n /** This step\'s id on the canvas. */\n nodeId: string\n /** Whose run it is. */\n userId: string\n /** This run, distinct from every other run of the same workflow. */\n executionId: string\n /** When the run started, as an ISO string. */\n startedAt: string\n /** Values the plugin was published with. Not the user\'s credentials. */\n secrets: Record<string, string>\n\n /** The account\'s files. See PluginStorage. */\n storage: PluginStorage\n /** Values that outlive one run. See PluginState. */\n state: PluginState\n /** ffmpeg. Absent when the server has no sandbox or storage configured. */\n media?: PluginMedia\n /** The accounts this step was configured with. Absent when it has none. */\n connections?: PluginConnections\n}\n\n// \u2500\u2500\u2500 What a step hands back \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The result of a step.\n *\n * `output` names which port the run leaves by, and therefore which lines on the\n * canvas are followed. It has to be one of the `outputs` in the manifest \u2014\n * usually `"success"` or `"error"`.\n *\n * `data` is what the next steps read as `{{this_step.field}}`. Keep it flat and\n * named for what it is; those names are what somebody picks from a dropdown.\n */\ndeclare interface PluginOutput {\n output: string\n data: Record<string, unknown>\n metadata?: {\n durationMs?: number\n memoryUsedMb?: number\n logs?: string[]\n }\n}\n\n/**\n * A plugin.\n *\n * Export `execute`, either as `module.exports.execute` or as the default export\n * of a module with an `execute` on it. Both are accepted, and so is a bare\n * function as the default export.\n *\n * @example\n * export default {\n * async execute(input, config, context) {\n * const file = await context.storage.get(config.path as string)\n * return { output: "success", data: { bytes: file?.length ?? 0 } }\n * }\n * }\n */\ndeclare interface PluginModule {\n execute(\n input: PluginInput,\n config: PluginConfig,\n context: PluginContext,\n ): Promise<PluginOutput> | PluginOutput\n}\n\n/** The bare function form, for a plugin that is only an execute. */\ndeclare type PluginExecute = PluginModule["execute"]\n\n// \u2500\u2500\u2500 How a run reports on a step \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * `skipped` is a decision rather than a fault: the filters on every line into\n * the step said no, so it never ran. Counting it as an error turns a branch\n * that was simply not chosen into a broken workflow.\n */\ndeclare type ExecutionStatus = "success" | "error" | "timeout" | "skipped" | "cancelled"\n\ndeclare interface PluginExecutionResult {\n status: ExecutionStatus\n output: PluginOutput | null\n error?: string\n durationMs: number\n memoryMb?: number\n outputPort?: string\n logs?: string[]\n}\n\n// \u2500\u2500\u2500 The manifest \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n/**\n * The declaration that sits beside the code.\n *\n * It says which fields the canvas renders, which account the step acts as, what\n * it hands downstream, and whether it is a starting point that watches\n * something. One shape wherever it is stored \u2014 `manifest.json` next to your\n * code, or an `@schema` comment in the source.\n *\n * Every name below is one the platform keeps. A block it does not recognise is\n * dropped when the plugin is saved, so a field that is not listed here will not\n * survive an upload.\n */\ndeclare type PluginPropertyType =\n | "string"\n | "number"\n | "boolean"\n | "select"\n | "multiselect"\n | "json"\n /**\n * A program, not a value.\n *\n * Stored exactly as typed and never parsed, which is the difference from\n * `json` above: that one keeps its value structured, so `return 5` would be\n * saved as the number five and the step would run nothing.\n */\n | "code"\n /**\n * A key the user types in: an API key, a token. A string to your code and to\n * a declared call (`Bearer {{config.apiKey}}`), and masked wherever it is\n * shown, because it is the one value on a step that somebody looking over a\n * shoulder must not be able to read off.\n */\n | "secret"\n | "color"\n | "date"\n /** An account the user connected. Rendered as a picker, not a text box. */\n | "connection"\n /**\n * A file the account already has, read into the config before the step\n * runs. `{{config.attachment.name}}`, `.contentType`, `.size`,\n * `.contentBytes`. Right for attaching a PDF; a video would be hundreds of\n * megabytes of base64 \u2014 use `path` for those.\n */\n | "file"\n /** A file in storage, named rather than read. What ffmpeg steps take. */\n | "path"\n /** Several of them, one per line, in the order they were chosen. */\n | "paths"\n\ndeclare interface PluginProperty {\n /** Shown above the input on the canvas. Defaults to the property name. */\n label: string\n type: PluginPropertyType\n required?: boolean\n default?: unknown\n /** Helper text under the input. */\n description?: string\n /** For `select` and `multiselect`. */\n options?: Array<{ label: string; value: string }>\n /** Puts related fields in a section together. */\n group?: string\n /** For `number`. */\n min?: number\n max?: number\n /** For `string`: a regex the value must match. */\n pattern?: string\n /**\n * For `connection`: which integration the account must belong to, e.g.\n * `outlook`. A module inherits its app\'s, so it does not repeat this.\n */\n provider?: string\n /** Longer help, shown on request rather than under the input. */\n help?: string\n /** Shown but not editable. */\n disabled?: boolean\n /**\n * For `select` and `multiselect`: read the options from the user\'s account\n * instead of listing them, e.g. the channels of the chosen Slack workspace.\n */\n optionsFrom?: OptionsSource\n /**\n * For `secret`: the properties that say where this secret is sent, such as\n * a server URL. A stored secret is only kept when those are unchanged, so\n * pointing the URL somewhere else cannot carry the key along.\n */\n sentTo?: string[]\n /**\n * For `secret`: the value is sent exactly as typed and never computed with,\n * which lets the platform keep it out of the step\'s code entirely.\n */\n verbatim?: boolean\n}\n\n/**\n * Where a select\'s options come from: one GET as the account in `connection`,\n * a list in the answer, and what each option shows and stores.\n */\ndeclare interface OptionsSource {\n /** The `connection` property whose account the options are read as. */\n connection: string\n request: { path: string; query?: Record<string, unknown>; headers?: Record<string, string> }\n /** Where the list is in the answer (`""` when the answer is the list), and how many to offer. */\n items: { path: string; limit?: number }\n paginate?: Record<string, unknown>\n /** What each option shows, e.g. `{{item.name}}`. */\n label: string\n /** What choosing it stores, e.g. `{{item.id}}`. */\n value: string\n}\n\n/**\n * How a step reaches the account it acts as.\n *\n * `none` means the plugin holds its own key in a property. That is not the same\n * as leaving the block out, which means "written before auth could be\n * declared".\n *\n * A MANIFEST MAY NOT NAME A HOST. `url`, `baseUrl`, `base_url` and `host` are\n * refused outright rather than ignored, so nobody writes one and believes it\n * took effect: the base URL belongs to the provider configuration, and that is\n * what stops a connection being pointed at an internal address.\n */\ndeclare interface PluginAuth {\n type: "oauth2" | "api_key" | "basic" | "none"\n provider?: string\n scopes?: string[]\n}\n\n/** One request the platform makes on your behalf. Relative paths only. */\ndeclare interface TriggerRequest {\n method?: "GET" | "POST"\n /** Relative to the provider\'s base URL. The proxy refuses anything else. */\n path: string\n query?: Record<string, string>\n headers?: Record<string, string>\n body?: unknown\n}\n\n/** Where the next poll should carry on from. */\ndeclare interface TriggerCursor {\n from: "response" | "item"\n /** Dotted path to the value. `@odata.deltaLink` is one key, not two. */\n path: string\n into?: string\n}\n\n/**\n * A second request per item, for providers that answer with identifiers.\n *\n * Gmail\'s message list returns `{ id, threadId }` \u2014 enough to know a mail\n * arrived, not enough to tell anyone what it says. Without this the trigger\n * polls correctly, dedupes correctly, and hands every workflow an empty\n * message, which is the worst kind of working.\n */\ndeclare interface TriggerHydrate {\n path: string\n method?: "GET" | "POST"\n query?: Record<string, string>\n headers?: Record<string, string>\n}\n\n/** For providers that can tell us instead of being asked. */\ndeclare interface TriggerPush {\n resource: string\n changeType: string\n maxMinutes?: number\n}\n\n/**\n * What makes a plugin a starting point.\n *\n * A declaration rather than code, because the runtime is billed by the second\n * and a step blocking on an empty inbox would be paid for every second of doing\n * nothing. The platform polls for you and starts the workflow when something\n * arrives.\n */\ndeclare interface PluginTrigger {\n /** Must name one of your own properties of type `connection`. */\n connection: string\n /** Minutes between checks. The account\'s plan sets the floor. */\n poll?: string | number\n request: TriggerRequest\n /** Dotted path to the array of items in the response. */\n items: string\n /** Dotted path, within one item, to what makes it unique. */\n id: string\n cursor?: TriggerCursor\n hydrate?: TriggerHydrate\n push?: TriggerPush\n /** How many items one poll may start workflows for. */\n maxPerPoll?: number\n}\n\n/**\n * A step that is a declaration rather than a program.\n *\n * One request built from the properties somebody filled in, or no request at\n * all for a step that only reshapes what reached it. No container, no code, and\n * nothing for a review to be about \u2014 which is why most steps should be one.\n *\n * Left loosely typed here on purpose: writing an action is a different job from\n * writing a plugin, and the platform validates its shape when it is saved.\n */\ndeclare type PluginAction = Record<string, unknown>\n\n/**\n * Every block the validator keeps. Anything else is dropped on save.\n *\n * Note what is NOT here: the hosts a plugin may contact are set on the platform\n * rather than declared in the manifest, so a plugin cannot widen its own egress.\n */\ndeclare interface PluginManifest {\n description?: string\n icon?: string\n color?: string\n /** Bumped by you when the declaration changes shape. */\n version?: number\n /** How long one run may take. Capped by the account\'s plan. */\n timeoutMs?: number\n memoryLimitMb?: number\n auth?: PluginAuth\n properties?: Record<string, PluginProperty>\n inputs?: string[]\n outputs?: string[]\n /** What downstream steps can read, described for the picker. */\n outputFields?: Record<string, string>\n trigger?: PluginTrigger\n action?: PluginAction\n}\n\n/** The older name for the same thing, kept because authors still say it. */\ndeclare type PluginSchema = PluginManifest\n';
1411
2976
 
1412
2977
  // src/repo-types.ts
1413
2978
  var TYPES_FILE = "visualautomate.d.ts";
@@ -1448,32 +3013,32 @@ var JSCONFIG = `${JSON.stringify(
1448
3013
  `;
1449
3014
  function ensureRepoTypes(root, version) {
1450
3015
  const written = [];
1451
- const typesPath = join(root, TYPES_FILE);
3016
+ const typesPath = join2(root, TYPES_FILE);
1452
3017
  const wanted2 = declarationsFor(version);
1453
- const current = existsSync(typesPath) ? read(typesPath) : null;
3018
+ const current = existsSync2(typesPath) ? read(typesPath) : null;
1454
3019
  if (current === null || current.startsWith(TYPES_MARKER) && current !== wanted2) {
1455
- writeFileSync(typesPath, wanted2);
3020
+ writeFileSync2(typesPath, wanted2);
1456
3021
  written.push(TYPES_FILE);
1457
3022
  }
1458
- const jsconfigPath = join(root, JSCONFIG_FILE);
1459
- if (!existsSync(jsconfigPath)) {
1460
- writeFileSync(jsconfigPath, JSCONFIG);
3023
+ const jsconfigPath = join2(root, JSCONFIG_FILE);
3024
+ if (!existsSync2(jsconfigPath)) {
3025
+ writeFileSync2(jsconfigPath, JSCONFIG);
1461
3026
  written.push(JSCONFIG_FILE);
1462
3027
  }
1463
3028
  return written;
1464
3029
  }
1465
3030
  function read(path) {
1466
3031
  try {
1467
- return readFileSync(path, "utf8");
3032
+ return readFileSync2(path, "utf8");
1468
3033
  } catch {
1469
3034
  return null;
1470
3035
  }
1471
3036
  }
1472
3037
  function projectRoot(cwd) {
1473
- const parent = dirname(cwd);
3038
+ const parent = dirname2(cwd);
1474
3039
  if (basename(parent) === "modules") {
1475
- const root = dirname(parent);
1476
- if (existsSync(join(root, "package.json")) || existsSync(join(root, TYPES_FILE))) return root;
3040
+ const root = dirname2(parent);
3041
+ if (existsSync2(join2(root, "package.json")) || existsSync2(join2(root, TYPES_FILE))) return root;
1477
3042
  }
1478
3043
  return cwd;
1479
3044
  }
@@ -1886,13 +3451,15 @@ function parseJsonc(text) {
1886
3451
  }
1887
3452
 
1888
3453
  // src/manifest-property.ts
1889
- var PROPERTY_TYPES = [
3454
+ var PROPERTY_TYPES2 = [
1890
3455
  "string",
1891
3456
  "number",
1892
3457
  "boolean",
1893
3458
  "select",
1894
3459
  "multiselect",
1895
3460
  "json",
3461
+ "code",
3462
+ "secret",
1896
3463
  "color",
1897
3464
  "date",
1898
3465
  "connection",
@@ -1901,7 +3468,7 @@ var PROPERTY_TYPES = [
1901
3468
  "paths"
1902
3469
  ];
1903
3470
  function isPropertyType(value) {
1904
- return PROPERTY_TYPES.includes(value);
3471
+ return PROPERTY_TYPES2.includes(value);
1905
3472
  }
1906
3473
  function isPropertyName(name) {
1907
3474
  return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
@@ -2111,13 +3678,13 @@ function sampleFor(property) {
2111
3678
  }
2112
3679
 
2113
3680
  // src/project.ts
2114
- import { existsSync as existsSync2, readdirSync, statSync } from "node:fs";
3681
+ import { existsSync as existsSync3, readdirSync, statSync } from "node:fs";
2115
3682
  import { readFile } from "node:fs/promises";
2116
- import { basename as basename2, join as join2, relative, resolve } from "node:path";
3683
+ import { basename as basename2, join as join3, relative, resolve } from "node:path";
2117
3684
  var TEST_FILES = ["visualautomate.test.json", "test.json"];
2118
3685
  var ENTRIES = ["plugin.ts", "plugin.js", "index.ts", "index.js"];
2119
3686
  function entryIn(dir) {
2120
- return ENTRIES.map((file2) => join2(dir, file2)).find((file2) => existsSync2(file2)) ?? null;
3687
+ return ENTRIES.map((file2) => join3(dir, file2)).find((file2) => existsSync3(file2)) ?? null;
2121
3688
  }
2122
3689
  function matchFolders(folders, name) {
2123
3690
  const wanted2 = name.trim().toLowerCase();
@@ -2129,9 +3696,10 @@ function matchFolders(folders, name) {
2129
3696
  }
2130
3697
  function findTargets(cwd, only) {
2131
3698
  const dir = resolve(cwd);
2132
- const modulesDir = join2(dir, "modules");
2133
- if (!entryIn(dir) && existsSync2(modulesDir) && statSync(modulesDir).isDirectory()) {
2134
- const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync2(join2(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
3699
+ const modulesDir = join3(dir, "modules");
3700
+ const stepHere = Boolean(entryIn(dir)) || existsSync3(join3(dir, "manifest.json"));
3701
+ if (!stepHere && existsSync3(modulesDir) && statSync(modulesDir).isDirectory()) {
3702
+ const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync3(join3(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
2135
3703
  if (folders.length === 0) {
2136
3704
  throw new Error("There is a modules/ folder here, but none of its folders has a manifest.json.");
2137
3705
  }
@@ -2139,20 +3707,20 @@ function findTargets(cwd, only) {
2139
3707
  if (only && chosen.length === 0) {
2140
3708
  throw new Error(`No module matches "${only}". This repository has: ${folders.join(", ")}.`);
2141
3709
  }
2142
- return chosen.map((folder) => targetIn(join2(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
3710
+ return chosen.map((folder) => targetIn(join3(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
2143
3711
  }
2144
3712
  const target = targetIn(dir, basename2(dir));
2145
3713
  if (!target) {
2146
3714
  throw new Error(
2147
- "Nothing to test here: no plugin.ts, plugin.js or index.js, and no modules/ folder.\nRun this in a module's folder, at the root of its repository, or run `vsa init <name>`."
3715
+ "Nothing to test here: no manifest.json, no plugin.ts, plugin.js or index.js, and no modules/ folder.\nRun this in a module's folder, at the root of its repository, or run `vsa init <name>`."
2148
3716
  );
2149
3717
  }
2150
3718
  return [target];
2151
3719
  }
2152
3720
  function targetIn(dir, name) {
2153
3721
  const entry = entryIn(dir);
2154
- if (!entry) return null;
2155
- const raw = basename2(entry) === "index.js" && !existsSync2(join2(dir, "plugin.json"));
3722
+ if (!entry) return existsSync3(join3(dir, "manifest.json")) ? { name, dir, entry: null, raw: true } : null;
3723
+ const raw = basename2(entry) === "index.js" && !existsSync3(join3(dir, "plugin.json"));
2156
3724
  return { name, dir, entry, raw };
2157
3725
  }
2158
3726
  function targetForPath(targets, changed) {
@@ -2166,9 +3734,9 @@ function isPlainObject(value) {
2166
3734
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
2167
3735
  }
2168
3736
  async function readTestFile(dir, override) {
2169
- const path = override ? resolve(override) : TEST_FILES.map((f) => join2(dir, f)).find((f) => existsSync2(f)) ?? null;
3737
+ const path = override ? resolve(override) : TEST_FILES.map((f) => join3(dir, f)).find((f) => existsSync3(f)) ?? null;
2170
3738
  if (!path) return { path: null, config: {}, input: {} };
2171
- if (!existsSync2(path)) throw new Error(`${path} does not exist.`);
3739
+ if (!existsSync3(path)) throw new Error(`${path} does not exist.`);
2172
3740
  let raw;
2173
3741
  try {
2174
3742
  raw = JSON.parse(await readFile(path, "utf8"));
@@ -2300,6 +3868,7 @@ function policyFor(allowedDomains, config) {
2300
3868
  return { hosts, wildcard, enforce: allowedDomains !== void 0 };
2301
3869
  }
2302
3870
  function permits(policy, host) {
3871
+ if (isAlwaysDenied(host)) return false;
2303
3872
  if (policy.wildcard) return true;
2304
3873
  return policy.hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
2305
3874
  }
@@ -2320,12 +3889,12 @@ function guardFetch(policy, base, onDenied) {
2320
3889
  }
2321
3890
 
2322
3891
  // src/persist.ts
2323
- import { existsSync as existsSync3 } from "node:fs";
3892
+ import { existsSync as existsSync4 } from "node:fs";
2324
3893
  import { mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
2325
- import { dirname as dirname2, join as join3, relative as relative2, resolve as resolve2, sep } from "node:path";
3894
+ import { dirname as dirname3, join as join4, relative as relative2, resolve as resolve2, sep } from "node:path";
2326
3895
  var PERSIST_DIR = ".visualautomate";
2327
3896
  function persistEnabled(dir, flag) {
2328
- return flag === "true" || existsSync3(join3(dir, PERSIST_DIR));
3897
+ return flag === "true" || existsSync4(join4(dir, PERSIST_DIR));
2329
3898
  }
2330
3899
  function diskPathFor(storageRoot, path) {
2331
3900
  if (!path || path.includes("\0")) return null;
@@ -2335,22 +3904,22 @@ function diskPathFor(storageRoot, path) {
2335
3904
  return target;
2336
3905
  }
2337
3906
  async function walk(root, dir = root) {
2338
- if (!existsSync3(dir)) return [];
3907
+ if (!existsSync4(dir)) return [];
2339
3908
  const entries = await readdir(dir, { withFileTypes: true });
2340
3909
  const nested = await Promise.all(
2341
- entries.map((entry) => entry.isDirectory() ? walk(root, join3(dir, entry.name)) : Promise.resolve([join3(dir, entry.name)]))
3910
+ entries.map((entry) => entry.isDirectory() ? walk(root, join4(dir, entry.name)) : Promise.resolve([join4(dir, entry.name)]))
2342
3911
  );
2343
3912
  return nested.flat();
2344
3913
  }
2345
3914
  async function loadPersisted(dir) {
2346
- const storageRoot = join3(dir, PERSIST_DIR, "storage");
3915
+ const storageRoot = join4(dir, PERSIST_DIR, "storage");
2347
3916
  const storage = {};
2348
3917
  for (const file2 of await walk(storageRoot)) {
2349
3918
  storage[relative2(storageRoot, file2).split(sep).join("/")] = new Uint8Array(await readFile2(file2));
2350
3919
  }
2351
3920
  let state = {};
2352
- const statePath = join3(dir, PERSIST_DIR, "state.json");
2353
- if (existsSync3(statePath)) {
3921
+ const statePath = join4(dir, PERSIST_DIR, "state.json");
3922
+ if (existsSync4(statePath)) {
2354
3923
  try {
2355
3924
  const parsed = JSON.parse(await readFile2(statePath, "utf8"));
2356
3925
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
@@ -2361,7 +3930,7 @@ async function loadPersisted(dir) {
2361
3930
  return { storage, state };
2362
3931
  }
2363
3932
  async function savePersisted(dir, files, state) {
2364
- const storageRoot = join3(dir, PERSIST_DIR, "storage");
3933
+ const storageRoot = join4(dir, PERSIST_DIR, "storage");
2365
3934
  const refused = [];
2366
3935
  const keep = /* @__PURE__ */ new Set();
2367
3936
  for (const [path, file2] of files) {
@@ -2371,14 +3940,14 @@ async function savePersisted(dir, files, state) {
2371
3940
  continue;
2372
3941
  }
2373
3942
  keep.add(target);
2374
- await mkdir(dirname2(target), { recursive: true });
3943
+ await mkdir(dirname3(target), { recursive: true });
2375
3944
  await writeFile(target, file2.bytes);
2376
3945
  }
2377
3946
  for (const existing of await walk(storageRoot)) {
2378
3947
  if (!keep.has(existing)) await rm(existing);
2379
3948
  }
2380
- await mkdir(join3(dir, PERSIST_DIR), { recursive: true });
2381
- await writeFile(join3(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
3949
+ await mkdir(join4(dir, PERSIST_DIR), { recursive: true });
3950
+ await writeFile(join4(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
2382
3951
  return refused;
2383
3952
  }
2384
3953
 
@@ -2417,12 +3986,9 @@ function colourEnv() {
2417
3986
 
2418
3987
  // src/docker.ts
2419
3988
  var IMAGE = "ghcr.io/visualautomate/plugin-sandbox";
2420
- var TIERS = {
2421
- standard: { cpus: "0.25", memory: "1g" },
2422
- boosted: { cpus: "0.5", memory: "4g" },
2423
- high: { cpus: "1", memory: "6g" },
2424
- max: { cpus: "2", memory: "8g" }
2425
- };
3989
+ var TIERS = Object.fromEntries(
3990
+ SANDBOX_TIERS.map((tier) => [tier.id, { cpus: String(tier.vcpus), memory: `${tier.memoryGb}g`, label: tier.label }])
3991
+ );
2426
3992
  function dockerArgs(run) {
2427
3993
  const tier = TIERS[run.tier];
2428
3994
  return [
@@ -2461,8 +4027,13 @@ function forwardedFlags(flags) {
2461
4027
  return Object.entries(flags).filter(([key]) => !local.has(key)).flatMap(([key, value]) => value === "true" ? [`--${key}`] : [`--${key}`, value]);
2462
4028
  }
2463
4029
  function tierOf(value) {
2464
- if (!value) return "standard";
4030
+ if (!value) return SANDBOX_TIERS[0].id;
2465
4031
  if (value in TIERS) return value;
4032
+ const now = RETIRED_TIERS[value];
4033
+ if (now) {
4034
+ console.error(yellow(`! The ${value} tier is no longer offered; it runs as ${now} (${TIERS[now].cpus} vCPU, ${TIERS[now].memory.replace("g", " GB")}), as on the platform.`));
4035
+ return now;
4036
+ }
2466
4037
  throw new Error(`Unknown tier "${value}". Available: ${Object.keys(TIERS).join(", ")}.`);
2467
4038
  }
2468
4039
  function dockerProblem() {
@@ -2519,7 +4090,7 @@ function runInDocker(run) {
2519
4090
  }
2520
4091
 
2521
4092
  // src/index.ts
2522
- var VERSION = true ? "0.6.1" : "0.0.0-dev";
4093
+ var VERSION = true ? "0.7.0" : "0.0.0-dev";
2523
4094
  function fail(message) {
2524
4095
  console.error(red(message));
2525
4096
  throw new SilentExit();
@@ -2542,22 +4113,22 @@ async function cmdInit(args, flags) {
2542
4113
  fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
2543
4114
  }
2544
4115
  const target = resolve3(process.cwd(), name);
2545
- if (existsSync4(target)) fail(`"${name}" already exists here.`);
4116
+ if (existsSync5(target)) fail(`"${name}" already exists here.`);
2546
4117
  const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
2547
4118
  await mkdir2(target, { recursive: true });
2548
- await writeFile2(join4(target, "plugin.ts"), template.code);
2549
- await writeFile2(join4(target, "manifest.json"), template.manifest);
4119
+ await writeFile2(join5(target, "plugin.ts"), template.code);
4120
+ await writeFile2(join5(target, "manifest.json"), template.manifest);
2550
4121
  await writeFile2(
2551
- join4(target, "plugin.json"),
4122
+ join5(target, "plugin.json"),
2552
4123
  JSON.stringify(
2553
4124
  { name: slug, version: "1.0.0", description: template.description, category: template.category },
2554
4125
  null,
2555
4126
  2
2556
4127
  ) + "\n"
2557
4128
  );
2558
- await writeFile2(join4(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
4129
+ await writeFile2(join5(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
2559
4130
  await writeFile2(
2560
- join4(target, "tsconfig.json"),
4131
+ join5(target, "tsconfig.json"),
2561
4132
  JSON.stringify(
2562
4133
  {
2563
4134
  compilerOptions: {
@@ -2576,30 +4147,57 @@ async function cmdInit(args, flags) {
2576
4147
  ) + "\n"
2577
4148
  );
2578
4149
  await writeFile2(
2579
- join4(target, "test.json"),
4150
+ join5(target, "test.json"),
2580
4151
  JSON.stringify(template.testInput, null, 2) + "\n"
2581
4152
  );
2582
4153
  await writeFile2(
2583
- join4(target, "README.md"),
4154
+ join5(target, "README.md"),
2584
4155
  `# ${name}
2585
4156
 
2586
4157
  ${template.description}
2587
4158
 
2588
4159
  \`\`\`bash
2589
- visualautomate test # run it here
2590
- visualautomate push # publish it to your account
4160
+ vsa dev # run it in the sandbox, again on every save
4161
+ vsa test # run it once, as the platform's check does
2591
4162
  \`\`\`
2592
4163
 
2593
4164
  \`manifest.json\` declares the fields the canvas shows and what this step hands on.
2594
4165
  \`test.json\` is the input, config and state \`test\` runs with.
4166
+ \`AGENTS.md\` and \`CLAUDE.md\` tell an AI assistant the platform's rules; \`vsa skill\` adds the full guide.
4167
+
4168
+ To publish, put the step in a GitHub repository (\`manifest.json\`, \`index.js\`, \`visualautomate.test.json\`)
4169
+ and connect it on https://developers.visualautomate.com.
2595
4170
  `
2596
4171
  );
4172
+ ensureAgentFiles(target, VERSION);
2597
4173
  ok(green(`
2598
4174
  \u2713 Created ${name} from the "${templateName}" template
2599
4175
  `));
2600
4176
  ok(" cd " + name);
2601
- ok(" visualautomate test");
2602
- ok(" visualautomate push\n");
4177
+ ok(" vsa dev");
4178
+ ok(dim("\n Building with an AI assistant? `vsa skill` installs the guide it reads.\n"));
4179
+ }
4180
+ async function cmdSkill(flags) {
4181
+ const cwd = process.cwd();
4182
+ const project = !flags.global && !flags.dir;
4183
+ const root = projectRoot(cwd);
4184
+ const dir = flags.global ? join5(homedir(), ".claude", "skills", SKILL_NAME) : flags.dir ? resolve3(cwd, flags.dir) : join5(root, ".claude", "skills", SKILL_NAME);
4185
+ const changed = installSkill(dir);
4186
+ const where = flags.global ? dir : relative3(cwd, dir) || ".";
4187
+ ok(
4188
+ changed > 0 ? green(`\u2713 Installed the ${SKILL_NAME} skill in ${where} (${changed} of ${skillFileCount()} files written)`) : `The ${SKILL_NAME} skill in ${where} is already this CLI's version.`
4189
+ );
4190
+ if (project) {
4191
+ const written = ensureAgentFiles(root, VERSION);
4192
+ if (written.length > 0) ok(green(`\u2713 Wrote ${written.join(" and ")}`));
4193
+ for (const name of foreignAgentFiles(root)) {
4194
+ ok(yellow(` ${name} is your own and was left alone. Point it at .claude/skills/${SKILL_NAME}/SKILL.md if you like.`));
4195
+ }
4196
+ }
4197
+ ok(dim("\nAsk your assistant for a step in a sentence, for example:"));
4198
+ ok(dim(' "Make a VisualAutomate step that creates an invoice in Moneybird."'));
4199
+ if (project) ok(dim(`Claude Code finds the skill on its own; Cursor, Copilot and Codex read ${AGENTS_FILE}.`));
4200
+ if (flags.dir) ok(dim("For Claude on the web or desktop, zip that folder and upload it as a skill."));
2603
4201
  }
2604
4202
  async function bundlePlugin(entry) {
2605
4203
  try {
@@ -2609,7 +4207,8 @@ async function bundlePlugin(entry) {
2609
4207
  write: false,
2610
4208
  platform: "node",
2611
4209
  format: "esm",
2612
- target: "node18",
4210
+ // The Node the platform runs steps on.
4211
+ target: `node${PLATFORM_NODE_MAJOR}`,
2613
4212
  packages: "external",
2614
4213
  logLevel: "silent"
2615
4214
  });
@@ -2622,6 +4221,7 @@ ${message}`);
2622
4221
  }
2623
4222
  }
2624
4223
  async function codeFor(target) {
4224
+ if (target.entry === null) throw new Error(`${target.name} is only a manifest; there is no code to run.`);
2625
4225
  return target.raw ? readFile3(target.entry, "utf8") : bundlePlugin(target.entry);
2626
4226
  }
2627
4227
  function line(indent, text) {
@@ -2632,6 +4232,34 @@ async function runTarget(target, flags, several) {
2632
4232
  if (several) ok(`
2633
4233
  ${bold(`\u25B8 ${target.name}`)}`);
2634
4234
  try {
4235
+ const manifestPath = join5(target.dir, "manifest.json");
4236
+ if (!existsSync5(manifestPath)) {
4237
+ say(`${red("\u2717 refused")} There is no manifest.json beside the code. The push check refuses a step without one.`);
4238
+ return false;
4239
+ }
4240
+ let manifest;
4241
+ try {
4242
+ manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
4243
+ } catch (error) {
4244
+ say(red(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`));
4245
+ return false;
4246
+ }
4247
+ const shape = checkManifest(manifest, target.entry !== null);
4248
+ for (const problem of shape.errors) say(`${red("\u2717 manifest")} ${problem}`);
4249
+ for (const warning of shape.warnings) say(yellow(`! ${warning}`));
4250
+ if (!shape.ok) return false;
4251
+ if (target.entry === null) {
4252
+ if (shape.kind === "trigger") {
4253
+ say(`${green(bold("\u2713 checked"))} ${dim("a trigger: the manifest is sound")}`);
4254
+ say(dim(" Not run here. A push publishes only a module with an index.js that passes its test, so this one cannot be published from GitHub."));
4255
+ return true;
4256
+ }
4257
+ const what = shape.kind === "outputs-only" ? "a declared step with no request" : "a declared step";
4258
+ say(`${green(bold("\u2713 checked"))} ${dim(`${what}: the manifest is sound`)}`);
4259
+ say(dim(" Not run here, and not published from GitHub. On the developer portal: New module \u2192 Call an API,"));
4260
+ say(dim(" paste this manifest.json into the module's manifest.json editor, press Try it, then publish it there."));
4261
+ return true;
4262
+ }
2635
4263
  const code = await codeFor(target);
2636
4264
  const check = validatePluginCode(code);
2637
4265
  if (!check.valid) {
@@ -2640,63 +4268,78 @@ ${bold(`\u25B8 ${target.name}`)}`);
2640
4268
  }
2641
4269
  const settings = await readTestFile(target.dir, flags.input);
2642
4270
  if (!settings.path) say("No visualautomate.test.json \u2014 running with empty input and config.");
2643
- const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)));
2644
- const plugin = typeof exported === "function" ? exported : typeof exported?.execute === "function" ? exported : exported?.default;
2645
- let outputs = [];
2646
- const manifestPath = join4(target.dir, "manifest.json");
2647
- if (existsSync4(manifestPath)) {
2648
- try {
2649
- const manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
2650
- outputs = manifest.outputs ?? [];
2651
- } catch (error) {
2652
- say(red(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`));
2653
- return false;
2654
- }
4271
+ const declaredTimeout = Number(manifest.timeoutMs);
4272
+ const timeoutMs = Number.isFinite(declaredTimeout) && declaredTimeout > 0 ? Math.min(declaredTimeout, PUSH_CHECK.maxTimeoutMs) : PUSH_CHECK.defaultTimeoutMs;
4273
+ if (settings.timeoutMs !== void 0 && settings.timeoutMs !== timeoutMs) {
4274
+ say(yellow(`! The push check does not read timeoutMs from the test file; it gives this step ${timeoutMs} ms, from manifest.json. Set it there.`));
2655
4275
  }
4276
+ const outputs = Array.isArray(manifest.outputs) ? manifest.outputs : ["success", "error"];
4277
+ const expected = settings.expectOutput ?? PUSH_CHECK.defaultExpectOutput;
4278
+ const properties = manifest.properties ?? {};
4279
+ const accountProperties = Object.keys(properties).filter((name) => properties[name]?.type === "connection");
4280
+ const config = settings.config ?? {};
4281
+ const named = accountProperties.filter((name) => typeof config[name] === "string" && String(config[name]).trim());
4282
+ const unnamed = accountProperties.filter((name) => !named.includes(name));
2656
4283
  const persist = persistEnabled(target.dir, flags.persist);
2657
4284
  const kept = persist ? await loadPersisted(target.dir) : { storage: {}, state: {} };
2658
4285
  const policy = policyFor(settings.allowedDomains, settings.config);
2659
4286
  const denied = /* @__PURE__ */ new Map();
2660
- const baseFetch = globalThis.fetch;
2661
- globalThis.fetch = guardFetch(policy, baseFetch, (host, enforced) => denied.set(host, enforced));
2662
- let result;
2663
- try {
2664
- result = await simulate(plugin, {
2665
- input: settings.input,
2666
- config: settings.config,
2667
- storage: { ...settings.storage, ...kept.storage },
2668
- state: { ...settings.state, ...kept.state },
2669
- secrets: settings.secrets,
2670
- timeoutMs: settings.timeoutMs
2671
- });
2672
- } finally {
2673
- globalThis.fetch = baseFetch;
4287
+ const logs = [];
4288
+ const capture = (level) => (...args) => logs.push(`${level === "log" ? "" : `[${level}] `}${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}`);
4289
+ const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)), {
4290
+ fetch: guardFetch(policy, globalThis.fetch, (host, enforced) => denied.set(host, enforced)),
4291
+ console: { log: capture("log"), error: capture("error"), warn: capture("warn"), info: capture("info") },
4292
+ timeoutMs
4293
+ });
4294
+ const execute = executeOf(exported);
4295
+ if (!execute) {
4296
+ say(`${red("\u2717 failed")} Plugin must export an execute function: module.exports = { execute }, or a function.`);
4297
+ return false;
2674
4298
  }
4299
+ const result = await simulate(execute, {
4300
+ input: settings.input,
4301
+ config: settings.config,
4302
+ storage: { ...settings.storage, ...kept.storage },
4303
+ state: { ...settings.state, ...kept.state },
4304
+ secrets: settings.secrets,
4305
+ timeoutMs
4306
+ });
2675
4307
  const port = result.output?.output ?? null;
2676
- const expected = settings.expectOutput;
2677
- const passed = result.status === "success" && (!expected || port === expected);
4308
+ const passed = result.status === "success" && port === expected;
2678
4309
  const verdict = passed ? green(bold("\u2713 passed")) : red(bold("\u2717 failed"));
2679
4310
  say(`${verdict} ${dim(`${result.status}, ${result.durationMs}ms`)}`);
2680
4311
  if (result.error) say(`${dim("error")} ${red(result.error)}`);
2681
4312
  if (result.output) {
2682
- const port_ = passed || !expected ? port : red(String(port));
2683
- say(`${dim("output")} ${port_}${expected ? dim(` (expected ${expected})`) : ""}`);
4313
+ const shown = passed ? port : red(String(port));
4314
+ const why = settings.expectOutput === void 0 ? "expected success, the default" : `expected ${expected}`;
4315
+ say(`${dim("output")} ${shown}${dim(` (${why})`)}`);
2684
4316
  say(`${dim("data")} ${JSON.stringify(result.output.data, null, 2).replace(/\n/g, "\n ")}`);
2685
4317
  }
2686
4318
  if (result.files.size > 0) say(`${dim("files")} ${[...result.files.keys()].join(", ")}`);
2687
4319
  if (result.state.size > 0) say(`${dim("state")} ${JSON.stringify(Object.fromEntries(result.state))}`);
2688
- for (const entry of result.logs ?? []) say(`${dim("log")} ${entry}`);
4320
+ for (const entry of [...logs, ...result.logs ?? []]) say(`${dim("log")} ${entry}`);
2689
4321
  for (const [host, enforced] of denied) {
2690
4322
  say(
2691
4323
  enforced ? `${dim("egress")} ${red(`blocked ${host}`)} \u2014 not in allowedDomains` : yellow(`! egress ${host} is not allowed on the platform unless the plugin declares it. `) + `Add "allowedDomains": ["${host}"] to the test file to check with the same rule.`
2692
4324
  );
2693
4325
  }
4326
+ if (named.length > 0) {
4327
+ say(dim(`account the test file names an account for ${named.join(", ")}. The push check acts as it; here there is none, so the step ran without it.`));
4328
+ }
4329
+ if (unnamed.length > 0 && expected === "success") {
4330
+ say(yellow(
4331
+ `! ${unnamed.join(", ")} ${unnamed.length > 1 ? "are account properties" : "is an account property"} the test file names no account for. The push check then runs without an account, and publishing needs a run that comes back on success. Put the id of one of your connected accounts under config (the module's page on the portal lists them).`
4332
+ ));
4333
+ }
4334
+ if (passed && expected !== "success") {
4335
+ say(dim("note a pass that is not on success does not count for publishing: that needs one push whose test comes back on success."));
4336
+ }
2694
4337
  if (persist) {
2695
4338
  const refused = await savePersisted(target.dir, result.files, result.state);
2696
- say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join4(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
4339
+ say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join5(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
2697
4340
  for (const path of refused) say(yellow(`! storage path "${path}" points outside the storage folder and was not written`));
2698
4341
  }
2699
- if (port && outputs.length > 0 && !outputs.includes(port)) {
4342
+ if (port && !outputs.includes(port)) {
2700
4343
  say(
2701
4344
  yellow(
2702
4345
  `! This returned output "${port}", which manifest.json does not list (it has ${outputs.map((o) => `"${o}"`).join(", ")}). On a canvas nothing would follow it.`
@@ -2705,11 +4348,22 @@ ${bold(`\u25B8 ${target.name}`)}`);
2705
4348
  }
2706
4349
  return passed;
2707
4350
  } catch (error) {
2708
- say(red(`\u2717 ${error instanceof Error ? error.message : String(error)}`));
4351
+ const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
4352
+ say(red(`\u2717 ${message}`));
2709
4353
  return false;
2710
4354
  }
2711
4355
  }
4356
+ var nodeNoted = false;
4357
+ function noteNodeVersion() {
4358
+ if (nodeNoted || process.env.VISUALAUTOMATE_PACKAGES) return;
4359
+ nodeNoted = true;
4360
+ const here = Number(process.versions.node.split(".")[0]);
4361
+ if (here !== PLATFORM_NODE_MAJOR) {
4362
+ ok(dim(`Node ${process.versions.node} here; the platform runs steps on Node ${PLATFORM_NODE_MAJOR}. \`vsa dev\` and \`vsa test --docker\` run on that version.`));
4363
+ }
4364
+ }
2712
4365
  async function runTargets(targets, flags) {
4366
+ noteNodeVersion();
2713
4367
  let passed = 0;
2714
4368
  for (const target of targets) {
2715
4369
  if (await runTarget(target, flags, targets.length > 1)) passed++;
@@ -2722,9 +4376,9 @@ ${passed === targets.length ? green(bold(tally)) : red(bold(tally))}`);
2722
4376
  return passed === targets.length;
2723
4377
  }
2724
4378
  function packageLoader(dir) {
2725
- const fromProject = createRequire(join4(dir, "noop.js"));
4379
+ const fromProject = createRequire(join5(dir, "noop.js"));
2726
4380
  const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
2727
- const fromImage = imageDir ? createRequire(join4(imageDir, "noop.js")) : null;
4381
+ const fromImage = imageDir ? createRequire(join5(imageDir, "noop.js")) : null;
2728
4382
  return (name) => {
2729
4383
  try {
2730
4384
  return fromProject(name);
@@ -2818,11 +4472,11 @@ async function cmdAddProperty(args, flags) {
2818
4472
  `What property? vsa add:property <type> <name> [description] [default]
2819
4473
  vsa add:property string apiKey "The key to call with"
2820
4474
  vsa add:property number retries "How many times to try again" 2
2821
- Types: ${PROPERTY_TYPES.join(", ")}`
4475
+ Types: ${PROPERTY_TYPES2.join(", ")}`
2822
4476
  );
2823
4477
  }
2824
4478
  if (!isPropertyType(type)) {
2825
- fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES.join(", ")}`);
4479
+ fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES2.join(", ")}`);
2826
4480
  }
2827
4481
  if (!isPropertyName(name)) {
2828
4482
  fail(`"${name}" is not a name your code can read: config.${name} has to be something you can write.`);
@@ -2836,8 +4490,8 @@ Types: ${PROPERTY_TYPES.join(", ")}`
2836
4490
  );
2837
4491
  }
2838
4492
  const target = targets[0];
2839
- const file2 = join4(target.dir, "manifest.json");
2840
- if (!existsSync4(file2)) fail(`${target.name} has no manifest.json.`);
4493
+ const file2 = join5(target.dir, "manifest.json");
4494
+ if (!existsSync5(file2)) fail(`${target.name} has no manifest.json.`);
2841
4495
  const text = await readFile3(file2, "utf8");
2842
4496
  if (hasMember(text, "properties", name)) {
2843
4497
  fail(`${target.name} already has a property called ${name}. Change it in ${target.name}/manifest.json.`);
@@ -2861,10 +4515,10 @@ Types: ${PROPERTY_TYPES.join(", ")}`
2861
4515
  }
2862
4516
  await writeFile2(file2, next, "utf8");
2863
4517
  const written = [`${target.name}/manifest.json`];
2864
- const testPath = TEST_FILES.map((name2) => join4(target.dir, name2)).find((path) => existsSync4(path)) ?? join4(target.dir, TEST_FILES[0]);
4518
+ const testPath = TEST_FILES.map((name2) => join5(target.dir, name2)).find((path) => existsSync5(path)) ?? join5(target.dir, TEST_FILES[0]);
2865
4519
  const sample = sampleFor(property);
2866
4520
  try {
2867
- const before = existsSync4(testPath) ? await readFile3(testPath, "utf8") : `{
4521
+ const before = existsSync5(testPath) ? await readFile3(testPath, "utf8") : `{
2868
4522
  "config": {},
2869
4523
  "input": {}
2870
4524
  }
@@ -2925,7 +4579,7 @@ async function cmdDev(args, flags) {
2925
4579
  };
2926
4580
  const watcher = watch(root, { recursive: true }, (_event, name) => {
2927
4581
  if (!name) return;
2928
- const path = join4(root, name.toString());
4582
+ const path = join5(root, name.toString());
2929
4583
  if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
2930
4584
  pending.add(path);
2931
4585
  if (timer) clearTimeout(timer);
@@ -2948,6 +4602,7 @@ vsa \u2014 build and test VisualAutomate plugins
2948
4602
  vsa types write the types your editor reads
2949
4603
  vsa add:property <type> <name> [description] [default]
2950
4604
  add a field to a module's manifest
4605
+ vsa skill [--global] the guide an AI assistant reads to build steps
2951
4606
  vsa --version this CLI's version
2952
4607
 
2953
4608
  \`va\` and \`visualautomate\` are the same command.
@@ -2966,11 +4621,16 @@ Options for \`add:property\`:
2966
4621
  --options <list> for select and multiselect: "Fast:fast,Thorough:thorough"
2967
4622
  --provider <name> for connection: whose account this is
2968
4623
 
2969
- Property types: ${PROPERTY_TYPES.join(", ")}
4624
+ Property types: ${PROPERTY_TYPES2.join(", ")}
4625
+
4626
+ Options for \`skill\`:
4627
+ (none) .claude/skills/${SKILL_NAME} here, plus ${AGENTS_FILE} and ${CLAUDE_FILE}
4628
+ --global ~/.claude/skills/${SKILL_NAME}, for every project
4629
+ --dir <folder> the skill's folder anywhere, e.g. to zip for Claude on the web
2970
4630
 
2971
4631
  Options for \`dev\` and \`test\`:
2972
4632
  --module <name> the same as naming it: vsa dev send-message
2973
- --tier <name> the sandbox's size: standard, boosted, high or max
4633
+ --tier <name> the sandbox's size: ${Object.keys(TIERS).join(", ")}
2974
4634
  --image <ref> a different sandbox image
2975
4635
  --persist keep storage and state in .visualautomate/ between runs
2976
4636
  --local \`dev\` without the sandbox, on this machine's Node
@@ -3017,6 +4677,8 @@ async function main() {
3017
4677
  case "dev":
3018
4678
  case "watch":
3019
4679
  return cmdDev(args, flags);
4680
+ case "skill":
4681
+ return cmdSkill(flags);
3020
4682
  // `plugin test` is accepted as an alias.
3021
4683
  case "plugin": {
3022
4684
  if (args.shift() === "test") return cmdTest(args, flags);