@visualautomate/cli 0.3.1 → 0.4.1
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 +52 -3
- package/dist/index.js +336 -51
- package/package.json +3 -3
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 `
|
|
116
|
-
stops the run rather than running with
|
|
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
|
|
6
|
-
import { existsSync as
|
|
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
|
|
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.1" : "0.0.0-dev";
|
|
1944
2152
|
var DEFAULT_API_URL = "https://visualautomate.com";
|
|
1945
2153
|
function configPath() {
|
|
1946
|
-
return
|
|
2154
|
+
return join3(homedir(), ".visualautomate", "config.json");
|
|
1947
2155
|
}
|
|
1948
2156
|
async function readConfig() {
|
|
1949
2157
|
try {
|
|
1950
|
-
return JSON.parse(await
|
|
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
|
|
1958
|
-
await
|
|
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 =
|
|
2082
|
-
if (
|
|
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
|
|
2085
|
-
await
|
|
2086
|
-
await
|
|
2087
|
-
await
|
|
2088
|
-
|
|
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
|
|
2096
|
-
await
|
|
2097
|
-
|
|
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
|
|
2116
|
-
|
|
2323
|
+
await writeFile2(
|
|
2324
|
+
join3(target, "test.json"),
|
|
2117
2325
|
JSON.stringify(template.testInput, null, 2) + "\n"
|
|
2118
2326
|
);
|
|
2119
|
-
await
|
|
2120
|
-
|
|
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 ?
|
|
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
|
|
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 =
|
|
2185
|
-
if (
|
|
2391
|
+
const manifestPath = join3(target.dir, "manifest.json");
|
|
2392
|
+
if (existsSync3(manifestPath)) {
|
|
2186
2393
|
try {
|
|
2187
|
-
const manifest = parseJsonc(await
|
|
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
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2254
|
-
|
|
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) =>
|
|
2278
|
-
await
|
|
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 =
|
|
2284
|
-
if (ignored.some((part) => `${
|
|
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 =
|
|
2300
|
-
const pkgPath =
|
|
2301
|
-
const metaFile =
|
|
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
|
|
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 =
|
|
2584
|
+
const manifestPath = resolve3(process.cwd(), "manifest.json");
|
|
2311
2585
|
let parsed = {};
|
|
2312
2586
|
let manifest = null;
|
|
2313
|
-
if (
|
|
2587
|
+
if (existsSync3(manifestPath)) {
|
|
2314
2588
|
try {
|
|
2315
|
-
const result = parseJsonc(await
|
|
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) =>
|
|
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
|
|
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
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Build and publish VisualAutomate plugins.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
"deploy"
|
|
24
24
|
],
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=20"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"esbuild": "^0.
|
|
29
|
+
"esbuild": "^0.28.2"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/node": "^22",
|