run402 4.18.2 → 4.20.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
@@ -61,6 +61,19 @@ run402 allowance balance # mainnet + testnet + billing balance
61
61
  run402 allowance export # print {"address":"0x..."} for funding
62
62
  ```
63
63
 
64
+ ### Promo codes
65
+
66
+ ```bash
67
+ run402 redeem R402-K8F3-Q2W9 # any time, before or after `run402 init`
68
+ run402 init --voucher R402-K8F3-Q2W9 # same redemption, folded into setup
69
+ ```
70
+
71
+ A promo code credits the organization with prepaid credit that settles tiers and
72
+ priced calls with no on-chain payment. Case-insensitive, hyphens optional.
73
+ Retrying is safe — a repeat returns the original result and never credits twice.
74
+ A bad code never blocks `init`; it warns and records `voucher_error` in the JSON
75
+ summary.
76
+
64
77
  ### Public Buzz/Nostr identity attribution
65
78
 
66
79
  Human accounts connect through <https://console.run402.com/identity-links/connect>: a normal browser, fresh passkey, and Buzz approval, with no terminal/event/passkey credential handling. The CLI commands below are the agent EOA ceremony. `identity link list` preserves every active/revoked record and its proof protocol; public identity links and organization memberships are independently revocable.
package/cli.mjs CHANGED
@@ -23,6 +23,7 @@ Commands:
23
23
  init Set up allowance, funding, and check tier status (x402 default)
24
24
  init mpp Set up with MPP payment rail (Tempo Moderato testnet)
25
25
  pay Call an arbitrary x402-priced URL with a bounded payment
26
+ redeem Redeem a promo code for run402 credit
26
27
  status Show full account state (allowance, balance, tier, projects)
27
28
  wallets Manage multiple named wallets (list, new, use, rename, bind, import)
28
29
  credentials Manage local credential material (project-keys)
@@ -165,6 +166,11 @@ switch (cmd) {
165
166
  await run([sub, ...rest].filter(Boolean));
166
167
  break;
167
168
  }
169
+ case "redeem": {
170
+ const { run } = await import("./lib/redeem.mjs");
171
+ await run([sub, ...rest].filter(Boolean));
172
+ break;
173
+ }
168
174
  case "status": {
169
175
  const { run } = await import("./lib/status.mjs");
170
176
  await run([sub, ...rest].filter(Boolean));
@@ -64,6 +64,7 @@ export const COMMAND_MANIFEST = [
64
64
  { path: ["init"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "flat", skipBehavioral: "creates a wallet and polls funding" },
65
65
  { path: ["pay"], positionals: [p("url")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["https://example.com/"], runStyle: "flat", skipBehavioral: "calls an external URL and may authorize an x402 payment" },
66
66
  { path: ["status"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [], runStyle: "flat" },
67
+ { path: ["redeem"], positionals: [p("code")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["R402-K8F3-Q2W9"], runStyle: "flat", skipBehavioral: "credits real money against the live organization" },
67
68
 
68
69
  // ── wallets ──────────────────────────────────────────────────────────────
69
70
  { path: ["wallets", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
@@ -76,6 +77,14 @@ export const COMMAND_MANIFEST = [
76
77
  { path: ["wallets", "import"], positionals: [p("name")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["gate-import-wallet", "--key", "__FIXTURE_FILE__"] },
77
78
  { path: ["wallets", "rm"], positionals: [p("name")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["no-such-wallet", "--yes"] },
78
79
 
80
+ // ── credentials (gateway project credentials) ────────────────────────────
81
+ { path: ["credentials", "issue"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["--kind", "service", "--name", "gate-cred"] },
82
+ { path: ["credentials", "list"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [] },
83
+ { path: ["credentials", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [] },
84
+ { path: ["credentials", "rotate"], positionals: [p("credential_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["pcr_gate1"] },
85
+ { path: ["credentials", "revoke"], positionals: [p("credential_id")], projectScoped: true, legacyPositionalProject: false, minimalArgs: ["pcr_gate1"] },
86
+ { path: ["credentials", "token"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [] },
87
+
79
88
  // ── credentials (project-keys group) ─────────────────────────────────────
80
89
  { path: ["credentials", "project-keys", "list"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
81
90
  { path: ["credentials", "project-keys", "status"], positionals: [], projectScoped: true, legacyPositionalProject: false, minimalArgs: [] },
@@ -8,22 +8,55 @@ import {
8
8
  removeProject,
9
9
  saveProject,
10
10
  } from "./config.mjs";
11
- import { assertKnownFlags, flagValue, normalizeArgv, positionalArgs, failUnknownSubcommand } from "./argparse.mjs";
12
- import { fail } from "./sdk-errors.mjs";
11
+ import {
12
+ assertKnownFlags,
13
+ flagValue,
14
+ normalizeArgv,
15
+ positionalArgs,
16
+ failUnknownSubcommand,
17
+ requirePositionalCount,
18
+ resolveProjectSelector,
19
+ } from "./argparse.mjs";
20
+ import { fail, reportSdkError } from "./sdk-errors.mjs";
21
+ import { getSdk } from "./sdk.mjs";
13
22
 
14
- const HELP = `run402 credentials — Manage local credential material
23
+ const HELP = `run402 credentials — Project credentials, and the local key cache
15
24
 
16
25
  Usage:
17
26
  run402 credentials <subcommand> [args...]
18
27
 
19
- Subcommands:
20
- project-keys Manage the local project-key cache
28
+ Project credentials (on the gateway — named, revocable, rotatable):
29
+ issue --kind <anon|service> --name <name> [--project <id>] [--expires <iso8601>]
30
+ Mint one. The secret is printed ONCE.
31
+ list [--project <id>] [--include-revoked]
32
+ List credentials (metadata only, never secrets)
33
+ status [--project <id>] Are you still on the retiring legacy key?
34
+ rotate <credential_id> [--project <id>]
35
+ Replace in one step; new secret printed ONCE
36
+ revoke <credential_id> [--project <id>] [--reason <text>]
37
+ Revoke immediately, freeing the name
38
+ token [--project <id>] [--kind <anon|service>]
39
+ Mint a SHORT-LIVED token. Works with only a
40
+ delegate — the unattended recovery path.
41
+
42
+ Local cache (on this machine):
43
+ project-keys Manage the local project-key cache
44
+
45
+ Notes:
46
+ - 'issue', 'rotate' and 'revoke' need owner membership on the project's org
47
+ plus a fresh step-up, so a scoped agent credential can never escalate
48
+ itself into a permanent root. Authenticate with a wallet (SIWX) or a
49
+ control-plane session ('run402 operator login --step-up').
50
+ - 'token' is the exception: a delegate can mint one with no human present.
51
+ - Secrets are returned EXACTLY ONCE and are never recoverable. Full JSON goes
52
+ to stdout so you can pipe it; the warnings go to stderr.
21
53
 
22
54
  Examples:
55
+ run402 credentials status --project prj_abc123
56
+ run402 credentials issue --kind service --name ci-deploy --project prj_abc123
57
+ run402 credentials issue --kind service --name ci-deploy | jq -r .secret
58
+ run402 credentials rotate pcr_123 --project prj_abc123
23
59
  run402 credentials project-keys list
24
- run402 credentials project-keys status --project prj_abc123
25
- run402 credentials project-keys import --project prj_abc123 --service-key-stdin
26
- run402 credentials project-keys export --project prj_abc123 --reveal
27
60
  `;
