run402 4.0.2 → 4.1.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 (42) hide show
  1. package/README.md +6 -2
  2. package/cli.mjs +21 -0
  3. package/lib/branches.mjs +113 -0
  4. package/lib/core.mjs +2 -1
  5. package/lib/deploy-v2.mjs +265 -5
  6. package/lib/deploy.mjs +5 -1
  7. package/lib/projects.mjs +11 -0
  8. package/lib/snapshots.mjs +137 -0
  9. package/lib/up.mjs +79 -1
  10. package/package.json +1 -1
  11. package/sdk/dist/credentials.d.ts +1 -1
  12. package/sdk/dist/credentials.d.ts.map +1 -1
  13. package/sdk/dist/index.d.ts +8 -0
  14. package/sdk/dist/index.d.ts.map +1 -1
  15. package/sdk/dist/index.js +8 -0
  16. package/sdk/dist/index.js.map +1 -1
  17. package/sdk/dist/namespaces/branches.d.ts +11 -0
  18. package/sdk/dist/namespaces/branches.d.ts.map +1 -0
  19. package/sdk/dist/namespaces/branches.js +92 -0
  20. package/sdk/dist/namespaces/branches.js.map +1 -0
  21. package/sdk/dist/namespaces/branches.types.d.ts +48 -0
  22. package/sdk/dist/namespaces/branches.types.d.ts.map +1 -0
  23. package/sdk/dist/namespaces/branches.types.js +2 -0
  24. package/sdk/dist/namespaces/branches.types.js.map +1 -0
  25. package/sdk/dist/namespaces/deploy.d.ts +16 -1
  26. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/deploy.js +85 -0
  28. package/sdk/dist/namespaces/deploy.js.map +1 -1
  29. package/sdk/dist/namespaces/deploy.types.d.ts +151 -0
  30. package/sdk/dist/namespaces/deploy.types.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/snapshots.d.ts +13 -0
  32. package/sdk/dist/namespaces/snapshots.d.ts.map +1 -0
  33. package/sdk/dist/namespaces/snapshots.js +115 -0
  34. package/sdk/dist/namespaces/snapshots.js.map +1 -0
  35. package/sdk/dist/namespaces/snapshots.types.d.ts +79 -0
  36. package/sdk/dist/namespaces/snapshots.types.d.ts.map +1 -0
  37. package/sdk/dist/namespaces/snapshots.types.js +2 -0
  38. package/sdk/dist/namespaces/snapshots.types.js.map +1 -0
  39. package/sdk/dist/scoped.d.ts +34 -1
  40. package/sdk/dist/scoped.d.ts.map +1 -1
  41. package/sdk/dist/scoped.js +61 -0
  42. package/sdk/dist/scoped.js.map +1 -1
package/README.md CHANGED
@@ -74,6 +74,8 @@ run402 projects schema <id> # introspect tables + R
74
74
  ```bash
75
75
  run402 sites deploy-dir ./dist # incremental upload (plan/commit transport)
76
76
  run402 deploy apply --manifest app.json # one-call full stack deploy
77
+ run402 apply --manifest app.json --rehearse --json
78
+ run402 deploy verify op_... --project prj_... --wait
77
79
  run402 deploy release active # inspect current-live release inventory
78
80
  run402 deploy release diff --from empty --to active
79
81
  run402 deploy diagnose --project prj_123 https://example.com/events --method GET
@@ -82,7 +84,9 @@ run402 subdomains claim my-app # → my-app.run402.com (auto-reas
82
84
  ```
83
85
 
84
86
  `deploy-dir` hashes each file client-side and only uploads bytes the gateway doesn't already have. Re-deploying an unchanged tree returns immediately with `bytes_uploaded: 0`. Progress events stream to stderr.
85
- Release inspection commands print `{ release: ... }` or `{ diff: ... }` (raw payload, no envelope — see the "Output Contract" section in [llms-cli.txt](llms-cli.txt)); use them after deploys to compare release inventory without starting another mutation. Inventories include `release_generation`, `static_manifest_sha256`, and nullable `static_manifest_metadata`; diffs include `static_assets` counters such as unchanged/changed/added/removed and CAS byte reuse. `deploy diagnose` / `deploy resolve --url` print URL-first diagnostics with `would_serve`, `diagnostic_status`, `match`, warnings, `edge_propagation`, and next steps; host misses are successful diagnostic calls with `would_serve: false`. Stable-host resolve fields can include `authorization_result`, `cas_object`, `response_variant`, `allow`, `route_pattern`, `target_type`, `target_name`, `target_file`, and `edge_propagation` (`settled`, `propagating`, or `sync_pending`).
87
+ Release inspection commands print `{ release: ... }` or `{ diff: ... }` (raw payload, no envelope — see the "Output Contract" section in [llms-cli.txt](llms-cli.txt)); use them after deploys to compare release inventory without starting another mutation. `deploy verify` prints the canonical edge-coherence report and exits 2 when the report is valid but not yet coherent. Inventories include `release_generation`, `static_manifest_sha256`, and nullable `static_manifest_metadata`; diffs include `static_assets` counters such as unchanged/changed/added/removed and CAS byte reuse. `deploy diagnose` / `deploy resolve --url` print URL-first diagnostics with `would_serve`, `diagnostic_status`, `match`, warnings, `edge_propagation`, and next steps; host misses are successful diagnostic calls with `would_serve: false`. Stable-host resolve fields can include `authorization_result`, `cas_object`, `response_variant`, `allow`, `route_pattern`, `target_type`, `target_name`, `target_file`, and `edge_propagation` (`settled`, `propagating`, or `sync_pending`).
88
+
89
+ For database-bearing changes, use `run402 apply --manifest app.json --rehearse --json` before commit. It creates a contained branch, applies the candidate plan there, runs checks, and exits nonzero on a failed rehearsal. Manual restore points live under `run402 snapshots create|list|get|restore|delete`; temporary data branches live under `run402 branches create|list|renew|delete`.
86
90
 
