run402 2.39.3 → 2.40.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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Command-line interface for [Run402](https://run402.com) — provision Postgres databases, deploy static sites, run Node 22 serverless functions, host content-addressed CDN assets, send email, sign on-chain. Paid autonomously with x402 USDC on Base. **Prototype tier is free on testnet.**
4
4
 
5
- For the full CLI reference (every flag, every subcommand) see **<https://run402.com/llms-cli.txt>**.
5
+ For the full CLI reference (every flag, every subcommand) see **<https://docs.run402.com/llms-cli.txt>**.
6
6
 
7
7
  ## Install
8
8
 
@@ -243,7 +243,7 @@ run402 functions --help
243
243
 
244
244
  The canonical, comprehensive CLI reference — every flag, every subcommand, edge cases, troubleshooting — lives at:
245
245
 
246
- **<https://run402.com/llms-cli.txt>**
246
+ **<https://docs.run402.com/llms-cli.txt>**
247
247
 
248
248
  Same content also at [`cli/llms-cli.txt`](./llms-cli.txt) in the repo. Treat that file as authoritative; this README is a quick-orientation landing page.
249
249
 
package/cli.mjs CHANGED
@@ -30,7 +30,7 @@ Commands:
30
30
  deploy Unified deploy operations (requires active tier)
31
31
  ci Link GitHub Actions OIDC deploy bindings
32
32
  transfer Two-party project transfer (init, preview, list, accept, cancel)
33
- org Org membership + roles (whoami, list, members, add-member, set-role, remove-member)
33
+ org Org membership, invites & audit (whoami, list, member, invite, audit)
34
34
  grants Per-project capability grants for agent/CI principals (create, revoke)
35
35
  jobs Submit and inspect fixed platform-managed jobs
36
36
  functions Manage serverless functions (deploy, invoke, logs, list, delete)
@@ -0,0 +1,114 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync, rmSync, } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { randomBytes } from "node:crypto";
4
+ import { getConfigBaseDir } from "./config.js";
5
+ /**
6
+ * Path to the cached control-plane session: `{base}/control-plane-session.json`.
7
+ * `RUN402_CONTROL_PLANE_SESSION_PATH` overrides for testing.
8
+ */
9
+ export function getControlPlaneSessionPath() {
10
+ return (process.env.RUN402_CONTROL_PLANE_SESSION_PATH ||
11
+ join(getConfigBaseDir(), "control-plane-session.json"));
12
+ }
13
+ /** Tighten 0600 if group/other-readable, warning once. Best-effort, POSIX-only. */
14
+ function selfHealPermissions(p) {
15
+ if (process.platform === "win32")
16
+ return;
17
+ try {
18
+ const mode = statSync(p).mode & 0o777;
19
+ if ((mode & 0o077) !== 0) {
20
+ chmodSync(p, 0o600);
21
+ process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
22
+ }
23
+ }
24
+ catch {
25
+ // Best-effort; never block a read on a chmod/stat failure.
26
+ }
27
+ }
28
+ /**
29
+ * Load the cached control-plane session. Returns `null` for the "no session"
30
+ * cases (absent, unreadable, unparseable). Throws when the file parses as JSON
31
+ * but the shape is wrong, so a corrupted cache surfaces a clear fix-it.
32
+ */
33
+ export function readControlPlaneSession(path) {
34
+ const p = path ?? getControlPlaneSessionPath();
35
+ if (!existsSync(p))
36
+ return null;
37
+ selfHealPermissions(p);
38
+ let raw;
39
+ try {
40
+ raw = readFileSync(p, "utf-8");
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ let parsed;
46
+ try {
47
+ parsed = JSON.parse(raw);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ throw new Error("control-plane-session.json must contain a JSON object. Delete it and run 'run402 operator login --loopback' to recreate it.");
54
+ }
55
+ const data = parsed;
56
+ if (typeof data.control_plane_session_token !== "string" ||
57
+ data.control_plane_session_token.length === 0) {
58
+ throw new Error("control-plane-session.json missing valid 'control_plane_session_token'. Run 'run402 operator login --loopback' to refresh it.");
59
+ }
60
+ if (typeof data.expires_at !== "number" || !Number.isFinite(data.expires_at)) {
61
+ throw new Error("control-plane-session.json missing valid 'expires_at'. Run 'run402 operator login --loopback' to refresh it.");
62
+ }
63
+ return {
64
+ control_plane_session_token: data.control_plane_session_token,
65
+ token_type: typeof data.token_type === "string" ? data.token_type : "Bearer",
66
+ provenance: typeof data.provenance === "string" ? data.provenance : "loopback_pkce",
67
+ principal_id: typeof data.principal_id === "string" ? data.principal_id : "",
68
+ amr: Array.isArray(data.amr) ? data.amr.filter((a) => typeof a === "string") : [],
69
+ expires_at: data.expires_at,
70
+ };
71
+ }
72
+ /** Persist a control-plane session atomically (temp-file + rename), mode 0600. */
73
+ export function saveControlPlaneSession(data, path) {
74
+ const p = path ?? getControlPlaneSessionPath();
75
+ const dir = dirname(p);
76
+ mkdirSync(dir, { recursive: true });
77
+ const tmp = join(dir, `.control-plane-session.${randomBytes(4).toString("hex")}.tmp`);
78
+ writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
79
+ renameSync(tmp, p);
80
+ chmodSync(p, 0o600);
81
+ }
82
+ /** Delete the cached control-plane session — local half of `operator logout`. Idempotent. */
83
+ export function clearControlPlaneSession(path) {
84
+ const p = path ?? getControlPlaneSessionPath();
85
+ try {
86
+ rmSync(p, { force: true });
87
+ }
88
+ catch {
89
+ // Best-effort: a failed unlink should never crash logout.
90
+ }
91
+ }
92
+ /** Whether a cached session is past its usable life (with a small skew buffer). */
93
+ export function isControlPlaneSessionExpired(session, nowMs = Date.now(), skewMs = 10_000) {
94
+ return nowMs + skewMs >= session.expires_at;
95
+ }
96
+ /** Read the cached session and return it only if still usable; `null` if absent or expired. */
97
+ export function loadLiveControlPlaneSession(path, nowMs = Date.now()) {
98
+ const s = readControlPlaneSession(path);
99
+ if (!s)
100
+ return null;
101
+ return isControlPlaneSessionExpired(s, nowMs) ? null : s;
102
+ }
103
+ /** Map a gateway token payload (relative `expires_in`) into the cached shape (absolute `expires_at`). */
104
+ export function controlPlaneSessionFromTokenResponse(resp, nowMs = Date.now()) {
105
+ return {
106
+ control_plane_session_token: resp.control_plane_session_token,
107
+ token_type: resp.token_type ?? "Bearer",
108
+ provenance: resp.provenance ?? "loopback_pkce",
109
+ principal_id: resp.principal_id ?? "",
110
+ amr: Array.isArray(resp.amr) ? resp.amr.filter((a) => typeof a === "string") : [],
111
+ expires_at: nowMs + (typeof resp.expires_in === "number" ? resp.expires_in : 0) * 1000,
112
+ };
113
+ }
114
+ //# sourceMappingURL=control-plane-session.js.map
package/lib/deploy-v2.mjs CHANGED
@@ -93,7 +93,7 @@ Options:
93
93
  --allow-warnings Continue past plan warnings that require confirmation
94
94
 
95
95
  Output:
96
- stdout: { "status": "ok", "release_id": "rel_...", "operation_id": "op_...", "urls": {...}, "warnings": [...] }
96
+ stdout: { "release_id": "rel_...", "operation_id": "op_...", "urls": {...}, "warnings": [...] }
97
97
  stderr: one JSON event per line (suppressed with --quiet or --final-only)
98
98
 
99
99
  Secrets:
@@ -141,7 +141,7 @@ the pointer-swap activation). The gateway re-runs only the failed phase
141
141
  forward — SQL is never replayed.