28
61
 
29
62
  const PROJECT_KEYS_HELP = `run402 credentials project-keys — Manage local project-key cache entries
@@ -50,6 +83,79 @@ Notes:
50
83
  service key, so an anon rotation never puts a service key through a shell.
51
84
  `;
52
85
 
86
+ const SUB_HELP = {
87
+ issue: `run402 credentials issue — mint a named project credential
88
+
89
+ Usage:
90
+ run402 credentials issue --kind <anon|service> --name <name> [--project <id>]
91
+ [--expires <iso8601>]
92
+
93
+ --kind "anon" is the tenant-facing key; "service" is the privileged one.
94
+ --name Unique among this project's LIVE credentials. Re-using a live name
95
+ returns 409 CREDENTIAL_NAME_TAKEN — that collision is the idempotency
96
+ story, so a retried create never mints a second credential by accident.
97
+ --expires Optional; must be in the future and within one year.
98
+
99
+ The secret is printed ONCE, on stdout, inside the JSON. Pipe it:
100
+ run402 credentials issue --kind service --name ci | jq -r .secret
101
+
102
+ Requires owner membership on the project's org plus a fresh step-up. A delegate
103
+ can NEVER do this; use 'run402 credentials token' instead.
104
+ `,
105
+ list: `run402 credentials list — list a project's credentials
106
+
107
+ Usage:
108
+ run402 credentials list [--project <id>] [--include-revoked]
109
+
110
+ Metadata only — never a secret or a secret hash. Only project.read is needed.
111
+ `,
112
+ status: `run402 credentials status — are you still on the retiring legacy key?
113
+
114
+ Usage:
115
+ run402 credentials status [--project <id>]
116
+
117
+ Returns state "legacy" while the project still depends on the derived
118
+ anon/service keys, or "rotatable" once it holds credentials it can revoke
119
+ individually.
120
+
121
+ There is deliberately NO deadline: retirement is gated on conditions (every
122
+ tenant migrated, 30 consecutive days of zero legacy-key use, explicit operator
123
+ approval), not a date. Read retirement.gated_on rather than planning against a
124
+ date the platform has not committed to.
125
+ `,
126
+ rotate: `run402 credentials rotate — replace a credential in one step
127
+
128
+ Usage:
129
+ run402 credentials rotate <credential_id> [--project <id>]
130
+
131
+ Mints a replacement and revokes the old one in a single transaction, keeping
132
+ the name. The NEW secret is printed once.
133
+
134
+ For a rotation with no downtime window, prefer issuing a SECOND credential,
135
+ deploying it, then revoking the first — several may be live per kind at once,
136
+ and that overlap is the point. Use 'rotate' when the old secret is already
137
+ compromised.
138
+ `,
139
+ revoke: `run402 credentials revoke — revoke a credential immediately
140
+
141
+ Usage:
142
+ run402 credentials revoke <credential_id> [--project <id>] [--reason <text>]
143
+
144
+ Takes effect for every subsequent request and frees the name for reuse.
145
+ Requires owner membership plus step-up.
146
+ `,
147
+ token: `run402 credentials token — mint a short-lived project token
148
+
149
+ Usage:
150
+ run402 credentials token [--project <id>] [--kind <anon|service>]
151
+
152
+ The cold-restart recovery path, and the ONE credential call an agent can make
153
+ with no human present: a delegate is accepted here. There is no step-up because
154
+ there is nobody to prompt, and what you get back expires, so it cannot become a
155
+ durable root. Defaults to --kind service.
156
+ `,
157
+ };
158
+
53
159
  function parseProjectKeyFlags(args, extraKnown = [], valueFlagsExtra = []) {
54
160
  const parsed = normalizeArgv(args);
55
161
  const valueFlags = ["--project", ...valueFlagsExtra];
@@ -273,6 +379,166 @@ async function runProjectKeys(sub, args) {
273
379
  }
274
380
  }