87
91
  ### GitHub Actions OIDC deploys
88
92
 
@@ -150,7 +154,7 @@ import { db, adminDb, auth, email, ai } from "@run402/functions";
150
154
 
151
155
  ### Same-origin web routes
152
156
 
153
- `run402 deploy apply` accepts `site.public_paths` for clean static browser URLs and `routes.replace` for function ingress or exact method-aware static aliases. Release asset paths such as `events.html` are distinct from public paths such as `/events`: prefer `{ "site": { "replace": { "events.html": { "data": "<h1>Events</h1>" } }, "public_paths": { "mode": "explicit", "replace": { "/events": { "asset": "events.html" } } } } }` for ordinary clean static URLs. In explicit mode `/events.html` is not public unless separately declared; `mode: "implicit"` restores filename-derived reachability and can widen access. `routes.replace` is an array of route entries, not a path-keyed map. Use exact `/admin` plus final-wildcard `/admin/*` for a routed section root, narrow `/api/*` methods such as `["GET","POST","OPTIONS"]`, POST-only `/login` function routes, and exact static route targets like `{ "pattern": "/events", "methods": ["GET","HEAD"], "target": { "type": "static", "file": "events.html" } }` only for route-table alias behavior. Static route `file` is a release asset path, not a public path, URL, CAS hash, rewrite, or redirect. Routed functions use Node 22 Fetch Request -> Response; `req.url` is the full public URL on managed subdomains, deployment hosts, and verified custom domains. Direct `/functions/v1/:name` remains API-key protected. Known resolve literals include `host_missing`, `manifest_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `none`, `static_exact`, `static_index`, `spa_fallback`, `spa_fallback_missing`, `route_function`, `route_static_alias`, and `route_method_miss`; known `authorization_result` values include `authorized`, `not_public`, `not_applicable`, `manifest_missing`, `target_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `missing_cas_object`, `unfinalized_or_deleting_cas_object`, `size_mismatch`, and `unauthorized_cas_object`. Known `fallback_state` values include `active_release_missing`, `unsupported_manifest_version`, and `negative_cache_hit`; preserve unknown future strings. Release inventories may include `static_public_paths` with `public_path`, `asset_path`, `reachability_authority`, and `direct`; resolve may return the same reachability fields plus `authorization_result`, `cas_object`, `response_variant`, and route/static fields like `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`. Static route target warnings include `STATIC_ALIAS_SHADOWS_STATIC_PATH`, `STATIC_ALIAS_RELATIVE_ASSET_RISK`, `STATIC_ALIAS_DUPLICATE_CANONICAL_URL`, `STATIC_ALIAS_EXTENSIONLESS_NON_HTML`, and `STATIC_ALIAS_TABLE_NEAR_LIMIT`; inspect active routes, `static_public_paths`, and the backing `asset_path`. Runtime route failure codes to branch on: `ROUTE_MANIFEST_LOAD_FAILED`, `ROUTED_INVOKE_WORKER_SECRET_MISSING`, `ROUTED_INVOKE_AUTH_FAILED`, `ROUTED_ROUTE_STALE`, `ROUTE_METHOD_NOT_ALLOWED`, and `ROUTED_RESPONSE_TOO_LARGE`.
157
+ `run402 deploy apply` accepts `site.public_paths` for clean static browser URLs and `routes.replace` for function ingress or exact method-aware static aliases. Release asset paths such as `events.html` are distinct from public paths such as `/events`: prefer `{ "site": { "replace": { "events.html": { "data": "<h1>Events</h1>" } }, "public_paths": { "mode": "explicit", "replace": { "/events": { "asset": "events.html" } } } } }` for ordinary clean static URLs. In explicit mode `/events.html` is not public unless separately declared; `mode: "implicit"` restores filename-derived reachability and can widen access. `routes.replace` is an array of route entries, not a path-keyed map. Use exact `/admin` plus final-wildcard `/admin/*` for a routed section root, narrow `/api/*` methods such as `["GET","POST","OPTIONS"]`, POST-only `/login` function routes, and exact static route targets like `{ "pattern": "/events", "methods": ["GET","HEAD"], "target": { "type": "static", "file": "events.html" } }` only for route-table alias behavior. Static route `file` is a release asset path, not a public path, URL, CAS hash, rewrite, or redirect. Recipe — static home page + SPA shell: a root alias `{ "pattern": "/", "target": { "type": "static", "file": "home.html" } }` (with `home.html` at the site root) serves real static bytes at `GET /` (`route_static_alias`) while unmatched app routes keep the `index.html` shell (`spa_fallback`); expect non-blocking `STATIC_ALIAS_SHADOWS_STATIC_PATH` (warn) / `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` (info) plan lints and verify with `run402 deploy resolve --url https://<your-site>/ --method GET`. Routed functions use Node 22 Fetch Request -> Response; `req.url` is the full public URL on managed subdomains, deployment hosts, and verified custom domains. Direct `/functions/v1/:name` remains API-key protected. Known resolve literals include `host_missing`, `manifest_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `none`, `static_exact`, `static_index`, `spa_fallback`, `spa_fallback_missing`, `route_function`, `route_static_alias`, and `route_method_miss`; known `authorization_result` values include `authorized`, `not_public`, `not_applicable`, `manifest_missing`, `target_missing`, `active_release_missing`, `unsupported_manifest_version`, `path_error`, `missing_cas_object`, `unfinalized_or_deleting_cas_object`, `size_mismatch`, and `unauthorized_cas_object`. Known `fallback_state` values include `active_release_missing`, `unsupported_manifest_version`, and `negative_cache_hit`; preserve unknown future strings. Release inventories may include `static_public_paths` with `public_path`, `asset_path`, `reachability_authority`, and `direct`; resolve may return the same reachability fields plus `authorization_result`, `cas_object`, `response_variant`, and route/static fields like `allow`, `route_pattern`, `target_type`, `target_name`, and `target_file`. Static route target warnings include `STATIC_ALIAS_SHADOWS_STATIC_PATH`, `STATIC_ALIAS_RELATIVE_ASSET_RISK`, `STATIC_ALIAS_DUPLICATE_CANONICAL_URL`, `STATIC_ALIAS_EXTENSIONLESS_NON_HTML`, and `STATIC_ALIAS_TABLE_NEAR_LIMIT`; inspect active routes, `static_public_paths`, and the backing `asset_path`. Runtime route failure codes to branch on: `ROUTE_MANIFEST_LOAD_FAILED`, `ROUTED_INVOKE_WORKER_SECRET_MISSING`, `ROUTED_INVOKE_AUTH_FAILED`, `ROUTED_ROUTE_STALE`, `ROUTE_METHOD_NOT_ALLOWED`, and `ROUTED_RESPONSE_TOO_LARGE`.
154
158
 
155
159
  ### Secrets
156
160
 
package/cli.mjs CHANGED
@@ -28,6 +28,9 @@ Commands:
28
28
  allowance Manage your agent allowance (create, fund, balance, status)
29
29
  tier Manage tier subscription (status, set)
30
30
  projects Manage projects (provision, list, query, inspect, delete)
31
+ apply Alias for deploy apply; supports --rehearse for migration rehearsal
32
+ snapshots Create/list/restore/delete project data snapshots
33
+ branches Create/list/renew/delete contained project branches
31
34
  admin Platform-admin operations (lease-perpetual, archive, reactivate)
32
35
  cloud Cloud portability archive export (archives create/download/status)
33
36
  archives Inspect and verify portable project archives locally
@@ -73,6 +76,9 @@ Examples:
73
76
  run402 allowance create
74
77
  run402 allowance fund
75
78
  run402 deploy apply --manifest app.json
79
+ run402 apply --manifest app.json --rehearse --json
80
+ run402 snapshots list prj_...
81
+ run402 branches create prj_... --ttl-days 7 --json
76
82
  run402 cloud archives create prj_... --wait --output ./project.r402ar --json
77
83
  run402 core projects import ./project.r402ar --name imported-project --env-file ./required.env --json
78
84
  run402 jobs submit --file job.json
@@ -175,6 +181,21 @@ switch (cmd) {
175
181
  await run(sub, rest);
176
182
  break;
177
183
  }
184
+ case "apply": {
185
+ const { runDeployV2 } = await import("./lib/deploy-v2.mjs");
186
+ await runDeployV2("apply", [sub, ...rest].filter(Boolean));
187
+ break;
188
+ }
189
+ case "snapshots": {
190
+ const { run } = await import("./lib/snapshots.mjs");
191
+ await run(sub, rest);
192
+ break;
193
+ }
194
+ case "branches": {
195
+ const { run } = await import("./lib/branches.mjs");
196
+ await run(sub, rest);
197
+ break;
198
+ }
178
199
  case "admin": {
179
200
  const { run } = await import("./lib/admin.mjs");
180
201
  await run(sub, rest);
@@ -0,0 +1,113 @@
1
+ import { getSdk } from "./sdk.mjs";
2
+ import { fail, reportSdkError } from "./sdk-errors.mjs";
3
+ import {
4
+ assertAllowedValue,
5
+ assertKnownFlags,
6
+ flagValue,
7
+ hasHelp,
8
+ normalizeArgv,
9
+ parseIntegerFlag,
10
+ positionalArgs,
11
+ } from "./argparse.mjs";
12
+ import { resolveProjectId } from "./config.mjs";
13
+
14
+ const HELP = `run402 branches — Contained project data branches
15
+
16
+ Usage:
17
+ run402 branches create [project-id] [--from-snapshot <snapshot-id>] [--name <label>] [--email-mode sandbox|off] [--enable-cron] [--ttl-days <n>] [--json]
18
+ run402 branches list [project-id] [--json]
19
+ run402 branches renew [project-id] <branch-project-id> [--ttl-days <n>] [--json]
20
+ run402 branches delete [project-id] <branch-project-id> [--json]
21
+ `;
22
+
23
+ const FLAG_VALUES = ["--from-snapshot", "--name", "--email-mode", "--ttl-days"];
24
+ const FLAGS = new Set([...FLAG_VALUES, "--enable-cron", "--json", "--help", "-h"]);
25
+
26
+ export async function run(sub, args = []) {
27
+ const all = [sub, ...args].filter(Boolean);
28
+ if (!sub || hasHelp(all)) {
29
+ console.log(HELP);
30
+ return;
31
+ }
32
+ const rest = normalizeArgv(args);
33
+ assertKnownFlags(rest, [...FLAGS], FLAG_VALUES);
34
+ switch (sub) {
35
+ case "create": return create(rest);
36
+ case "list": return list(rest);
37
+ case "renew": return renew(rest);
38
+ case "delete": return deleteBranch(rest);
39
+ default:
40
+ fail({
41
+ code: "UNKNOWN_SUBCOMMAND",
42
+ message: `Unknown branches subcommand: ${sub}`,
43
+ hint: "Run `run402 branches --help` for usage.",
44
+ details: { command: "branches", subcommand: sub },
45
+ });
46
+ }
47
+ }
48
+
49
+ async function create(args) {
50
+ const projectId = resolveOptionalProject(positionalArgs(args, FLAG_VALUES)[0]);
51
+ const emailMode = flagValue(args, "--email-mode") ?? undefined;
52
+ if (emailMode !== undefined) assertAllowedValue(emailMode, ["sandbox", "off"], "--email-mode");
53
+ const ttlDaysFlag = flagValue(args, "--ttl-days");
54
+ const ttlDays = ttlDaysFlag === null ? undefined : parseIntegerFlag("--ttl-days", ttlDaysFlag, { min: 1, max: 30 });
55
+ try {
56
+ const branch = await getSdk().branches.create(projectId, {
57
+ ...(flagValue(args, "--from-snapshot") ? { fromSnapshotId: flagValue(args, "--from-snapshot") } : {}),
58
+ ...(flagValue(args, "--name") ? { name: flagValue(args, "--name") } : {}),
59
+ ...(emailMode ? { emailMode } : {}),
60
+ ...(args.includes("--enable-cron") ? { enableCron: true } : {}),
61
+ ...(ttlDays !== undefined ? { ttlDays } : {}),
62
+ });
63
+ console.log(JSON.stringify({ ok: true, branch }, null, 2));
64
+ } catch (err) {
65
+ reportSdkError(err);
66
+ }
67
+ }
68
+
69
+ async function list(args) {
70
+ const projectId = resolveOptionalProject(positionalArgs(args, FLAG_VALUES)[0]);
71
+ try {
72
+ const result = await getSdk().branches.list(projectId);
73
+ console.log(JSON.stringify({ project_id: projectId, ...result }, null, 2));
74
+ } catch (err) {
75
+ reportSdkError(err);
76
+ }
77
+ }
78
+
79
+ async function renew(args) {
80
+ const { projectId, branchProjectId } = resolveProjectAndBranch(args, "run402 branches renew [project-id] <branch-project-id>");
81
+ const ttlDaysFlag = flagValue(args, "--ttl-days");
82
+ const ttlDays = ttlDaysFlag === null ? undefined : parseIntegerFlag("--ttl-days", ttlDaysFlag, { min: 1, max: 30 });
83
+ try {
84
+ const branch = await getSdk().branches.renew(projectId, branchProjectId, {
85
+ ...(ttlDays !== undefined ? { ttlDays } : {}),
86
+ });
87
+ console.log(JSON.stringify({ ok: true, branch }, null, 2));
88
+ } catch (err) {
89
+ reportSdkError(err);
90
+ }
91
+ }
92
+
93
+ async function deleteBranch(args) {
94
+ const { projectId, branchProjectId } = resolveProjectAndBranch(args, "run402 branches delete [project-id] <branch-project-id>");
95
+ try {
96
+ await getSdk().branches.delete(projectId, branchProjectId);
97
+ console.log(JSON.stringify({ ok: true, project_id: projectId, branch_project_id: branchProjectId, deleted: true }, null, 2));
98
+ } catch (err) {
99
+ reportSdkError(err);
100
+ }
101
+ }
102
+
103
+ function resolveOptionalProject(value) {
104
+ if (value && String(value).startsWith("prj_")) return value;
105
+ return resolveProjectId(null);
106
+ }
107
+
108
+ function resolveProjectAndBranch(args, usage) {
109
+ const pos = positionalArgs(args, FLAG_VALUES);
110
+ if (pos.length === 1) return { projectId: resolveProjectId(null), branchProjectId: pos[0] };
111
+ if (pos.length === 2 && pos[0].startsWith("prj_")) return { projectId: pos[0], branchProjectId: pos[1] };
112
+ fail({ code: "BAD_USAGE", message: `Usage: ${usage}` });
113
+ }
package/lib/core.mjs CHANGED
@@ -6,6 +6,7 @@ const HELP = `run402 core — Local Run402 Core commands
6
6
 
