@visualautomate/cli 0.6.1 → 0.8.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.
- package/README.md +292 -243
- package/dist/index.js +1716 -516
- 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
|
|
6
|
-
import { basename as basename3, join as
|
|
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": "^
|
|
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.
|
|
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": "^
|
|
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": "^
|
|
691
|
+
"version": "^8.0.4"
|
|
885
692
|
},
|
|
886
693
|
{
|
|
887
694
|
"name": "diff-match-patch",
|
|
@@ -1287,127 +1094,1540 @@ var ALLOWED_PACKAGES = [
|
|
|
1287
1094
|
"name": "exponential-backoff",
|
|
1288
1095
|
"version": "^3.1.3"
|
|
1289
1096
|
},
|
|
1290
|
-
{
|
|
1291
|
-
"name": "bottleneck",
|
|
1292
|
-
"version": "^2.19.5"
|
|
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
|
+
var ACCOUNTS_ARE_OURS = `Only VisualAutomate's own apps act as a connected account: call the API at its own address instead (action.baseUrl, or fetch in code), with a key the user enters in a property of type "secret"`;
|
|
1628
|
+
function accountUseIn(manifest) {
|
|
1629
|
+
if (!isObject(manifest)) return [];
|
|
1630
|
+
const m = manifest;
|
|
1631
|
+
const uses = [];
|
|
1632
|
+
if (isObject(m.properties)) {
|
|
1633
|
+
for (const [key, value] of Object.entries(m.properties)) {
|
|
1634
|
+
if (!isObject(value)) continue;
|
|
1635
|
+
if (value.type === "connection") uses.push(`properties.${key} is a connection: an account to act as`);
|
|
1636
|
+
if (value.optionsFrom !== void 0) uses.push(`properties.${key}.optionsFrom reads its options as an account`);
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
if (isObject(m.action) && m.action.connection !== void 0) uses.push("action.connection makes the call as an account");
|
|
1640
|
+
if (m.trigger !== void 0) uses.push("trigger watches an account");
|
|
1641
|
+
if (isObject(m.auth) && typeof m.auth.provider === "string" && m.auth.provider) uses.push(`auth.provider names "${m.auth.provider}"`);
|
|
1642
|
+
return uses;
|
|
1643
|
+
}
|
|
1644
|
+
function checkManifest(raw, hasCode) {
|
|
1645
|
+
const errors = [];
|
|
1646
|
+
const warnings = [];
|
|
1647
|
+
if (!isObject(raw)) {
|
|
1648
|
+
return { ok: false, kind: hasCode ? "code" : "declared", errors: ["manifest.json must be a JSON object."], warnings };
|
|
1649
|
+
}
|
|
1650
|
+
const m = raw;
|
|
1651
|
+
const kind = isObject(m.trigger) ? "trigger" : isObject(m.action) ? isObject(m.action.request) ? "declared" : "outputs-only" : "code";
|
|
1652
|
+
for (const key of Object.keys(m)) {
|
|
1653
|
+
if (!KNOWN_BLOCKS.has(key)) warnings.push(`"${key}" is not something a manifest can declare, and is dropped when the step is saved.`);
|
|
1654
|
+
}
|
|
1655
|
+
const properties = isObject(m.properties) ? m.properties : {};
|
|
1656
|
+
if (m.properties !== void 0 && !isObject(m.properties)) errors.push("properties must be an object of fields.");
|
|
1657
|
+
const typeOf = (name) => typeof name === "string" && isObject(properties[name]) ? properties[name].type : void 0;
|
|
1658
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
1659
|
+
if (!isObject(value)) {
|
|
1660
|
+
errors.push(`properties.${key} must be an object with at least a label and a type.`);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
if (typeof value.type !== "string" || !PROPERTY_TYPES.has(value.type)) {
|
|
1664
|
+
warnings.push(`properties.${key} has type ${JSON.stringify(value.type)}, which the platform does not know; it becomes a plain text field.`);
|
|
1665
|
+
}
|
|
1666
|
+
if (typeof value.label !== "string" || !value.label.trim()) {
|
|
1667
|
+
warnings.push(`properties.${key} has no label; the canvas shows "${key}" instead.`);
|
|
1668
|
+
}
|
|
1669
|
+
if ((value.type === "select" || value.type === "multiselect") && value.options === void 0 && value.optionsFrom === void 0) {
|
|
1670
|
+
warnings.push(`properties.${key} is a ${value.type} with no options and no optionsFrom; there is nothing to choose.`);
|
|
1671
|
+
}
|
|
1672
|
+
if (value.options !== void 0) {
|
|
1673
|
+
const options = Array.isArray(value.options) ? value.options : null;
|
|
1674
|
+
if (!options || options.some((o) => !isObject(o) || typeof o.label !== "string" || typeof o.value !== "string")) {
|
|
1675
|
+
errors.push(`properties.${key}.options must be a list of { "label": "\u2026", "value": "\u2026" }, both text.`);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
if (value.optionsFrom !== void 0) checkOptionsFrom(key, value, typeOf, errors);
|
|
1679
|
+
}
|
|
1680
|
+
if (m.auth !== void 0) {
|
|
1681
|
+
if (!isObject(m.auth)) errors.push("auth must be an object.");
|
|
1682
|
+
else {
|
|
1683
|
+
if (typeof m.auth.type !== "string" || !AUTH_TYPES.has(m.auth.type)) {
|
|
1684
|
+
errors.push(`auth.type must be one of ${[...AUTH_TYPES].join(", ")}.`);
|
|
1685
|
+
} else if (m.auth.type !== "none" && !m.auth.provider) {
|
|
1686
|
+
errors.push(`auth.type "${m.auth.type}" needs a provider to sign in to, and only VisualAutomate's own apps name one: use { "type": "none" } and take the user's key in a property of type "secret".`);
|
|
1687
|
+
}
|
|
1688
|
+
for (const key of HOST_KEYS) {
|
|
1689
|
+
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.`);
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
for (const ports of ["inputs", "outputs"]) {
|
|
1694
|
+
if (m[ports] !== void 0 && (!Array.isArray(m[ports]) || m[ports].some((p) => typeof p !== "string"))) {
|
|
1695
|
+
errors.push(`${ports} must be a list of port names.`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
if (Array.isArray(m.outputs) && m.outputs.length === 0 && kind !== "trigger") {
|
|
1699
|
+
warnings.push("outputs is empty: no line can be drawn from this step.");
|
|
1700
|
+
}
|
|
1701
|
+
if (m.timeoutMs !== void 0) {
|
|
1702
|
+
if (typeof m.timeoutMs !== "number" || m.timeoutMs <= 0) errors.push("timeoutMs must be a number of milliseconds.");
|
|
1703
|
+
else if (m.timeoutMs > PUSH_CHECK.maxTimeoutMs) warnings.push(`timeoutMs ${m.timeoutMs} is cut to ${PUSH_CHECK.maxTimeoutMs}, the most a step is given.`);
|
|
1704
|
+
}
|
|
1705
|
+
if (typeof m.memoryLimitMb === "number" && m.memoryLimitMb > MAX_MEMORY_MB) {
|
|
1706
|
+
warnings.push(`memoryLimitMb ${m.memoryLimitMb} is cut to ${MAX_MEMORY_MB}.`);
|
|
1707
|
+
}
|
|
1708
|
+
if (m.action !== void 0) {
|
|
1709
|
+
if (!isObject(m.action)) errors.push("action must be an object.");
|
|
1710
|
+
else checkAction(m.action, properties, typeOf, errors, warnings);
|
|
1711
|
+
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.");
|
|
1712
|
+
}
|
|
1713
|
+
if (m.trigger !== void 0) {
|
|
1714
|
+
if (!isObject(m.trigger)) errors.push("trigger must be an object.");
|
|
1715
|
+
else checkTrigger(m.trigger, typeOf, errors, warnings);
|
|
1716
|
+
}
|
|
1717
|
+
if (kind === "code" && !hasCode) {
|
|
1718
|
+
errors.push("There is no index.js and no action or trigger block, so this step has nothing to run.");
|
|
1719
|
+
}
|
|
1720
|
+
for (const use of accountUseIn(m)) errors.push(`${use}. ${ACCOUNTS_ARE_OURS}`);
|
|
1721
|
+
return { ok: errors.length === 0, kind, errors, warnings };
|
|
1722
|
+
}
|
|
1723
|
+
function checkConnectionName(where, name, typeOf, errors) {
|
|
1724
|
+
if (typeof name !== "string" || !name) {
|
|
1725
|
+
errors.push(`${where} must name one of this step's properties of type "connection".`);
|
|
1726
|
+
} else if (typeOf(name) === void 0) {
|
|
1727
|
+
errors.push(`${where} names "${name}", which is not one of this step's properties.`);
|
|
1728
|
+
} else if (typeOf(name) !== "connection") {
|
|
1729
|
+
errors.push(`${where} names "${name}", which is a ${String(typeOf(name))} property rather than a connection.`);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
function checkAction(a, properties, typeOf, errors, warnings) {
|
|
1733
|
+
const request = a.request;
|
|
1734
|
+
if (request !== void 0) {
|
|
1735
|
+
if (!isObject(request)) {
|
|
1736
|
+
errors.push("action.request must be an object.");
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
const viaConnection = a.connection !== void 0;
|
|
1740
|
+
const viaBaseUrl = a.baseUrl !== void 0;
|
|
1741
|
+
if (viaConnection && viaBaseUrl) errors.push("action has both connection and baseUrl: a request goes as an account or to a named host, never both.");
|
|
1742
|
+
if (!viaConnection && !viaBaseUrl) errors.push("action.request needs either connection (an account to act as) or baseUrl (the https:// host to call).");
|
|
1743
|
+
if (viaConnection) checkConnectionName("action.connection", a.connection, typeOf, errors);
|
|
1744
|
+
if (viaBaseUrl) {
|
|
1745
|
+
if (typeof a.baseUrl !== "string" || !/^https:\/\//i.test(a.baseUrl)) errors.push("action.baseUrl must start with https://.");
|
|
1746
|
+
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.");
|
|
1747
|
+
}
|
|
1748
|
+
const method = String(request.method ?? "GET").toUpperCase();
|
|
1749
|
+
if (!METHODS.has(method)) errors.push(`action.request.method must be one of ${[...METHODS].join(", ")}.`);
|
|
1750
|
+
if (!isRelativePath(request.path)) errors.push("action.request.path must be a path relative to the base URL, never a full URL.");
|
|
1751
|
+
if (request.bodyType !== void 0 && !BODY_TYPES.has(String(request.bodyType))) {
|
|
1752
|
+
errors.push(`action.request.bodyType must be one of ${[...BODY_TYPES].join(", ")}.`);
|
|
1753
|
+
}
|
|
1754
|
+
if (request.body !== void 0 && method === "GET") warnings.push("action.request has a body on a GET; most APIs ignore it.");
|
|
1755
|
+
if (viaConnection && isObject(request.headers)) {
|
|
1756
|
+
for (const header of Object.keys(request.headers)) {
|
|
1757
|
+
if (header.toLowerCase() === "authorization") warnings.push("action.request sets Authorization while acting as an account; the platform adds the account's credential itself.");
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
if (a.outputs !== void 0 && !isObject(a.outputs)) errors.push("action.outputs must be an object of name to value.");
|
|
1762
|
+
if (a.outputs === void 0) warnings.push("action has no outputs: the step hands nothing on.");
|
|
1763
|
+
for (const key of ["errorPath", "okPath", "okValue"]) {
|
|
1764
|
+
if (a[key] !== void 0 && typeof a[key] !== "string") errors.push(`action.${key} must be text.`);
|
|
1765
|
+
}
|
|
1766
|
+
if (a.items !== void 0) {
|
|
1767
|
+
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.');
|
|
1768
|
+
else if (a.items.limit !== void 0 && (typeof a.items.limit !== "number" || a.items.limit < 1 || a.items.limit > MAX_ITEM_LIMIT)) {
|
|
1769
|
+
errors.push(`action.items.limit must be a number from 1 to ${MAX_ITEM_LIMIT}.`);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
if (a.paginate !== void 0) {
|
|
1773
|
+
if (!isObject(a.paginate) || !PAGINATE_FROM.has(String(a.paginate.from))) {
|
|
1774
|
+
errors.push(`action.paginate.from must be one of ${[...PAGINATE_FROM].join(", ")}.`);
|
|
1775
|
+
} else if (a.paginate.maxPages !== void 0 && (typeof a.paginate.maxPages !== "number" || a.paginate.maxPages > MAX_PAGES)) {
|
|
1776
|
+
errors.push(`action.paginate.maxPages must be at most ${MAX_PAGES}.`);
|
|
1777
|
+
}
|
|
1778
|
+
if (a.items === void 0) errors.push("action.paginate needs action.items: pages are pages of a list.");
|
|
1779
|
+
}
|
|
1780
|
+
const text = JSON.stringify({ baseUrl: a.baseUrl, request: a.request, outputs: a.outputs });
|
|
1781
|
+
for (const [, key] of text.matchAll(/\{\{\s*config\.([A-Za-z0-9_$]+)/g)) {
|
|
1782
|
+
if (!(key in properties)) warnings.push(`{{config.${key}}} names no property; it is always empty.`);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
function checkTrigger(t, typeOf, errors, warnings) {
|
|
1786
|
+
checkConnectionName("trigger.connection", t.connection, typeOf, errors);
|
|
1787
|
+
if (!isObject(t.request) || !isRelativePath(t.request.path)) errors.push("trigger.request.path must be a path relative to the provider.");
|
|
1788
|
+
else if (t.request.method !== void 0 && !["GET", "POST"].includes(String(t.request.method).toUpperCase())) {
|
|
1789
|
+
errors.push("trigger.request.method must be GET or POST.");
|
|
1790
|
+
}
|
|
1791
|
+
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).');
|
|
1792
|
+
if (typeof t.id !== "string" || !t.id) errors.push("trigger.id must be the dotted path, within an item, to what makes it unique.");
|
|
1793
|
+
if (t.poll !== void 0 && typeof t.poll !== "number" && (typeof t.poll !== "string" || typeOf(t.poll) === void 0)) {
|
|
1794
|
+
errors.push("trigger.poll must be a number of minutes, or the name of a property that holds one.");
|
|
1795
|
+
}
|
|
1796
|
+
if (t.cursor !== void 0) {
|
|
1797
|
+
if (!isObject(t.cursor) || !["response", "item"].includes(String(t.cursor.from)) || typeof t.cursor.path !== "string") {
|
|
1798
|
+
errors.push('trigger.cursor must be { "from": "response" | "item", "path": "\u2026" }.');
|
|
1799
|
+
} else if (t.cursor.initial !== "now") {
|
|
1800
|
+
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.`);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
if (t.hydrate !== void 0 && (!isObject(t.hydrate) || !isRelativePath(t.hydrate.path))) {
|
|
1804
|
+
errors.push("trigger.hydrate.path must be a path relative to the provider.");
|
|
1805
|
+
}
|
|
1806
|
+
if (t.maxPerPoll !== void 0 && (typeof t.maxPerPoll !== "number" || t.maxPerPoll < 1)) errors.push("trigger.maxPerPoll must be a positive number.");
|
|
1807
|
+
}
|
|
1808
|
+
function checkOptionsFrom(key, property, typeOf, errors) {
|
|
1809
|
+
const where = `properties.${key}.optionsFrom`;
|
|
1810
|
+
if (property.type !== "select" && property.type !== "multiselect") {
|
|
1811
|
+
errors.push(`${where} only means something on a select or multiselect, and ${key} is a ${String(property.type)}.`);
|
|
1812
|
+
}
|
|
1813
|
+
const o = property.optionsFrom;
|
|
1814
|
+
if (!isObject(o)) {
|
|
1815
|
+
errors.push(`${where} must be an object.`);
|
|
1816
|
+
return;
|
|
1817
|
+
}
|
|
1818
|
+
checkConnectionName(`${where}.connection`, o.connection, typeOf, errors);
|
|
1819
|
+
if (typeof o.label !== "string" || !o.label.trim()) errors.push(`${where}.label must say what each option shows, e.g. {{item.name}}.`);
|
|
1820
|
+
if (typeof o.value !== "string" || !o.value.trim()) errors.push(`${where}.value must say what choosing it stores, e.g. {{item.id}}.`);
|
|
1821
|
+
if (!isObject(o.request) || !isRelativePath(o.request.path)) errors.push(`${where}.request.path must be a path relative to the provider.`);
|
|
1822
|
+
else {
|
|
1823
|
+
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.`);
|
|
1824
|
+
if (o.request.body !== void 0) errors.push(`${where}.request cannot have a body: it is a GET.`);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/declarations.ts
|
|
1829
|
+
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 /**\n * The accounts this step was configured with. Absent when it has none.\n *\n * @deprecated Only VisualAutomate\'s own apps act as a connected account, so\n * a step you build never has this: the platform refuses a step that names\n * an account. Take the user\'s key in a property of type "secret" and send\n * it to the API\'s own host with fetch.\n */\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';
|
|
1830
|
+
|
|
1831
|
+
// src/ai-files.ts
|
|
1832
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1833
|
+
import { dirname, join } from "node:path";
|
|
1834
|
+
|
|
1835
|
+
// src/skill-files.ts
|
|
1836
|
+
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": `{
|
|
1837
|
+
"description": "How much of this month's character allowance is used",
|
|
1838
|
+
"icon": "gauge",
|
|
1839
|
+
"color": "#0f2b46",
|
|
1840
|
+
"timeoutMs": 30000,
|
|
1841
|
+
"version": 1,
|
|
1842
|
+
"auth": {
|
|
1843
|
+
"type": "none"
|
|
1844
|
+
},
|
|
1845
|
+
"inputs": [
|
|
1846
|
+
"input"
|
|
1847
|
+
],
|
|
1848
|
+
"outputs": [
|
|
1849
|
+
"success",
|
|
1850
|
+
"error"
|
|
1851
|
+
],
|
|
1852
|
+
"properties": {
|
|
1853
|
+
"apiKey": {
|
|
1854
|
+
"label": "DeepL API key",
|
|
1855
|
+
"type": "secret",
|
|
1856
|
+
"required": true,
|
|
1857
|
+
"group": "Account",
|
|
1858
|
+
"description": "From your DeepL account, under API keys"
|
|
1859
|
+
},
|
|
1860
|
+
"host": {
|
|
1861
|
+
"label": "Plan",
|
|
1862
|
+
"type": "select",
|
|
1863
|
+
"default": "api-free.deepl.com",
|
|
1864
|
+
"group": "Account",
|
|
1865
|
+
"description": "Free keys end in :fx and use the free API",
|
|
1866
|
+
"options": [
|
|
1867
|
+
{
|
|
1868
|
+
"label": "DeepL API Free",
|
|
1869
|
+
"value": "api-free.deepl.com"
|
|
1870
|
+
},
|
|
1871
|
+
{
|
|
1872
|
+
"label": "DeepL API Pro",
|
|
1873
|
+
"value": "api.deepl.com"
|
|
1874
|
+
}
|
|
1875
|
+
]
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
"outputFields": {
|
|
1879
|
+
"used": {
|
|
1880
|
+
"type": "number",
|
|
1881
|
+
"description": "Characters translated this period"
|
|
1882
|
+
},
|
|
1883
|
+
"limit": {
|
|
1884
|
+
"type": "number",
|
|
1885
|
+
"description": "Characters allowed this period"
|
|
1886
|
+
}
|
|
1887
|
+
},
|
|
1888
|
+
"action": {
|
|
1889
|
+
"baseUrl": "https://{{config.host}}/v2",
|
|
1890
|
+
"request": {
|
|
1891
|
+
"method": "GET",
|
|
1892
|
+
"path": "usage",
|
|
1893
|
+
"headers": {
|
|
1894
|
+
"Authorization": "DeepL-Auth-Key {{config.apiKey}}"
|
|
1895
|
+
}
|
|
1896
|
+
},
|
|
1897
|
+
"errorPath": "message",
|
|
1898
|
+
"outputs": {
|
|
1899
|
+
"used": "{{response.character_count}}",
|
|
1900
|
+
"limit": "{{response.character_limit}}"
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
`, "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, **Test the call** 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": `{
|
|
1905
|
+
"description": "Add a comment to an issue or a pull request",
|
|
1906
|
+
"icon": "message-square",
|
|
1907
|
+
"color": "#24292f",
|
|
1908
|
+
"timeoutMs": 30000,
|
|
1909
|
+
"version": 1,
|
|
1910
|
+
"auth": {
|
|
1911
|
+
"type": "none"
|
|
1912
|
+
},
|
|
1913
|
+
"properties": {
|
|
1914
|
+
"token": {
|
|
1915
|
+
"label": "GitHub token",
|
|
1916
|
+
"type": "secret",
|
|
1917
|
+
"required": true,
|
|
1918
|
+
"group": "Account",
|
|
1919
|
+
"description": "A fine-grained personal access token with access to the repository"
|
|
1920
|
+
},
|
|
1921
|
+
"owner": {
|
|
1922
|
+
"label": "Owner",
|
|
1923
|
+
"type": "string",
|
|
1924
|
+
"required": true,
|
|
1925
|
+
"group": "Repository"
|
|
1926
|
+
},
|
|
1927
|
+
"repo": {
|
|
1928
|
+
"label": "Repository",
|
|
1929
|
+
"type": "string",
|
|
1930
|
+
"required": true,
|
|
1931
|
+
"group": "Repository"
|
|
1932
|
+
},
|
|
1933
|
+
"issue_number": {
|
|
1934
|
+
"label": "Issue or pull request number",
|
|
1935
|
+
"type": "number",
|
|
1936
|
+
"required": true,
|
|
1937
|
+
"group": "Comment"
|
|
1938
|
+
},
|
|
1939
|
+
"body": {
|
|
1940
|
+
"label": "Comment",
|
|
1941
|
+
"type": "string",
|
|
1942
|
+
"required": true,
|
|
1943
|
+
"description": "Markdown is understood",
|
|
1944
|
+
"group": "Comment"
|
|
1945
|
+
}
|
|
1946
|
+
},
|
|
1947
|
+
"inputs": [
|
|
1948
|
+
"input"
|
|
1949
|
+
],
|
|
1950
|
+
"outputs": [
|
|
1951
|
+
"success",
|
|
1952
|
+
"error"
|
|
1953
|
+
],
|
|
1954
|
+
"outputFields": {
|
|
1955
|
+
"id": {
|
|
1956
|
+
"type": "text",
|
|
1957
|
+
"description": "The comment's id"
|
|
1958
|
+
},
|
|
1959
|
+
"url": {
|
|
1960
|
+
"type": "text",
|
|
1961
|
+
"description": "A link to it"
|
|
1962
|
+
},
|
|
1963
|
+
"body": {
|
|
1964
|
+
"type": "object",
|
|
1965
|
+
"description": "What was written"
|
|
1966
|
+
}
|
|
1967
|
+
},
|
|
1968
|
+
"action": {
|
|
1969
|
+
"baseUrl": "https://api.github.com",
|
|
1970
|
+
"request": {
|
|
1971
|
+
"method": "POST",
|
|
1972
|
+
"path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}/comments",
|
|
1973
|
+
"body": {
|
|
1974
|
+
"body": "{{config.body}}"
|
|
1975
|
+
},
|
|
1976
|
+
"headers": {
|
|
1977
|
+
"Authorization": "Bearer {{config.token}}",
|
|
1978
|
+
"Accept": "application/vnd.github+json",
|
|
1979
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
1980
|
+
}
|
|
1981
|
+
},
|
|
1982
|
+
"errorPath": "message",
|
|
1983
|
+
"outputs": {
|
|
1984
|
+
"id": "{{response.id}}",
|
|
1985
|
+
"url": "{{response.html_url}}",
|
|
1986
|
+
"body": "{{config.body}}"
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
`, "examples/apps/github/modules/create-issue/manifest.json": `{
|
|
1991
|
+
"description": "Open an issue on a repository",
|
|
1992
|
+
"icon": "circle-dot",
|
|
1993
|
+
"color": "#24292f",
|
|
1994
|
+
"timeoutMs": 30000,
|
|
1995
|
+
"version": 1,
|
|
1996
|
+
"auth": {
|
|
1997
|
+
"type": "none"
|
|
1998
|
+
},
|
|
1999
|
+
"properties": {
|
|
2000
|
+
"token": {
|
|
2001
|
+
"label": "GitHub token",
|
|
2002
|
+
"type": "secret",
|
|
2003
|
+
"required": true,
|
|
2004
|
+
"group": "Account",
|
|
2005
|
+
"description": "A fine-grained personal access token with access to the repository"
|
|
2006
|
+
},
|
|
2007
|
+
"owner": {
|
|
2008
|
+
"label": "Owner",
|
|
2009
|
+
"type": "string",
|
|
2010
|
+
"required": true,
|
|
2011
|
+
"description": "The user or organisation",
|
|
2012
|
+
"group": "Repository"
|
|
2013
|
+
},
|
|
2014
|
+
"repo": {
|
|
2015
|
+
"label": "Repository",
|
|
2016
|
+
"type": "string",
|
|
2017
|
+
"required": true,
|
|
2018
|
+
"group": "Repository"
|
|
2019
|
+
},
|
|
2020
|
+
"title": {
|
|
2021
|
+
"label": "Title",
|
|
2022
|
+
"type": "string",
|
|
2023
|
+
"required": true,
|
|
2024
|
+
"group": "Issue"
|
|
2025
|
+
},
|
|
2026
|
+
"body": {
|
|
2027
|
+
"label": "Description",
|
|
2028
|
+
"type": "string",
|
|
2029
|
+
"description": "Markdown is understood",
|
|
2030
|
+
"group": "Issue"
|
|
2031
|
+
},
|
|
2032
|
+
"labels": {
|
|
2033
|
+
"label": "Labels",
|
|
2034
|
+
"type": "string",
|
|
2035
|
+
"description": "Separated by commas",
|
|
2036
|
+
"group": "Issue"
|
|
2037
|
+
},
|
|
2038
|
+
"assignees": {
|
|
2039
|
+
"label": "Assignees",
|
|
2040
|
+
"type": "string",
|
|
2041
|
+
"description": "Usernames, separated by commas",
|
|
2042
|
+
"group": "Issue"
|
|
2043
|
+
}
|
|
2044
|
+
},
|
|
2045
|
+
"inputs": [
|
|
2046
|
+
"input"
|
|
2047
|
+
],
|
|
2048
|
+
"outputs": [
|
|
2049
|
+
"success",
|
|
2050
|
+
"error"
|
|
2051
|
+
],
|
|
2052
|
+
"outputFields": {
|
|
2053
|
+
"number": {
|
|
2054
|
+
"type": "number",
|
|
2055
|
+
"description": "The issue number, which every other step wants"
|
|
2056
|
+
},
|
|
2057
|
+
"id": {
|
|
2058
|
+
"type": "text",
|
|
2059
|
+
"description": "GitHub's internal id"
|
|
2060
|
+
},
|
|
2061
|
+
"url": {
|
|
2062
|
+
"type": "text",
|
|
2063
|
+
"description": "A link to it"
|
|
2064
|
+
},
|
|
2065
|
+
"state": {
|
|
2066
|
+
"type": "text",
|
|
2067
|
+
"description": "open or closed"
|
|
2068
|
+
}
|
|
2069
|
+
},
|
|
2070
|
+
"action": {
|
|
2071
|
+
"baseUrl": "https://api.github.com",
|
|
2072
|
+
"request": {
|
|
2073
|
+
"method": "POST",
|
|
2074
|
+
"path": "repos/{{config.owner}}/{{config.repo}}/issues",
|
|
2075
|
+
"body": {
|
|
2076
|
+
"title": "{{config.title}}",
|
|
2077
|
+
"body": "{{config.body}}",
|
|
2078
|
+
"labels": "{{config.labels | list}}",
|
|
2079
|
+
"assignees": "{{config.assignees | list}}"
|
|
2080
|
+
},
|
|
2081
|
+
"headers": {
|
|
2082
|
+
"Authorization": "Bearer {{config.token}}",
|
|
2083
|
+
"Accept": "application/vnd.github+json",
|
|
2084
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
2085
|
+
}
|
|
2086
|
+
},
|
|
2087
|
+
"errorPath": "message",
|
|
2088
|
+
"outputs": {
|
|
2089
|
+
"number": "{{response.number}}",
|
|
2090
|
+
"id": "{{response.id}}",
|
|
2091
|
+
"url": "{{response.html_url}}",
|
|
2092
|
+
"state": "{{response.state}}"
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
`, "examples/apps/github/modules/list-issues/manifest.json": `{
|
|
2097
|
+
"description": "List a repository's issues, newest first",
|
|
2098
|
+
"icon": "list",
|
|
2099
|
+
"color": "#24292f",
|
|
2100
|
+
"timeoutMs": 30000,
|
|
2101
|
+
"version": 1,
|
|
2102
|
+
"auth": {
|
|
2103
|
+
"type": "none"
|
|
2104
|
+
},
|
|
2105
|
+
"properties": {
|
|
2106
|
+
"token": {
|
|
2107
|
+
"label": "GitHub token",
|
|
2108
|
+
"type": "secret",
|
|
2109
|
+
"required": true,
|
|
2110
|
+
"group": "Account",
|
|
2111
|
+
"description": "A fine-grained personal access token with access to the repository"
|
|
2112
|
+
},
|
|
2113
|
+
"owner": {
|
|
2114
|
+
"label": "Owner",
|
|
2115
|
+
"type": "string",
|
|
2116
|
+
"required": true,
|
|
2117
|
+
"group": "Repository"
|
|
2118
|
+
},
|
|
2119
|
+
"repo": {
|
|
2120
|
+
"label": "Repository",
|
|
2121
|
+
"type": "string",
|
|
2122
|
+
"required": true,
|
|
2123
|
+
"group": "Repository"
|
|
2124
|
+
},
|
|
2125
|
+
"state": {
|
|
2126
|
+
"label": "State",
|
|
2127
|
+
"type": "select",
|
|
2128
|
+
"default": "open",
|
|
2129
|
+
"options": [
|
|
2130
|
+
{
|
|
2131
|
+
"label": "Open",
|
|
2132
|
+
"value": "open"
|
|
2133
|
+
},
|
|
2134
|
+
{
|
|
2135
|
+
"label": "Closed",
|
|
2136
|
+
"value": "closed"
|
|
2137
|
+
},
|
|
2138
|
+
{
|
|
2139
|
+
"label": "Both",
|
|
2140
|
+
"value": "all"
|
|
2141
|
+
}
|
|
2142
|
+
],
|
|
2143
|
+
"group": "Search"
|
|
2144
|
+
},
|
|
2145
|
+
"labels": {
|
|
2146
|
+
"label": "Labels",
|
|
2147
|
+
"type": "string",
|
|
2148
|
+
"description": "Only issues with all of these, separated by commas",
|
|
2149
|
+
"group": "Search"
|
|
2150
|
+
},
|
|
2151
|
+
"per_page": {
|
|
2152
|
+
"label": "How many",
|
|
2153
|
+
"type": "number",
|
|
2154
|
+
"default": 30,
|
|
2155
|
+
"min": 1,
|
|
2156
|
+
"max": 100,
|
|
2157
|
+
"group": "Search"
|
|
2158
|
+
}
|
|
2159
|
+
},
|
|
2160
|
+
"inputs": [
|
|
2161
|
+
"input"
|
|
2162
|
+
],
|
|
2163
|
+
"outputs": [
|
|
2164
|
+
"success",
|
|
2165
|
+
"error"
|
|
2166
|
+
],
|
|
2167
|
+
"outputFields": {
|
|
2168
|
+
"issues": {
|
|
2169
|
+
"type": "list",
|
|
2170
|
+
"description": "The issues, each with a number, a title and a state",
|
|
2171
|
+
"fields": {
|
|
2172
|
+
"number": "number",
|
|
2173
|
+
"title": "text",
|
|
2174
|
+
"body": "object",
|
|
2175
|
+
"state": "text",
|
|
2176
|
+
"html_url": "text"
|
|
2177
|
+
}
|
|
2178
|
+
},
|
|
2179
|
+
"count": {
|
|
2180
|
+
"type": "number",
|
|
2181
|
+
"description": "How many came back"
|
|
2182
|
+
}
|
|
1293
2183
|
},
|
|
1294
|
-
{
|
|
1295
|
-
"
|
|
1296
|
-
"
|
|
2184
|
+
"action": {
|
|
2185
|
+
"baseUrl": "https://api.github.com",
|
|
2186
|
+
"request": {
|
|
2187
|
+
"method": "GET",
|
|
2188
|
+
"path": "repos/{{config.owner}}/{{config.repo}}/issues",
|
|
2189
|
+
"query": {
|
|
2190
|
+
"state": "{{config.state}}",
|
|
2191
|
+
"labels": "{{config.labels}}",
|
|
2192
|
+
"per_page": "{{config.per_page}}"
|
|
2193
|
+
},
|
|
2194
|
+
"headers": {
|
|
2195
|
+
"Authorization": "Bearer {{config.token}}",
|
|
2196
|
+
"Accept": "application/vnd.github+json",
|
|
2197
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
2198
|
+
}
|
|
2199
|
+
},
|
|
2200
|
+
"errorPath": "message",
|
|
2201
|
+
"outputs": {
|
|
2202
|
+
"issues": "{{response}}",
|
|
2203
|
+
"count": "{{response | length}}"
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
`, "examples/apps/github/modules/update-issue/manifest.json": `{
|
|
2208
|
+
"description": "Change an issue's title, body, state or labels",
|
|
2209
|
+
"icon": "pencil",
|
|
2210
|
+
"color": "#24292f",
|
|
2211
|
+
"timeoutMs": 30000,
|
|
2212
|
+
"version": 1,
|
|
2213
|
+
"auth": {
|
|
2214
|
+
"type": "none"
|
|
2215
|
+
},
|
|
2216
|
+
"properties": {
|
|
2217
|
+
"token": {
|
|
2218
|
+
"label": "GitHub token",
|
|
2219
|
+
"type": "secret",
|
|
2220
|
+
"required": true,
|
|
2221
|
+
"group": "Account",
|
|
2222
|
+
"description": "A fine-grained personal access token with access to the repository"
|
|
2223
|
+
},
|
|
2224
|
+
"owner": {
|
|
2225
|
+
"label": "Owner",
|
|
2226
|
+
"type": "string",
|
|
2227
|
+
"required": true,
|
|
2228
|
+
"group": "Repository"
|
|
2229
|
+
},
|
|
2230
|
+
"repo": {
|
|
2231
|
+
"label": "Repository",
|
|
2232
|
+
"type": "string",
|
|
2233
|
+
"required": true,
|
|
2234
|
+
"group": "Repository"
|
|
2235
|
+
},
|
|
2236
|
+
"issue_number": {
|
|
2237
|
+
"label": "Issue number",
|
|
2238
|
+
"type": "number",
|
|
2239
|
+
"required": true,
|
|
2240
|
+
"group": "Issue"
|
|
2241
|
+
},
|
|
2242
|
+
"title": {
|
|
2243
|
+
"label": "Title",
|
|
2244
|
+
"type": "string",
|
|
2245
|
+
"group": "Issue"
|
|
2246
|
+
},
|
|
2247
|
+
"body": {
|
|
2248
|
+
"label": "Description",
|
|
2249
|
+
"type": "string",
|
|
2250
|
+
"group": "Issue"
|
|
2251
|
+
},
|
|
2252
|
+
"state": {
|
|
2253
|
+
"label": "State",
|
|
2254
|
+
"type": "select",
|
|
2255
|
+
"default": "",
|
|
2256
|
+
"options": [
|
|
2257
|
+
{
|
|
2258
|
+
"label": "Leave as it is",
|
|
2259
|
+
"value": ""
|
|
2260
|
+
},
|
|
2261
|
+
{
|
|
2262
|
+
"label": "Open",
|
|
2263
|
+
"value": "open"
|
|
2264
|
+
},
|
|
2265
|
+
{
|
|
2266
|
+
"label": "Closed",
|
|
2267
|
+
"value": "closed"
|
|
2268
|
+
}
|
|
2269
|
+
],
|
|
2270
|
+
"group": "Issue"
|
|
2271
|
+
},
|
|
2272
|
+
"labels": {
|
|
2273
|
+
"label": "Labels",
|
|
2274
|
+
"type": "string",
|
|
2275
|
+
"description": "Replaces the labels on the issue",
|
|
2276
|
+
"group": "Issue"
|
|
2277
|
+
}
|
|
1297
2278
|
},
|
|
1298
|
-
|
|
1299
|
-
"
|
|
1300
|
-
|
|
2279
|
+
"inputs": [
|
|
2280
|
+
"input"
|
|
2281
|
+
],
|
|
2282
|
+
"outputs": [
|
|
2283
|
+
"success",
|
|
2284
|
+
"error"
|
|
2285
|
+
],
|
|
2286
|
+
"outputFields": {
|
|
2287
|
+
"number": {
|
|
2288
|
+
"type": "number",
|
|
2289
|
+
"description": "The issue that changed"
|
|
2290
|
+
},
|
|
2291
|
+
"url": {
|
|
2292
|
+
"type": "text",
|
|
2293
|
+
"description": "A link to it"
|
|
2294
|
+
},
|
|
2295
|
+
"state": {
|
|
2296
|
+
"type": "text",
|
|
2297
|
+
"description": "Its state afterwards"
|
|
2298
|
+
}
|
|
1301
2299
|
},
|
|
1302
|
-
{
|
|
1303
|
-
"
|
|
1304
|
-
"
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
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
|
-
};
|
|
2300
|
+
"action": {
|
|
2301
|
+
"baseUrl": "https://api.github.com",
|
|
2302
|
+
"request": {
|
|
2303
|
+
"method": "PATCH",
|
|
2304
|
+
"path": "repos/{{config.owner}}/{{config.repo}}/issues/{{config.issue_number}}",
|
|
2305
|
+
"body": {
|
|
2306
|
+
"title": "{{config.title}}",
|
|
2307
|
+
"body": "{{config.body}}",
|
|
2308
|
+
"state": "{{config.state}}",
|
|
2309
|
+
"labels": "{{config.labels | list}}"
|
|
2310
|
+
},
|
|
2311
|
+
"headers": {
|
|
2312
|
+
"Authorization": "Bearer {{config.token}}",
|
|
2313
|
+
"Accept": "application/vnd.github+json",
|
|
2314
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
2315
|
+
}
|
|
2316
|
+
},
|
|
2317
|
+
"errorPath": "message",
|
|
2318
|
+
"outputs": {
|
|
2319
|
+
"number": "{{response.number}}",
|
|
2320
|
+
"url": "{{response.html_url}}",
|
|
2321
|
+
"state": "{{response.state}}"
|
|
1359
2322
|
}
|
|
1360
2323
|
}
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
2324
|
+
}
|
|
2325
|
+
`, "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. Each is reached with a token the user pastes: only VisualAutomate\'s own apps act as a connected account, so a GitHub app of yours uses a fine-grained personal access token in a `secret` field.\n\nOn the developer portal, the app is:\n\n| | |\n| --- | --- |\n| Name | GitHub |\n| Connection | none: the token is a `secret` field on each module |\n| `baseUrl` | `https://api.github.com`, with `Authorization: Bearer {{config.token}}`, `Accept: application/vnd.github+json` and the API version in the headers |\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, fill in a token, press **Test the call**, and publish. There is nothing here for GitHub to publish.\n', "examples/apps/README.md": "# App examples\n\nAn **app** is several steps (its **modules**) for one service, under one name. 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 translate-long-text/\n manifest.json\n index.js a code module\n visualautomate.test.json\n```\n\nThe app itself has no file: its name, description and logo are set on the developer portal. It connects no account: only VisualAutomate's own apps act as one. So every module asks for the service's key or token the same way, a `secret` property with the same name, and names the API's host itself.\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, **Test the call**, publish.\n\n| App | Modules | Reached with | Shows |\n| --- | --- | --- | --- |\n| `github/` | 4 declared | a personal access token the user pastes | An app without code: create, list, update and comment |\n| `slack/` | 4 declared | a bot token the user pastes | An API that answers 200 to a failure, caught with `okPath` |\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": "none"\n },\n "properties": {\n "token": {\n "label": "Slack bot token",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "The xoxb- token of your Slack app, installed in the workspace"\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 "baseUrl": "https://slack.com/api",\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 "headers": {\n "Authorization": "Bearer {{config.token}}"\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": "none"\n },\n "properties": {\n "token": {\n "label": "Slack bot token",\n "type": "secret",\n "required": true,\n "group": "Account",\n "description": "The xoxb- token of your Slack app, installed in the workspace"\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 "baseUrl": "https://slack.com/api",\n "request": {\n "method": "GET",\n "path": "users.lookupByEmail",\n "query": {\n "email": "{{config.email}}"\n },\n "headers": {\n "Authorization": "Bearer {{config.token}}"\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": `{
|
|
2326
|
+
"description": "List the channels in the workspace, with their ids",
|
|
2327
|
+
"icon": "list",
|
|
2328
|
+
"color": "#4a154b",
|
|
2329
|
+
"timeoutMs": 30000,
|
|
2330
|
+
"version": 1,
|
|
2331
|
+
"auth": {
|
|
2332
|
+
"type": "none"
|
|
2333
|
+
},
|
|
2334
|
+
"properties": {
|
|
2335
|
+
"token": {
|
|
2336
|
+
"label": "Slack bot token",
|
|
2337
|
+
"type": "secret",
|
|
2338
|
+
"required": true,
|
|
2339
|
+
"group": "Account",
|
|
2340
|
+
"description": "The xoxb- token of your Slack app, installed in the workspace"
|
|
2341
|
+
},
|
|
2342
|
+
"types": {
|
|
2343
|
+
"label": "Kinds",
|
|
2344
|
+
"type": "select",
|
|
2345
|
+
"default": "public_channel",
|
|
2346
|
+
"options": [
|
|
2347
|
+
{
|
|
2348
|
+
"label": "Public channels",
|
|
2349
|
+
"value": "public_channel"
|
|
2350
|
+
},
|
|
2351
|
+
{
|
|
2352
|
+
"label": "Private channels",
|
|
2353
|
+
"value": "private_channel"
|
|
2354
|
+
},
|
|
2355
|
+
{
|
|
2356
|
+
"label": "Public and private",
|
|
2357
|
+
"value": "public_channel,private_channel"
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
"label": "Direct messages",
|
|
2361
|
+
"value": "im"
|
|
2362
|
+
}
|
|
2363
|
+
],
|
|
2364
|
+
"group": "Search"
|
|
2365
|
+
},
|
|
2366
|
+
"limit": {
|
|
2367
|
+
"label": "How many",
|
|
2368
|
+
"type": "number",
|
|
2369
|
+
"default": 100,
|
|
2370
|
+
"min": 1,
|
|
2371
|
+
"max": 1000,
|
|
2372
|
+
"group": "Search"
|
|
2373
|
+
},
|
|
2374
|
+
"exclude_archived": {
|
|
2375
|
+
"label": "Skip archived",
|
|
2376
|
+
"type": "boolean",
|
|
2377
|
+
"default": true,
|
|
2378
|
+
"group": "Search"
|
|
2379
|
+
}
|
|
2380
|
+
},
|
|
2381
|
+
"inputs": [
|
|
2382
|
+
"input"
|
|
2383
|
+
],
|
|
2384
|
+
"outputs": [
|
|
2385
|
+
"success",
|
|
2386
|
+
"error"
|
|
2387
|
+
],
|
|
2388
|
+
"outputFields": {
|
|
2389
|
+
"channels": {
|
|
2390
|
+
"type": "list",
|
|
2391
|
+
"description": "The channels, each with an id and a name"
|
|
2392
|
+
},
|
|
2393
|
+
"count": {
|
|
2394
|
+
"type": "number",
|
|
2395
|
+
"description": "How many came back"
|
|
2396
|
+
},
|
|
2397
|
+
"cursor": {
|
|
2398
|
+
"type": "text",
|
|
2399
|
+
"description": "Slack's cursor for the next page, when there is one"
|
|
2400
|
+
}
|
|
2401
|
+
},
|
|
2402
|
+
"action": {
|
|
2403
|
+
"baseUrl": "https://slack.com/api",
|
|
2404
|
+
"request": {
|
|
2405
|
+
"method": "GET",
|
|
2406
|
+
"path": "conversations.list",
|
|
2407
|
+
"query": {
|
|
2408
|
+
"types": "{{config.types}}",
|
|
2409
|
+
"limit": "{{config.limit}}",
|
|
2410
|
+
"exclude_archived": "{{config.exclude_archived}}"
|
|
2411
|
+
},
|
|
2412
|
+
"headers": {
|
|
2413
|
+
"Authorization": "Bearer {{config.token}}"
|
|
2414
|
+
}
|
|
2415
|
+
},
|
|
2416
|
+
"okPath": "ok",
|
|
2417
|
+
"errorPath": "error",
|
|
2418
|
+
"outputs": {
|
|
2419
|
+
"channels": "{{response.channels}}",
|
|
2420
|
+
"count": "{{response.channels | length}}",
|
|
2421
|
+
"cursor": "{{response.response_metadata.next_cursor}}"
|
|
1371
2422
|
}
|
|
1372
|
-
if (!packages.includes(name)) packages.push(name);
|
|
1373
2423
|
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2424
|
+
}
|
|
2425
|
+
`, "examples/apps/slack/modules/post-message/manifest.json": `{
|
|
2426
|
+
"description": "Post a message to a channel, a private channel or a direct message",
|
|
2427
|
+
"icon": "send",
|
|
2428
|
+
"color": "#4a154b",
|
|
2429
|
+
"timeoutMs": 30000,
|
|
2430
|
+
"version": 1,
|
|
2431
|
+
"auth": {
|
|
2432
|
+
"type": "none"
|
|
2433
|
+
},
|
|
2434
|
+
"properties": {
|
|
2435
|
+
"token": {
|
|
2436
|
+
"label": "Slack bot token",
|
|
2437
|
+
"type": "secret",
|
|
2438
|
+
"required": true,
|
|
2439
|
+
"group": "Account",
|
|
2440
|
+
"description": "The xoxb- token of your Slack app, installed in the workspace"
|
|
2441
|
+
},
|
|
2442
|
+
"channel": {
|
|
2443
|
+
"label": "Channel id",
|
|
2444
|
+
"type": "string",
|
|
2445
|
+
"required": true,
|
|
2446
|
+
"description": "The channel to post in, e.g. C0123456789. Use List channels to find one.",
|
|
2447
|
+
"group": "Message"
|
|
2448
|
+
},
|
|
2449
|
+
"text": {
|
|
2450
|
+
"label": "Message",
|
|
2451
|
+
"type": "string",
|
|
2452
|
+
"required": true,
|
|
2453
|
+
"description": "The text to post. Slack's mrkdwn is understood.",
|
|
2454
|
+
"group": "Message"
|
|
2455
|
+
},
|
|
2456
|
+
"thread_ts": {
|
|
2457
|
+
"label": "Reply to",
|
|
2458
|
+
"type": "string",
|
|
2459
|
+
"description": "The ts of a message to reply to, leaving this one in its thread",
|
|
2460
|
+
"group": "Message"
|
|
2461
|
+
},
|
|
2462
|
+
"unfurl_links": {
|
|
2463
|
+
"label": "Show link previews",
|
|
2464
|
+
"type": "boolean",
|
|
2465
|
+
"default": true,
|
|
2466
|
+
"group": "Message"
|
|
2467
|
+
}
|
|
2468
|
+
},
|
|
2469
|
+
"inputs": [
|
|
2470
|
+
"input"
|
|
2471
|
+
],
|
|
2472
|
+
"outputs": [
|
|
2473
|
+
"success",
|
|
2474
|
+
"error"
|
|
2475
|
+
],
|
|
2476
|
+
"outputFields": {
|
|
2477
|
+
"ts": {
|
|
2478
|
+
"type": "text",
|
|
2479
|
+
"description": "The message's timestamp, which is its id in every other method"
|
|
2480
|
+
},
|
|
2481
|
+
"channel": {
|
|
2482
|
+
"type": "text",
|
|
2483
|
+
"description": "The channel it was posted in"
|
|
2484
|
+
},
|
|
2485
|
+
"text": {
|
|
2486
|
+
"type": "text",
|
|
2487
|
+
"description": "What was sent"
|
|
2488
|
+
},
|
|
2489
|
+
"permalink": {
|
|
2490
|
+
"type": "text",
|
|
2491
|
+
"description": "A link to the message, when Slack returns one"
|
|
2492
|
+
}
|
|
2493
|
+
},
|
|
2494
|
+
"action": {
|
|
2495
|
+
"baseUrl": "https://slack.com/api",
|
|
2496
|
+
"request": {
|
|
2497
|
+
"method": "POST",
|
|
2498
|
+
"path": "chat.postMessage",
|
|
2499
|
+
"body": {
|
|
2500
|
+
"channel": "{{config.channel}}",
|
|
2501
|
+
"text": "{{config.text}}",
|
|
2502
|
+
"thread_ts": "{{config.thread_ts}}",
|
|
2503
|
+
"unfurl_links": "{{config.unfurl_links}}"
|
|
2504
|
+
},
|
|
2505
|
+
"headers": {
|
|
2506
|
+
"Authorization": "Bearer {{config.token}}"
|
|
2507
|
+
}
|
|
2508
|
+
},
|
|
2509
|
+
"okPath": "ok",
|
|
2510
|
+
"errorPath": "error",
|
|
2511
|
+
"outputs": {
|
|
2512
|
+
"ts": "{{response.ts}}",
|
|
2513
|
+
"channel": "{{response.channel}}",
|
|
2514
|
+
"text": "{{config.text}}",
|
|
2515
|
+
"permalink": "{{response.message.permalink}}"
|
|
2516
|
+
}
|
|
1379
2517
|
}
|
|
1380
|
-
return { valid: true, packages };
|
|
1381
2518
|
}
|
|
1382
|
-
function sandboxRequire(load) {
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
`"${name}" is available in the sandbox but not installed here. To run this locally: npm install --save-dev ${name}@${allowed.version}`
|
|
1391
|
-
);
|
|
2519
|
+
`, "examples/apps/slack/README.md": '# Slack: an app without code, and a 200 that is a failure\n\nFour declared modules, reached with the bot token of the user\'s own Slack app (`xoxb-\u2026`), pasted into a `secret` field: only VisualAutomate\'s own apps act as a connected account. Like every declared module, each is created on the portal (**New module \u2192 Call an API**, paste its `manifest.json`, **Test the call** with a token) and published there, not from GitHub.\n\n| | |\n| --- | --- |\n| Name | Slack |\n| Connection | none: the bot token is a `secret` field on each module, with the scopes the user\'s Slack app was given |\n| `baseUrl` | `https://slack.com/api`, with `Authorization: Bearer {{config.token}}` |\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/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-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 or extra package are run with `vsa test` by this repository's tests, so they cannot quietly go stale. None uses a connected account: only VisualAutomate's own apps act as one.\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| `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-reshape/` | declared | No request at all: outputs built from the fields with filters |\n| `apps/` | apps | Whole apps in the `modules/` shape: GitHub and Slack without code, DeepL with declared and code modules side by side. See `apps/README.md` |\n\nDeclared steps 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`, **Test the call**, publish.\n\nThe test files here are what passes **locally**. To publish a step, its test must come back on `success` in a push check; a real key never goes in a committed test file (see `apps/deepl`).\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": `{
|
|
2520
|
+
"description": "Lets each item through once",
|
|
2521
|
+
"icon": "database",
|
|
2522
|
+
"properties": {
|
|
2523
|
+
"key": {
|
|
2524
|
+
"label": "Remember under",
|
|
2525
|
+
"type": "string",
|
|
2526
|
+
"default": "seen-ids"
|
|
1392
2527
|
}
|
|
1393
|
-
}
|
|
2528
|
+
},
|
|
2529
|
+
"inputs": [
|
|
2530
|
+
"input"
|
|
2531
|
+
],
|
|
2532
|
+
"outputs": [
|
|
2533
|
+
"new",
|
|
2534
|
+
"duplicate",
|
|
2535
|
+
"error"
|
|
2536
|
+
],
|
|
2537
|
+
"outputFields": {
|
|
2538
|
+
"id": "The item's id"
|
|
2539
|
+
}
|
|
1394
2540
|
}
|
|
1395
|
-
function loadPluginCode(code, require2) {
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
2541
|
+
`, "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', "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| `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.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 (an `https://` `baseUrl` with no `{{input}}`, a relative path, items and pages) but does not run it. The action page runs it with **Test the call** ("Save and test" while there are unsaved changes), with a real key filled in for the test. Publishing needs a test that came back 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 "baseUrl": "https://api.example.com/v1",\n "request": {\n "method": "POST",\n "path": "messages",\n "query": {},\n "headers": { "Authorization": "Bearer {{config.apiKey}}" },\n "body": {\n "subject": "{{config.subject}}",\n "format": { "$if": "{{config.isHtml}}", "then": "html", "else": "text" },\n "content": "{{config.body}}",\n "recipients": { "$map": "{{config.to | list}}", "to": { "email": "{{item}}" } }\n }\n },\n "errorPath": "error.message",\n "outputs": { "sent": true, "to": "{{config.to | list}}", "statusCode": "{{status}}" }\n}\n```\n\n| Key | Notes |\n| --- | --- |\n| `baseUrl` | The API\'s host, e.g. `"https://api.example.com/v1"`. `https://` only. May use `{{config.\u2026}}`, never `{{input.\u2026}}`. Required when there is a request: only VisualAutomate\'s own apps make a call as a connected account (`connection`) |\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- `baseUrl` is `https://` and reads no `input`; there is no `connection`.\n- `path` is relative, and the key goes in a header from a `secret` property.\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, 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"]` |\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": "none" }`, or leave it out. The other types (`oauth2`, `api_key`, `basic`) name a provider to sign in to, which only VisualAutomate\'s own apps do. A user\'s key goes in a `secret` property |\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\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| `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\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| `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## No accounts\n\nOnly VisualAutomate\'s own apps act as somebody\'s connected account. A step built here has no `connection` property, `optionsFrom`, `action.connection`, `trigger` or `auth.provider`, and `vsa test` refuses each of them. It reaches an API at its own address, with the user\'s key in a `secret` property:\n\n```json\n"auth": { "type": "none" },\n"properties": {\n "apiKey": { "label": "API key", "type": "secret", "required": true, "description": "Each user enters their own" }\n}\n```\n\nA declared step sends it in a header (`"Authorization": "Bearer {{config.apiKey}}"`, see `declared-steps.md`); code reads `config.apiKey`.\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- 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\\|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": { "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. Never a real key: the file is committed |\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 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\n`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 **Test the call** 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 and a PNG logo up to 2 MB; an app made there connects no account) 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, tested there with **Test the call**, 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', "SKILL.md": '---\nname: visualautomate-steps\ndescription: Build, fix or extend VisualAutomate steps (plugins, and apps with their modules) 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/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, with a token the user pastes), 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 API key or token the user pastes into the step \u2192 a `secret` property, sent in a header;\n - nothing external \u2192 no property.\n\n There is no third way: **only VisualAutomate\'s own apps act as somebody\'s connected account**, so a step you build never uses a `connection` property, `optionsFrom`, `action.connection`, a `trigger` or `auth.provider`, and `vsa test` refuses them. A service that only offers OAuth, with no key or token a user can paste, cannot be built as a step: say so rather than work around it.\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.\n\nA step that *starts* workflows when something new appears (a trigger) watches an account, so it is VisualAutomate\'s own to build, not yours.\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 in the repository is skipped by the push and created on the portal. 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/deepl`, where a long text needs code and the rest does not). Every module asks for the key the same way, a `secret` property with the same name (`apiKey`), and names the API\'s host in `action.baseUrl` or in its `fetch`; the app\'s name and logo 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`. A real key never goes in the test file, which is committed; see `examples/apps/deepl` for a step whose test needs one.\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- **`\u2717 manifest \u2026 Only VisualAutomate\'s own apps act as a connected account`**: the step names an account (a `connection` property, `optionsFrom`, `action.connection`, a `trigger` or `auth.provider`). Rewrite it to call the API at its own address with a key in a `secret` property.\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 **Test the call** with a real key filled in, and publish there. Publishing needs a test 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`, **Test the call**, publish. They are never published from GitHub.\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. **No connected accounts.** Only VisualAutomate\'s own apps act as somebody\'s account: no `connection` property, `optionsFrom`, `action.connection`, `trigger` or `auth.provider`, and `auth` is `{ "type": "none" }` or left out. A step reaches an API at its own address, with the user\'s key from a `secret` property in a header.\n5. **A declared `action` names its host in `baseUrl`**, `https://` only; it may read `config` but never `input`, and the request path is relative to it. A manifest may not contain `url`, `baseUrl`, `base_url` or `host` in `auth`.\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. **Nothing waits.** A step runs when the workflow reaches it and returns; there is no polling loop and no trigger of your own.\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' };
|
|
2542
|
+
|
|
2543
|
+
// src/ai-files.ts
|
|
2544
|
+
var SKILL_NAME = "visualautomate-steps";
|
|
2545
|
+
var AGENTS_FILE = "AGENTS.md";
|
|
2546
|
+
var CLAUDE_FILE = "CLAUDE.md";
|
|
2547
|
+
var AGENTS_MARKER = "<!-- Written by the VisualAutomate CLI: `vsa skill` keeps it current. Replace this line to keep your own edits. -->";
|
|
2548
|
+
function agentsFor(version) {
|
|
2549
|
+
return `${AGENTS_MARKER}
|
|
2550
|
+
|
|
2551
|
+
# Building VisualAutomate steps here
|
|
2552
|
+
|
|
2553
|
+
This folder holds VisualAutomate steps: modules that run on the VisualAutomate workflow canvas. (CLI ${version})
|
|
2554
|
+
|
|
2555
|
+
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.
|
|
2556
|
+
|
|
2557
|
+
## Shape
|
|
2558
|
+
|
|
2559
|
+
- A plugin: \`manifest.json\`, \`index.js\` and \`visualautomate.test.json\` at the root.
|
|
2560
|
+
- An app: one folder per module under \`modules/<slug>/\`, each with those three files.
|
|
2561
|
+
- 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\`.
|
|
2562
|
+
- A \`plugin.ts\` made by \`vsa init\` is a local playground; the files above are what gets published.
|
|
2563
|
+
|
|
2564
|
+
## Rules the platform enforces
|
|
2565
|
+
|
|
2566
|
+
1. \`index.js\` is CommonJS, loaded as written: \`module.exports = { execute: async (input, config, context) => ... }\`, with \`/** @type {PluginModule} */\` above it.
|
|
2567
|
+
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\`.
|
|
2568
|
+
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\`.
|
|
2569
|
+
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.
|
|
2570
|
+
4. No connected accounts: only VisualAutomate's own apps act as somebody's account, so there is no \`connection\` property, \`optionsFrom\`, \`action.connection\`, \`trigger\` or \`auth.provider\`, and \`vsa test\` refuses them. Call the API at its own address (\`action.baseUrl\`, or \`fetch\` in code).
|
|
2571
|
+
5. A user's own key is a \`secret\` property, sent as e.g. \`Authorization: Bearer {{config.apiKey}}\`; a key the plugin holds is \`context.secrets\`. Nothing secret goes in the repository.
|
|
2572
|
+
6. Return \`{ output, data }\`, where \`output\` is one of the manifest's \`outputs\`. Expected failures leave by \`"error"\` with a readable \`data.error\`.
|
|
2573
|
+
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.
|
|
2574
|
+
|
|
2575
|
+
## Checking
|
|
2576
|
+
|
|
2577
|
+
- \`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.
|
|
2578
|
+
- A run must leave by the test file's \`expectOutput\`, which is \`success\` when it names none.
|
|
2579
|
+
- \`context.media\` only runs on the platform.
|
|
2580
|
+
- Declared steps are checked by \`vsa test\`, then pasted into the portal, run with Test the call, and published there.
|
|
2581
|
+
|
|
2582
|
+
Publishing is done by the developer, from GitHub, on https://developers.visualautomate.com, and needs one push whose test came back on \`success\`. A key the test needs goes in the plugin's \`context.secrets\` on the portal, never in a committed test file.
|
|
2583
|
+
`;
|
|
1400
2584
|
}
|
|
2585
|
+
var CLAUDE_TEXT = `${AGENTS_MARKER}
|
|
1401
2586
|
|
|
1402
|
-
|
|
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';
|
|
2587
|
+
@${AGENTS_FILE}
|
|
2588
|
+
`;
|
|
2589
|
+
function ensureAgentFiles(root, version) {
|
|
2590
|
+
const written = [];
|
|
2591
|
+
for (const [name, wanted2] of [
|
|
2592
|
+
[AGENTS_FILE, agentsFor(version)],
|
|
2593
|
+
[CLAUDE_FILE, CLAUDE_TEXT]
|
|
2594
|
+
]) {
|
|
2595
|
+
const path = join(root, name);
|
|
2596
|
+
const current = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
2597
|
+
if (current === null || current.startsWith(AGENTS_MARKER) && current !== wanted2) {
|
|
2598
|
+
writeFileSync(path, wanted2);
|
|
2599
|
+
written.push(name);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return written;
|
|
2603
|
+
}
|
|
2604
|
+
function foreignAgentFiles(root) {
|
|
2605
|
+
return [AGENTS_FILE, CLAUDE_FILE].filter((name) => {
|
|
2606
|
+
const path = join(root, name);
|
|
2607
|
+
return existsSync(path) && !readFileSync(path, "utf8").startsWith(AGENTS_MARKER);
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
function installSkill(dir) {
|
|
2611
|
+
let changed = 0;
|
|
2612
|
+
for (const [rel, text] of Object.entries(SKILL_FILES)) {
|
|
2613
|
+
const path = join(dir, rel);
|
|
2614
|
+
if (existsSync(path) && readFileSync(path, "utf8") === text) continue;
|
|
2615
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
2616
|
+
writeFileSync(path, text);
|
|
2617
|
+
changed++;
|
|
2618
|
+
}
|
|
2619
|
+
return changed;
|
|
2620
|
+
}
|
|
2621
|
+
function skillFileCount() {
|
|
2622
|
+
return Object.keys(SKILL_FILES).length;
|
|
2623
|
+
}
|
|
1404
2624
|
|
|
1405
2625
|
// src/repo-types.ts
|
|
1406
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
1407
|
-
import { basename, dirname, join } from "node:path";
|
|
2626
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2627
|
+
import { basename, dirname as dirname2, join as join2 } from "node:path";
|
|
1408
2628
|
|
|
1409
2629
|
// 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';
|
|
2630
|
+
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 /**\n * The accounts this step was configured with. Absent when it has none.\n *\n * @deprecated Only VisualAutomate\'s own apps act as a connected account, so\n * a step you build never has this: the platform refuses a step that names\n * an account. Take the user\'s key in a property of type "secret" and send\n * it to the API\'s own host with fetch.\n */\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
2631
|
|
|
1412
2632
|
// src/repo-types.ts
|
|
1413
2633
|
var TYPES_FILE = "visualautomate.d.ts";
|
|
@@ -1448,32 +2668,32 @@ var JSCONFIG = `${JSON.stringify(
|
|
|
1448
2668
|
`;
|
|
1449
2669
|
function ensureRepoTypes(root, version) {
|
|
1450
2670
|
const written = [];
|
|
1451
|
-
const typesPath =
|
|
2671
|
+
const typesPath = join2(root, TYPES_FILE);
|
|
1452
2672
|
const wanted2 = declarationsFor(version);
|
|
1453
|
-
const current =
|
|
2673
|
+
const current = existsSync2(typesPath) ? read(typesPath) : null;
|
|
1454
2674
|
if (current === null || current.startsWith(TYPES_MARKER) && current !== wanted2) {
|
|
1455
|
-
|
|
2675
|
+
writeFileSync2(typesPath, wanted2);
|
|
1456
2676
|
written.push(TYPES_FILE);
|
|
1457
2677
|
}
|
|
1458
|
-
const jsconfigPath =
|
|
1459
|
-
if (!
|
|
1460
|
-
|
|
2678
|
+
const jsconfigPath = join2(root, JSCONFIG_FILE);
|
|
2679
|
+
if (!existsSync2(jsconfigPath)) {
|
|
2680
|
+
writeFileSync2(jsconfigPath, JSCONFIG);
|
|
1461
2681
|
written.push(JSCONFIG_FILE);
|
|
1462
2682
|
}
|
|
1463
2683
|
return written;
|
|
1464
2684
|
}
|
|
1465
2685
|
function read(path) {
|
|
1466
2686
|
try {
|
|
1467
|
-
return
|
|
2687
|
+
return readFileSync2(path, "utf8");
|
|
1468
2688
|
} catch {
|
|
1469
2689
|
return null;
|
|
1470
2690
|
}
|
|
1471
2691
|
}
|
|
1472
2692
|
function projectRoot(cwd) {
|
|
1473
|
-
const parent =
|
|
2693
|
+
const parent = dirname2(cwd);
|
|
1474
2694
|
if (basename(parent) === "modules") {
|
|
1475
|
-
const root =
|
|
1476
|
-
if (
|
|
2695
|
+
const root = dirname2(parent);
|
|
2696
|
+
if (existsSync2(join2(root, "package.json")) || existsSync2(join2(root, TYPES_FILE))) return root;
|
|
1477
2697
|
}
|
|
1478
2698
|
return cwd;
|
|
1479
2699
|
}
|
|
@@ -1487,7 +2707,7 @@ var EXAMPLES = `
|
|
|
1487
2707
|
// receives strict JSON.
|
|
1488
2708
|
//
|
|
1489
2709
|
// PROPERTY TYPES: string, number, boolean, select, multiselect, json,
|
|
1490
|
-
// color, date,
|
|
2710
|
+
// color, date, secret, file, path, paths.
|
|
1491
2711
|
//
|
|
1492
2712
|
// "properties": {
|
|
1493
2713
|
// "mode": {
|
|
@@ -1501,7 +2721,7 @@ var EXAMPLES = `
|
|
|
1501
2721
|
// ]
|
|
1502
2722
|
// },
|
|
1503
2723
|
// "retries": { "label": "Retries", "type": "number", "min": 0, "max": 5, "default": 2 },
|
|
1504
|
-
// "
|
|
2724
|
+
// "apiKey": { "label": "API key", "type": "secret", "required": true, "description": "Each user enters their own" },
|
|
1505
2725
|
// "attachment": { "label": "Attachment", "type": "file", "description": "Read into config before the step runs -- right for a PDF" },
|
|
1506
2726
|
// "clip": { "label": "Clip", "type": "path", "description": "Named, not read. What an ffmpeg step takes." },
|
|
1507
2727
|
// "advanced": { "label": "Raw options", "type": "json", "group": "Advanced" }
|
|
@@ -1522,19 +2742,9 @@ var EXAMPLES = `
|
|
|
1522
2742
|
//
|
|
1523
2743
|
// "timeoutMs": 60000,
|
|
1524
2744
|
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1529
|
-
// "trigger": {
|
|
1530
|
-
// "connection": "account",
|
|
1531
|
-
// "poll": 5,
|
|
1532
|
-
// "request": { "method": "GET", "path": "/me/messages", "query": { "$top": "25" } },
|
|
1533
|
-
// "items": "value",
|
|
1534
|
-
// "id": "id",
|
|
1535
|
-
// "cursor": { "from": "response", "path": "@odata.deltaLink" },
|
|
1536
|
-
// "hydrate": { "path": "/me/messages/{id}" }
|
|
1537
|
-
// }
|
|
2745
|
+
// NO ACCOUNTS. Only VisualAutomate's own apps act as somebody's connected
|
|
2746
|
+
// account, so there is no "connection" property and no "trigger" here: call
|
|
2747
|
+
// the API at its own address with the key in a "secret" property.
|
|
1538
2748
|
`;
|
|
1539
2749
|
function manifestFile(active) {
|
|
1540
2750
|
const body = JSON.stringify(active, null, 2);
|
|
@@ -1596,9 +2806,8 @@ var httpRequest = {
|
|
|
1596
2806
|
* A step that calls an API the user holds their own key for.
|
|
1597
2807
|
*
|
|
1598
2808
|
* The key is a \`password\` property, so it is filled in on the canvas and never
|
|
1599
|
-
* lives in this code.
|
|
1600
|
-
*
|
|
1601
|
-
* at all.
|
|
2809
|
+
* lives in this code. Only VisualAutomate's own apps act as a connected
|
|
2810
|
+
* account, so this is how a step of yours reaches any API.
|
|
1602
2811
|
*/
|
|
1603
2812
|
const plugin: PluginModule = {
|
|
1604
2813
|
async execute(input, config) {
|
|
@@ -1664,87 +2873,6 @@ export default plugin
|
|
|
1664
2873
|
}),
|
|
1665
2874
|
testInput: { config: { url: "https://api.github.com/repos/nodejs/node" } }
|
|
1666
2875
|
};
|
|
1667
|
-
var connection = {
|
|
1668
|
-
description: "Act on an account the user already connected.",
|
|
1669
|
-
category: "integration",
|
|
1670
|
-
code: `import type { PluginModule } from "./visualautomate"
|
|
1671
|
-
|
|
1672
|
-
/**
|
|
1673
|
-
* A step that acts as one of the user's connected accounts.
|
|
1674
|
-
*
|
|
1675
|
-
* You never see a token and you never name a host. You name a path, and the
|
|
1676
|
-
* platform attaches the credential on the way past \u2014 which is what stops a
|
|
1677
|
-
* plugin pointing somebody's account at an address of its own choosing.
|
|
1678
|
-
*
|
|
1679
|
-
* \`connections.get\` takes the name of one of YOUR properties ("account"
|
|
1680
|
-
* below), not a connection id, so this step can only reach the account it was
|
|
1681
|
-
* configured with.
|
|
1682
|
-
*/
|
|
1683
|
-
const plugin: PluginModule = {
|
|
1684
|
-
async execute(input, config, context) {
|
|
1685
|
-
if (!context.connections) {
|
|
1686
|
-
return { output: "error", data: { error: "No account is connected to this step." } }
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
const account = await context.connections.get("account")
|
|
1690
|
-
|
|
1691
|
-
const response = await account.request({
|
|
1692
|
-
method: "GET",
|
|
1693
|
-
path: (config.path as string) || "/me/messages",
|
|
1694
|
-
query: { $top: String(config.limit ?? 10) },
|
|
1695
|
-
})
|
|
1696
|
-
|
|
1697
|
-
if (!response.ok) {
|
|
1698
|
-
return {
|
|
1699
|
-
output: "error",
|
|
1700
|
-
data: { error: \`The provider answered \${response.status}\`, status: response.status },
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
|
|
1704
|
-
const body = response.body as { value?: unknown[] }
|
|
1705
|
-
return {
|
|
1706
|
-
output: "success",
|
|
1707
|
-
data: { items: body.value ?? [], count: (body.value ?? []).length },
|
|
1708
|
-
}
|
|
1709
|
-
},
|
|
1710
|
-
}
|
|
1711
|
-
|
|
1712
|
-
export default plugin
|
|
1713
|
-
`,
|
|
1714
|
-
manifest: manifestFile({
|
|
1715
|
-
description: "Read from a connected account",
|
|
1716
|
-
icon: "link",
|
|
1717
|
-
auth: { type: "oauth2", provider: "outlook" },
|
|
1718
|
-
properties: {
|
|
1719
|
-
account: {
|
|
1720
|
-
label: "Account",
|
|
1721
|
-
type: "connection",
|
|
1722
|
-
provider: "outlook",
|
|
1723
|
-
required: true,
|
|
1724
|
-
description: "The account this step acts as"
|
|
1725
|
-
},
|
|
1726
|
-
path: {
|
|
1727
|
-
label: "Path",
|
|
1728
|
-
type: "string",
|
|
1729
|
-
required: false,
|
|
1730
|
-
default: "/me/messages",
|
|
1731
|
-
description: "Relative to the provider's base URL. Never a full URL."
|
|
1732
|
-
},
|
|
1733
|
-
limit: { label: "How many", type: "number", required: false, default: 10, min: 1, max: 100 }
|
|
1734
|
-
},
|
|
1735
|
-
inputs: ["input"],
|
|
1736
|
-
outputs: ["success", "error"],
|
|
1737
|
-
outputFields: {
|
|
1738
|
-
items: "What the provider returned",
|
|
1739
|
-
count: "How many items came back"
|
|
1740
|
-
}
|
|
1741
|
-
}),
|
|
1742
|
-
testInput: {
|
|
1743
|
-
config: { path: "/me/messages", limit: 2 }
|
|
1744
|
-
// context.connections cannot be simulated honestly, so `test` refuses
|
|
1745
|
-
// it. Stub it in a test of your own to exercise the code around it.
|
|
1746
|
-
}
|
|
1747
|
-
};
|
|
1748
2876
|
var file = {
|
|
1749
2877
|
description: "Work on a file in the account's storage.",
|
|
1750
2878
|
category: "utility",
|
|
@@ -1818,7 +2946,6 @@ export default plugin
|
|
|
1818
2946
|
var TEMPLATES = {
|
|
1819
2947
|
blank,
|
|
1820
2948
|
"http-request": httpRequest,
|
|
1821
|
-
connection,
|
|
1822
2949
|
file
|
|
1823
2950
|
};
|
|
1824
2951
|
|
|
@@ -1886,22 +3013,23 @@ function parseJsonc(text) {
|
|
|
1886
3013
|
}
|
|
1887
3014
|
|
|
1888
3015
|
// src/manifest-property.ts
|
|
1889
|
-
var
|
|
3016
|
+
var PROPERTY_TYPES2 = [
|
|
1890
3017
|
"string",
|
|
1891
3018
|
"number",
|
|
1892
3019
|
"boolean",
|
|
1893
3020
|
"select",
|
|
1894
3021
|
"multiselect",
|
|
1895
3022
|
"json",
|
|
3023
|
+
"code",
|
|
3024
|
+
"secret",
|
|
1896
3025
|
"color",
|
|
1897
3026
|
"date",
|
|
1898
|
-
"connection",
|
|
1899
3027
|
"file",
|
|
1900
3028
|
"path",
|
|
1901
3029
|
"paths"
|
|
1902
3030
|
];
|
|
1903
3031
|
function isPropertyType(value) {
|
|
1904
|
-
return
|
|
3032
|
+
return PROPERTY_TYPES2.includes(value);
|
|
1905
3033
|
}
|
|
1906
3034
|
function isPropertyName(name) {
|
|
1907
3035
|
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
@@ -1957,12 +3085,6 @@ function buildProperty(spec) {
|
|
|
1957
3085
|
}
|
|
1958
3086
|
property.options = options;
|
|
1959
3087
|
}
|
|
1960
|
-
if (spec.type === "connection") {
|
|
1961
|
-
if (!spec.provider) {
|
|
1962
|
-
throw new Error("A connection needs the provider it is for: --provider outlook.");
|
|
1963
|
-
}
|
|
1964
|
-
property.provider = spec.provider;
|
|
1965
|
-
}
|
|
1966
3088
|
if (spec.required) property.required = true;
|
|
1967
3089
|
if (spec.value !== void 0) property.default = defaultFor(spec.type, spec.value);
|
|
1968
3090
|
if (spec.description?.trim()) property.description = spec.description.trim();
|
|
@@ -2111,13 +3233,13 @@ function sampleFor(property) {
|
|
|
2111
3233
|
}
|
|
2112
3234
|
|
|
2113
3235
|
// src/project.ts
|
|
2114
|
-
import { existsSync as
|
|
3236
|
+
import { existsSync as existsSync3, readdirSync, statSync } from "node:fs";
|
|
2115
3237
|
import { readFile } from "node:fs/promises";
|
|
2116
|
-
import { basename as basename2, join as
|
|
3238
|
+
import { basename as basename2, join as join3, relative, resolve } from "node:path";
|
|
2117
3239
|
var TEST_FILES = ["visualautomate.test.json", "test.json"];
|
|
2118
3240
|
var ENTRIES = ["plugin.ts", "plugin.js", "index.ts", "index.js"];
|
|
2119
3241
|
function entryIn(dir) {
|
|
2120
|
-
return ENTRIES.map((file2) =>
|
|
3242
|
+
return ENTRIES.map((file2) => join3(dir, file2)).find((file2) => existsSync3(file2)) ?? null;
|
|
2121
3243
|
}
|
|
2122
3244
|
function matchFolders(folders, name) {
|
|
2123
3245
|
const wanted2 = name.trim().toLowerCase();
|
|
@@ -2129,9 +3251,10 @@ function matchFolders(folders, name) {
|
|
|
2129
3251
|
}
|
|
2130
3252
|
function findTargets(cwd, only) {
|
|
2131
3253
|
const dir = resolve(cwd);
|
|
2132
|
-
const modulesDir =
|
|
2133
|
-
|
|
2134
|
-
|
|
3254
|
+
const modulesDir = join3(dir, "modules");
|
|
3255
|
+
const stepHere = Boolean(entryIn(dir)) || existsSync3(join3(dir, "manifest.json"));
|
|
3256
|
+
if (!stepHere && existsSync3(modulesDir) && statSync(modulesDir).isDirectory()) {
|
|
3257
|
+
const folders = readdirSync(modulesDir, { withFileTypes: true }).filter((d) => d.isDirectory() && existsSync3(join3(modulesDir, d.name, "manifest.json"))).map((d) => d.name).sort();
|
|
2135
3258
|
if (folders.length === 0) {
|
|
2136
3259
|
throw new Error("There is a modules/ folder here, but none of its folders has a manifest.json.");
|
|
2137
3260
|
}
|
|
@@ -2139,20 +3262,20 @@ function findTargets(cwd, only) {
|
|
|
2139
3262
|
if (only && chosen.length === 0) {
|
|
2140
3263
|
throw new Error(`No module matches "${only}". This repository has: ${folders.join(", ")}.`);
|
|
2141
3264
|
}
|
|
2142
|
-
return chosen.map((folder) => targetIn(
|
|
3265
|
+
return chosen.map((folder) => targetIn(join3(modulesDir, folder), `modules/${folder}`)).filter(Boolean);
|
|
2143
3266
|
}
|
|
2144
3267
|
const target = targetIn(dir, basename2(dir));
|
|
2145
3268
|
if (!target) {
|
|
2146
3269
|
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>`."
|
|
3270
|
+
"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
3271
|
);
|
|
2149
3272
|
}
|
|
2150
3273
|
return [target];
|
|
2151
3274
|
}
|
|
2152
3275
|
function targetIn(dir, name) {
|
|
2153
3276
|
const entry = entryIn(dir);
|
|
2154
|
-
if (!entry) return null;
|
|
2155
|
-
const raw = basename2(entry) === "index.js" && !
|
|
3277
|
+
if (!entry) return existsSync3(join3(dir, "manifest.json")) ? { name, dir, entry: null, raw: true } : null;
|
|
3278
|
+
const raw = basename2(entry) === "index.js" && !existsSync3(join3(dir, "plugin.json"));
|
|
2156
3279
|
return { name, dir, entry, raw };
|
|
2157
3280
|
}
|
|
2158
3281
|
function targetForPath(targets, changed) {
|
|
@@ -2166,9 +3289,9 @@ function isPlainObject(value) {
|
|
|
2166
3289
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
2167
3290
|
}
|
|
2168
3291
|
async function readTestFile(dir, override) {
|
|
2169
|
-
const path = override ? resolve(override) : TEST_FILES.map((f) =>
|
|
3292
|
+
const path = override ? resolve(override) : TEST_FILES.map((f) => join3(dir, f)).find((f) => existsSync3(f)) ?? null;
|
|
2170
3293
|
if (!path) return { path: null, config: {}, input: {} };
|
|
2171
|
-
if (!
|
|
3294
|
+
if (!existsSync3(path)) throw new Error(`${path} does not exist.`);
|
|
2172
3295
|
let raw;
|
|
2173
3296
|
try {
|
|
2174
3297
|
raw = JSON.parse(await readFile(path, "utf8"));
|
|
@@ -2300,6 +3423,7 @@ function policyFor(allowedDomains, config) {
|
|
|
2300
3423
|
return { hosts, wildcard, enforce: allowedDomains !== void 0 };
|
|
2301
3424
|
}
|
|
2302
3425
|
function permits(policy, host) {
|
|
3426
|
+
if (isAlwaysDenied(host)) return false;
|
|
2303
3427
|
if (policy.wildcard) return true;
|
|
2304
3428
|
return policy.hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
|
|
2305
3429
|
}
|
|
@@ -2320,12 +3444,12 @@ function guardFetch(policy, base, onDenied) {
|
|
|
2320
3444
|
}
|
|
2321
3445
|
|
|
2322
3446
|
// src/persist.ts
|
|
2323
|
-
import { existsSync as
|
|
3447
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
2324
3448
|
import { mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
|
|
2325
|
-
import { dirname as
|
|
3449
|
+
import { dirname as dirname3, join as join4, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
2326
3450
|
var PERSIST_DIR = ".visualautomate";
|
|
2327
3451
|
function persistEnabled(dir, flag) {
|
|
2328
|
-
return flag === "true" ||
|
|
3452
|
+
return flag === "true" || existsSync4(join4(dir, PERSIST_DIR));
|
|
2329
3453
|
}
|
|
2330
3454
|
function diskPathFor(storageRoot, path) {
|
|
2331
3455
|
if (!path || path.includes("\0")) return null;
|
|
@@ -2335,22 +3459,22 @@ function diskPathFor(storageRoot, path) {
|
|
|
2335
3459
|
return target;
|
|
2336
3460
|
}
|
|
2337
3461
|
async function walk(root, dir = root) {
|
|
2338
|
-
if (!
|
|
3462
|
+
if (!existsSync4(dir)) return [];
|
|
2339
3463
|
const entries = await readdir(dir, { withFileTypes: true });
|
|
2340
3464
|
const nested = await Promise.all(
|
|
2341
|
-
entries.map((entry) => entry.isDirectory() ? walk(root,
|
|
3465
|
+
entries.map((entry) => entry.isDirectory() ? walk(root, join4(dir, entry.name)) : Promise.resolve([join4(dir, entry.name)]))
|
|
2342
3466
|
);
|
|
2343
3467
|
return nested.flat();
|
|
2344
3468
|
}
|
|
2345
3469
|
async function loadPersisted(dir) {
|
|
2346
|
-
const storageRoot =
|
|
3470
|
+
const storageRoot = join4(dir, PERSIST_DIR, "storage");
|
|
2347
3471
|
const storage = {};
|
|
2348
3472
|
for (const file2 of await walk(storageRoot)) {
|
|
2349
3473
|
storage[relative2(storageRoot, file2).split(sep).join("/")] = new Uint8Array(await readFile2(file2));
|
|
2350
3474
|
}
|
|
2351
3475
|
let state = {};
|
|
2352
|
-
const statePath =
|
|
2353
|
-
if (
|
|
3476
|
+
const statePath = join4(dir, PERSIST_DIR, "state.json");
|
|
3477
|
+
if (existsSync4(statePath)) {
|
|
2354
3478
|
try {
|
|
2355
3479
|
const parsed = JSON.parse(await readFile2(statePath, "utf8"));
|
|
2356
3480
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
|
|
@@ -2361,7 +3485,7 @@ async function loadPersisted(dir) {
|
|
|
2361
3485
|
return { storage, state };
|
|
2362
3486
|
}
|
|
2363
3487
|
async function savePersisted(dir, files, state) {
|
|
2364
|
-
const storageRoot =
|
|
3488
|
+
const storageRoot = join4(dir, PERSIST_DIR, "storage");
|
|
2365
3489
|
const refused = [];
|
|
2366
3490
|
const keep = /* @__PURE__ */ new Set();
|
|
2367
3491
|
for (const [path, file2] of files) {
|
|
@@ -2371,14 +3495,14 @@ async function savePersisted(dir, files, state) {
|
|
|
2371
3495
|
continue;
|
|
2372
3496
|
}
|
|
2373
3497
|
keep.add(target);
|
|
2374
|
-
await mkdir(
|
|
3498
|
+
await mkdir(dirname3(target), { recursive: true });
|
|
2375
3499
|
await writeFile(target, file2.bytes);
|
|
2376
3500
|
}
|
|
2377
3501
|
for (const existing of await walk(storageRoot)) {
|
|
2378
3502
|
if (!keep.has(existing)) await rm(existing);
|
|
2379
3503
|
}
|
|
2380
|
-
await mkdir(
|
|
2381
|
-
await writeFile(
|
|
3504
|
+
await mkdir(join4(dir, PERSIST_DIR), { recursive: true });
|
|
3505
|
+
await writeFile(join4(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
|
|
2382
3506
|
return refused;
|
|
2383
3507
|
}
|
|
2384
3508
|
|
|
@@ -2417,12 +3541,9 @@ function colourEnv() {
|
|
|
2417
3541
|
|
|
2418
3542
|
// src/docker.ts
|
|
2419
3543
|
var IMAGE = "ghcr.io/visualautomate/plugin-sandbox";
|
|
2420
|
-
var TIERS =
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
high: { cpus: "1", memory: "6g" },
|
|
2424
|
-
max: { cpus: "2", memory: "8g" }
|
|
2425
|
-
};
|
|
3544
|
+
var TIERS = Object.fromEntries(
|
|
3545
|
+
SANDBOX_TIERS.map((tier) => [tier.id, { cpus: String(tier.vcpus), memory: `${tier.memoryGb}g`, label: tier.label }])
|
|
3546
|
+
);
|
|
2426
3547
|
function dockerArgs(run) {
|
|
2427
3548
|
const tier = TIERS[run.tier];
|
|
2428
3549
|
return [
|
|
@@ -2461,8 +3582,13 @@ function forwardedFlags(flags) {
|
|
|
2461
3582
|
return Object.entries(flags).filter(([key]) => !local.has(key)).flatMap(([key, value]) => value === "true" ? [`--${key}`] : [`--${key}`, value]);
|
|
2462
3583
|
}
|
|
2463
3584
|
function tierOf(value) {
|
|
2464
|
-
if (!value) return
|
|
3585
|
+
if (!value) return SANDBOX_TIERS[0].id;
|
|
2465
3586
|
if (value in TIERS) return value;
|
|
3587
|
+
const now = RETIRED_TIERS[value];
|
|
3588
|
+
if (now) {
|
|
3589
|
+
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.`));
|
|
3590
|
+
return now;
|
|
3591
|
+
}
|
|
2466
3592
|
throw new Error(`Unknown tier "${value}". Available: ${Object.keys(TIERS).join(", ")}.`);
|
|
2467
3593
|
}
|
|
2468
3594
|
function dockerProblem() {
|
|
@@ -2519,7 +3645,7 @@ function runInDocker(run) {
|
|
|
2519
3645
|
}
|
|
2520
3646
|
|
|
2521
3647
|
// src/index.ts
|
|
2522
|
-
var VERSION = true ? "0.
|
|
3648
|
+
var VERSION = true ? "0.8.0" : "0.0.0-dev";
|
|
2523
3649
|
function fail(message) {
|
|
2524
3650
|
console.error(red(message));
|
|
2525
3651
|
throw new SilentExit();
|
|
@@ -2542,22 +3668,22 @@ async function cmdInit(args, flags) {
|
|
|
2542
3668
|
fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
|
|
2543
3669
|
}
|
|
2544
3670
|
const target = resolve3(process.cwd(), name);
|
|
2545
|
-
if (
|
|
3671
|
+
if (existsSync5(target)) fail(`"${name}" already exists here.`);
|
|
2546
3672
|
const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
2547
3673
|
await mkdir2(target, { recursive: true });
|
|
2548
|
-
await writeFile2(
|
|
2549
|
-
await writeFile2(
|
|
3674
|
+
await writeFile2(join5(target, "plugin.ts"), template.code);
|
|
3675
|
+
await writeFile2(join5(target, "manifest.json"), template.manifest);
|
|
2550
3676
|
await writeFile2(
|
|
2551
|
-
|
|
3677
|
+
join5(target, "plugin.json"),
|
|
2552
3678
|
JSON.stringify(
|
|
2553
3679
|
{ name: slug, version: "1.0.0", description: template.description, category: template.category },
|
|
2554
3680
|
null,
|
|
2555
3681
|
2
|
|
2556
3682
|
) + "\n"
|
|
2557
3683
|
);
|
|
2558
|
-
await writeFile2(
|
|
3684
|
+
await writeFile2(join5(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
|
|
2559
3685
|
await writeFile2(
|
|
2560
|
-
|
|
3686
|
+
join5(target, "tsconfig.json"),
|
|
2561
3687
|
JSON.stringify(
|
|
2562
3688
|
{
|
|
2563
3689
|
compilerOptions: {
|
|
@@ -2576,30 +3702,57 @@ async function cmdInit(args, flags) {
|
|
|
2576
3702
|
) + "\n"
|
|
2577
3703
|
);
|
|
2578
3704
|
await writeFile2(
|
|
2579
|
-
|
|
3705
|
+
join5(target, "test.json"),
|
|
2580
3706
|
JSON.stringify(template.testInput, null, 2) + "\n"
|
|
2581
3707
|
);
|
|
2582
3708
|
await writeFile2(
|
|
2583
|
-
|
|
3709
|
+
join5(target, "README.md"),
|
|
2584
3710
|
`# ${name}
|
|
2585
3711
|
|
|
2586
3712
|
${template.description}
|
|
2587
3713
|
|
|
2588
3714
|
\`\`\`bash
|
|
2589
|
-
|
|
2590
|
-
|
|
3715
|
+
vsa dev # run it in the sandbox, again on every save
|
|
3716
|
+
vsa test # run it once, as the platform's check does
|
|
2591
3717
|
\`\`\`
|
|
2592
3718
|
|
|
2593
3719
|
\`manifest.json\` declares the fields the canvas shows and what this step hands on.
|
|
2594
3720
|
\`test.json\` is the input, config and state \`test\` runs with.
|
|
3721
|
+
\`AGENTS.md\` and \`CLAUDE.md\` tell an AI assistant the platform's rules; \`vsa skill\` adds the full guide.
|
|
3722
|
+
|
|
3723
|
+
To publish, put the step in a GitHub repository (\`manifest.json\`, \`index.js\`, \`visualautomate.test.json\`)
|
|
3724
|
+
and connect it on https://developers.visualautomate.com.
|
|
2595
3725
|
`
|
|
2596
3726
|
);
|
|
3727
|
+
ensureAgentFiles(target, VERSION);
|
|
2597
3728
|
ok(green(`
|
|
2598
3729
|
\u2713 Created ${name} from the "${templateName}" template
|
|
2599
3730
|
`));
|
|
2600
3731
|
ok(" cd " + name);
|
|
2601
|
-
ok("
|
|
2602
|
-
ok("
|
|
3732
|
+
ok(" vsa dev");
|
|
3733
|
+
ok(dim("\n Building with an AI assistant? `vsa skill` installs the guide it reads.\n"));
|
|
3734
|
+
}
|
|
3735
|
+
async function cmdSkill(flags) {
|
|
3736
|
+
const cwd = process.cwd();
|
|
3737
|
+
const project = !flags.global && !flags.dir;
|
|
3738
|
+
const root = projectRoot(cwd);
|
|
3739
|
+
const dir = flags.global ? join5(homedir(), ".claude", "skills", SKILL_NAME) : flags.dir ? resolve3(cwd, flags.dir) : join5(root, ".claude", "skills", SKILL_NAME);
|
|
3740
|
+
const changed = installSkill(dir);
|
|
3741
|
+
const where = flags.global ? dir : relative3(cwd, dir) || ".";
|
|
3742
|
+
ok(
|
|
3743
|
+
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.`
|
|
3744
|
+
);
|
|
3745
|
+
if (project) {
|
|
3746
|
+
const written = ensureAgentFiles(root, VERSION);
|
|
3747
|
+
if (written.length > 0) ok(green(`\u2713 Wrote ${written.join(" and ")}`));
|
|
3748
|
+
for (const name of foreignAgentFiles(root)) {
|
|
3749
|
+
ok(yellow(` ${name} is your own and was left alone. Point it at .claude/skills/${SKILL_NAME}/SKILL.md if you like.`));
|
|
3750
|
+
}
|
|
3751
|
+
}
|
|
3752
|
+
ok(dim("\nAsk your assistant for a step in a sentence, for example:"));
|
|
3753
|
+
ok(dim(' "Make a VisualAutomate step that creates an invoice in Moneybird."'));
|
|
3754
|
+
if (project) ok(dim(`Claude Code finds the skill on its own; Cursor, Copilot and Codex read ${AGENTS_FILE}.`));
|
|
3755
|
+
if (flags.dir) ok(dim("For Claude on the web or desktop, zip that folder and upload it as a skill."));
|
|
2603
3756
|
}
|
|
2604
3757
|
async function bundlePlugin(entry) {
|
|
2605
3758
|
try {
|
|
@@ -2609,7 +3762,8 @@ async function bundlePlugin(entry) {
|
|
|
2609
3762
|
write: false,
|
|
2610
3763
|
platform: "node",
|
|
2611
3764
|
format: "esm",
|
|
2612
|
-
|
|
3765
|
+
// The Node the platform runs steps on.
|
|
3766
|
+
target: `node${PLATFORM_NODE_MAJOR}`,
|
|
2613
3767
|
packages: "external",
|
|
2614
3768
|
logLevel: "silent"
|
|
2615
3769
|
});
|
|
@@ -2622,6 +3776,7 @@ ${message}`);
|
|
|
2622
3776
|
}
|
|
2623
3777
|
}
|
|
2624
3778
|
async function codeFor(target) {
|
|
3779
|
+
if (target.entry === null) throw new Error(`${target.name} is only a manifest; there is no code to run.`);
|
|
2625
3780
|
return target.raw ? readFile3(target.entry, "utf8") : bundlePlugin(target.entry);
|
|
2626
3781
|
}
|
|
2627
3782
|
function line(indent, text) {
|
|
@@ -2632,6 +3787,29 @@ async function runTarget(target, flags, several) {
|
|
|
2632
3787
|
if (several) ok(`
|
|
2633
3788
|
${bold(`\u25B8 ${target.name}`)}`);
|
|
2634
3789
|
try {
|
|
3790
|
+
const manifestPath = join5(target.dir, "manifest.json");
|
|
3791
|
+
if (!existsSync5(manifestPath)) {
|
|
3792
|
+
say(`${red("\u2717 refused")} There is no manifest.json beside the code. The push check refuses a step without one.`);
|
|
3793
|
+
return false;
|
|
3794
|
+
}
|
|
3795
|
+
let manifest;
|
|
3796
|
+
try {
|
|
3797
|
+
manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
|
|
3798
|
+
} catch (error) {
|
|
3799
|
+
say(red(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`));
|
|
3800
|
+
return false;
|
|
3801
|
+
}
|
|
3802
|
+
const shape = checkManifest(manifest, target.entry !== null);
|
|
3803
|
+
for (const problem of shape.errors) say(`${red("\u2717 manifest")} ${problem}`);
|
|
3804
|
+
for (const warning of shape.warnings) say(yellow(`! ${warning}`));
|
|
3805
|
+
if (!shape.ok) return false;
|
|
3806
|
+
if (target.entry === null) {
|
|
3807
|
+
const what = shape.kind === "outputs-only" ? "a declared step with no request" : "a declared step";
|
|
3808
|
+
say(`${green(bold("\u2713 checked"))} ${dim(`${what}: the manifest is sound`)}`);
|
|
3809
|
+
say(dim(" Not run here, and not published from GitHub. On the developer portal: New module \u2192 Call an API,"));
|
|
3810
|
+
say(dim(" paste this manifest.json into the module's manifest.json editor, press Test the call, then publish it there."));
|
|
3811
|
+
return true;
|
|
3812
|
+
}
|
|
2635
3813
|
const code = await codeFor(target);
|
|
2636
3814
|
const check = validatePluginCode(code);
|
|
2637
3815
|
if (!check.valid) {
|
|
@@ -2640,63 +3818,65 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2640
3818
|
}
|
|
2641
3819
|
const settings = await readTestFile(target.dir, flags.input);
|
|
2642
3820
|
if (!settings.path) say("No visualautomate.test.json \u2014 running with empty input and config.");
|
|
2643
|
-
const
|
|
2644
|
-
const
|
|
2645
|
-
|
|
2646
|
-
|
|
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
|
-
}
|
|
3821
|
+
const declaredTimeout = Number(manifest.timeoutMs);
|
|
3822
|
+
const timeoutMs = Number.isFinite(declaredTimeout) && declaredTimeout > 0 ? Math.min(declaredTimeout, PUSH_CHECK.maxTimeoutMs) : PUSH_CHECK.defaultTimeoutMs;
|
|
3823
|
+
if (settings.timeoutMs !== void 0 && settings.timeoutMs !== timeoutMs) {
|
|
3824
|
+
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
3825
|
}
|
|
3826
|
+
const outputs = Array.isArray(manifest.outputs) ? manifest.outputs : ["success", "error"];
|
|
3827
|
+
const expected = settings.expectOutput ?? PUSH_CHECK.defaultExpectOutput;
|
|
2656
3828
|
const persist = persistEnabled(target.dir, flags.persist);
|
|
2657
3829
|
const kept = persist ? await loadPersisted(target.dir) : { storage: {}, state: {} };
|
|
2658
3830
|
const policy = policyFor(settings.allowedDomains, settings.config);
|
|
2659
3831
|
const denied = /* @__PURE__ */ new Map();
|
|
2660
|
-
const
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
});
|
|
2672
|
-
} finally {
|
|
2673
|
-
globalThis.fetch = baseFetch;
|
|
3832
|
+
const logs = [];
|
|
3833
|
+
const capture = (level) => (...args) => logs.push(`${level === "log" ? "" : `[${level}] `}${args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ")}`);
|
|
3834
|
+
const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)), {
|
|
3835
|
+
fetch: guardFetch(policy, globalThis.fetch, (host, enforced) => denied.set(host, enforced)),
|
|
3836
|
+
console: { log: capture("log"), error: capture("error"), warn: capture("warn"), info: capture("info") },
|
|
3837
|
+
timeoutMs
|
|
3838
|
+
});
|
|
3839
|
+
const execute = executeOf(exported);
|
|
3840
|
+
if (!execute) {
|
|
3841
|
+
say(`${red("\u2717 failed")} Plugin must export an execute function: module.exports = { execute }, or a function.`);
|
|
3842
|
+
return false;
|
|
2674
3843
|
}
|
|
3844
|
+
const result = await simulate(execute, {
|
|
3845
|
+
input: settings.input,
|
|
3846
|
+
config: settings.config,
|
|
3847
|
+
storage: { ...settings.storage, ...kept.storage },
|
|
3848
|
+
state: { ...settings.state, ...kept.state },
|
|
3849
|
+
secrets: settings.secrets,
|
|
3850
|
+
timeoutMs
|
|
3851
|
+
});
|
|
2675
3852
|
const port = result.output?.output ?? null;
|
|
2676
|
-
const
|
|
2677
|
-
const passed = result.status === "success" && (!expected || port === expected);
|
|
3853
|
+
const passed = result.status === "success" && port === expected;
|
|
2678
3854
|
const verdict = passed ? green(bold("\u2713 passed")) : red(bold("\u2717 failed"));
|
|
2679
3855
|
say(`${verdict} ${dim(`${result.status}, ${result.durationMs}ms`)}`);
|
|
2680
3856
|
if (result.error) say(`${dim("error")} ${red(result.error)}`);
|
|
2681
3857
|
if (result.output) {
|
|
2682
|
-
const
|
|
2683
|
-
|
|
3858
|
+
const shown = passed ? port : red(String(port));
|
|
3859
|
+
const why = settings.expectOutput === void 0 ? "expected success, the default" : `expected ${expected}`;
|
|
3860
|
+
say(`${dim("output")} ${shown}${dim(` (${why})`)}`);
|
|
2684
3861
|
say(`${dim("data")} ${JSON.stringify(result.output.data, null, 2).replace(/\n/g, "\n ")}`);
|
|
2685
3862
|
}
|
|
2686
3863
|
if (result.files.size > 0) say(`${dim("files")} ${[...result.files.keys()].join(", ")}`);
|
|
2687
3864
|
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}`);
|
|
3865
|
+
for (const entry of [...logs, ...result.logs ?? []]) say(`${dim("log")} ${entry}`);
|
|
2689
3866
|
for (const [host, enforced] of denied) {
|
|
2690
3867
|
say(
|
|
2691
3868
|
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
3869
|
);
|
|
2693
3870
|
}
|
|
3871
|
+
if (passed && expected !== "success") {
|
|
3872
|
+
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."));
|
|
3873
|
+
}
|
|
2694
3874
|
if (persist) {
|
|
2695
3875
|
const refused = await savePersisted(target.dir, result.files, result.state);
|
|
2696
|
-
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(),
|
|
3876
|
+
say(`${dim("kept")} ${dim(`storage and state in ${relative3(process.cwd(), join5(target.dir, ".visualautomate")) || ".visualautomate"}`)}`);
|
|
2697
3877
|
for (const path of refused) say(yellow(`! storage path "${path}" points outside the storage folder and was not written`));
|
|
2698
3878
|
}
|
|
2699
|
-
if (port &&
|
|
3879
|
+
if (port && !outputs.includes(port)) {
|
|
2700
3880
|
say(
|
|
2701
3881
|
yellow(
|
|
2702
3882
|
`! 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 +3885,22 @@ ${bold(`\u25B8 ${target.name}`)}`);
|
|
|
2705
3885
|
}
|
|
2706
3886
|
return passed;
|
|
2707
3887
|
} catch (error) {
|
|
2708
|
-
|
|
3888
|
+
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
|
3889
|
+
say(red(`\u2717 ${message}`));
|
|
2709
3890
|
return false;
|
|
2710
3891
|
}
|
|
2711
3892
|
}
|
|
3893
|
+
var nodeNoted = false;
|
|
3894
|
+
function noteNodeVersion() {
|
|
3895
|
+
if (nodeNoted || process.env.VISUALAUTOMATE_PACKAGES) return;
|
|
3896
|
+
nodeNoted = true;
|
|
3897
|
+
const here = Number(process.versions.node.split(".")[0]);
|
|
3898
|
+
if (here !== PLATFORM_NODE_MAJOR) {
|
|
3899
|
+
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.`));
|
|
3900
|
+
}
|
|
3901
|
+
}
|
|
2712
3902
|
async function runTargets(targets, flags) {
|
|
3903
|
+
noteNodeVersion();
|
|
2713
3904
|
let passed = 0;
|
|
2714
3905
|
for (const target of targets) {
|
|
2715
3906
|
if (await runTarget(target, flags, targets.length > 1)) passed++;
|
|
@@ -2722,9 +3913,9 @@ ${passed === targets.length ? green(bold(tally)) : red(bold(tally))}`);
|
|
|
2722
3913
|
return passed === targets.length;
|
|
2723
3914
|
}
|
|
2724
3915
|
function packageLoader(dir) {
|
|
2725
|
-
const fromProject = createRequire(
|
|
3916
|
+
const fromProject = createRequire(join5(dir, "noop.js"));
|
|
2726
3917
|
const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
|
|
2727
|
-
const fromImage = imageDir ? createRequire(
|
|
3918
|
+
const fromImage = imageDir ? createRequire(join5(imageDir, "noop.js")) : null;
|
|
2728
3919
|
return (name) => {
|
|
2729
3920
|
try {
|
|
2730
3921
|
return fromProject(name);
|
|
@@ -2818,11 +4009,14 @@ async function cmdAddProperty(args, flags) {
|
|
|
2818
4009
|
`What property? vsa add:property <type> <name> [description] [default]
|
|
2819
4010
|
vsa add:property string apiKey "The key to call with"
|
|
2820
4011
|
vsa add:property number retries "How many times to try again" 2
|
|
2821
|
-
Types: ${
|
|
4012
|
+
Types: ${PROPERTY_TYPES2.join(", ")}`
|
|
2822
4013
|
);
|
|
2823
4014
|
}
|
|
4015
|
+
if (type === "connection") {
|
|
4016
|
+
fail(`There is no connection property for your steps. ${ACCOUNTS_ARE_OURS}.`);
|
|
4017
|
+
}
|
|
2824
4018
|
if (!isPropertyType(type)) {
|
|
2825
|
-
fail(`There is no "${type}" property. Types: ${
|
|
4019
|
+
fail(`There is no "${type}" property. Types: ${PROPERTY_TYPES2.join(", ")}`);
|
|
2826
4020
|
}
|
|
2827
4021
|
if (!isPropertyName(name)) {
|
|
2828
4022
|
fail(`"${name}" is not a name your code can read: config.${name} has to be something you can write.`);
|
|
@@ -2836,8 +4030,8 @@ Types: ${PROPERTY_TYPES.join(", ")}`
|
|
|
2836
4030
|
);
|
|
2837
4031
|
}
|
|
2838
4032
|
const target = targets[0];
|
|
2839
|
-
const file2 =
|
|
2840
|
-
if (!
|
|
4033
|
+
const file2 = join5(target.dir, "manifest.json");
|
|
4034
|
+
if (!existsSync5(file2)) fail(`${target.name} has no manifest.json.`);
|
|
2841
4035
|
const text = await readFile3(file2, "utf8");
|
|
2842
4036
|
if (hasMember(text, "properties", name)) {
|
|
2843
4037
|
fail(`${target.name} already has a property called ${name}. Change it in ${target.name}/manifest.json.`);
|
|
@@ -2852,8 +4046,7 @@ Types: ${PROPERTY_TYPES.join(", ")}`
|
|
|
2852
4046
|
value,
|
|
2853
4047
|
label: flags.label,
|
|
2854
4048
|
required: flags.required === "true",
|
|
2855
|
-
options: flags.options
|
|
2856
|
-
provider: flags.provider
|
|
4049
|
+
options: flags.options
|
|
2857
4050
|
});
|
|
2858
4051
|
next = withMember(text, "properties", name, property);
|
|
2859
4052
|
} catch (error) {
|
|
@@ -2861,10 +4054,10 @@ Types: ${PROPERTY_TYPES.join(", ")}`
|
|
|
2861
4054
|
}
|
|
2862
4055
|
await writeFile2(file2, next, "utf8");
|
|
2863
4056
|
const written = [`${target.name}/manifest.json`];
|
|
2864
|
-
const testPath = TEST_FILES.map((name2) =>
|
|
4057
|
+
const testPath = TEST_FILES.map((name2) => join5(target.dir, name2)).find((path) => existsSync5(path)) ?? join5(target.dir, TEST_FILES[0]);
|
|
2865
4058
|
const sample = sampleFor(property);
|
|
2866
4059
|
try {
|
|
2867
|
-
const before =
|
|
4060
|
+
const before = existsSync5(testPath) ? await readFile3(testPath, "utf8") : `{
|
|
2868
4061
|
"config": {},
|
|
2869
4062
|
"input": {}
|
|
2870
4063
|
}
|
|
@@ -2925,7 +4118,7 @@ async function cmdDev(args, flags) {
|
|
|
2925
4118
|
};
|
|
2926
4119
|
const watcher = watch(root, { recursive: true }, (_event, name) => {
|
|
2927
4120
|
if (!name) return;
|
|
2928
|
-
const path =
|
|
4121
|
+
const path = join5(root, name.toString());
|
|
2929
4122
|
if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
|
|
2930
4123
|
pending.add(path);
|
|
2931
4124
|
if (timer) clearTimeout(timer);
|
|
@@ -2948,6 +4141,7 @@ vsa \u2014 build and test VisualAutomate plugins
|
|
|
2948
4141
|
vsa types write the types your editor reads
|
|
2949
4142
|
vsa add:property <type> <name> [description] [default]
|
|
2950
4143
|
add a field to a module's manifest
|
|
4144
|
+
vsa skill [--global] the guide an AI assistant reads to build steps
|
|
2951
4145
|
vsa --version this CLI's version
|
|
2952
4146
|
|
|
2953
4147
|
\`va\` and \`visualautomate\` are the same command.
|
|
@@ -2964,13 +4158,17 @@ Options for \`add:property\`:
|
|
|
2964
4158
|
--required the workflow will not run without it
|
|
2965
4159
|
--label <text> instead of one made from the name
|
|
2966
4160
|
--options <list> for select and multiselect: "Fast:fast,Thorough:thorough"
|
|
2967
|
-
--provider <name> for connection: whose account this is
|
|
2968
4161
|
|
|
2969
|
-
Property types: ${
|
|
4162
|
+
Property types: ${PROPERTY_TYPES2.join(", ")}
|
|
4163
|
+
|
|
4164
|
+
Options for \`skill\`:
|
|
4165
|
+
(none) .claude/skills/${SKILL_NAME} here, plus ${AGENTS_FILE} and ${CLAUDE_FILE}
|
|
4166
|
+
--global ~/.claude/skills/${SKILL_NAME}, for every project
|
|
4167
|
+
--dir <folder> the skill's folder anywhere, e.g. to zip for Claude on the web
|
|
2970
4168
|
|
|
2971
4169
|
Options for \`dev\` and \`test\`:
|
|
2972
4170
|
--module <name> the same as naming it: vsa dev send-message
|
|
2973
|
-
--tier <name> the sandbox's size:
|
|
4171
|
+
--tier <name> the sandbox's size: ${Object.keys(TIERS).join(", ")}
|
|
2974
4172
|
--image <ref> a different sandbox image
|
|
2975
4173
|
--persist keep storage and state in .visualautomate/ between runs
|
|
2976
4174
|
--local \`dev\` without the sandbox, on this machine's Node
|
|
@@ -3017,6 +4215,8 @@ async function main() {
|
|
|
3017
4215
|
case "dev":
|
|
3018
4216
|
case "watch":
|
|
3019
4217
|
return cmdDev(args, flags);
|
|
4218
|
+
case "skill":
|
|
4219
|
+
return cmdSkill(flags);
|
|
3020
4220
|
// `plugin test` is accepted as an alias.
|
|
3021
4221
|
case "plugin": {
|
|
3022
4222
|
if (args.shift() === "test") return cmdTest(args, flags);
|