275
381
 
382
+ // ---------------------------------------------------------------------------
383
+ // Project credentials — the gateway rows, not the local cache.
384
+ //
385
+ // These five routes shipped with no client at all, so the documented way off
386
+ // the legacy derived anon/service keys was to hand-roll a SIWX-signed request.
387
+ // `run402 pay` is NOT that escape hatch (it is the x402 buyer path and 401s
388
+ // here), which is how an agent could read the docs, find the route, and still
389
+ // have no way to call it.
390
+ // ---------------------------------------------------------------------------
391
+
392
+ /** Full JSON on stdout (pipeable); the one-shot warning on stderr. */
393
+ function emitIssued(res) {
394
+ console.log(JSON.stringify(res, null, 2));
395
+ if (res?.secret) {
396
+ console.error("");
397
+ console.error("The secret above is shown ONCE and cannot be read back.");
398
+ console.error("Store it now — 'rotate' is the only way to get a new one.");
399
+ }
400
+ }
401
+
402
+ const ISSUE_VALUE_FLAGS = ["--project", "--kind", "--name", "--expires"];
403
+
404
+ async function issue(args) {
405
+ const a = normalizeArgv(args);
406
+ assertKnownFlags(a, [...ISSUE_VALUE_FLAGS, "--help", "-h"], ISSUE_VALUE_FLAGS);
407
+ const kind = flagValue(a, "--kind");
408
+ const name = flagValue(a, "--name");
409
+ const expiresAt = flagValue(a, "--expires");
410
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ISSUE_VALUE_FLAGS });
411
+ requirePositionalCount(rest, ISSUE_VALUE_FLAGS, {
412
+ min: 0,
413
+ max: 0,
414
+ command: "run402 credentials issue --kind <anon|service> --name <name> [--project <id>]",
415
+ missing: "",
416
+ });
417
+ if (kind !== "anon" && kind !== "service") {
418
+ fail({
419
+ message: 'Missing or invalid --kind. Use "anon" (tenant-facing) or "service" (privileged).',
420
+ code: "BAD_USAGE",
421
+ hint: "run402 credentials issue --kind service --name ci-deploy",
422
+ });
423
+ }
424
+ if (!name) {
425
+ fail({
426
+ message: "Missing --name.",
427
+ code: "BAD_USAGE",
428
+ hint: "The name identifies this credential in 'list' and is how you rotate it later, e.g. --name ci-deploy",
429
+ });
430
+ }
431
+ try {
432
+ emitIssued(await getSdk().credentials.issue(projectId, { kind, name, expiresAt: expiresAt || undefined }));
433
+ } catch (err) {
434
+ reportSdkError(err);
435
+ }
436
+ }
437
+
438
+ async function listCredentials(args) {
439
+ const a = normalizeArgv(args);
440
+ assertKnownFlags(a, ["--project", "--include-revoked", "--help", "-h"], ["--project"]);
441
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ["--project"] });
442
+ requirePositionalCount(rest, ["--project"], {
443
+ min: 0,
444
+ max: 0,
445
+ command: "run402 credentials list [--project <id>]",
446
+ missing: "",
447
+ });
448
+ try {
449
+ const includeRevoked = a.includes("--include-revoked");
450
+ console.log(JSON.stringify(await getSdk().credentials.list(projectId, { includeRevoked }), null, 2));
451
+ } catch (err) {
452
+ reportSdkError(err);
453
+ }
454
+ }
455
+
456
+ async function credentialStatus(args) {
457
+ const a = normalizeArgv(args);
458
+ assertKnownFlags(a, ["--project", "--help", "-h"], ["--project"]);
459
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ["--project"] });
460
+ requirePositionalCount(rest, ["--project"], {
461
+ min: 0,
462
+ max: 0,
463
+ command: "run402 credentials status [--project <id>]",
464
+ missing: "",
465
+ });
466
+ try {
467
+ const res = await getSdk().credentials.status(projectId);
468
+ console.log(JSON.stringify(res, null, 2));
469
+ if (res?.state === "legacy") {
470
+ console.error("");
471
+ console.error("This project still depends on the derived anon/service keys, whose signing");
472
+ console.error("key is being retired. Issue a credential to stop depending on them:");
473
+ console.error(` run402 credentials issue --kind service --name primary --project ${projectId}`);
474
+ }
475
+ } catch (err) {
476
+ reportSdkError(err);
477
+ }
478
+ }
479
+
480
+ async function rotate(args) {
481
+ const a = normalizeArgv(args);
482
+ assertKnownFlags(a, ["--project", "--help", "-h"], ["--project"]);
483
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ["--project"] });
484
+ const [credentialId] = requirePositionalCount(rest, ["--project"], {
485
+ min: 1,
486
+ max: 1,
487
+ command: "run402 credentials rotate <credential_id> [--project <id>]",
488
+ missing: "Missing <credential_id>. Find it with: run402 credentials list",
489
+ });
490
+ try {
491
+ emitIssued(await getSdk().credentials.rotate(projectId, credentialId));
492
+ } catch (err) {
493
+ reportSdkError(err);
494
+ }
495
+ }
496
+
497
+ async function revoke(args) {
498
+ const a = normalizeArgv(args);
499
+ assertKnownFlags(a, ["--project", "--reason", "--help", "-h"], ["--project", "--reason"]);
500
+ const reason = flagValue(a, "--reason");
501
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ["--project", "--reason"] });
502
+ const [credentialId] = requirePositionalCount(rest, ["--project", "--reason"], {
503
+ min: 1,
504
+ max: 1,
505
+ command: "run402 credentials revoke <credential_id> [--project <id>]",
506
+ missing: "Missing <credential_id>. Find it with: run402 credentials list",
507
+ });
508
+ try {
509
+ const res = await getSdk().credentials.revoke(projectId, credentialId, reason ? { reason } : {});
510
+ console.log(JSON.stringify(res, null, 2));
511
+ } catch (err) {
512
+ reportSdkError(err);
513
+ }
514
+ }
515
+
516
+ async function token(args) {
517
+ const a = normalizeArgv(args);
518
+ assertKnownFlags(a, ["--project", "--kind", "--help", "-h"], ["--project", "--kind"]);
519
+ const kind = flagValue(a, "--kind");
520
+ const { projectId, rest } = resolveProjectSelector(a, { valueFlags: ["--project", "--kind"] });
521
+ requirePositionalCount(rest, ["--project", "--kind"], {
522
+ min: 0,
523
+ max: 0,
524
+ command: "run402 credentials token [--project <id>]",
525
+ missing: "",
526
+ });
527
+ if (kind && kind !== "anon" && kind !== "service") {
528
+ fail({ message: '--kind must be "anon" or "service".', code: "BAD_USAGE" });
529
+ }
530
+ try {
531
+ const res = await getSdk().credentials.mintToken(projectId, kind ? { kind } : {});
532
+ console.log(JSON.stringify(res, null, 2));
533
+ if (res?.secret) {
534
+ console.error("");
535
+ console.error(`The token above is shown ONCE and expires in ${res.expires_in ?? "?"}s.`);
536
+ }
537
+ } catch (err) {
538
+ reportSdkError(err);
539
+ }
540
+ }
541
+
276
542
  export async function run(sub, args = []) {
277
543
  if (!sub || sub === "--help" || sub === "-h") {
278
544
  console.log(HELP);
@@ -283,5 +549,18 @@ export async function run(sub, args = []) {
283
549
  await runProjectKeys(projectKeySub, rest);
284
550
  return;
285
551
  }
286
- failUnknownSubcommand("credentials", sub);
552
+ if (Array.isArray(args) && (args.includes("--help") || args.includes("-h"))) {
553
+ console.log(SUB_HELP[sub] || HELP);
554
+ process.exit(0);
555
+ }
556
+ switch (sub) {
557
+ case "issue": await issue(args); break;
558
+ case "list": await listCredentials(args); break;
559
+ case "status": await credentialStatus(args); break;
560
+ case "rotate": await rotate(args); break;
561
+ case "revoke": await revoke(args); break;
562
+ case "token": await token(args); break;
563
+ default:
564
+ failUnknownSubcommand("credentials", sub);
565
+ }
287
566
  }
package/lib/init.mjs CHANGED
@@ -26,6 +26,11 @@ Usage:
26
26
  silently flipping billing networks.
27
27
 
28
28
  Options:
29
+ --voucher <code> Redeem a promo code after setup and credit this
30
+ organization. Never blocks setup: if the code is invalid,
31
+ expired, already used, or the call fails, init warns and
32
+ finishes normally with 'voucher_error' in the summary.
33
+ Equivalent to running 'run402 redeem <code>' afterwards.
29
34
  --api-base <url> Configure the active profile to use this API base. Use this
30
35
  for a self-hosted Run402 Core Gateway, e.g.
31
36
  http://my-core:4020.
@@ -81,6 +86,43 @@ function parseApiBaseFlag(args) {
81
86
  return { value: null, args };
82
87
  }
83
88
 
89
+ /**
90
+ * Pull `--voucher <code>` / `--voucher=<code>` out of argv, mirroring
91
+ * `parseApiBaseFlag`. A MISSING VALUE still fails fast — that is a usage error
92
+ * the caller can fix, unlike a redemption failure, which must never take init
93
+ * down with it (see the redemption step at the end of `run`).
94
+ */
95
+ function parseVoucherFlag(args) {
96
+ for (let i = 0; i < args.length; i++) {
97
+ const arg = args[i];
98
+ if (arg === "--voucher") {
99
+ const value = args[i + 1];
100
+ if (value === undefined || String(value).startsWith("--")) {
101
+ fail({
102
+ code: "BAD_USAGE",
103
+ message: "--voucher requires a value.",
104
+ hint: "run402 init --voucher R402-K8F3-Q2W9",
105
+ details: { flag: "--voucher" },
106
+ });
107
+ }
108
+ return { value, args: [...args.slice(0, i), ...args.slice(i + 2)] };
109
+ }
110
+ if (typeof arg === "string" && arg.startsWith("--voucher=")) {
111
+ const value = arg.slice("--voucher=".length);
112
+ if (!value) {
113
+ fail({
114
+ code: "BAD_USAGE",
115
+ message: "--voucher requires a non-empty value.",
116
+ hint: "run402 init --voucher R402-K8F3-Q2W9",
117
+ details: { flag: "--voucher" },
118
+ });
119
+ }
120
+ return { value, args: [...args.slice(0, i), ...args.slice(i + 1)] };
121
+ }
122
+ }
123
+ return { value: null, args };
124
+ }
125
+
84
126
  function sameOrigin(a, b) {
85
127
  try {
86
128
  return new URL(a).origin === new URL(b).origin;
@@ -128,6 +170,13 @@ export async function run(args = []) {
128
170
 
129
171
  if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
130
172
 
173
+ // Strip --voucher before anything else parses argv, so the rail/positional
174
+ // logic below never sees it (an unrecognized token there would be read as a
175
+ // rail name).
176
+ const parsedVoucher = parseVoucherFlag(args);
177
+ args = parsedVoucher.args;
178
+ const voucherCode = parsedVoucher.value;
179
+
131
180
  const parsedApiBase = parseApiBaseFlag(args);
132
181
  if (parsedApiBase.value) {
133
182
  if (parsedApiBase.args.some((arg) => typeof arg === "string" && !arg.startsWith("--"))) {
@@ -137,6 +186,17 @@ export async function run(args = []) {
137
186
  hint: "Run `run402 init --api-base=http://my-core:4020` for Core, or `run402 init` for Run402 Cloud.",
138
187
  });
139
188
  }
189
+ // This branch configures a Core/API target and returns without setting up
190
+ // an allowance — there is no organization here to credit, and promo
191
+ // vouchers are a Run402 Cloud concept. Say so instead of accepting the
192
+ // flag and silently dropping it.
193
+ if (voucherCode) {
194
+ fail({
195
+ code: "BAD_USAGE",
196
+ message: "run402 init --api-base cannot be combined with --voucher.",
197
+ hint: "Configure the target first (`run402 init --api-base=…`), then redeem against Run402 Cloud with `run402 redeem <code>`.",
198
+ });
199
+ }
140
200
  const CONFIG_DIR = configDir();
141
201
  const detected = await detectTarget(parsedApiBase.value);
142
202
  const config = configureApiBase(parsedApiBase.value, {
@@ -199,6 +259,10 @@ export async function run(args = []) {
199
259
  rail: null,
200
260
  network: null,
201
261
  balances: null,
262
+ // Present (null or an object) only when --voucher was passed, so its
263
+ // absence means "no code was offered" rather than "a code silently
264
+ // vanished". `voucher_error` appears alongside a null `voucher`.
265
+ ...(voucherCode ? { voucher: null } : {}),
202
266
  tier: null,
203
267
  projects_saved: 0,
204
268
  next_actions: [],
@@ -335,6 +399,46 @@ export async function run(args = []) {
335
399
  }
336
400
  }
337
401
 
402
+ // 3b. Promo code, when one was handed to us.
403
+ //
404
+ // This runs BEFORE the balance read below so the credited amount shows up in
405
+ // `prepaid_credit_usd_micros` without a second round-trip, and AFTER the
406
+ // wallet exists so the redemption authenticates as this agent.
407
+ //
408
+ // NOTHING here may fail init. An advertised gift that dead-ends a build is
409
+ // worse than no gift at all: the agent was told to run one command, and that
410
+ // command must still leave it set up and able to work. Every failure — bad
411
+ // code, expired, already used by someone else, org at its ceiling, network
412
+ // down, gateway too old to know the route — warns on stderr, records
413
+ // `voucher_error` in the JSON summary, and lets setup finish.
414
+ if (voucherCode) {
415
+ try {
416
+ const redemption = await getSdk().vouchers.redeem(voucherCode);
417
+ const credited = (redemption.amount_usd_micros / 1_000_000).toFixed(2);
418
+ line(
419
+ "Voucher",
420
+ redemption.already_redeemed
421
+ ? `$${credited} already credited (no second credit)`
422
+ : `$${credited} credited`,
423
+ );
424
+ summary.voucher = {
425
+ voucher_id: redemption.voucher_id,
426
+ amount_usd_micros: redemption.amount_usd_micros,
427
+ already_redeemed: redemption.already_redeemed,
428
+ };
429
+ } catch (err) {
430
+ // Faithful: name what failed and keep going. `voucher_error` is a
431
+ // first-class summary field, not an omission the caller has to infer.
432
+ const reason = errorMessage(err);
433
+ line("Voucher", `not applied: ${reason}`);
434
+ summary.voucher = null;
435
+ summary.voucher_error = {
436
+ code: err?.body?.code ?? err?.code ?? "VOUCHER_REDEEM_FAILED",
437
+ message: reason,
438
+ };
439
+ }
440
+ }
441
+
338
442
  // Balances mirror `run402 status`: the on-chain figure above plus the
339
443
  // Run402-held prepaid credit (rail-independent). Prepaid credit is fetched
340
444
  // best-effort so a billing read failure never blocks setup.
package/lib/redeem.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { getSdk } from "./sdk.mjs";
2
+ import { reportSdkError, fail } from "./sdk-errors.mjs";
3
+ import { assertKnownFlags, normalizeArgv, positionalArgs } from "./argparse.mjs";
4
+
5
+ const HELP = `run402 redeem — Redeem a promo code for run402 credit
6
+
7
+ Usage:
8
+ run402 redeem <code> [--json]
9
+
10
+ A promo code credits your organization with run402 prepaid credit. That credit
11
+ spends like any other prepaid balance — a tier purchase settles from it with no
12
+ on-chain payment.
13
+
14
+ Notes:
15
+ - Order does not matter. This works as your very first authenticated call
16
+ (the organization is created on demand) or long after 'run402 init'.
17
+ - Codes are forgiving: case-insensitive and hyphens are optional, so
18
+ 'R402-K8F3-Q2W9' and 'r402k8f3q2w9' are the same code.
19
+ - Retrying is safe. A repeat by the same organization returns the original
20
+ result with "already_redeemed": true and never credits twice.
21
+ - Being in a grace state does not block you. Redeeming is how an owner funds
22
+ a renewal, so it is never gated.
23
+ - One gift per organization: past the lifetime ceiling you get 403
24
+ PROMO_LIMIT_REACHED with the exact numbers.
25
+
26
+ To fold this into first-time setup instead, use:
27
+ run402 init --voucher <code>
28
+
29
+ Examples:
30
+ run402 redeem R402-K8F3-Q2W9
31
+ run402 redeem r402k8f3q2w9
32
+ `;
33
+
34
+ export async function run(args = []) {
35
+ const parsedArgs = normalizeArgv(args);
36
+ if (parsedArgs.includes("--help") || parsedArgs.includes("-h")) {
37
+ console.log(HELP);
38
+ process.exit(0);
39
+ }
40
+ assertKnownFlags(parsedArgs, ["--help", "-h"], []);
41
+ const positionals = positionalArgs(parsedArgs, []);
42
+ if (positionals.length > 1) {
43
+ fail({
44
+ code: "BAD_USAGE",
45
+ message: `Unexpected argument for redeem: ${positionals[1]}`,
46
+ hint: "Use `run402 redeem <code>`.",
47
+ });
48
+ }
49
+ const code = positionals[0];
50
+ if (!code) {
51
+ fail({
52
+ code: "BAD_USAGE",
53
+ message: "Missing <code>.",
54
+ hint: "run402 redeem <code> (e.g. run402 redeem R402-K8F3-Q2W9)",
55
+ });
56
+ }
57
+
58
+ try {
59
+ const data = await getSdk().vouchers.redeem(code);
60
+ // Progress to stderr, JSON to stdout — the pipe contract. The one-liner
61
+ // exists because the interesting fact (money arrived) is otherwise buried
62
+ // in a field a human skims past.
63
+ console.error("");
64
+ console.error(
65
+ ` Voucher ${usd(data.amount_usd_micros)} credited` +
66
+ `${data.already_redeemed ? " (already redeemed " + data.redeemed_at + " — no second credit)" : ""}`,
67
+ );
68
+ console.error(` Balance ${usd(data.balance_usd_micros)} available`);
69
+ const next = Array.isArray(data.next_actions) ? data.next_actions.find((a) => a?.cli) : null;
70
+ if (next?.cli) console.error(`\n Next: ${next.cli}`);
71
+ console.error("");
72
+ console.log(JSON.stringify(data, null, 2));
73
+ } catch (err) {
74
+ reportSdkError(err);
75
+ }
76
+ }
77
+
78
+ function usd(micros) {
79
+ return `$${(Number(micros ?? 0) / 1_000_000).toFixed(2)}`;
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.18.2",
3
+ "version": "4.20.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": {
@@ -22,6 +22,7 @@ import { Ai } from "./namespaces/ai.js";
22
22
  import { Auth } from "./namespaces/auth.js";
23
23
  import { SenderDomain } from "./namespaces/sender-domain.js";
24
24
  import { Billing } from "./namespaces/billing.js";
25
+ import { Vouchers } from "./namespaces/vouchers.js";
25
26
  import { Wallets, ScopedWallet } from "./namespaces/wallets.js";
26
27
  import { Apps } from "./namespaces/apps.js";
27
28
  import { Email } from "./namespaces/email.js";
@@ -86,6 +87,7 @@ export declare class Run402 {
86
87
  readonly auth: Auth;
87
88
  readonly senderDomain: SenderDomain;
88
89
  readonly billing: Billing;
90
+ readonly vouchers: Vouchers;
89
91
  readonly wallets: Wallets;
90
92
  readonly apps: Apps;
91
93
  readonly email: Email;
@@ -286,6 +288,7 @@ export type * from "./namespaces/assets.types.js";
286
288
  export type * from "./namespaces/ci.types.js";
287
289
  export type * from "./namespaces/contracts.js";
288
290
  export type * from "./namespaces/credentials.js";
291
+ export type * from "./namespaces/credentials.types.js";
289
292
  export type * from "./namespaces/deploy.types.js";
290
293
  export { Deploy } from "./namespaces/deploy.js";
291
294
  export type { ByteReader } from "./namespaces/deploy.js";
@@ -309,6 +312,8 @@ export { Rooms, ScopedRoom } from "./namespaces/rooms.js";
309
312
  export type * from "./namespaces/rooms.types.js";
310
313
  export { Errors } from "./namespaces/errors.js";
311
314
  export type * from "./namespaces/errors.types.js";
315
+ export { Vouchers } from "./namespaces/vouchers.js";
316
+ export type * from "./namespaces/vouchers.js";
312
317
  export { Pay, PaymentBuyerError, PaymentPolicyError, DEFAULT_PAYMENT_MAX_USD_MICROS, X402_COMMERCE_RESULT_SCHEMA_VERSION, X402_EVIDENCE_STATUSES, X402_GATEWAY_AVAILABILITY_ERROR_CODE, X402_MUTATION_STATES, X402_PAYMENT_POLICY_ERROR_CODES, X402_RECOVERY_ACTIONS, RUN402_PENDING_CLASSIFIER_VERSION, isPaymentBuyerError, isPaymentPolicyError, isTrustedRun402PaymentUrl, isTrustedRun402PendingResponse, payFetchResultToJson, } from "./namespaces/pay.js";
313
318
  export type * from "./namespaces/pay.js";
314
319
  export { Archives } from "./namespaces/archives.js";