7
7
  Usage:
8
8
  run402 core projects import <archive-path> --name <project-name> [options]
9
+ run402 core projects apply <archive-path> --name <project-name> [options]
9
10
 
10
11
  Options:
11
12
  --name <name> New Core project name (default imported-project)
@@ -36,7 +37,7 @@ export async function run(sub, args = []) {
36
37
  });
37
38
  }
38
39
  const action = args[0];
39
- if (action === "import") return importProject(args.slice(1));
40
+ if (action === "import" || action === "apply") return importProject(args.slice(1));
40
41
  fail({
41
42
  code: "UNKNOWN_SUBCOMMAND",
42
43
  message: `Unknown core projects subcommand: ${action}`,
package/lib/deploy-v2.mjs CHANGED
@@ -43,8 +43,8 @@ import { createUpdateCheckScheduler, emitUpdateNotice } from "./update-check.mjs
43
43
  const APPLY_HELP = `run402 deploy apply — Unified deploy primitive (v1.34+)
44
44
 
45
45
  Usage:
46
- run402 deploy apply --manifest <path> [--project <id>] [--check|--print-spec|--plan|--require-plan <id>] [--quiet|--final-only]
47
- run402 deploy apply --spec '<json>' [--project <id>] [--check|--print-spec|--plan|--require-plan <id>] [--quiet|--final-only]
46
+ run402 deploy apply --manifest <path> [--project <id>] [--check|--print-spec|--plan|--rehearse|--require-plan <id>] [--quiet|--final-only] [--json]
47
+ run402 deploy apply --spec '<json>' [--project <id>] [--check|--print-spec|--plan|--rehearse|--require-plan <id>] [--quiet|--final-only] [--json]
48
48
  run402 deploy apply --dir <build-output> [--manifest <path>] [--project <id>]
49
49
  cat spec.json | run402 deploy apply [--project <id>]
50
50
 
@@ -95,7 +95,11 @@ Options:
95
95
  --check Validate and normalize locally. No gateway calls or uploads.
96
96
  --print-spec Print the normalized ReleaseSpec JSON. No gateway calls or uploads.
97
97
  --plan Ask the gateway for a reviewed plan. No upload or commit.
98
+ --rehearse Plan, upload missing content, run the plan against a contained branch, and print the rehearsal report. Does not commit unless --commit is also passed.
99
+ --teardown <policy> Rehearsal branch cleanup policy: keep (default), on_pass, or always.
100
+ --commit After a passing rehearsal, commit the original plan.
98
101
  --require-plan <id> Apply only if this reviewed plan still matches.
102
+ --json No-op compatibility flag; success output is always JSON.
99
103
  --quiet Suppress per-event JSON-line stderr (final result still on stdout)
100
104
  --final-only Alias for --quiet; final success/error envelope is still preserved
101
105
  --allow-warning <code> Continue past this reviewed warning code (repeatable)
@@ -186,6 +190,42 @@ Output:
186
190
  stdout: { "events": [...] }
187
191
  `;
188
192
 
193
+ const VERIFY_HELP = `run402 deploy verify — Verify gateway and edge coherence
194
+
195
+ Usage:
196
+ run402 deploy verify <operation_id> [--project <id>] [--wait] [--timeout <seconds>] [--json]
197
+ run402 deploy verify --operation <operation_id> [--project <id>] [--wait] [--timeout <seconds>] [--json]
198
+
199
+ Checks that the gateway's live release and the edge pointers observed by
200
+ Run402 agree for the deployment operation. Use --wait to poll until the report
201
+ is coherent or the timeout elapses.
202
+
203
+ Options:
204
+ --operation <id> Operation id to verify. Equivalent to the positional id.
205
+ --project <id> Project ID that owns the operation (default: active project)
206
+ --wait Poll until coherent or timeout
207
+ --timeout <seconds> Maximum wait time with --wait (default 60)
208
+ --json No-op compatibility flag; output is always JSON
209
+
210
+ Exit codes:
211
+ 0 coherent
212
+ 2 valid report, but not coherent before timeout
213
+ 1 usage, auth, network, or gateway error
214
+
215
+ Output:
216
+ stdout: { "status": "coherent"|"not_coherent", "coherent": boolean, "report": {...} }
217
+ stderr: one JSON poll progress line per attempt when --wait is set
218
+ `;
219
+
220
+ const REHEARSE_HELP = `run402 deploy rehearse — Run a persisted plan on a contained branch
221
+
222
+ Usage:
223
+ run402 deploy rehearse <plan_id> [--project <id>] [--teardown keep|on_pass|always] [--json]
224
+
225
+ Use \`run402 apply --manifest app.json --rehearse --json\` for the canonical
226
+ one-shot plan → upload → rehearse report flow.
227
+ `;
228
+
189
229
  const RELEASE_HELP = `run402 deploy release — Inspect deploy release inventory and diffs
190
230
 
191
231
  Usage:
@@ -283,10 +323,12 @@ exit 0; inspect would_serve and diagnostic_status in the result payload.
283
323
 
284
324
  export async function runDeployV2(sub, args) {
285
325
  if (sub === "apply") return await applyCmd(args);
326
+ if (sub === "rehearse") return await rehearseCmd(args);
286
327
  if (sub === "promote") return await promoteCmd(args);
287
328
  if (sub === "resume") return await resumeCmd(args);
288
329
  if (sub === "list") return await listCmd(args);
289
330
  if (sub === "events") return await eventsCmd(args);
331
+ if (sub === "verify") return await verifyCmd(args);
290
332
  if (sub === "release") return await releaseCmd(args);
291
333
  if (sub === "diagnose") return await diagnoseCmd(args);
292
334
  if (sub === "resolve") return await resolveCmd(args);
@@ -297,6 +339,59 @@ export async function runDeployV2(sub, args) {
297
339
  });
298
340
  }
299
341
 
342
+ async function rehearseCmd(rawArgs) {
343
+ const args = normalizeArgv(rawArgs);
344
+ if (args.includes("--help") || args.includes("-h")) {
345
+ console.log(REHEARSE_HELP);
346
+ return;
347
+ }
348
+ const valueFlags = new Set(["--project", "--teardown"]);
349
+ const allowedFlags = ["--project", "--teardown", "--json", "--help", "-h"];
350
+ const positionals = [];
351
+ for (let i = 0; i < args.length; i += 1) {
352
+ const arg = args[i];
353
+ if (valueFlags.has(arg)) {
354
+ if (args[i + 1] === undefined || String(args[i + 1]).startsWith("--")) {
355
+ fail({ code: "BAD_USAGE", message: `${arg} requires a value`, details: { flag: arg } });
356
+ }
357
+ i += 1;
358
+ continue;
359
+ }
360
+ if (String(arg).startsWith("-")) {
361
+ if (!allowedFlags.includes(arg)) {
362
+ fail({ code: "BAD_USAGE", message: `Unknown flag for deploy rehearse: ${arg}`, details: { flag: arg, allowed_flags: allowedFlags } });
363
+ }
364
+ continue;
365
+ }
366
+ positionals.push(arg);
367
+ }
368
+ const planId = positionals[0];
369
+ if (!planId || positionals.length > 1) {
370
+ fail({ code: "BAD_USAGE", message: "Usage: run402 deploy rehearse <plan_id> [--project <id>] [--teardown keep|on_pass|always] [--json]" });
371
+ }
372
+ const teardown = flagValue(args, "--teardown") ?? "keep";
373
+ if (!["keep", "on_pass", "always"].includes(teardown)) {
374
+ fail({ code: "BAD_USAGE", message: "--teardown must be one of: keep, on_pass, always", details: { flag: "--teardown", value: teardown } });
375
+ }
376
+ const project = flagValue(args, "--project") ?? undefined;
377
+ if (!isCoreApiTarget() && !loadLiveControlPlaneSession()) {
378
+ allowanceAuthHeaders(`/apply/v1/plans/${planId}/rehearse`);
379
+ }
380
+ try {
381
+ const rehearsal = await withAutoApprove(() =>
382
+ getSdk()._applyEngine.rehearse(planId, { project, teardown }),
383
+ );
384
+ console.log(JSON.stringify({
385
+ ok: rehearsal.report.status === "passed",
386
+ rehearsal,
387
+ commit_command: `run402 deploy apply --require-plan ${planId}`,
388
+ }, null, 2));
389
+ if (rehearsal.report.status !== "passed") process.exit(1);
390
+ } catch (err) {
391
+ reportSdkError(err);
392
+ }
393
+ }
394
+
300
395
  const PROMOTE_HELP = `run402 deploy promote — Operator pointer-swap recovery (v1.58+)
301
396
 
302
397
  Usage:
@@ -520,8 +615,10 @@ function parseApplyArgs(args) {
520
615
  allowWarningCodes: [],
521
616
  mode: null,
522
617
  planFingerprint: null,
618
+ rehearsalTeardown: "keep",
619
+ commitAfterRehearse: false,
523
620
  };
524
- const allowedFlags = ["--manifest", "--spec", "--dir", "--project", "--quiet", "--final-only", "--allow-warning", "--allow-warnings", "--check", "--print-spec", "--plan", "--require-plan", "--plan-fingerprint", "--help", "-h"];
621
+ const allowedFlags = ["--manifest", "--spec", "--dir", "--project", "--quiet", "--final-only", "--json", "--allow-warning", "--allow-warnings", "--check", "--print-spec", "--plan", "--rehearse", "--teardown", "--commit", "--require-plan", "--plan-fingerprint", "--help", "-h"];
525
622
 
526
623
  for (let i = 0; i < args.length; i++) {
527
624
  const arg = args[i];
@@ -529,7 +626,7 @@ function parseApplyArgs(args) {
529
626
  console.log(APPLY_HELP);
530
627
  process.exit(0);
531
628
  }
532
- if (arg === "--manifest" || arg === "--spec" || arg === "--dir" || arg === "--project" || arg === "--allow-warning" || arg === "--require-plan" || arg === "--plan-fingerprint") {
629
+ if (arg === "--manifest" || arg === "--spec" || arg === "--dir" || arg === "--project" || arg === "--allow-warning" || arg === "--require-plan" || arg === "--plan-fingerprint" || arg === "--teardown") {
533
630
  const value = args[i + 1];
534
631
  if (value === undefined || (typeof value === "string" && value.startsWith("--"))) {
535
632
  fail({
@@ -571,6 +668,15 @@ function parseApplyArgs(args) {
571
668
  opts.allowWarningCodes.push(value);
572
669
  } else if (arg === "--require-plan") {
573
670
  setApplyMode(opts, { kind: "applyReviewed", planId: value }, "--require-plan");
671
+ } else if (arg === "--teardown") {
672
+ if (!["keep", "on_pass", "always"].includes(value)) {
673
+ fail({
674
+ code: "BAD_USAGE",
675
+ message: "--teardown must be one of: keep, on_pass, always",
676
+ details: { flag: "--teardown", value },
677
+ });
678
+ }
679
+ opts.rehearsalTeardown = value;
574
680
  } else {
575
681
  opts.planFingerprint = value;
576
682
  }
@@ -578,10 +684,13 @@ function parseApplyArgs(args) {
578
684
  continue;
579
685
  }
580
686
  if (arg === "--quiet" || arg === "--final-only") { opts.quiet = true; continue; }
687
+ if (arg === "--json") { continue; }
581
688
  if (arg === "--allow-warnings") { opts.allowWarnings = true; continue; }
582
689
  if (arg === "--check") { setApplyMode(opts, "check", "--check"); continue; }
583
690
  if (arg === "--print-spec") { setApplyMode(opts, "printSpec", "--print-spec"); continue; }
584
691
  if (arg === "--plan") { setApplyMode(opts, "plan", "--plan"); continue; }
692
+ if (arg === "--rehearse") { setApplyMode(opts, "rehearse", "--rehearse"); continue; }
693
+ if (arg === "--commit") { opts.commitAfterRehearse = true; continue; }
585
694
  if (typeof arg === "string" && arg.startsWith("-")) {
586
695
  fail({
587
696
  code: "BAD_USAGE",
@@ -612,6 +721,13 @@ function parseApplyArgs(args) {
612
721
  });
613
722
  }
614
723
  }
724
+ if (opts.commitAfterRehearse && opts.mode !== "rehearse") {
725
+ fail({
726
+ code: "BAD_USAGE",
727
+ message: "--commit can only be used with --rehearse.",
728
+ details: { flag: "--commit" },
729
+ });
730
+ }
615
731
 
616
732
  return opts;
617
733
  }
@@ -1027,6 +1143,56 @@ async function applyCmd(args) {
1027
1143
  console.log(JSON.stringify(result.plan, null, 2));
1028
1144
  return;
1029
1145
  }
1146
+ if (opts.mode === "rehearse") {
1147
+ const sdk = getSdk(sdkOpts);
1148
+ const planned = await sdk._applyEngine.plan(releaseSpec, { idempotencyKey });
1149
+ const planId = planned.plan.plan_id;
1150
+ if (!planId) {
1151
+ fail({
1152
+ code: "DRY_RUN_PLAN_NOT_COMMITTABLE",
1153
+ message: "Rehearsal requires a persisted plan_id, but the plan response did not include one.",
1154
+ details: { project_id: releaseSpec.project },
1155
+ });
1156
+ }
1157
+ await sdk._applyEngine.upload(planned.plan, {
1158
+ project: releaseSpec.project,
1159
+ byteReaders: planned.byteReaders,
1160
+ onEvent: makeStderrEventWriter(opts.quiet),
1161
+ });
1162
+ const rehearsal = await withAutoApprove(() =>
1163
+ sdk._applyEngine.rehearse(planId, {
1164
+ project: releaseSpec.project,
1165
+ teardown: opts.rehearsalTeardown,
1166
+ }),
1167
+ );
1168
+ if (rehearsal.report.status !== "passed") {
1169
+ console.log(JSON.stringify({
1170
+ ok: false,
1171
+ project_id: releaseSpec.project,
1172
+ plan_id: planId,
1173
+ rehearsal,
1174
+ commit_command: `run402 deploy apply --require-plan ${planId}${planned.plan.plan_fingerprint ? ` --plan-fingerprint ${planned.plan.plan_fingerprint}` : ""}`,
1175
+ }, null, 2));
1176
+ process.exit(1);
1177
+ }
1178
+ if (opts.commitAfterRehearse) {
1179
+ const committed = await sdk._applyEngine.commit(planId, {
1180
+ project: releaseSpec.project,
1181
+ onEvent: makeStderrEventWriter(opts.quiet),
1182
+ idempotencyKey,
1183
+ });
1184
+ console.log(JSON.stringify({ ok: true, project_id: releaseSpec.project, plan: planned.plan, rehearsal, commit: committed }, null, 2));
1185
+ return;
1186
+ }
1187
+ console.log(JSON.stringify({
1188
+ ok: true,
1189
+ project_id: releaseSpec.project,
1190
+ plan: planned.plan,
1191
+ rehearsal,
1192
+ commit_command: `run402 deploy apply --require-plan ${planId}${planned.plan.plan_fingerprint ? ` --plan-fingerprint ${planned.plan.plan_fingerprint}` : ""}`,
1193
+ }, null, 2));
1194
+ return;
1195
+ }
1030
1196
  const requiredPlan = opts.mode && opts.mode.kind === "applyReviewed"
1031
1197
  ? { planId: opts.mode.planId, ...(opts.mode.planFingerprint ? { planFingerprint: opts.mode.planFingerprint } : {}) }
1032
1198
  : undefined;
@@ -1456,6 +1622,100 @@ async function eventsCmd(args) {
1456
1622
  }
1457
1623
  }
1458
1624
 
1625
+ async function verifyCmd(args) {
1626
+ const parsed = parseDeploySubcommandArgs(args, {
1627
+ command: "deploy verify",
1628
+ help: VERIFY_HELP,
1629
+ valueFlags: ["--project", "--operation", "--timeout"],
1630
+ booleanFlags: ["--wait", "--json"],
1631
+ });
1632
+ const positionals = expectPositionals(parsed.positionals, {
1633
+ command: "run402 deploy verify <operation_id> [--project <id>] [--wait]",
1634
+ max: 1,
1635
+ });
1636
+ const positionalOperationId = positionals[0] ?? null;
1637
+ const flaggedOperationId = parsed.flags["--operation"] ?? null;
1638
+ if (positionalOperationId && flaggedOperationId && positionalOperationId !== flaggedOperationId) {
1639
+ fail({
1640
+ code: "BAD_USAGE",
1641
+ message: "Pass the operation id either positionally or with --operation, not both.",
1642
+ details: { positional: positionalOperationId, operation: flaggedOperationId },
1643
+ });
1644
+ }
1645
+ const operationId = flaggedOperationId ?? positionalOperationId;
1646
+ if (!operationId) {
1647
+ fail({
1648
+ code: "BAD_USAGE",
1649
+ message: "Missing <operation_id>.",
1650
+ hint: "run402 deploy verify <operation_id> [--project <id>] [--wait]",
1651
+ });
1652
+ }
1653
+ const project = resolveProjectId(parsed.flags["--project"] ?? null);
1654
+ const wait = Boolean(parsed.flags["--wait"]);
1655
+ const timeoutSeconds = parsed.flags["--timeout"] === undefined
1656
+ ? 60
1657
+ : parsePositiveInt(parsed.flags["--timeout"], "--timeout");
1658
+
1659
+ allowanceAuthHeaders("/apply/v1/operations");
1660
+
1661
+ try {
1662
+ let result;
1663
+ if (wait) {
1664
+ result = await getSdk()._applyEngine.waitEdgeCoherent(operationId, {
1665
+ project,
1666
+ timeoutMs: timeoutSeconds * 1000,
1667
+ onPoll: (event) => {
1668
+ console.error(JSON.stringify({
1669
+ type: "deploy.verify.poll",
1670
+ coherent: event.report.coherent,
1671
+ attempts: event.attempts,
1672
+ elapsed_ms: event.elapsedMs,
1673
+ pending_count: event.report.pending_count,
1674
+ path_count: event.report.path_count,
1675
+ total_path_count: event.report.total_path_count,
1676
+ paths_truncated: event.report.paths_truncated,
1677
+ paths: summarizeEdgeCoherencePaths(event.report.paths),
1678
+ }));
1679
+ },
1680
+ });
1681
+ } else {
1682
+ const report = await getSdk()._applyEngine.edgeCoherence(operationId, { project });
1683
+ result = { coherent: report.coherent, attempts: 1, elapsedMs: 0, report };
1684
+ }
1685
+ const output = {
1686
+ status: result.coherent ? "coherent" : "not_coherent",
1687
+ coherent: result.coherent,
1688
+ operation_id: result.report.operation_id,
1689
+ project_id: result.report.project_id,
1690
+ attempts: result.attempts,
1691
+ elapsed_ms: result.elapsedMs,
1692
+ report: result.report,
1693
+ };
1694
+ console.log(JSON.stringify(output, null, 2));
1695
+ if (!result.coherent) process.exitCode = 2;
1696
+ } catch (err) {
1697
+ reportSdkError(err);
1698
+ }
1699
+ }
1700
+
1701
+ function summarizeEdgeCoherencePaths(paths) {
1702
+ if (!Array.isArray(paths)) return [];
1703
+ return paths.map((path) => ({
1704
+ path: path.path,
1705
+ host: path.host,
1706
+ state: path.state,
1707
+ observed_confidence: path.observed_confidence,
1708
+ expected_release_id: path.expected_release_id,
1709
+ observed_release_id: path.observed_release_id,
1710
+ expected_release_generation: path.expected_release_generation,
1711
+ observed_release_generation: path.observed_release_generation,
1712
+ status: path.status,
1713
+ x_cache: path.x_cache,
1714
+ age_seconds: path.age_seconds,
1715
+ error: path.error,
1716
+ }));
1717
+ }
1718
+
1459
1719
  async function releaseCmd(args) {
1460
1720
  const action = args[0];
1461
1721
  if (!action || action === "--help" || action === "-h") {
@@ -1705,7 +1965,7 @@ function parseDeploySubcommandArgs(rawArgs, { command, help, valueFlags = [], bo
1705
1965
  const args = normalizeArgv(rawArgs);
1706
1966
  const valueFlagSet = new Set(valueFlags);
1707
1967
  const booleanFlagSet = new Set(booleanFlags);
1708
- const numericFlagSet = new Set(["--limit", "--site-limit"]);
1968
+ const numericFlagSet = new Set(["--limit", "--site-limit", "--timeout"]);
1709
1969
  const allowedFlags = new Set([...valueFlags, ...booleanFlags, "--help", "-h"]);
1710
1970
  const flags = {};
1711
1971
  const positionals = [];
package/lib/deploy.mjs CHANGED
@@ -7,9 +7,11 @@ Usage:
7
7
 
8
8
  Subcommands:
9
9
  apply --manifest <file> Apply a v2 ReleaseSpec manifest
10
+ rehearse <plan_id> Rehearse a persisted plan on a contained branch
10
11
  resume <operation_id> Resume a stuck operation
11
12
  list [--project <id>] List recent deploy operations
12
13
  events <operation_id> Fetch event stream for an operation
14
+ verify <operation_id> Verify gateway and edge coherence
13
15
  diagnose <url> Diagnose public URL routing
14
16
  resolve --url <url> Low-level resolve diagnostics
15
17
  release ... Inspect release inventory and diffs
@@ -48,11 +50,13 @@ export async function run(args) {
48
50
  }
49
51
 
50
52
  switch (sub) {
51
- case "apply":
53
+ case "apply":
54
+ case "rehearse":
52
55
  case "promote":
53
56
  case "resume":
54
57
  case "list":
55
58
  case "events":
59
+ case "verify":
56
60
  case "diagnose":
57
61
  case "resolve":
58
62
  case "release": {
package/lib/projects.mjs CHANGED
@@ -33,6 +33,7 @@ Subcommands:
33
33
  delete [id] --confirm Immediately and irreversibly delete a project (cascade purge) and remove from local state. Requires --confirm.
34
34
  promote-user [id] <email> Promote a user to project_admin role
35
35
  demote-user [id] <email> Demote a user from project_admin role
36
+ export [id] [archive options] Export a portable Run402 Core archive
36
37
 
37
38
  Examples:
38
39
  run402 projects quote
@@ -56,6 +57,7 @@ Examples:
56
57
  run402 projects apply-expose prj_abc123 --file manifest.json
57
58
  run402 projects get-expose prj_abc123
58
59
  run402 projects delete prj_abc123 --confirm
60
+ run402 projects export prj_abc123 --wait --output ./project.r402ar --json
59
61
 
60
62
  Global options (any command):
61
63
  --wallet <name> Use a named wallet (profile) for this command. Precedence:
@@ -799,6 +801,10 @@ const FLAGS_BY_SUB = {
799
801
  values: ["--file", "--migration-file", "--migration-sql"],
800
802
  },
801
803
  delete: { known: ["--confirm"], values: [] },
804
+ export: {
805
+ known: ["--scope", "--auth", "--consistency", "--idempotency-key", "--output", "--poll-interval", "--timeout", "--wait", "--json", "--json-stream"],
806
+ values: ["--scope", "--auth", "--consistency", "--idempotency-key", "--output", "--poll-interval", "--timeout"],
807
+ },
802
808
  };
803
809
 
804
810
  function validateFlags(sub, args) {
@@ -845,6 +851,11 @@ export async function run(sub, args) {
845
851
  case "validate-expose": await validateExpose(args); break;
846
852
  case "get-expose": { const { projectId } = resolvePositionalProject(args, { rejectBareFirst: true }); await getExpose(projectId); break; }
847
853
  case "delete": { const { projectId, rest } = resolvePositionalProject(args, { rejectBareFirst: true }); await deleteProject(projectId, rest); break; }
854
+ case "export": {
855
+ const { run } = await import("./cloud.mjs");
856
+ await run("archives", ["create", ...args]);
857
+ break;
858
+ }
848
859
  case "promote-user": { const { projectId, rest } = resolvePositionalProject(args); await promoteUser(projectId, rest[0]); break; }
849
860
  case "demote-user": { const { projectId, rest } = resolvePositionalProject(args); await demoteUser(projectId, rest[0]); break; }
850
861
  default: