run402 1.53.0 → 1.54.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 (87) hide show
  1. package/core-dist/keystore.js +65 -21
  2. package/lib/apps.mjs +1 -1
  3. package/lib/domains.mjs +13 -4
  4. package/lib/email.mjs +5 -10
  5. package/lib/init.mjs +30 -6
  6. package/lib/projects.mjs +23 -6
  7. package/lib/sites.mjs +84 -1
  8. package/lib/subdomains.mjs +23 -7
  9. package/package.json +1 -1
  10. package/sdk/core-dist/keystore.js +65 -21
  11. package/sdk/dist/errors.d.ts +91 -0
  12. package/sdk/dist/errors.d.ts.map +1 -1
  13. package/sdk/dist/errors.js +162 -6
  14. package/sdk/dist/errors.js.map +1 -1
  15. package/sdk/dist/index.d.ts +4 -2
  16. package/sdk/dist/index.d.ts.map +1 -1
  17. package/sdk/dist/index.js +15 -1
  18. package/sdk/dist/index.js.map +1 -1
  19. package/sdk/dist/namespaces/admin.d.ts +4 -1
  20. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  21. package/sdk/dist/namespaces/admin.js +1 -1
  22. package/sdk/dist/namespaces/admin.js.map +1 -1
  23. package/sdk/dist/namespaces/apps.d.ts +27 -30
  24. package/sdk/dist/namespaces/apps.d.ts.map +1 -1
  25. package/sdk/dist/namespaces/apps.js.map +1 -1
  26. package/sdk/dist/namespaces/auth.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/auth.js +10 -1
  28. package/sdk/dist/namespaces/auth.js.map +1 -1
  29. package/sdk/dist/namespaces/billing.d.ts +15 -6
  30. package/sdk/dist/namespaces/billing.d.ts.map +1 -1
  31. package/sdk/dist/namespaces/billing.js.map +1 -1
  32. package/sdk/dist/namespaces/contracts.d.ts +67 -15
  33. package/sdk/dist/namespaces/contracts.d.ts.map +1 -1
  34. package/sdk/dist/namespaces/contracts.js +32 -8
  35. package/sdk/dist/namespaces/contracts.js.map +1 -1
  36. package/sdk/dist/namespaces/deploy.d.ts +5 -4
  37. package/sdk/dist/namespaces/deploy.d.ts.map +1 -1
  38. package/sdk/dist/namespaces/deploy.js +13 -7
  39. package/sdk/dist/namespaces/deploy.js.map +1 -1
  40. package/sdk/dist/namespaces/domains.d.ts +5 -1
  41. package/sdk/dist/namespaces/domains.d.ts.map +1 -1
  42. package/sdk/dist/namespaces/domains.js +1 -1
  43. package/sdk/dist/namespaces/domains.js.map +1 -1
  44. package/sdk/dist/namespaces/email.d.ts +21 -13
  45. package/sdk/dist/namespaces/email.d.ts.map +1 -1
  46. package/sdk/dist/namespaces/email.js +5 -4
  47. package/sdk/dist/namespaces/email.js.map +1 -1
  48. package/sdk/dist/namespaces/functions.d.ts +2 -2
  49. package/sdk/dist/namespaces/functions.d.ts.map +1 -1
  50. package/sdk/dist/namespaces/functions.js +1 -1
  51. package/sdk/dist/namespaces/functions.js.map +1 -1
  52. package/sdk/dist/namespaces/functions.types.d.ts +4 -0
  53. package/sdk/dist/namespaces/functions.types.d.ts.map +1 -1
  54. package/sdk/dist/namespaces/projects.d.ts.map +1 -1
  55. package/sdk/dist/namespaces/projects.js +3 -5
  56. package/sdk/dist/namespaces/projects.js.map +1 -1
  57. package/sdk/dist/namespaces/projects.types.d.ts +9 -1
  58. package/sdk/dist/namespaces/projects.types.d.ts.map +1 -1
  59. package/sdk/dist/namespaces/secrets.d.ts +5 -1
  60. package/sdk/dist/namespaces/secrets.d.ts.map +1 -1
  61. package/sdk/dist/namespaces/secrets.js +1 -1
  62. package/sdk/dist/namespaces/secrets.js.map +1 -1
  63. package/sdk/dist/namespaces/sender-domain.d.ts +4 -1
  64. package/sdk/dist/namespaces/sender-domain.d.ts.map +1 -1
  65. package/sdk/dist/namespaces/sender-domain.js +1 -1
  66. package/sdk/dist/namespaces/sender-domain.js.map +1 -1
  67. package/sdk/dist/namespaces/service.d.ts +11 -31
  68. package/sdk/dist/namespaces/service.d.ts.map +1 -1
  69. package/sdk/dist/namespaces/service.js.map +1 -1
  70. package/sdk/dist/namespaces/subdomains.d.ts +12 -2
  71. package/sdk/dist/namespaces/subdomains.d.ts.map +1 -1
  72. package/sdk/dist/namespaces/subdomains.js +23 -18
  73. package/sdk/dist/namespaces/subdomains.js.map +1 -1
  74. package/sdk/dist/namespaces/tier.d.ts +9 -1
  75. package/sdk/dist/namespaces/tier.d.ts.map +1 -1
  76. package/sdk/dist/namespaces/tier.js.map +1 -1
  77. package/sdk/dist/node/index.d.ts +2 -2
  78. package/sdk/dist/node/index.d.ts.map +1 -1
  79. package/sdk/dist/node/index.js +1 -1
  80. package/sdk/dist/node/index.js.map +1 -1
  81. package/sdk/dist/retry.d.ts +50 -0
  82. package/sdk/dist/retry.d.ts.map +1 -0
  83. package/sdk/dist/retry.js +61 -0
  84. package/sdk/dist/retry.js.map +1 -0
  85. package/sdk/dist/scoped.d.ts +13 -13
  86. package/sdk/dist/scoped.d.ts.map +1 -1
  87. package/sdk/dist/scoped.js.map +1 -1
