@visualautomate/cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +52 -3
  2. package/dist/index.js +336 -51
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -107,13 +107,62 @@ one fails with a sentence saying so.
107
107
  "storage": { "notes/hello.txt": "hello from a file" },
108
108
  "state": { "runs": 0 },
109
109
  "expectOutput": "success",
110
- "timeoutMs": 30000
110
+ "timeoutMs": 30000,
111
+ "allowedDomains": ["api.example.com"]
111
112
  }
112
113
  ```
113
114
 
114
115
  The platform's push check reads `config`, `input`, `expectOutput` and
115
- `timeoutMs`; `storage`, `state` and `secrets` are for running locally. A file that is not valid JSON
116
- stops the run rather than running with nothing.
116
+ `timeoutMs`; `storage`, `state`, `secrets` and `allowedDomains` are for running
117
+ locally. A file that is not valid JSON stops the run rather than running with
118
+ nothing.
119
+
120
+ ## Where `fetch` may go
121
+
122
+ On the platform a step may contact the domains its plugin declares, plus any
123
+ host in the configuration filled in for that step. A declared domain covers its
124
+ subdomains; localhost, private networks and metadata addresses are never
125
+ allowed.
126
+
127
+ Put the domains in `allowedDomains` and `test` applies the same rule: a request
128
+ anywhere else fails. Without `allowedDomains` nothing is blocked, but every host
129
+ the platform would refuse is listed after the run.
130
+
131
+ ## `--persist`
132
+
133
+ Keeps storage and state between runs, in `.visualautomate/` next to the plugin:
134
+
135
+ ```
136
+ .visualautomate/storage/<path> files the step wrote
137
+ .visualautomate/state.json the workflow state
138
+ ```
139
+
140
+ Once that folder exists it is used without the flag. Delete it to start over,
141
+ and add it to `.gitignore`.
142
+
143
+ ## `--docker`: the sandbox on your machine
144
+
145
+ ```bash
146
+ va test --docker
147
+ va dev --docker --tier boosted
148
+ ```
149
+
150
+ Runs the test inside the plugin sandbox image,
151
+ `ghcr.io/visualautomate/plugin-sandbox`, at the version of this CLI:
152
+
153
+ - the Node version the platform runs plugins on, with every allowed npm
154
+ package installed — no `npm install` needed;
155
+ - the CPU and memory of the sandbox tier: `standard` (¼ vCPU, 1 GB, the
156
+ default), `boosted` (½ vCPU, 4 GB), `high` (1 vCPU, 6 GB), `max` (2 vCPU, 8 GB);
157
+ - no Linux capabilities, a read-only filesystem apart from your project and a
158
+ small `/tmp`, and nothing else from your machine.
159
+
160
+ A step that runs out of memory or time here does the same on the platform. Docker
161
+ must be running. `--image` uses a different image, for example one you built
162
+ yourself. With `dev`, the watcher stays on your machine and each run starts a
163
+ fresh container.
164
+
165
+ `context.media` is not available locally, in or out of Docker.
117
166
 
118
167
  ## `push`
119
168
 
package/dist/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { createServer } from "node:http";
5
- import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
6
- import { existsSync as existsSync2, watch } from "node:fs";
5
+ import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
6
+ import { existsSync as existsSync3, watch } from "node:fs";
7
7
  import { homedir } from "node:os";
8
- import { dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
8
+ import { dirname as dirname2, join as join3, relative as relative3, resolve as resolve3, sep as sep2 } from "node:path";
9
9
  import { randomBytes } from "node:crypto";
10
10
  import { spawn } from "node:child_process";
11
11
  import { createRequire } from "node:module";
@@ -1882,8 +1882,12 @@ async function readTestFile(dir, override) {
1882
1882
  if (raw.timeoutMs !== void 0 && (typeof raw.timeoutMs !== "number" || raw.timeoutMs <= 0 || raw.timeoutMs > 6e4)) {
1883
1883
  throw new Error(`${path}: "timeoutMs" must be a number of milliseconds up to 60000.`);
1884
1884
  }
1885
+ if (raw.allowedDomains !== void 0 && (!Array.isArray(raw.allowedDomains) || !raw.allowedDomains.every((d) => typeof d === "string" && d.trim() !== ""))) {
1886
+ throw new Error(`${path}: "allowedDomains" must be a list of hosts, like ["api.example.com"], or ["*"].`);
1887
+ }
1885
1888
  return {
1886
1889
  path,
1890
+ allowedDomains: raw.allowedDomains,
1887
1891
  config: raw.config ?? {},
1888
1892
  input: raw.input ?? {},
1889
1893
  storage: raw.storage,
@@ -1940,22 +1944,226 @@ if (${fallback} && typeof ${fallback}.execute === "function") module.exports.exe
1940
1944
  return code.replace(/^\n+/, "");
1941
1945
  }
1942
1946
 
1947
+ // src/egress.ts
1948
+ var ALWAYS_DENIED = [
1949
+ /^localhost$/i,
1950
+ /^127\./,
1951
+ /^0\./,
1952
+ /^10\./,
1953
+ /^192\.168\./,
1954
+ /^172\.(1[6-9]|2\d|3[01])\./,
1955
+ /^169\.254\./,
1956
+ /^\[?::1\]?$/,
1957
+ /^\[?f[cd][0-9a-f]{2}:/i,
1958
+ /\.internal$/i,
1959
+ /\.local$/i
1960
+ ];
1961
+ function hostOf(value) {
1962
+ if (typeof value !== "string" || value.trim() === "") return null;
1963
+ const trimmed = value.trim();
1964
+ try {
1965
+ const host = new URL(trimmed.includes("://") ? trimmed : `https://${trimmed}`).hostname.toLowerCase();
1966
+ return host || null;
1967
+ } catch {
1968
+ return null;
1969
+ }
1970
+ }
1971
+ function isAlwaysDenied(host) {
1972
+ return ALWAYS_DENIED.some((pattern) => pattern.test(host));
1973
+ }
1974
+ function hostsInConfig(config, depth = 0) {
1975
+ if (depth > 6 || config === null || config === void 0) return [];
1976
+ if (typeof config === "string") {
1977
+ const host = hostOf(config);
1978
+ return host && (config.includes("://") || /^[a-z0-9.-]+\.[a-z]{2,}(:\d+)?$/i.test(config.trim())) ? [host] : [];
1979
+ }
1980
+ if (Array.isArray(config)) return config.flatMap((entry) => hostsInConfig(entry, depth + 1));
1981
+ if (typeof config === "object") {
1982
+ return Object.entries(config).filter(([key]) => !key.startsWith("_")).flatMap(([, value]) => hostsInConfig(value, depth + 1));
1983
+ }
1984
+ return [];
1985
+ }
1986
+ function policyFor(allowedDomains, config) {
1987
+ const declared = (allowedDomains ?? []).map((d) => d.trim().toLowerCase()).filter(Boolean);
1988
+ const wildcard = declared.includes("*");
1989
+ const hosts = [
1990
+ .../* @__PURE__ */ new Set([
1991
+ ...declared.filter((d) => d !== "*").map((d) => hostOf(d.replace(/^\*\./, "")) ?? d),
1992
+ ...hostsInConfig(config)
1993
+ ])
1994
+ ].filter((host) => !isAlwaysDenied(host));
1995
+ return { hosts, wildcard, enforce: allowedDomains !== void 0 };
1996
+ }
1997
+ function permits(policy, host) {
1998
+ if (policy.wildcard) return true;
1999
+ return policy.hosts.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
2000
+ }
2001
+ function guardFetch(policy, base, onDenied) {
2002
+ return (resource, init) => {
2003
+ const raw = typeof resource === "string" ? resource : resource instanceof URL ? resource.href : resource.url;
2004
+ const host = hostOf(raw);
2005
+ if (host && !permits(policy, host)) {
2006
+ onDenied(host, policy.enforce);
2007
+ if (policy.enforce) {
2008
+ return Promise.reject(
2009
+ new Error(`This step is not allowed to contact ${host}. Add it to the plugin's allowed domains.`)
2010
+ );
2011
+ }
2012
+ }
2013
+ return base(resource, init);
2014
+ };
2015
+ }
2016
+
2017
+ // src/persist.ts
2018
+ import { existsSync as existsSync2 } from "node:fs";
2019
+ import { mkdir, readdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
2020
+ import { dirname, join as join2, relative as relative2, resolve as resolve2, sep } from "node:path";
2021
+ var PERSIST_DIR = ".visualautomate";
2022
+ function persistEnabled(dir, flag) {
2023
+ return flag === "true" || existsSync2(join2(dir, PERSIST_DIR));
2024
+ }
2025
+ function diskPathFor(storageRoot, path) {
2026
+ if (!path || path.includes("\0")) return null;
2027
+ const target = resolve2(storageRoot, path);
2028
+ const rel = relative2(storageRoot, target);
2029
+ if (!rel || rel.startsWith("..") || rel.split(sep).includes("..") || resolve2(rel) === rel) return null;
2030
+ return target;
2031
+ }
2032
+ async function walk(root, dir = root) {
2033
+ if (!existsSync2(dir)) return [];
2034
+ const entries = await readdir(dir, { withFileTypes: true });
2035
+ const nested = await Promise.all(
2036
+ entries.map((entry) => entry.isDirectory() ? walk(root, join2(dir, entry.name)) : Promise.resolve([join2(dir, entry.name)]))
2037
+ );
2038
+ return nested.flat();
2039
+ }
2040
+ async function loadPersisted(dir) {
2041
+ const storageRoot = join2(dir, PERSIST_DIR, "storage");
2042
+ const storage = {};
2043
+ for (const file2 of await walk(storageRoot)) {
2044
+ storage[relative2(storageRoot, file2).split(sep).join("/")] = new Uint8Array(await readFile2(file2));
2045
+ }
2046
+ let state = {};
2047
+ const statePath = join2(dir, PERSIST_DIR, "state.json");
2048
+ if (existsSync2(statePath)) {
2049
+ try {
2050
+ const parsed = JSON.parse(await readFile2(statePath, "utf8"));
2051
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) state = parsed;
2052
+ } catch {
2053
+ throw new Error(`${statePath} is not valid JSON. Fix it, or delete it to start from empty state.`);
2054
+ }
2055
+ }
2056
+ return { storage, state };
2057
+ }
2058
+ async function savePersisted(dir, files, state) {
2059
+ const storageRoot = join2(dir, PERSIST_DIR, "storage");
2060
+ const refused = [];
2061
+ const keep = /* @__PURE__ */ new Set();
2062
+ for (const [path, file2] of files) {
2063
+ const target = diskPathFor(storageRoot, path);
2064
+ if (!target) {
2065
+ refused.push(path);
2066
+ continue;
2067
+ }
2068
+ keep.add(target);
2069
+ await mkdir(dirname(target), { recursive: true });
2070
+ await writeFile(target, file2.bytes);
2071
+ }
2072
+ for (const existing of await walk(storageRoot)) {
2073
+ if (!keep.has(existing)) await rm(existing);
2074
+ }
2075
+ await mkdir(join2(dir, PERSIST_DIR), { recursive: true });
2076
+ await writeFile(join2(dir, PERSIST_DIR, "state.json"), JSON.stringify(Object.fromEntries(state), null, 2) + "\n");
2077
+ return refused;
2078
+ }
2079
+
2080
+ // src/docker.ts
2081
+ import { spawnSync } from "node:child_process";
2082
+ var IMAGE = "ghcr.io/visualautomate/plugin-sandbox";
2083
+ var TIERS = {
2084
+ standard: { cpus: "0.25", memory: "1g" },
2085
+ boosted: { cpus: "0.5", memory: "4g" },
2086
+ high: { cpus: "1", memory: "6g" },
2087
+ max: { cpus: "2", memory: "8g" }
2088
+ };
2089
+ function dockerArgs(run) {
2090
+ const tier = TIERS[run.tier];
2091
+ return [
2092
+ "run",
2093
+ "--rm",
2094
+ "--init",
2095
+ "--volume",
2096
+ `${run.cwd}:/work`,
2097
+ "--workdir",
2098
+ "/work",
2099
+ "--cpus",
2100
+ tier.cpus,
2101
+ "--memory",
2102
+ tier.memory,
2103
+ "--memory-swap",
2104
+ tier.memory,
2105
+ "--pids-limit",
2106
+ "256",
2107
+ "--read-only",
2108
+ "--tmpfs",
2109
+ "/tmp:rw,size=64m",
2110
+ "--cap-drop",
2111
+ "ALL",
2112
+ "--security-opt",
2113
+ "no-new-privileges",
2114
+ ...run.user ? ["--user", `${run.user.uid}:${run.user.gid}`] : [],
2115
+ run.image,
2116
+ ...run.args
2117
+ ];
2118
+ }
2119
+ function forwardedFlags(flags) {
2120
+ const local = /* @__PURE__ */ new Set(["docker", "image", "tier"]);
2121
+ return Object.entries(flags).filter(([key]) => !local.has(key)).flatMap(([key, value]) => value === "true" ? [`--${key}`] : [`--${key}`, value]);
2122
+ }
2123
+ function tierOf(value) {
2124
+ if (!value) return "standard";
2125
+ if (value in TIERS) return value;
2126
+ throw new Error(`Unknown tier "${value}". Available: ${Object.keys(TIERS).join(", ")}.`);
2127
+ }
2128
+ function dockerProblem() {
2129
+ const probe = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], { encoding: "utf8" });
2130
+ if (probe.error) return "Docker is not installed, or not on PATH. Install Docker Desktop, or run without --docker.";
2131
+ if (probe.status !== 0) return "Docker is installed but not running. Start Docker Desktop and try again.";
2132
+ return null;
2133
+ }
2134
+ function explainExit(code, tier) {
2135
+ if (code === 137) {
2136
+ const next = Object.keys(TIERS)[Object.keys(TIERS).indexOf(tier) + 1];
2137
+ return `\u2717 stopped the step used more than the ${TIERS[tier].memory.replace("g", " GB")} of memory the ${tier} tier has, and the container was killed. On the platform it would stop the same way.` + (next ? ` To check it fits a bigger machine: --tier ${next}` : "");
2138
+ }
2139
+ if (code === 125) return "\u2717 Docker could not start the container. Is the image available? Try: docker pull " + IMAGE;
2140
+ return null;
2141
+ }
2142
+ function runInDocker(run) {
2143
+ const result = spawnSync("docker", dockerArgs(run), { stdio: "inherit" });
2144
+ const code = result.status ?? 1;
2145
+ const explained = explainExit(code, run.tier);
2146
+ if (explained) console.error(explained);
2147
+ return code;
2148
+ }
2149
+
1943
2150
  // src/index.ts
2151
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
1944
2152
  var DEFAULT_API_URL = "https://visualautomate.com";
1945
2153
  function configPath() {
1946
- return join2(homedir(), ".visualautomate", "config.json");
2154
+ return join3(homedir(), ".visualautomate", "config.json");
1947
2155
  }
1948
2156
  async function readConfig() {
1949
2157
  try {
1950
- return JSON.parse(await readFile2(configPath(), "utf8"));
2158
+ return JSON.parse(await readFile3(configPath(), "utf8"));
1951
2159
  } catch {
1952
2160
  return {};
1953
2161
  }
1954
2162
  }
1955
2163
  async function writeConfig(next) {
1956
2164
  const path = configPath();
1957
- await mkdir(dirname(path), { recursive: true });
1958
- await writeFile(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
2165
+ await mkdir2(dirname2(path), { recursive: true });
2166
+ await writeFile2(path, JSON.stringify(next, null, 2) + "\n", { mode: 384 });
1959
2167
  }
1960
2168
  async function tokenFor(flags) {
1961
2169
  const stored = await readConfig();
@@ -2078,23 +2286,23 @@ async function cmdInit(args, flags) {
2078
2286
  if (!template) {
2079
2287
  fail(`Unknown template "${templateName}". Available: ${Object.keys(TEMPLATES).join(", ")}`);
2080
2288
  }
2081
- const target = resolve2(process.cwd(), name);
2082
- if (existsSync2(target)) fail(`"${name}" already exists here.`);
2289
+ const target = resolve3(process.cwd(), name);
2290
+ if (existsSync3(target)) fail(`"${name}" already exists here.`);
2083
2291
  const slug = name.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
2084
- await mkdir(target, { recursive: true });
2085
- await writeFile(join2(target, "plugin.ts"), template.code);
2086
- await writeFile(join2(target, "manifest.json"), template.manifest);
2087
- await writeFile(
2088
- join2(target, "plugin.json"),
2292
+ await mkdir2(target, { recursive: true });
2293
+ await writeFile2(join3(target, "plugin.ts"), template.code);
2294
+ await writeFile2(join3(target, "manifest.json"), template.manifest);
2295
+ await writeFile2(
2296
+ join3(target, "plugin.json"),
2089
2297
  JSON.stringify(
2090
2298
  { name: slug, version: "1.0.0", description: template.description, category: template.category },
2091
2299
  null,
2092
2300
  2
2093
2301
  ) + "\n"
2094
2302
  );
2095
- await writeFile(join2(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
2096
- await writeFile(
2097
- join2(target, "tsconfig.json"),
2303
+ await writeFile2(join3(target, "visualautomate.d.ts"), TYPE_DECLARATIONS);
2304
+ await writeFile2(
2305
+ join3(target, "tsconfig.json"),
2098
2306
  JSON.stringify(
2099
2307
  {
2100
2308
  compilerOptions: {
@@ -2112,12 +2320,12 @@ async function cmdInit(args, flags) {
2112
2320
  2
2113
2321
  ) + "\n"
2114
2322
  );
2115
- await writeFile(
2116
- join2(target, "test.json"),
2323
+ await writeFile2(
2324
+ join3(target, "test.json"),
2117
2325
  JSON.stringify(template.testInput, null, 2) + "\n"
2118
2326
  );
2119
- await writeFile(
2120
- join2(target, "README.md"),
2327
+ await writeFile2(
2328
+ join3(target, "README.md"),
2121
2329
  `# ${name}
2122
2330
 
2123
2331
  ${template.description}
@@ -2159,7 +2367,7 @@ ${message}`);
2159
2367
  }
2160
2368
  }
2161
2369
  async function codeFor(target) {
2162
- return target.raw ? readFile2(target.entry, "utf8") : bundlePlugin(target.entry);
2370
+ return target.raw ? readFile3(target.entry, "utf8") : bundlePlugin(target.entry);
2163
2371
  }
2164
2372
  function line(indent, text) {
2165
2373
  ok(indent ? " " + text.replace(/\n/g, "\n ") : text);
@@ -2177,28 +2385,38 @@ async function runTarget(target, flags, several) {
2177
2385
  }
2178
2386
  const settings = await readTestFile(target.dir, flags.input);
2179
2387
  if (!settings.path) say("No visualautomate.test.json \u2014 running with empty input and config.");
2180
- const load = createRequire(join2(target.dir, "noop.js"));
2181
- const exported = loadPluginCode(code, sandboxRequire(load));
2388
+ const exported = loadPluginCode(code, sandboxRequire(packageLoader(target.dir)));
2182
2389
  const plugin = typeof exported === "function" ? exported : typeof exported?.execute === "function" ? exported : exported?.default;
2183
2390
  let outputs = [];
2184
- const manifestPath = join2(target.dir, "manifest.json");
2185
- if (existsSync2(manifestPath)) {
2391
+ const manifestPath = join3(target.dir, "manifest.json");
2392
+ if (existsSync3(manifestPath)) {
2186
2393
  try {
2187
- const manifest = parseJsonc(await readFile2(manifestPath, "utf8")).value;
2394
+ const manifest = parseJsonc(await readFile3(manifestPath, "utf8")).value;
2188
2395
  outputs = manifest.outputs ?? [];
2189
2396
  } catch (error) {
2190
2397
  say(`\u2717 manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2191
2398
  return false;
2192
2399
  }
2193
2400
  }
2194
- const result = await simulate(plugin, {
2195
- input: settings.input,
2196
- config: settings.config,
2197
- storage: settings.storage,
2198
- state: settings.state,
2199
- secrets: settings.secrets,
2200
- timeoutMs: settings.timeoutMs
2201
- });
2401
+ const persist = persistEnabled(target.dir, flags.persist);
2402
+ const kept = persist ? await loadPersisted(target.dir) : { storage: {}, state: {} };
2403
+ const policy = policyFor(settings.allowedDomains, settings.config);
2404
+ const denied = /* @__PURE__ */ new Map();
2405
+ const baseFetch = globalThis.fetch;
2406
+ globalThis.fetch = guardFetch(policy, baseFetch, (host, enforced) => denied.set(host, enforced));
2407
+ let result;
2408
+ try {
2409
+ result = await simulate(plugin, {
2410
+ input: settings.input,
2411
+ config: settings.config,
2412
+ storage: { ...settings.storage, ...kept.storage },
2413
+ state: { ...settings.state, ...kept.state },
2414
+ secrets: settings.secrets,
2415
+ timeoutMs: settings.timeoutMs
2416
+ });
2417
+ } finally {
2418
+ globalThis.fetch = baseFetch;
2419
+ }
2202
2420
  const port = result.output?.output ?? null;
2203
2421
  const expected = settings.expectOutput;
2204
2422
  const passed = result.status === "success" && (!expected || port === expected);
@@ -2211,6 +2429,16 @@ async function runTarget(target, flags, several) {
2211
2429
  if (result.files.size > 0) say(`files ${[...result.files.keys()].join(", ")}`);
2212
2430
  if (result.state.size > 0) say(`state ${JSON.stringify(Object.fromEntries(result.state))}`);
2213
2431
  for (const entry of result.logs ?? []) say(`log ${entry}`);
2432
+ for (const [host, enforced] of denied) {
2433
+ say(
2434
+ enforced ? `egress blocked ${host} \u2014 not in allowedDomains` : `! 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.`
2435
+ );
2436
+ }
2437
+ if (persist) {
2438
+ const refused = await savePersisted(target.dir, result.files, result.state);
2439
+ say(`kept storage and state in ${relative3(process.cwd(), join3(target.dir, ".visualautomate")) || ".visualautomate"}`);
2440
+ for (const path of refused) say(`! storage path "${path}" points outside the storage folder and was not written`);
2441
+ }
2214
2442
  if (port && outputs.length > 0 && !outputs.includes(port)) {
2215
2443
  say(
2216
2444
  `! This returned output "${port}", which manifest.json does not list (it has ${outputs.map((o) => `"${o}"`).join(", ")}). On a canvas nothing would follow it.`
@@ -2231,6 +2459,49 @@ async function runTargets(targets, flags) {
2231
2459
  ${passed} of ${targets.length} passed`);
2232
2460
  return passed === targets.length;
2233
2461
  }
2462
+ function packageLoader(dir) {
2463
+ const fromProject = createRequire(join3(dir, "noop.js"));
2464
+ const imageDir = process.env.VISUALAUTOMATE_PACKAGES;
2465
+ const fromImage = imageDir ? createRequire(join3(imageDir, "noop.js")) : null;
2466
+ return (name) => {
2467
+ try {
2468
+ return fromProject(name);
2469
+ } catch (error) {
2470
+ if (fromImage) return fromImage(name);
2471
+ throw error;
2472
+ }
2473
+ };
2474
+ }
2475
+ function dockerRunFor(flags, args) {
2476
+ const problem = dockerProblem();
2477
+ if (problem) fail(problem);
2478
+ let tier;
2479
+ try {
2480
+ tier = tierOf(flags.tier);
2481
+ } catch (error) {
2482
+ fail(error instanceof Error ? error.message : String(error));
2483
+ }
2484
+ return {
2485
+ cwd: process.cwd(),
2486
+ image: flags.image || `${IMAGE}:${VERSION}`,
2487
+ tier,
2488
+ args,
2489
+ user: typeof process.getuid === "function" && typeof process.getgid === "function" ? { uid: process.getuid(), gid: process.getgid() } : void 0
2490
+ };
2491
+ }
2492
+ function runTargetsInDocker(targets, all, flags) {
2493
+ const base = forwardedFlags(flags).filter((f, i, list) => f !== "--module" && list[i - 1] !== "--module");
2494
+ if (targets.length === all.length) {
2495
+ const extra = flags.module ? ["--module", flags.module] : [];
2496
+ return runInDocker(dockerRunFor(flags, ["test", ...base, ...extra])) === 0;
2497
+ }
2498
+ let passed = true;
2499
+ for (const target of targets) {
2500
+ const module = target.name.replace(/^modules\//, "");
2501
+ if (runInDocker(dockerRunFor(flags, ["test", ...base, "--module", module])) !== 0) passed = false;
2502
+ }
2503
+ return passed;
2504
+ }
2234
2505
  function targetsOrFail(flags) {
2235
2506
  try {
2236
2507
  return findTargets(process.cwd(), flags.module);
@@ -2240,7 +2511,8 @@ function targetsOrFail(flags) {
2240
2511
  }
2241
2512
  async function cmdTest(flags) {
2242
2513
  const targets = targetsOrFail(flags);
2243
- if (!await runTargets(targets, flags)) process.exitCode = 1;
2514
+ const passed = flags.docker ? runTargetsInDocker(targets, targets, flags) : await runTargets(targets, flags);
2515
+ if (!passed) process.exitCode = 1;
2244
2516
  }
2245
2517
  async function cmdDev(flags) {
2246
2518
  const root = process.cwd();
@@ -2249,9 +2521,11 @@ async function cmdDev(flags) {
2249
2521
  process.stdout.write("\x1Bc");
2250
2522
  ok(`visualautomate dev \u2014 ${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \u2014 Ctrl+C to stop`);
2251
2523
  };
2524
+ const run = async (chosen) => flags.docker ? runTargetsInDocker(chosen, targets, flags) : runTargets(chosen, flags);
2252
2525
  header();
2253
- await runTargets(targets, flags);
2254
- const ignored = [`${sep}node_modules${sep}`, `${sep}.git${sep}`, `${sep}dist${sep}`, `${sep}.visualautomate${sep}`];
2526
+ if (flags.docker) ok(`in ${flags.image || `${IMAGE}:${VERSION}`}, tier ${flags.tier || "standard"}`);
2527
+ await run(targets);
2528
+ const ignored = [`${sep2}node_modules${sep2}`, `${sep2}.git${sep2}`, `${sep2}dist${sep2}`, `${sep2}.visualautomate${sep2}`];
2255
2529
  const pending = /* @__PURE__ */ new Set();
2256
2530
  let timer;
2257
2531
  let running = false;
@@ -2274,14 +2548,14 @@ async function cmdDev(flags) {
2274
2548
  const hit = changed.map((path) => targetForPath(targets, path));
2275
2549
  const chosen = hit.some((t) => t === null) ? targets : [...new Set(hit)];
2276
2550
  header();
2277
- ok(`changed: ${changed.map((p) => relative2(root, p)).join(", ")}`);
2278
- await runTargets(chosen, flags);
2551
+ ok(`changed: ${changed.map((p) => relative3(root, p)).join(", ")}`);
2552
+ await run(chosen);
2279
2553
  running = false;
2280
2554
  };
2281
2555
  const watcher = watch(root, { recursive: true }, (_event, name) => {
2282
2556
  if (!name) return;
2283
- const path = join2(root, name.toString());
2284
- if (ignored.some((part) => `${sep}${relative2(root, path)}${sep}`.includes(part))) return;
2557
+ const path = join3(root, name.toString());
2558
+ if (ignored.some((part) => `${sep2}${relative3(root, path)}${sep2}`.includes(part))) return;
2285
2559
  pending.add(path);
2286
2560
  if (timer) clearTimeout(timer);
2287
2561
  timer = setTimeout(flush, 150);
@@ -2296,30 +2570,30 @@ async function cmdDev(flags) {
2296
2570
  async function cmdPush(flags) {
2297
2571
  const dir = process.cwd();
2298
2572
  const { token, apiUrl } = await tokenFor(flags);
2299
- const metaPath = join2(dir, "plugin.json");
2300
- const pkgPath = join2(dir, "package.json");
2301
- const metaFile = existsSync2(metaPath) ? metaPath : existsSync2(pkgPath) ? pkgPath : null;
2573
+ const metaPath = join3(dir, "plugin.json");
2574
+ const pkgPath = join3(dir, "package.json");
2575
+ const metaFile = existsSync3(metaPath) ? metaPath : existsSync3(pkgPath) ? pkgPath : null;
2302
2576
  if (!metaFile) {
2303
2577
  fail("No plugin.json here. Run `visualautomate init <name>` to start a plugin.");
2304
2578
  }
2305
- const meta = JSON.parse(await readFile2(metaFile, "utf8"));
2579
+ const meta = JSON.parse(await readFile3(metaFile, "utf8"));
2306
2580
  if (!meta.name) fail(`${metaFile} has no "name".`);
2307
2581
  if (!meta.version || !/^\d+\.\d+\.\d+$/.test(meta.version)) {
2308
2582
  fail(`${metaFile} needs a "version" like 1.0.0.`);
2309
2583
  }
2310
- const manifestPath = resolve2(process.cwd(), "manifest.json");
2584
+ const manifestPath = resolve3(process.cwd(), "manifest.json");
2311
2585
  let parsed = {};
2312
2586
  let manifest = null;
2313
- if (existsSync2(manifestPath)) {
2587
+ if (existsSync3(manifestPath)) {
2314
2588
  try {
2315
- const result = parseJsonc(await readFile2(manifestPath, "utf8"));
2589
+ const result = parseJsonc(await readFile3(manifestPath, "utf8"));
2316
2590
  parsed = result.value;
2317
2591
  manifest = result.json;
2318
2592
  } catch (error) {
2319
2593
  fail(`manifest.json is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
2320
2594
  }
2321
2595
  }
2322
- const entry = ["plugin.ts", "plugin.js", "index.ts", "index.js"].map((f) => join2(dir, f)).find((f) => existsSync2(f));
2596
+ const entry = ["plugin.ts", "plugin.js", "index.ts", "index.js"].map((f) => join3(dir, f)).find((f) => existsSync3(f));
2323
2597
  if (!entry) {
2324
2598
  fail("No plugin.ts or plugin.js here. Run `visualautomate init <name>` to start one.");
2325
2599
  }
@@ -2331,7 +2605,7 @@ async function cmdPush(flags) {
2331
2605
  }
2332
2606
  let schemaJson = null;
2333
2607
  if (!manifest) {
2334
- const source = await readFile2(entry, "utf8");
2608
+ const source = await readFile3(entry, "utf8");
2335
2609
  const match = source.match(/@schema\s*\n([\s\S]*?)(?:\*\s*\/|\*\/)/);
2336
2610
  if (match) {
2337
2611
  schemaJson = match[1].replace(/^\s*\*\s?/gm, "").trim();
@@ -2388,6 +2662,7 @@ visualautomate \u2014 build and publish plugins
2388
2662
  visualautomate init <name> [--template] start a plugin
2389
2663
  visualautomate test [--module <name>] run it here, as the platform's check does
2390
2664
  visualautomate dev [--module <name>] run it again on every save
2665
+ visualautomate --version this CLI's version
2391
2666
  visualautomate push publish it
2392
2667
  visualautomate whoami which account this is
2393
2668
  visualautomate logout forget the token
@@ -2399,6 +2674,12 @@ GitHub repository, and at the root of an app's repository, where every folder
2399
2674
  in modules/ is run (or the one named with --module). The run's settings come
2400
2675
  from visualautomate.test.json, test.json, or --input <file>.
2401
2676
 
2677
+ Options for \`test\` and \`dev\`:
2678
+ --docker run inside the plugin sandbox image (Docker must be running)
2679
+ --tier <name> with --docker: standard, boosted, high or max
2680
+ --image <ref> with --docker: a different image
2681
+ --persist keep storage and state in .visualautomate/ between runs
2682
+
2402
2683
  Environment (for CI, where there is no browser):
2403
2684
  VISUALAUTOMATE_API_TOKEN use instead of \`login\`
2404
2685
  VISUALAUTOMATE_API_URL a different installation
@@ -2422,6 +2703,10 @@ function parse(argv) {
2422
2703
  async function main() {
2423
2704
  const { args, flags } = parse(process.argv.slice(2));
2424
2705
  const command = args.shift();
2706
+ if (flags.version || command === "version") {
2707
+ ok(VERSION);
2708
+ return;
2709
+ }
2425
2710
  switch (command) {
2426
2711
  case "login":
2427
2712
  return cmdLogin(flags);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@visualautomate/cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Build and publish VisualAutomate plugins.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "deploy"
24
24
  ],
25
25
  "engines": {
26
- "node": ">=22"
26
+ "node": ">=20"
27
27
  },
28
28
  "dependencies": {
29
29
  "esbuild": "^0.23.0"