142
142
 
143
143
  Output:
144
- stdout: { "status": "ok", "release_id": "...", "operation_id": "...", "urls": {...} }
144
+ stdout: { "release_id": "...", "operation_id": "...", "urls": {...} }
145
145
  stderr: one JSON event per line (suppressed with --quiet)
146
146
  `;
147
147
 
@@ -155,7 +155,7 @@ Options:
155
155
  --limit <n> Maximum number of operations to return
156
156
 
157
157
  Output:
158
- stdout: { "status": "ok", "operations": [...], "cursor": "..." | null }
158
+ stdout: { "operations": [...], "cursor": "..." | null }
159
159
  `;
160
160
 
161
161
  const EVENTS_HELP = `run402 deploy events — Fetch the recorded event stream for a deploy operation
@@ -167,7 +167,7 @@ Options:
167
167
  --project <id> Project ID that owns the operation (default: active project)
168
168
 
169
169
  Output:
170
- stdout: { "status": "ok", "events": [...] }
170
+ stdout: { "events": [...] }
171
171
  `;
172
172
 
173
173
  const RELEASE_HELP = `run402 deploy release — Inspect deploy release inventory and diffs
@@ -183,8 +183,8 @@ Subcommands:
183
183
  diff Diff two release targets
184
184
 
185
185
  Output:
186
- get/active: { "status": "ok", "release": {...} } # includes route inventory and inventory warnings when returned
187
- diff: { "status": "ok", "diff": {...} } # includes route added/removed/changed diff buckets
186
+ get/active: { "release": {...} } # includes route inventory and inventory warnings when returned
187
+ diff: { "diff": {...} } # includes route added/removed/changed diff buckets
188
188
  `;
189
189
 
190
190
  const RELEASE_GET_HELP = `run402 deploy release get — Fetch a release inventory by id
@@ -197,7 +197,7 @@ Options:
197
197
  --site-limit <n> Maximum site path entries to include (gateway default: 5000)
198
198
 
199
199
  Output:
200
- stdout: { "status": "ok", "release": {...} } # preserves full routes inventory and warnings
200
+ stdout: { "release": {...} } # preserves full routes inventory and warnings
201
201
  `;
202
202
 
203
203
  const RELEASE_ACTIVE_HELP = `run402 deploy release active — Fetch the active release inventory
@@ -210,7 +210,7 @@ Options:
210
210
  --site-limit <n> Maximum site path entries to include (gateway default: 5000)
211
211
 
212
212
  Output:
213
- stdout: { "status": "ok", "release": {...} } # preserves full routes inventory and warnings
213
+ stdout: { "release": {...} } # preserves full routes inventory and warnings
214
214
  `;
215
215
 
216
216
  const RELEASE_DIFF_HELP = `run402 deploy release diff — Diff two release targets
@@ -225,7 +225,7 @@ Options:
225
225
  --limit <n> Maximum entries per site diff bucket (gateway default: 1000)
226
226
 
227
227
  Output:
228
- stdout: { "status": "ok", "diff": {...} } # preserves routes.added/removed/changed
228
+ stdout: { "diff": {...} } # preserves routes.added/removed/changed
229
229
  `;
230
230
 
231
231
  const DIAGNOSE_HELP = `run402 deploy diagnose — Diagnose a Run402 public URL
@@ -244,7 +244,7 @@ Options:
244
244
  --method <method> HTTP method to diagnose (default: GET)
245
245
 
246
246
  Output:
247
- stdout: { "status": "ok", "would_serve": true|false, "diagnostic_status": 200|404|..., "match": "...", "summary": "...", "request": {...}, "warnings": [...], "resolution": {...}, "next_steps": [...] }
247
+ stdout: { "would_serve": true|false, "diagnostic_status": 200|404|..., "match": "...", "summary": "...", "request": {...}, "warnings": [...], "resolution": {...}, "next_steps": [...] }
248
248
  `;
249
249
 