@@ -1,7 +1,34 @@
1
- import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync } from "node:fs";
1
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, rmdirSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { getKeystorePath } from "./config.js";
5
+ function withFileLock(path, fn, { retries = 200, delayMs = 20 } = {}) {
6
+ const lockDir = path + ".lock";
7
+ mkdirSync(dirname(path), { recursive: true });
8
+ for (let i = 0; i < retries; i++) {
9
+ try {
10
+ mkdirSync(lockDir, { mode: 0o700 });
11
+ }
12
+ catch (e) {
13
+ const code = e.code;
14
+ if (code !== "EEXIST")
15
+ throw e;
16
+ const until = Date.now() + delayMs;
17
+ while (Date.now() < until) { /* spin */ }
18
+ continue;
19
+ }
20
+ try {
21
+ return fn();
22
+ }
23
+ finally {
24
+ try {
25
+ rmdirSync(lockDir);
26
+ }
27
+ catch { /* best-effort */ }
28
+ }
29
+ }
30
+ throw new Error(`Could not acquire keystore lock after ${retries} retries: ${lockDir}`);
31
+ }
5
32
  /**
6
33
  * Load the keystore from disk.
7
34
  * Auto-migrates legacy formats:
@@ -13,7 +40,6 @@ export function loadKeyStore(path) {
13
40
  try {
14
41
  const data = readFileSync(p, "utf-8");
15
42
  const parsed = JSON.parse(data);
16
- // Auto-migrate array format (CLI legacy) to object format
17
43
  if (Array.isArray(parsed)) {
18
44
  const projects = {};
19
45
  for (const item of parsed) {
@@ -29,7 +55,6 @@ export function loadKeyStore(path) {
29
55
  return { projects };
30
56
  }
31
57
  if (parsed && typeof parsed === "object" && parsed.projects) {
32
- // Strip legacy fields (tier, lease_expires_at, expires_at) from projects
33
58
  for (const proj of Object.values(parsed.projects)) {
34
59
  const rec = proj;
35
60
  delete rec.tier;
@@ -38,6 +63,7 @@ export function loadKeyStore(path) {
38
63
  }
39
64
  return {
40
65
  ...(parsed.active_project_id && { active_project_id: parsed.active_project_id }),
66
+ ...(parsed.previous_active_project_id && { previous_active_project_id: parsed.previous_active_project_id }),
41
67
  projects: parsed.projects,
42
68
  };
43
69
  }
@@ -62,27 +88,40 @@ export function getProject(projectId, path) {
62
88
  }
63
89
  export function saveProject(projectId, project, path) {
64
90
  const p = path ?? getKeystorePath();
65
- const store = loadKeyStore(p);
66
- store.projects[projectId] = project;
67
- saveKeyStore(store, p);
91
+ withFileLock(p, () => {
92
+ const store = loadKeyStore(p);
93
+ store.projects[projectId] = project;
94
+ saveKeyStore(store, p);
95
+ });
68
96
  }
69
97
  export function updateProject(projectId, update, path) {
70
98
  const p = path ?? getKeystorePath();
71
- const store = loadKeyStore(p);
72
- const existing = store.projects[projectId];
73
- if (existing) {
74
- store.projects[projectId] = { ...existing, ...update };
75
- saveKeyStore(store, p);
76
- }
99
+ withFileLock(p, () => {
100
+ const store = loadKeyStore(p);
101
+ const existing = store.projects[projectId];
102
+ if (existing) {
103
+ store.projects[projectId] = { ...existing, ...update };
104
+ saveKeyStore(store, p);
105
+ }
106
+ });
77
107
  }
78
108
  export function removeProject(projectId, path) {
79
109
  const p = path ?? getKeystorePath();
80
- const store = loadKeyStore(p);
81
- delete store.projects[projectId];
82
- if (store.active_project_id === projectId) {
83
- delete store.active_project_id;
84
- }
85
- saveKeyStore(store, p);
110
+ withFileLock(p, () => {
111
+ const store = loadKeyStore(p);
112
+ delete store.projects[projectId];
113
+ if (store.active_project_id === projectId) {
114
+ const fallback = store.previous_active_project_id;
115
+ if (fallback && fallback !== projectId && store.projects[fallback]) {
116
+ store.active_project_id = fallback;
117
+ }
118
+ else {
119
+ delete store.active_project_id;
120
+ }
121
+ delete store.previous_active_project_id;
122
+ }
123
+ saveKeyStore(store, p);
124
+ });
86
125
  }
87
126
  export function getActiveProjectId(path) {
88
127
  const store = loadKeyStore(path);
@@ -90,8 +129,13 @@ export function getActiveProjectId(path) {
90
129
  }
91
130
  export function setActiveProjectId(projectId, path) {
92
131
  const p = path ?? getKeystorePath();
93
- const store = loadKeyStore(p);
94
- store.active_project_id = projectId;
95
- saveKeyStore(store, p);
132
+ withFileLock(p, () => {
133
+ const store = loadKeyStore(p);
134
+ if (store.active_project_id && store.active_project_id !== projectId) {
135
+ store.previous_active_project_id = store.active_project_id;
136
+ }
137
+ store.active_project_id = projectId;
138
+ saveKeyStore(store, p);
139
+ });
96
140
  }
97
141
  //# sourceMappingURL=keystore.js.map
package/lib/apps.mjs CHANGED
@@ -72,7 +72,7 @@ Arguments:
72
72
  Options:
73
73
  --description <d> Human-readable description of the app
74
74
  --tags <t1,t2> Comma-separated list of tags
75
- --visibility <v> Visibility: 'public' or 'private'
75
+ --visibility <v> Visibility: 'public', 'unlisted', or 'private'
76
76
  --fork-allowed Allow other users to fork this app
77
77
 
78
78
  Examples:
package/lib/domains.mjs CHANGED
@@ -11,14 +11,14 @@ Subcommands:
11
11
  add <domain> <subdomain_name> [--project <id>] Register a custom domain
12
12
  list [<id>] List custom domains for a project
13
13
  status <domain> [--project <id>] Check domain DNS/SSL status
14
- delete <domain> [--project <id>] Release a custom domain
14
+ delete <domain> --confirm [--project <id>] Release a custom domain. Requires --confirm.
15
15
 
16
16
  Examples:
17
17
  run402 domains add example.com myapp
18
18
  run402 domains add example.com myapp --project prj_123
19
19
  run402 domains list
20
20
  run402 domains status example.com
21
- run402 domains delete example.com
21
+ run402 domains delete example.com --confirm
22
22
 
23
23
  Notes:
24
24
  - After adding a domain, configure DNS as shown in the response
@@ -75,8 +75,17 @@ async function status(args) {
75
75
 
76
76
  async function deleteDomain(args) {
77
77
  const { project, rest } = parseProjectFlag(args);
78
- const domain = rest[0];
79
- if (!domain) { console.error("Usage: run402 domains delete <domain> [--project <id>]"); process.exit(1); }
78
+ const domain = rest.find((a) => !a.startsWith("--"));
79
+ if (!domain) { console.error("Usage: run402 domains delete <domain> --confirm [--project <id>]"); process.exit(1); }
80
+ if (!Array.isArray(args) || !args.includes("--confirm")) {
81
+ console.error(JSON.stringify({
82
+ status: "error",
83
+ code: "CONFIRMATION_REQUIRED",
84
+ message: `Destructive: releasing custom domain '${domain}' detaches it from this project and clears its DNS/SSL configuration. This is irreversible. Re-run with --confirm to proceed.`,
85
+ details: { domain },
86
+ }));
87
+ process.exit(1);
88
+ }
80
89
  const projectId = resolveProjectId(project);
81
90
  try {
82
91
  await getSdk().domains.remove(domain, { projectId });
package/lib/email.mjs CHANGED
@@ -167,12 +167,7 @@ async function create(args) {
167
167
 
168
168
  try {
169
169
  const data = await getSdk().email.createMailbox(projectId, slug);
170
- // SDK may return CreateMailboxResult (with slug) on success, or MailboxInfo on 409 idempotency.
171
- if (data.slug) {
172
- console.log(JSON.stringify({ status: "ok", mailbox_id: data.mailbox_id, address: data.address, slug: data.slug }));
173
- } else {
174
- console.log(JSON.stringify({ status: "ok", mailbox_id: data.mailbox_id, address: data.address, already_existed: true }));
175
- }
170
+ console.log(JSON.stringify({ status: "ok", mailbox_id: data.mailbox_id, address: data.address, slug: data.slug }));
176
171
  } catch (err) {
177
172
  reportSdkError(err);
178
173
  }
@@ -203,7 +198,7 @@ async function send(args) {
203
198
  text: text ?? undefined,
204
199
  from_name: fromName ?? undefined,
205
200
  });
206
- console.log(JSON.stringify({ status: "ok", message_id: data.id, to: data.to, template: data.template || null, subject: data.subject || null }));
201
+ console.log(JSON.stringify({ status: "ok", message_id: data.message_id, to: data.to, template: data.template, subject: data.subject }));
207
202
  } catch (err) {
208
203
  reportSdkError(err);
209
204
  }
@@ -325,7 +320,7 @@ async function reply(args) {
325
320
  from_name: fromName ?? undefined,
326
321
  in_reply_to: messageId,
327
322
  });
328
- console.log(JSON.stringify({ status: "ok", message_id: data.id, to: data.to, subject: replySubject, in_reply_to: messageId }));
323
+ console.log(JSON.stringify({ status: "ok", message_id: data.message_id, to: data.to, subject: replySubject, in_reply_to: messageId }));
329
324
  } catch (err) {
330
325
  reportSdkError(err);
331
326
  }
@@ -352,8 +347,8 @@ async function deleteMailbox(args) {
352
347
  }
353
348
 
354
349
  try {
355
- await getSdk().email.deleteMailbox(projectId, positional ?? undefined);
356
- console.log(JSON.stringify({ status: "ok", mailbox_id: positional ?? "(resolved)", deleted: true }));
350
+ const data = await getSdk().email.deleteMailbox(projectId, positional ?? undefined);
351
+ console.log(JSON.stringify({ status: "ok", mailbox_id: data.mailbox_id, address: data.address, deleted: true }));
357
352
  } catch (err) {
358
353
  reportSdkError(err);
359
354
  }
package/lib/init.mjs CHANGED
@@ -10,12 +10,23 @@ const TEMPO_RPC = "https://rpc.moderato.tempo.xyz/";
10
10
  const HELP = `run402 init — Set up allowance, funding, and check tier status
11
11
 
12
12
  Usage:
13
- run402 init Set up with x402 (Base Sepolia) — default
14
- run402 init mpp Set up with MPP (Tempo Moderato)
15
- run402 init --json Same as init, but emit a JSON summary on stdout
16
- (human lines go to stderr for agent automation)
13
+ run402 init Set up with x402 (Base Sepolia) — default
14
+ run402 init mpp Set up with MPP (Tempo Moderato)
15
+ run402 init <rail> --switch-rail
16
+ Switch the persisted payment rail to <rail>.
17
+ Required when an allowance already exists on
18
+ the other rail; protects scripted re-runs from
19
+ silently flipping billing networks.
20
+ run402 init --json Same as init, but emit a JSON summary on stdout
21
+ (human lines go to stderr — for agent automation)
17
22
 
18
- Steps (idempotent — safe to re-run):
23
+ Options:
24
+ --switch-rail Confirm switching the persisted payment rail. Re-running
25
+ init with the SAME rail as the existing allowance is always
26
+ idempotent and does not need this flag.
27
+ --json Emit a structured JSON summary on stdout.
28
+
29
+ Steps (idempotent when re-run with the same rail; pass --switch-rail to change rails):
19
30
  1. Creates config directory (~/.config/run402)
20
31
  2. Creates agent allowance if none exists
21
32
  3. Checks on-chain balance; requests faucet if zero
@@ -32,6 +43,19 @@ export async function run(args = []) {
32
43
  if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
33
44
  const jsonMode = args.includes("--json");
34
45
  const isMpp = args[0] === "mpp";
46
+ const requestedRail = isMpp ? "mpp" : "x402";
47
+ const switchRailConfirmed = args.includes("--switch-rail");
48
+
49
+ const existingAllowance = readAllowance();
50
+ if (existingAllowance?.rail && existingAllowance.rail !== requestedRail && !switchRailConfirmed) {
51
+ console.error(JSON.stringify({
52
+ status: "error",
53
+ code: "RAIL_SWITCH_REQUIRES_CONFIRM",
54
+ message: `Already on rail '${existingAllowance.rail}'. Pass --switch-rail to switch to '${requestedRail}'.`,
55
+ details: { current_rail: existingAllowance.rail, requested_rail: requestedRail },
56
+ }));
57
+ process.exit(1);
58
+ }
35
59
 
36
60
  // In --json mode, human-readable lines go to stderr so stdout stays clean for
37
61
  // agents. We also collect structured data for the final JSON emit.
@@ -55,7 +79,7 @@ export async function run(args = []) {
55
79
  line("Config", CONFIG_DIR);
56
80
 
57
81
  // 2. Allowance
58
- let allowance = readAllowance();
82
+ let allowance = existingAllowance;
59
83
  const previousRail = allowance?.rail;
60
84
  if (!allowance) {
61
85
  const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
package/lib/projects.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from "fs";
2
- import { findProject, loadKeyStore, API, allowanceAuthHeaders, resolveProjectId } from "./config.mjs";
2
+ import { findProject, loadKeyStore, API, allowanceAuthHeaders, resolveProjectId, getActiveProjectId } from "./config.mjs";
3
3
  import { getSdk } from "./sdk.mjs";
4
4
  import { reportSdkError } from "./sdk-errors.mjs";
5
5
 
@@ -22,7 +22,7 @@ Subcommands:
22
22
  apply-expose [id] <manifest_json> Apply a declarative authorization manifest
23
23
  apply-expose [id] --file <path> Apply a manifest from a JSON file
24
24
  get-expose [id] Get the current authorization manifest
25
- delete [id] Immediately and irreversibly delete a project (cascade purge) and remove from local state
25
+ delete [id] --confirm Immediately and irreversibly delete a project (cascade purge) and remove from local state. Requires --confirm.
26
26
  pin [id] Pin a project (admin only; project owners get 403 admin_required)
27
27
  promote-user [id] <email> Promote a user to project_admin role
28
28
  demote-user [id] <email> Demote a user from project_admin role
@@ -43,7 +43,7 @@ Examples:
43
43
  run402 projects apply-expose abc123 --file manifest.json
44
44
  run402 projects get-expose abc123
45
45
  run402 projects keys abc123
46
- run402 projects delete abc123
46
+ run402 projects delete abc123 --confirm
47
47
 
48
48
  Notes:
49
49
  - <id> is the project_id shown in 'run402 projects list' (prefix: 'prj_')
@@ -128,9 +128,16 @@ async function provision(args) {
128
128
  // gives the user a more specific prompt than the SDK's 401/402 path.
129
129
  allowanceAuthHeaders("/projects/v1");
130
130
 
131
+ const activeBefore = getActiveProjectId();
131
132
  try {
132
133
  const data = await getSdk().projects.provision({ tier: opts.tier, name: opts.name });
133
- console.log(JSON.stringify(data, null, 2));
134
+ const activeAfter = getActiveProjectId();
135
+ const out = { ...data };
136
+ if (activeBefore && activeAfter && activeBefore !== activeAfter) {
137
+ out.note = `active project changed: ${activeBefore} -> ${activeAfter}`;
138
+ out.previous_active_project_id = activeBefore;
139
+ }
140
+ console.log(JSON.stringify(out, null, 2));
134
141
  } catch (err) {
135
142
  reportSdkError(err);
136
143
  }
@@ -302,7 +309,17 @@ async function demoteUser(projectId, email) {
302
309
  console.log(JSON.stringify(data, null, 2));
303
310
  }
304
311
 
305
- async function deleteProject(projectId) {
312
+ async function deleteProject(projectId, args = []) {
313
+ const confirmed = Array.isArray(args) && args.includes("--confirm");
314
+ if (!confirmed) {
315
+ console.error(JSON.stringify({
316
+ status: "error",
317
+ code: "CONFIRMATION_REQUIRED",
318
+ message: `Destructive: deleting project ${projectId} drops all DB schemas, functions, subdomains, mailbox, blobs, and secrets. This is irreversible. Re-run with --confirm to proceed.`,
319
+ details: { project_id: projectId, destroys: ["schemas", "functions", "subdomains", "mailbox", "blobs", "secrets"] },
320
+ }));
321
+ process.exit(1);
322
+ }
306
323
  try {
307
324
  await getSdk().projects.delete(projectId);
308
325
  console.log(JSON.stringify({ status: "ok", message: `Project ${projectId} deleted.` }));
@@ -346,7 +363,7 @@ export async function run(sub, args) {
346
363
  case "schema": { const { projectId } = resolvePositionalProject(args); await schema(projectId); break; }
347
364
  case "apply-expose": { const { projectId, rest } = resolvePositionalProject(args); await applyExpose(projectId, rest); break; }
348
365
  case "get-expose": { const { projectId } = resolvePositionalProject(args); await getExpose(projectId); break; }
349
- case "delete": { const { projectId } = resolvePositionalProject(args); await deleteProject(projectId); break; }
366
+ case "delete": { const { projectId, rest } = resolvePositionalProject(args); await deleteProject(projectId, rest); break; }
350
367
  case "pin": { const { projectId } = resolvePositionalProject(args); await pin(projectId); break; }
351
368
  case "promote-user": { const { projectId, rest } = resolvePositionalProject(args); await promoteUser(projectId, rest[0]); break; }
352
369
  case "demote-user": { const { projectId, rest } = resolvePositionalProject(args); await demoteUser(projectId, rest[0]); break; }
package/lib/sites.mjs CHANGED
@@ -1,11 +1,14 @@
1
1
  import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
2
2
  import { tmpdir } from "os";
3
3
  import { dirname, join, resolve } from "path";
4
+ import { fileSetFromDir } from "#sdk/node";
4
5
  import { allowanceAuthHeaders, resolveProjectId, updateProject } from "./config.mjs";
5
6
  import { resolveFilePathsInManifest } from "./manifest.mjs";
6
7
  import { getSdk } from "./sdk.mjs";
7
8
  import { reportSdkError } from "./sdk-errors.mjs";
8
9
 
10
+ const SMALL_DIR_THRESHOLD = 5;
11
+
9
12
  const HELP = `run402 sites - Deploy and manage static sites
10
13
 
11
14
  Usage:
@@ -28,6 +31,9 @@ Options (deploy-dir):
28
31
  --project <id> Project ID (defaults to active project)
29
32
  --target <target> Deployment target (e.g. 'production')
30
33
  --quiet Suppress progress events on stderr
34
+ --dry-run Plan-only: print the diff envelope and exit
35
+ --confirm-prune Required when <path> has fewer files than the
36
+ small-dir guardrail threshold
31
37
 
32
38
  Manifest format (JSON):
33
39
  {
@@ -97,6 +103,7 @@ Examples:
97
103
 
98
104
  Usage:
99
105
  run402 sites deploy-dir <path> [--project <id>] [--target <target>] [--quiet]
106
+ [--dry-run] [--confirm-prune]
100
107
 
101
108
  Arguments:
102
109
  <path> Local directory to deploy (positional, required)
@@ -106,11 +113,23 @@ Options:
106
113
  --target <target> Deployment target (e.g. 'production')
107
114
  --quiet Suppress progress events on stderr (events are on by
108
115
  default — see Progress events below)
116
+ --dry-run Plan the deploy and print a JSON envelope describing
117
+ what would happen, then exit. Does NOT upload bytes
118
+ or commit a release.
119
+ --confirm-prune Acknowledge that deploying <path> may remove files
120
+ that exist in the current site but are absent from
121
+ <path>. Required when <path> contains fewer than
122
+ ${SMALL_DIR_THRESHOLD} files (the small-dir guardrail).
109
123
 
110
124
  Behavior:
111
125
  - Walks <path> recursively, skips .git / node_modules / .DS_Store
112
126
  - Computes per-file SHA-256 and uploads only bytes the gateway doesn't
113
127
  already have (CAS-backed unified deploy primitive)
128
+ - A static-site deploy REPLACES the live release: any path in the current
129
+ site that is absent from <path> is removed from the new release. To
130
+ avoid accidentally wiping a multi-page site by deploying a single-file
131
+ directory, a small <path> (fewer than ${SMALL_DIR_THRESHOLD} files) requires
132
+ --confirm-prune.
114
133
  - Symlinks are rejected (no following)
115
134
  - Paths in the manifest are POSIX-style relative to <path>
116
135
 
@@ -131,6 +150,8 @@ Examples:
131
150
  run402 sites deploy-dir ./dist --project prj_abc
132
151
  run402 sites deploy-dir ./my-site --project prj_abc --target production
133
152
  run402 sites deploy-dir ./dist --project prj_abc --quiet
153
+ run402 sites deploy-dir ./tiny-site --project prj_abc --confirm-prune
154
+ run402 sites deploy-dir ./dist --project prj_abc --dry-run
134
155
  `,
135
156
  };
136
157
 
@@ -207,12 +228,21 @@ async function deploy(args) {
207
228
  }
208
229
 
209
230
  async function deployDir(args) {
210
- const opts = { dir: null, project: undefined, target: undefined, quiet: false };
231
+ const opts = {
232
+ dir: null,
233
+ project: undefined,
234
+ target: undefined,
235
+ quiet: false,
236
+ dryRun: false,
237
+ confirmPrune: false,
238
+ };
211
239
  for (let i = 0; i < args.length; i++) {
212
240
  if (args[i] === "--help" || args[i] === "-h") { console.log(SUB_HELP["deploy-dir"]); process.exit(0); }
213
241
  if (args[i] === "--project" && args[i + 1]) { opts.project = args[++i]; continue; }
214
242
  if (args[i] === "--target" && args[i + 1]) { opts.target = args[++i]; continue; }
215
243
  if (args[i] === "--quiet") { opts.quiet = true; continue; }
244
+ if (args[i] === "--dry-run") { opts.dryRun = true; continue; }
245
+ if (args[i] === "--confirm-prune") { opts.confirmPrune = true; continue; }
216
246
  if (args[i] === "--inherit") {
217
247
  console.error(JSON.stringify({
218
248
  status: "error",
@@ -231,6 +261,59 @@ async function deployDir(args) {
231
261
  // Preserve the aggressive early exit when no allowance is configured.
232
262
  allowanceAuthHeaders("/deploy/v2/plans");
233
263
 
264
+ let fileSet;
265
+ try {
266
+ fileSet = await fileSetFromDir(opts.dir);
267
+ } catch (err) {
268
+ reportSdkError(err);
269
+ return;
270
+ }
271
+ const fileCount = Object.keys(fileSet).length;
272
+
273
+ if (
274
+ fileCount < SMALL_DIR_THRESHOLD &&
275
+ !opts.confirmPrune &&
276
+ !opts.dryRun
277
+ ) {
278
+ console.error(JSON.stringify({
279
+ status: "error",
280
+ code: "PRUNE_CONFIRMATION_REQUIRED",
281
+ message:
282
+ `sites deploy-dir would replace the entire site with ${fileCount} ` +
283
+ `file(s) from ${opts.dir}. Any files in the current release that ` +
284
+ `are absent from this directory will be removed. Pass ` +
285
+ `--confirm-prune to proceed, or --dry-run to preview the diff.`,
286
+ details: {
287
+ local_file_count: fileCount,
288
+ threshold: SMALL_DIR_THRESHOLD,
289
+ dir: opts.dir,
290
+ },
291
+ }));
292
+ process.exit(1);
293
+ }
294
+
295
+ if (opts.dryRun) {
296
+ try {
297
+ const { plan } = await getSdk().deploy.plan({
298
+ project: projectId,
299
+ site: { replace: fileSet },
300
+ });
301
+ console.log(JSON.stringify({
302
+ status: "ok",
303
+ dry_run: true,
304
+ local_file_count: fileCount,
305
+ plan_id: plan.plan_id,
306
+ operation_id: plan.operation_id,
307
+ manifest_digest: plan.manifest_digest,
308
+ diff: plan.diff,
309
+ missing_content_count: plan.missing_content.filter((p) => !p.present).length,
310
+ }, null, 2));
311
+ } catch (err) {
312
+ reportSdkError(err);
313
+ }
314
+ return;
315
+ }
316
+
234
317
  try {
235
318
  const data = await getSdk().sites.deployDir({
236
319
  project: projectId,
@@ -9,7 +9,7 @@ Usage:
9
9
 
10
10
  Subcommands:
11
11
  claim <name> [--project <id>] [--deployment <id>] Claim a subdomain
12
- delete <name> [--project <id>] Release a subdomain
12
+ delete <name> --confirm [--project <id>] Release a subdomain. Requires --confirm.
13
13
  list [<id>] List subdomains for a project
14
14
 
15
15
  Options default to the active project and its last deployment when omitted.
@@ -18,7 +18,7 @@ Legacy syntax 'claim <deployment_id> <name>' is still supported.
18
18
  Examples:
19
19
  run402 subdomains claim myapp
20
20
  run402 subdomains claim myapp --deployment dpl_abc123 --project proj123
21
- run402 subdomains delete myapp
21
+ run402 subdomains delete myapp --confirm
22
22
  run402 subdomains list
23
23
 
24
24
  Notes:
@@ -77,10 +77,26 @@ async function claim(positionalArgs, flagArgs) {
77
77
  }
78
78
  }
79
79
 
80
- async function deleteSubdomain(name, args) {
81
- const opts = { project: null };
82
- for (let i = 0; i < args.length; i++) {
83
- if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
80
+ async function deleteSubdomain(allArgs) {
81
+ const argList = Array.isArray(allArgs) ? allArgs : [];
82
+ let opts = { project: null };
83
+ let name = null;
84
+ for (let i = 0; i < argList.length; i++) {
85
+ if (argList[i] === "--project" && argList[i + 1]) { opts.project = argList[++i]; }
86
+ else if (!argList[i].startsWith("--") && !name) { name = argList[i]; }
87
+ }
88
+ if (!name) {
89
+ console.error(JSON.stringify({ status: "error", message: "Usage: run402 subdomains delete <name> --confirm [--project <id>]" }));
90
+ process.exit(1);
91
+ }
92
+ if (!argList.includes("--confirm")) {
93
+ console.error(JSON.stringify({
94
+ status: "error",
95
+ code: "CONFIRMATION_REQUIRED",
96
+ message: `Destructive: releasing subdomain '${name}' makes it available for any other project to claim. This is irreversible. Re-run with --confirm to proceed.`,
97
+ details: { name },
98
+ }));
99
+ process.exit(1);
84
100
  }
85
101
  const projectId = resolveProjectId(opts.project);
86
102
  try {
@@ -116,7 +132,7 @@ export async function run(sub, args) {
116
132
  await claim(positional, flags);
117
133
  break;
118
134
  }
119
- case "delete": await deleteSubdomain(args[0], args.slice(1)); break;
135
+ case "delete": await deleteSubdomain(args); break;
120
136
  case "list": await list(args[0]); break;
121
137
  default:
122
138
  console.error(`Unknown subcommand: ${sub}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "1.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "CLI for Run402 — provision Postgres databases, deploy static sites, generate images, and manage wallets via x402 and MPP micropayments.",
5
5
  "type": "module",
6
6
  "bin": {