250
250
  const RESOLVE_HELP = `run402 deploy resolve — Low-level deploy URL diagnostics
@@ -326,7 +326,6 @@ Options:
326
326
 
327
327
  Output:
328
328
  stdout: {
329
- "status": "ok",
330
329
  "release_id": "rel_old_abc123",
331
330
  "operation_id": "op_...",
332
331
  "previous_release_id": "rel_new_xxx",
package/lib/image.mjs CHANGED
@@ -20,7 +20,7 @@ Examples:
20
20
  run402 image generate "portrait of a cat CEO" --aspect portrait --output cat.png
21
21
 
22
22
  Output (without --output):
23
- { "status": "ok", "aspect": "square", "content_type": "image/png", "image": "<base64>" }
23
+ { "aspect": "square", "content_type": "image/png", "image": "<base64>" }
24
24
 
25
25
  Notes:
26
26
  - Requires a funded allowance (run402 allowance create && run402 allowance fund)
package/lib/operator.mjs CHANGED
@@ -19,6 +19,8 @@
19
19
 
20
20
  import { setTimeout as sleep } from "node:timers/promises";
21
21
  import { spawn } from "node:child_process";
22
+ import { createServer } from "node:http";
23
+ import { randomBytes, createHash } from "node:crypto";
22
24
  import { fail, reportSdkError } from "./sdk-errors.mjs";
23
25
  import { getSdk } from "./sdk.mjs";
24
26
  import { normalizeArgv, hasHelp, assertKnownFlags } from "./argparse.mjs";
@@ -30,6 +32,13 @@ import {
30
32
  isOperatorSessionExpired,
31
33
  operatorSessionFromTokenResponse,
32
34
  } from "../core-dist/operator-session.js";
35
+ import {
36
+ saveControlPlaneSession,
37
+ clearControlPlaneSession,
38
+ readControlPlaneSession,
39
+ isControlPlaneSessionExpired,
40
+ controlPlaneSessionFromTokenResponse,
41
+ } from "../core-dist/control-plane-session.js";
33
42
 
34
43
  const CLIENT_NAME = "run402 CLI";
35
44
 
@@ -40,19 +49,25 @@ The operator is YOU, the human, identified by email — distinct from the agent
40
49
  For a single wallet's account state, use 'run402 status'.
41
50
 
42
51
  Usage:
43
- run402 operator login [--no-open]
52
+ run402 operator login [--no-open] (read session, device-flow)
53
+ run402 operator login --loopback [--no-open] (write session, loopback-PKCE)
54
+ run402 operator login --step-up (fresh write session for high-stakes ops)
44
55
  run402 operator overview
45
56
  run402 operator whoami
46
57
  run402 operator logout
47
58
 
48
59
  Subcommands:
49
- login Sign in via the browser (device-authorization, like 'aws sso login')
60
+ login Sign in via the browser. Default = device-flow READ session (powers
61
+ 'overview'). --loopback = write-capable control-plane session
62
+ (aws-sso-style, passkey-fresh). --step-up = re-mint a fresh write session.
50
63
  overview Account view across ALL wallets controlling your email (requires login)
51
- whoami Show the cached session (email, wallets, expiry) — local, no network
52
- logout Revoke the session server-side and clear the local cache
64
+ whoami Show the cached session(s) — local, no network
65
+ logout Revoke the session server-side and clear the local cache(s)
53
66
 
54
67
  Options:
55
- --no-open (login) Do not auto-open the browser; just print the URL + code.
68
+ --no-open (login) Do not auto-open the browser; just print the URL.
69
+ --loopback (login) Use the loopback-PKCE write login instead of the device flow.
70
+ --step-up (login) Re-mint a fresh write session (implies --loopback).
56
71
 
57
72
  Notes:
58
73
  - The session is cached at the base config dir, shared across named wallets.
@@ -96,8 +111,154 @@ function openBrowser(url) {
96
111
  }
97
112
  }
98
113
 
114
+ /** Output shape for the write (control-plane) session. NEVER includes the token. */
115
+ function controlPlaneView(session, nowMs = Date.now()) {
116
+ return {
117
+ logged_in: true,
118
+ kind: "control_plane_session",
119
+ provenance: session.provenance,
120
+ principal_id: session.principal_id || null,
121
+ amr: session.amr,
122
+ expires_at: new Date(session.expires_at).toISOString(),
123
+ expires_in_seconds: Math.max(0, Math.round((session.expires_at - nowMs) / 1000)),
124
+ write_capable: true,
125
+ };
126
+ }
127
+
128
+ const base64url = (buf) => buf.toString("base64url");
129
+
130
+ /** Generate PKCE (S256) + CSRF state + replay nonce for the loopback flow. */
131
+ function pkce() {
132
+ const codeVerifier = base64url(randomBytes(32));
133
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
134
+ return { codeVerifier, codeChallenge, state: base64url(randomBytes(16)), nonce: base64url(randomBytes(16)) };
135
+ }
136
+
137
+ /**
138
+ * Start a 127.0.0.1 loopback server (RFC 8252) that captures exactly one
139
+ * redirect. Returns the bound port (via `ready`), a promise for the auth code,
140
+ * and a `close()`. State is validated here to reject CSRF before exchange.
141
+ */
142
+ function startLoopbackServer({ expectedState, timeoutMs }) {
143
+ let resolveCode;
144
+ let rejectCode;
145
+ const codePromise = new Promise((res, rej) => {
146
+ resolveCode = res;
147
+ rejectCode = rej;
148
+ });
149
+ let timer;
150
+ const server = createServer((req, res) => {
151
+ let u;
152
+ try {
153
+ u = new URL(req.url, "http://127.0.0.1");
154
+ } catch {
155
+ res.writeHead(400).end("bad request");
156
+ return;
157
+ }
158
+ if (u.pathname !== "/callback") {
159
+ res.writeHead(404).end("not found");
160
+ return;
161
+ }
162
+ const code = u.searchParams.get("code");
163
+ const gotState = u.searchParams.get("state");
164
+ const errParam = u.searchParams.get("error");
165
+ res.writeHead(200, { "content-type": "text/html" });
166
+ res.end(
167
+ "<!doctype html><html><body style=\"font-family:system-ui;padding:3rem\">" +
168
+ "<h2>run402 - you're signed in.</h2><p>You can close this window and return to your terminal.</p></body></html>",
169
+ );
170
+ cleanup();
171
+ if (errParam) rejectCode(new Error(`authorization error: ${errParam}`));
172
+ else if (!code) rejectCode(new Error("no authorization code on the loopback redirect"));
173
+ else if (gotState !== expectedState) rejectCode(new Error("state mismatch on the loopback redirect (possible CSRF) - aborted"));
174
+ else resolveCode(code);
175
+ });
176
+ function cleanup() {
177
+ clearTimeout(timer);
178
+ try {
179
+ server.close();
180
+ } catch {
181
+ // already closing
182
+ }
183
+ }
184
+ timer = setTimeout(() => {
185
+ cleanup();
186
+ rejectCode(new Error("timed out waiting for browser approval"));
187
+ }, timeoutMs);
188
+ server.on("error", (e) => {
189
+ cleanup();
190
+ rejectCode(e);
191
+ });
192
+ const ready = new Promise((res, rej) => {
193
+ server.once("error", rej);
194
+ server.listen(0, "127.0.0.1", () => res(server.address().port));
195
+ });
196
+ return { ready, codePromise, close: cleanup };
197
+ }
198
+
199
+ /**
200
+ * Loopback-PKCE write-login (RFC 8252, the aws-sso-style flow). Mints a
201
+ * write-capable control-plane session via the browser passkey ceremony, caches
202
+ * it (mode 0600), and prints metadata only - never the token.
203
+ */
204
+ async function loopbackLogin(args, { stepUp }) {
205
+ const noOpen = args.includes("--no-open");
206
+ const sdk = getSdk();
207
+ const { codeVerifier, codeChallenge, state, nonce } = pkce();
208
+ const { ready, codePromise, close } = startLoopbackServer({ expectedState: state, timeoutMs: 300_000 });
209
+
210
+ let port;
211
+ try {
212
+ port = await ready;
213
+ } catch (err) {
214
+ close();
215
+ return fail({ code: "OPERATOR_LOOPBACK_FAILED", message: `Could not start the loopback server: ${err.message}` });
216
+ }
217
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
218
+ const authorizeUrl = sdk.operator.buildCliAuthorizeUrl({ redirectUri, codeChallenge, state, nonce });
219
+
220
+ process.stderr.write(
221
+ `\nTo ${stepUp ? "re-authenticate (step-up)" : "sign in (write-capable)"}, open:\n ${authorizeUrl}\n\n`,
222
+ );
223
+ if (!noOpen && process.stderr.isTTY) {
224
+ openBrowser(authorizeUrl);
225
+ process.stderr.write("(opening your browser…)\n\n");
226
+ }
227
+ process.stderr.write("Waiting for approval…\n");
228
+
229
+ let code;
230
+ try {
231
+ code = await codePromise;
232
+ } catch (err) {
233
+ close();
234
+ return fail({
235
+ code: "OPERATOR_LOGIN_FAILED",
236
+ message: err.message,
237
+ hint: "Run 'run402 operator login --loopback' to try again.",
238
+ });
239
+ }
240
+
241
+ let session;
242
+ try {
243
+ session = await sdk.operator.exchangeCliToken({ code, codeVerifier, redirectUri, state });
244
+ } catch (err) {
245
+ return reportSdkError(err);
246
+ }
247
+
248
+ const cached = controlPlaneSessionFromTokenResponse(session);
249
+ saveControlPlaneSession(cached);
250
+ process.stderr.write(`\nSigned in (write-capable, provenance=${cached.provenance}).\n`);
251
+ console.log(JSON.stringify(controlPlaneView(cached)));
252
+ }
253
+
99
254
  async function login(args) {
100
- assertKnownFlags(args, ["--help", "-h", "--no-open"]);
255
+ assertKnownFlags(args, ["--help", "-h", "--no-open", "--loopback", "--device", "--step-up"]);
256
+ // Loopback-PKCE = the write-capable control-plane login (--loopback/--step-up).
257
+ // The default stays the device-flow READ session (which powers 'overview');
258
+ // --device forces it explicitly.
259
+ if (args.includes("--loopback") || args.includes("--step-up")) {
260
+ return loopbackLogin(args, { stepUp: args.includes("--step-up") });
261
+ }
101
262
  const noOpen = args.includes("--no-open");
102
263
  const sdk = getSdk();
103
264
 
@@ -184,6 +345,7 @@ async function logout(args) {
184
345
  }
185
346
  }
186
347
  clearOperatorSession();
348
+ clearControlPlaneSession();
187
349
  console.log(JSON.stringify({ revoked, cleared: true }));
188
350
  }
189
351
 
@@ -219,17 +381,27 @@ async function whoami(args) {
219
381
  assertKnownFlags(args, ["--help", "-h"]);
220
382
  const now = Date.now();
221
383
  const session = readOperatorSession();
222
- if (!session) {
223
- console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." }));
224
- process.exitCode = 1;
384
+ const cp = readControlPlaneSession();
385
+ const liveCp = cp && !isControlPlaneSessionExpired(cp, now) ? cp : null;
386
+
387
+ if (session && !isOperatorSessionExpired(session, now)) {
388
+ const view = sessionView(session, now);
389
+ if (liveCp) view.control_plane = controlPlaneView(liveCp, now);
390
+ console.log(JSON.stringify(view));
225
391
  return;
226
392
  }
227
- if (isOperatorSessionExpired(session, now)) {
228
- console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." }));
393
+ // No live device-flow READ session fall back to the write session if present.
394
+ if (liveCp) {
395
+ console.log(JSON.stringify(controlPlaneView(liveCp, now)));
396
+ return;
397
+ }
398
+ if (!session) {
399
+ console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." }));
229
400
  process.exitCode = 1;
230
401
  return;
231
402
  }
232
- console.log(JSON.stringify(sessionView(session, now)));
403
+ console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." }));
404
+ process.exitCode = 1;
233
405
  }
234
406
 
235
407
  export async function run(sub, args = []) {