run402 2.27.0 → 2.29.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 (45) hide show
  1. package/README.md +15 -1
  2. package/cli.mjs +33 -6
  3. package/core-dist/allowance.js +24 -1
  4. package/core-dist/config.js +75 -3
  5. package/core-dist/profiles.js +196 -0
  6. package/lib/allowance.mjs +3 -3
  7. package/lib/config.mjs +16 -1
  8. package/lib/doctor.mjs +47 -2
  9. package/lib/functions.mjs +128 -0
  10. package/lib/init.mjs +5 -1
  11. package/lib/status.mjs +13 -0
  12. package/lib/wallet-context.mjs +223 -0
  13. package/lib/wallet-context.test.mjs +228 -0
  14. package/lib/wallets.mjs +344 -0
  15. package/package.json +1 -1
  16. package/sdk/core-dist/allowance.js +24 -1
  17. package/sdk/core-dist/config.js +75 -3
  18. package/sdk/core-dist/profiles.js +196 -0
  19. package/sdk/dist/credentials.d.ts +16 -0
  20. package/sdk/dist/credentials.d.ts.map +1 -1
  21. package/sdk/dist/index.d.ts +24 -0
  22. package/sdk/dist/index.d.ts.map +1 -1
  23. package/sdk/dist/index.js +30 -0
  24. package/sdk/dist/index.js.map +1 -1
  25. package/sdk/dist/namespaces/admin.d.ts +15 -0
  26. package/sdk/dist/namespaces/admin.d.ts.map +1 -1
  27. package/sdk/dist/namespaces/admin.js.map +1 -1
  28. package/sdk/dist/namespaces/functions.d.ts +42 -2
  29. package/sdk/dist/namespaces/functions.d.ts.map +1 -1
  30. package/sdk/dist/namespaces/functions.js +52 -1
  31. package/sdk/dist/namespaces/functions.js.map +1 -1
  32. package/sdk/dist/namespaces/functions.types.d.ts +54 -0
  33. package/sdk/dist/namespaces/functions.types.d.ts.map +1 -1
  34. package/sdk/dist/namespaces/wallets.d.ts +35 -0
  35. package/sdk/dist/namespaces/wallets.d.ts.map +1 -0
  36. package/sdk/dist/namespaces/wallets.js +50 -0
  37. package/sdk/dist/namespaces/wallets.js.map +1 -0
  38. package/sdk/dist/node/credentials.d.ts +2 -1
  39. package/sdk/dist/node/credentials.d.ts.map +1 -1
  40. package/sdk/dist/node/credentials.js +11 -1
  41. package/sdk/dist/node/credentials.js.map +1 -1
  42. package/sdk/dist/scoped.d.ts +3 -1
  43. package/sdk/dist/scoped.d.ts.map +1 -1
  44. package/sdk/dist/scoped.js +6 -0
  45. package/sdk/dist/scoped.js.map +1 -1
package/lib/functions.mjs CHANGED
@@ -25,6 +25,10 @@ Subcommands:
25
25
  Get function logs
26
26
  update <id> <name> [--schedule <cron>] [--schedule-remove] [--timeout <s>] [--memory <mb>]
27
27
  Update function schedule or config without re-deploying
28
+ rebuild <id> [<name>] [--all] Refresh function(s) onto the current platform
29
+ runtime (re-bundles from stored source; no
30
+ source change). Pass <name> for one function
31
+ or --all for every function in the project.
28
32
  list <id> List all functions for a project
29
33
  delete <id> <name> Delete a function
30
34
 
@@ -40,12 +44,17 @@ Examples:
40
44
  run402 functions update prj_abc123 send-reminders --schedule '0 */4 * * *'
41
45
  run402 functions update prj_abc123 send-reminders --schedule-remove
42
46
  run402 functions update prj_abc123 my-func --timeout 15 --memory 256
47
+ run402 functions rebuild prj_abc123 stripe-webhook
48
+ run402 functions rebuild prj_abc123 --all
43
49
  run402 functions list prj_abc123
44
50
  run402 functions delete prj_abc123 stripe-webhook
45
51
 
46
52
  Notes:
47
53
  - Code must export a default async function: export default async (req: Request) => Response
48
54
  - Deploy may require payment if the project lease has expired
55
+ - 'rebuild' is opt-in and never changes your source: it re-bundles the stored
56
+ source against the current platform runtime so a gateway-side wrapper fix
57
+ reaches an already-deployed function. 'run402 doctor' flags stale functions.
49
58
  `;
50
59
 
51
60
  const SUB_HELP = {
@@ -166,6 +175,45 @@ Examples:
166
175
  run402 functions update prj_abc123 send-reminders --schedule '0 */4 * * *'
167
176
  run402 functions update prj_abc123 send-reminders --schedule-remove
168
177
  run402 functions update prj_abc123 my-func --timeout 15 --memory 256
178
+ `,
179
+ rebuild: `run402 functions rebuild — Refresh function(s) onto the current platform runtime
180
+
181
+ Usage:
182
+ run402 functions rebuild <project_id> <name>
183
+ run402 functions rebuild <project_id> --all
184
+
185
+ Arguments:
186
+ <project_id> Target project ID
187
+ <name> Function name to rebuild (omit when using --all)
188
+
189
+ Options:
190
+ --all Rebuild every function in the project
191
+
192
+ What it does:
193
+ Re-bundles a function from its STORED source against the platform's current
194
+ entry wrapper + bundled runtime, with dependencies pinned to the exact
195
+ versions recorded at deploy time. The only thing that changes is the platform
196
+ runtime/wrapper — your source 'code_hash' is unchanged and no new release is
197
+ created. This is how a gateway-side wrapper fix (e.g. an SSR auth.* fix)
198
+ reaches a function that was already deployed: a plain redeploy with unchanged
199
+ source does NOT pick it up. Strictly opt-in — the platform never auto-rebuilds.
200
+
201
+ Allowed during billing grace (past_due / frozen / dormant).
202
+
203
+ Output:
204
+ Single: a JSON object { name, rebuilt, old_fingerprint, new_fingerprint,
205
+ runtime_version_before, runtime_version_after, code_hash }.
206
+ --all: a JSON object { rebuilt_count, total, results: [...] }; each result is
207
+ either a rebuild record or { name, rebuilt: false, code?, error }. Functions
208
+ deployed before dependency locking report code CANNOT_REBUILD_UNLOCKED_DEPS
209
+ — redeploy them from source ('run402 functions deploy') to refresh instead.
210
+
211
+ Notes:
212
+ - Run 'run402 doctor' to see which functions are on a stale runtime.
213
+
214
+ Examples:
215
+ run402 functions rebuild prj_abc123 stripe-webhook
216
+ run402 functions rebuild prj_abc123 --all
169
217
  `,
170
218
  list: `run402 functions list — List all functions for a project
171
219
 
@@ -418,6 +466,85 @@ async function update(projectId, name, args) {
418
466
  }
419
467
  }
420
468
 
469
+ const UNLOCKED_DEPS_HINT =
470
+ "Function was deployed before dependency locking — redeploy from source " +
471
+ "(run402 functions deploy <id> <name> --file <file>) to refresh its runtime instead.";
472
+
473
+ // Annotate a `rebuild --all` batch with an actionable hint on per-function
474
+ // CANNOT_REBUILD_UNLOCKED_DEPS refusals. The batch endpoint returns 200 with
475
+ // per-function outcomes (failures never abort the batch), so these arrive as
476
+ // data, not a thrown error.
477
+ function annotateRebuildBatch(data) {
478
+ if (!data || !Array.isArray(data.results)) return data;
479
+ return {
480
+ ...data,
481
+ results: data.results.map((entry) =>
482
+ entry && entry.rebuilt === false && entry.code === "CANNOT_REBUILD_UNLOCKED_DEPS"
483
+ ? { ...entry, hint: UNLOCKED_DEPS_HINT }
484
+ : entry,
485
+ ),
486
+ };
487
+ }
488
+
489
+ async function rebuild(projectId, args = []) {
490
+ assertRequiredProject(projectId, "run402 functions rebuild <project_id> [<name>] [--all]");
491
+ assertKnownFlags(args, ["--all", "--help", "-h"]);
492
+ let all = false;
493
+ const positionals = [];
494
+ for (const arg of args) {
495
+ if (arg === "--all") { all = true; continue; }
496
+ positionals.push(arg);
497
+ }
498
+ if (positionals.length > 1) {
499
+ fail({
500
+ code: "BAD_USAGE",
501
+ message: `Unexpected argument: ${positionals[1]}`,
502
+ hint: "run402 functions rebuild <project_id> [<name>] [--all]",
503
+ details: { argument: positionals[1] },
504
+ });
505
+ }
506
+ const name = positionals[0];
507
+ if (all && name) {
508
+ fail({
509
+ code: "BAD_USAGE",
510
+ message: "Pass either <name> or --all, not both.",
511
+ hint: "run402 functions rebuild <project_id> [<name>] [--all]",
512
+ });
513
+ }
514
+ if (!all && !name) {
515
+ fail({
516
+ code: "BAD_USAGE",
517
+ message: "Provide a function <name>, or pass --all to rebuild every function in the project.",
518
+ hint: "run402 functions rebuild <project_id> [<name>] [--all]",
519
+ });
520
+ }
521
+
522
+ try {
523
+ if (all) {
524
+ const data = await getSdk().functions.rebuildAll(projectId);
525
+ console.log(JSON.stringify(annotateRebuildBatch(data), null, 2));
526
+ } else {
527
+ const data = await getSdk().functions.rebuild(projectId, name);
528
+ console.log(JSON.stringify(data, null, 2));
529
+ }
530
+ } catch (err) {
531
+ // Map the single-function unlocked-deps refusal to a clear, actionable
532
+ // envelope. The gateway returns 409 with code CANNOT_REBUILD_UNLOCKED_DEPS
533
+ // for functions deployed before dependency locking.
534
+ if (err?.status === 409 && err?.body && typeof err.body === "object" && err.body.code === "CANNOT_REBUILD_UNLOCKED_DEPS") {
535
+ fail({
536
+ code: "CANNOT_REBUILD_UNLOCKED_DEPS",
537
+ message: err.body.error || err.body.message || "Function was deployed before dependency locking.",
538
+ hint: UNLOCKED_DEPS_HINT,
539
+ next_actions: [`run402 functions deploy ${projectId} ${name} --file <file>`],
540
+ retryable: false,
541
+ safe_to_retry: false,
542
+ });
543
+ }
544
+ reportSdkError(err);
545
+ }
546
+ }
547
+
421
548
  async function list(projectId, args = []) {
422
549
  assertRequiredProject(projectId, "run402 functions list <project_id>");
423
550
  assertKnownFlags(args, ["--help", "-h"]);
@@ -454,6 +581,7 @@ export async function run(sub, args) {
454
581
  case "invoke": await invoke(args[0], args[1], args.slice(2)); break;
455
582
  case "logs": await logs(args[0], args[1], args.slice(2)); break;
456
583
  case "update": await update(args[0], args[1], args.slice(2)); break;
584
+ case "rebuild": await rebuild(args[0], args.slice(1)); break;
457
585
  case "list": await list(args[0], args.slice(1)); break;
458
586
  case "delete": await deleteFunction(args[0], args[1], args.slice(2)); break;
459
587
  default:
package/lib/init.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { readAllowance, saveAllowance, loadKeyStore, CONFIG_DIR } from "./config.mjs";
1
+ import { readAllowance, saveAllowance, loadKeyStore, configDir } from "./config.mjs";
2
2
  import { getSdk } from "./sdk.mjs";
3
3
  import { fail } from "./sdk-errors.mjs";
4
4
  import { mkdirSync } from "fs";
@@ -64,6 +64,10 @@ export async function run(args = []) {
64
64
 
65
65
  if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
66
66
 
67
+ // Resolve once for this invocation — reflects the active wallet/profile that
68
+ // cli.mjs published to RUN402_WALLET before this module loaded.
69
+ const CONFIG_DIR = configDir();
70
+
67
71
  const isMpp = args[0] === "mpp";
68
72
  const requestedRail = isMpp ? "mpp" : "x402";
69
73
  const switchRailConfirmed = args.includes("--switch-rail");
package/lib/status.mjs CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readAllowance, loadKeyStore, getActiveProjectId } from "./config.mjs";
2
2
  import { getSdk } from "./sdk.mjs";
3
3
  import { assertKnownFlags, hasHelp, normalizeArgv } from "./argparse.mjs";
4
+ import { getActiveProfile } from "../core-dist/config.js";
5
+ import { readMeta } from "../core-dist/profiles.js";
4
6
 
5
7
  const HELP = `run402 status — Show full account state in one shot
6
8
 
@@ -104,7 +106,18 @@ export async function run(args = []) {
104
106
  ? remote.projects.map(normalizeProject)
105
107
  : Object.keys(store.projects).map(id => ({ project_id: id }));
106
108
 
109
+ // Which named wallet this state belongs to. `name` is the active profile
110
+ // (default for single-wallet installs); `label` is the cached server-side
111
+ // display name (null until set / when offline).
112
+ const walletName = getActiveProfile();
113
+ const walletMeta = readMeta(walletName);
114
+
107
115
  const result = {
116
+ wallet: {
117
+ name: walletName,
118
+ address: allowance.address,
119
+ label: walletMeta?.label ?? null,
120
+ },
108
121
  allowance: {
109
122
  address: allowance.address,
110
123
  funded: allowance.funded || false,
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Active-wallet (profile) resolution for the CLI edge.
3
+ *
4
+ * Runs at the top of cli.mjs BEFORE any subcommand module (and therefore
5
+ * before cli/lib/config.mjs snapshots its paths) is loaded. Resolves which
6
+ * named wallet a command operates on, sets `process.env.RUN402_WALLET` so all
7
+ * core path functions resolve under it, and emits a provenance line for
8
+ * non-default selections. Core itself stays env-only — the `--wallet` flag and
9
+ * the per-directory `.run402.json` binding are translated into the env var
10
+ * here, at the edge.
11
+ *
12
+ * Precedence (highest first):
13
+ * 1. --wallet <name> / --profile <name> (flag)
14
+ * 2. RUN402_WALLET / RUN402_PROFILE (env)
15
+ * 3. nearest .run402.local.json/.run402.json (directory binding, walk up)
16
+ * 4. config.json active_wallet (global `wallets use`)
17
+ * 5. "default" (root wallet)
18
+ *
19
+ * The flag is also the conflict resolver: when env and binding name different
20
+ * wallets and no flag is given, that is a hard error (not a silent pick).
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join, dirname, resolve } from "node:path";
25
+ import { fail } from "./sdk-errors.mjs";
26
+ import { isValidProfileName } from "../core-dist/config.js";
27
+ import { getDefaultWallet, profileExists, readMeta, profileDir } from "../core-dist/profiles.js";
28
+ import { readAllowance } from "../core-dist/allowance.js";
29
+
30
+ const DEFAULT = "default";
31
+ const GLOBAL_FLAGS = new Set(["--wallet", "--profile"]);
32
+ // The `wallets` group is the management + escape surface — it must work even
33
+ // when selection is ambiguous (so you can `wallets unbind`), and it validates
34
+ // its own positional targets. `init` creates wallets, so it must not fail
35
+ // closed on a not-yet-existing name.
36
+ const CONFLICT_EXEMPT = new Set(["wallets"]);
37
+ const EXISTENCE_EXEMPT = new Set(["wallets", "init"]);
38
+
39
+ /**
40
+ * Split the global --wallet/--profile flag (and its value) out of argv so the
41
+ * subcommand never sees it. Pure: no core imports, no side effects. Returns
42
+ * the cleaned argv and the selected flag (`{ flag, value }` or null). Last
43
+ * occurrence wins. A missing value is left as `value: undefined` for
44
+ * resolveWallet to reject with a precise error.
45
+ */
46
+ export function splitWalletFlag(rawArgv = []) {
47
+ const argv = [];
48
+ let flag = null;
49
+ for (let i = 0; i < rawArgv.length; i++) {
50
+ const a = rawArgv[i];
51
+ if (typeof a === "string" && a.startsWith("--") && a.includes("=")) {
52
+ const name = a.slice(0, a.indexOf("="));
53
+ if (GLOBAL_FLAGS.has(name)) {
54
+ flag = { flag: name, value: a.slice(a.indexOf("=") + 1) };
55
+ continue;
56
+ }
57
+ }
58
+ if (typeof a === "string" && GLOBAL_FLAGS.has(a)) {
59
+ const next = rawArgv[i + 1];
60
+ if (next === undefined || (typeof next === "string" && next.startsWith("-"))) {
61
+ flag = { flag: a, value: undefined };
62
+ } else {
63
+ flag = { flag: a, value: next };
64
+ i += 1;
65
+ }
66
+ continue;
67
+ }
68
+ argv.push(a);
69
+ }
70
+ return { argv, walletFlag: flag };
71
+ }
72
+
73
+ function readBindingFrom(dir) {
74
+ // .run402.local.json (gitignored personal override) beats .run402.json.
75
+ for (const fname of [".run402.local.json", ".run402.json"]) {
76
+ const p = join(dir, fname);
77
+ try {
78
+ const parsed = JSON.parse(readFileSync(p, "utf8"));
79
+ const w = parsed?.wallet;
80
+ if (typeof w === "string" && w.trim()) return { wallet: w.trim(), file: p };
81
+ } catch {
82
+ /* missing / unreadable / malformed → skip */
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** Nearest binding walking up from `startDir` to the filesystem root. */
89
+ export function findBinding(startDir) {
90
+ let dir = resolve(startDir);
91
+ for (;;) {
92
+ const b = readBindingFrom(dir);
93
+ if (b) return b;
94
+ const parent = dirname(dir);
95
+ if (parent === dir) return null;
96
+ dir = parent;
97
+ }
98
+ }
99
+
100
+ function assertValidName(name, origin) {
101
+ if (name === DEFAULT || isValidProfileName(name)) return;
102
+ fail({
103
+ code: "BAD_WALLET_NAME",
104
+ message: `Invalid wallet name ${JSON.stringify(name)} (from ${origin}).`,
105
+ hint: "Wallet names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-').",
106
+ details: { name, origin },
107
+ });
108
+ }
109
+
110
+ /** Pure precedence resolution + conflict detection. Returns { name, source, sourceDetail }. */
111
+ export function resolveWallet({ walletFlag, env = {}, cwd = process.cwd(), cmd } = {}) {
112
+ if (walletFlag) {
113
+ if (walletFlag.value === undefined || walletFlag.value === "") {
114
+ fail({ code: "BAD_FLAG", message: `${walletFlag.flag} requires a value`, details: { flag: walletFlag.flag } });
115
+ }
116
+ assertValidName(walletFlag.value, walletFlag.flag);
117
+ return { name: walletFlag.value, source: "flag", sourceDetail: walletFlag.flag };
118
+ }
119
+
120
+ const envRaw = env.RUN402_WALLET ?? env.RUN402_PROFILE;
121
+ const envName = typeof envRaw === "string" && envRaw.trim() ? envRaw.trim() : null;
122
+ const binding = findBinding(cwd);
123
+
124
+ if (envName && binding && envName !== binding.wallet && !CONFLICT_EXEMPT.has(cmd)) {
125
+ fail({
126
+ code: "WALLET_SELECTION_CONFLICT",
127
+ message: `Ambiguous wallet: RUN402_WALLET=${envName} but ${binding.file} selects '${binding.wallet}'.`,
128
+ hint: "Resolve with one of: pass --wallet <name>, unset RUN402_WALLET, or run402 wallets unbind.",
129
+ details: { env_wallet: envName, binding_wallet: binding.wallet, binding_file: binding.file },
130
+ });
131
+ }
132
+
133
+ if (envName) {
134
+ assertValidName(envName, "RUN402_WALLET");
135
+ return { name: envName, source: "env", sourceDetail: "RUN402_WALLET" };
136
+ }
137
+ if (binding) {
138
+ assertValidName(binding.wallet, binding.file);
139
+ return { name: binding.wallet, source: "binding", sourceDetail: binding.file };
140
+ }
141
+ const def = getDefaultWallet();
142
+ if (def && def !== DEFAULT) return { name: def, source: "config", sourceDetail: "wallets use" };
143
+ return { name: DEFAULT, source: "default", sourceDetail: null };
144
+ }
145
+
146
+ function looksLikeAddress(s) {
147
+ return typeof s === "string" && /^0x[a-fA-F0-9]{40}$/.test(s);
148
+ }
149
+
150
+ /** Fail closed when a non-default selection names a wallet that does not exist locally. */
151
+ export function enforceWalletExists({ name, source }, cmd) {
152
+ if (name === DEFAULT) return;
153
+ if (EXISTENCE_EXEMPT.has(cmd)) return;
154
+ if (profileExists(name)) return;
155
+ const hint = looksLikeAddress(name)
156
+ ? `'${name}' looks like an address. For billing use: run402 billing ... --wallet-address ${name}`
157
+ : `Run 'run402 wallets list' to see wallets, or 'run402 wallets new ${name}' to create it.`;
158
+ fail({
159
+ code: "WALLET_NOT_FOUND",
160
+ message: `No local wallet named '${name}'.`,
161
+ hint,
162
+ details: { wallet: name, source },
163
+ });
164
+ }
165
+
166
+ function shortAddr(a) {
167
+ return typeof a === "string" && a.length >= 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a;
168
+ }
169
+
170
+ /** Best-effort address for display: meta.json (no key) first, allowance second. */
171
+ function walletAddress(name) {
172
+ const meta = readMeta(name);
173
+ if (meta?.address) return meta.address;
174
+ try {
175
+ return readAllowance(join(profileDir(name), "allowance.json"))?.address ?? null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /** Emit the stderr provenance line for non-default selections (silent for default). */
182
+ export function emitProvenance({ name, source, sourceDetail }, { cmd, quiet } = {}) {
183
+ if (quiet) return;
184
+ if (name === DEFAULT) return;
185
+ if (cmd === "wallets") return; // the wallets group reports its own context
186
+ const where =
187
+ source === "env" ? "RUN402_WALLET" :
188
+ source === "config" ? "wallets use" :
189
+ sourceDetail || source;
190
+ const addr = walletAddress(name);
191
+ const addrPart = addr ? ` (${shortAddr(addr)})` : "";
192
+ process.stderr.write(` ↪ wallet: ${name}${addrPart} ← ${where}\n`);
193
+ }
194
+
195
+ /**
196
+ * Orchestrate edge resolution: resolve → fail-closed → publish to the env so
197
+ * core paths resolve → provenance. Returns the resolved selection.
198
+ */
199
+ export function applyWalletSelection({ walletFlag, cmd, cwd = process.cwd(), env = process.env, quiet = false } = {}) {
200
+ // Capture the pre-resolution signals so `wallets current` can report
201
+ // provenance and any env-vs-binding divergence (it can't recompute them once
202
+ // we overwrite RUN402_WALLET below).
203
+ const envRaw = env.RUN402_WALLET ?? env.RUN402_PROFILE;
204
+ const envName = typeof envRaw === "string" && envRaw.trim() ? envRaw.trim() : null;
205
+ const binding = findBinding(cwd);
206
+
207
+ const resolved = resolveWallet({ walletFlag, env, cwd, cmd });
208
+ enforceWalletExists(resolved, cmd);
209
+
210
+ // Publish to the env so all core path functions resolve under this wallet.
211
+ env.RUN402_WALLET = resolved.name;
212
+ env.RUN402_ACTIVE_WALLET_JSON = JSON.stringify({
213
+ name: resolved.name,
214
+ source: resolved.source,
215
+ sourceDetail: resolved.sourceDetail,
216
+ binding: binding ? { wallet: binding.wallet, file: binding.file } : null,
217
+ envName,
218
+ diverged: !!(envName && binding && envName !== binding.wallet),
219
+ });
220
+
221
+ emitProvenance(resolved, { cmd, quiet });
222
+ return resolved;
223
+ }
@@ -0,0 +1,228 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
6
+ import {
7
+ splitWalletFlag,
8
+ findBinding,
9
+ resolveWallet,
10
+ enforceWalletExists,
11
+ emitProvenance,
12
+ } from "./wallet-context.mjs";
13
+ import { ensureProfileDir, setDefaultWallet } from "../core-dist/profiles.js";
14
+
15
+ const origConfigDir = process.env.RUN402_CONFIG_DIR;
16
+ let tmp;
17
+
18
+ beforeEach(() => {
19
+ tmp = mkdtempSync(join(tmpdir(), "wallet-ctx-"));
20
+ process.env.RUN402_CONFIG_DIR = tmp;
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(tmp, { recursive: true, force: true });
25
+ if (origConfigDir !== undefined) process.env.RUN402_CONFIG_DIR = origConfigDir;
26
+ else delete process.env.RUN402_CONFIG_DIR;
27
+ });
28
+
29
+ // Capture a fail() invocation: fail() does console.error(envelope) + process.exit.
30
+ function captureFail(fn) {
31
+ const origExit = process.exit;
32
+ const origErr = console.error;
33
+ let envelope = null;
34
+ let exited = false;
35
+ console.error = (s) => { try { envelope = JSON.parse(s); } catch { envelope = s; } };
36
+ process.exit = () => { exited = true; throw new Error("__EXIT__"); };
37
+ try {
38
+ fn();
39
+ } catch (e) {
40
+ if (!String(e?.message).startsWith("__EXIT__")) throw e;
41
+ } finally {
42
+ process.exit = origExit;
43
+ console.error = origErr;
44
+ }
45
+ return { envelope, exited };
46
+ }
47
+
48
+ function bindingDir(wallet, localWallet) {
49
+ const dir = mkdtempSync(join(tmpdir(), "binding-"));
50
+ if (wallet) writeFileSync(join(dir, ".run402.json"), JSON.stringify({ wallet }));
51
+ if (localWallet) writeFileSync(join(dir, ".run402.local.json"), JSON.stringify({ wallet: localWallet }));
52
+ return dir;
53
+ }
54
+
55
+ describe("splitWalletFlag", () => {
56
+ it("passes through argv with no global flag", () => {
57
+ assert.deepEqual(splitWalletFlag(["status"]), { argv: ["status"], walletFlag: null });
58
+ });
59
+ it("strips --wallet <value>", () => {
60
+ const r = splitWalletFlag(["--wallet", "kychon", "status"]);
61
+ assert.deepEqual(r.argv, ["status"]);
62
+ assert.deepEqual(r.walletFlag, { flag: "--wallet", value: "kychon" });
63
+ });
64
+ it("strips --wallet=<value> mid-args", () => {
65
+ const r = splitWalletFlag(["deploy", "apply", "--wallet=foo", "--manifest", "x"]);
66
+ assert.deepEqual(r.argv, ["deploy", "apply", "--manifest", "x"]);
67
+ assert.equal(r.walletFlag.value, "foo");
68
+ });
69
+ it("accepts --profile as an alias", () => {
70
+ const r = splitWalletFlag(["--profile", "p", "status"]);
71
+ assert.equal(r.walletFlag.flag, "--profile");
72
+ assert.equal(r.walletFlag.value, "p");
73
+ });
74
+ it("last occurrence wins", () => {
75
+ const r = splitWalletFlag(["--wallet", "a", "--wallet", "b", "status"]);
76
+ assert.equal(r.walletFlag.value, "b");
77
+ assert.deepEqual(r.argv, ["status"]);
78
+ });
79
+ it("records a missing value as undefined", () => {
80
+ const r = splitWalletFlag(["status", "--wallet"]);
81
+ assert.deepEqual(r.argv, ["status"]);
82
+ assert.equal(r.walletFlag.value, undefined);
83
+ });
84
+ });
85
+
86
+ describe("findBinding", () => {
87
+ it("finds the nearest .run402.json walking up", () => {
88
+ const root = bindingDir("client-a");
89
+ const sub = join(root, "api");
90
+ mkdirSync(sub);
91
+ const b = findBinding(sub);
92
+ assert.equal(b.wallet, "client-a");
93
+ rmSync(root, { recursive: true, force: true });
94
+ });
95
+ it(".run402.local.json overrides .run402.json in the same dir", () => {
96
+ const dir = bindingDir("client-a", "client-a-staging");
97
+ assert.equal(findBinding(dir).wallet, "client-a-staging");
98
+ rmSync(dir, { recursive: true, force: true });
99
+ });
100
+ it("returns null when no binding exists in the tree", () => {
101
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
102
+ assert.equal(findBinding(dir), null);
103
+ rmSync(dir, { recursive: true, force: true });
104
+ });
105
+ });
106
+
107
+ describe("resolveWallet — precedence", () => {
108
+ it("flag beats everything", () => {
109
+ const dir = bindingDir("client-a");
110
+ const r = resolveWallet({ walletFlag: { flag: "--wallet", value: "kychon" }, env: { RUN402_WALLET: "personal" }, cwd: dir, cmd: "status" });
111
+ assert.equal(r.name, "kychon");
112
+ assert.equal(r.source, "flag");
113
+ rmSync(dir, { recursive: true, force: true });
114
+ });
115
+ it("env beats binding when they agree is moot; env returned when no binding", () => {
116
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
117
+ const r = resolveWallet({ env: { RUN402_WALLET: "kychon" }, cwd: dir, cmd: "status" });
118
+ assert.equal(r.name, "kychon");
119
+ assert.equal(r.source, "env");
120
+ rmSync(dir, { recursive: true, force: true });
121
+ });
122
+ it("binding used when no flag/env", () => {
123
+ const dir = bindingDir("client-a");
124
+ const r = resolveWallet({ env: {}, cwd: dir, cmd: "status" });
125
+ assert.equal(r.name, "client-a");
126
+ assert.equal(r.source, "binding");
127
+ rmSync(dir, { recursive: true, force: true });
128
+ });
129
+ it("global default (wallets use) applies when no flag/env/binding", () => {
130
+ setDefaultWallet("kychon");
131
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
132
+ const r = resolveWallet({ env: {}, cwd: dir, cmd: "status" });
133
+ assert.equal(r.name, "kychon");
134
+ assert.equal(r.source, "config");
135
+ rmSync(dir, { recursive: true, force: true });
136
+ });
137
+ it("falls back to default when nothing selects", () => {
138
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
139
+ const r = resolveWallet({ env: {}, cwd: dir, cmd: "status" });
140
+ assert.equal(r.name, "default");
141
+ assert.equal(r.source, "default");
142
+ rmSync(dir, { recursive: true, force: true });
143
+ });
144
+ });
145
+
146
+ describe("resolveWallet — conflict + validation", () => {
147
+ it("env vs binding mismatch is a hard error (non-wallets command)", () => {
148
+ const dir = bindingDir("client-a");
149
+ const { envelope, exited } = captureFail(() =>
150
+ resolveWallet({ env: { RUN402_WALLET: "personal" }, cwd: dir, cmd: "deploy" }));
151
+ assert.ok(exited);
152
+ assert.equal(envelope.code, "WALLET_SELECTION_CONFLICT");
153
+ assert.match(envelope.message, /personal/);
154
+ assert.match(envelope.message, /client-a/);
155
+ rmSync(dir, { recursive: true, force: true });
156
+ });
157
+ it("matching env and binding proceed without error", () => {
158
+ const dir = bindingDir("client-a");
159
+ const r = resolveWallet({ env: { RUN402_WALLET: "client-a" }, cwd: dir, cmd: "deploy" });
160
+ assert.equal(r.name, "client-a");
161
+ rmSync(dir, { recursive: true, force: true });
162
+ });
163
+ it("the wallets group is exempt from the conflict error", () => {
164
+ const dir = bindingDir("client-a");
165
+ const r = resolveWallet({ env: { RUN402_WALLET: "personal" }, cwd: dir, cmd: "wallets" });
166
+ assert.equal(r.name, "personal"); // env still wins; no error
167
+ rmSync(dir, { recursive: true, force: true });
168
+ });
169
+ it("rejects an invalid wallet name", () => {
170
+ const dir = mkdtempSync(join(tmpdir(), "nobind-"));
171
+ const { envelope } = captureFail(() =>
172
+ resolveWallet({ env: { RUN402_WALLET: "../evil" }, cwd: dir, cmd: "status" }));
173
+ assert.equal(envelope.code, "BAD_WALLET_NAME");
174
+ rmSync(dir, { recursive: true, force: true });
175
+ });
176
+ });
177
+
178
+ describe("enforceWalletExists — fail closed", () => {
179
+ it("no-op for default", () => {
180
+ assert.doesNotThrow(() => enforceWalletExists({ name: "default", source: "default" }, "deploy"));
181
+ });
182
+ it("no-op for an existing wallet", () => {
183
+ ensureProfileDir("client-a");
184
+ writeFileSync(join(tmp, "profiles", "client-a", "allowance.json"), "{}");
185
+ assert.doesNotThrow(() => enforceWalletExists({ name: "client-a", source: "binding" }, "deploy"));
186
+ });
187
+ it("fails closed for a missing wallet on a normal command", () => {
188
+ const { envelope, exited } = captureFail(() =>
189
+ enforceWalletExists({ name: "ghost", source: "binding" }, "deploy"));
190
+ assert.ok(exited);
191
+ assert.equal(envelope.code, "WALLET_NOT_FOUND");
192
+ });
193
+ it("wallets + init are exempt (create paths)", () => {
194
+ assert.doesNotThrow(() => enforceWalletExists({ name: "ghost", source: "flag" }, "wallets"));
195
+ assert.doesNotThrow(() => enforceWalletExists({ name: "ghost", source: "flag" }, "init"));
196
+ });
197
+ it("address-looking name hints at billing --wallet-address", () => {
198
+ const { envelope } = captureFail(() =>
199
+ enforceWalletExists({ name: "0x" + "a".repeat(40), source: "flag" }, "deploy"));
200
+ assert.match(envelope.hint, /--wallet-address/);
201
+ });
202
+ });
203
+
204
+ describe("emitProvenance", () => {
205
+ function captureStderr(fn) {
206
+ const orig = process.stderr.write.bind(process.stderr);
207
+ let out = "";
208
+ process.stderr.write = (c) => { out += typeof c === "string" ? c : Buffer.from(c).toString("utf8"); return true; };
209
+ try { fn(); } finally { process.stderr.write = orig; }
210
+ return out;
211
+ }
212
+ it("stays silent for the default wallet", () => {
213
+ assert.equal(captureStderr(() => emitProvenance({ name: "default", source: "default" }, { cmd: "status" })), "");
214
+ });
215
+ it("emits a provenance line for a named wallet", () => {
216
+ ensureProfileDir("kychon");
217
+ writeFileSync(join(tmp, "profiles", "kychon", "meta.json"), JSON.stringify({ name: "kychon", address: "0x1234567890abcdef" }));
218
+ const out = captureStderr(() => emitProvenance({ name: "kychon", source: "env", sourceDetail: "RUN402_WALLET" }, { cmd: "status" }));
219
+ assert.match(out, /wallet: kychon/);
220
+ assert.match(out, /RUN402_WALLET/);
221
+ });
222
+ it("honors --quiet", () => {
223
+ assert.equal(captureStderr(() => emitProvenance({ name: "kychon", source: "env" }, { cmd: "status", quiet: true })), "");
224
+ });
225
+ it("stays silent for the wallets group", () => {
226
+ assert.equal(captureStderr(() => emitProvenance({ name: "kychon", source: "flag" }, { cmd: "wallets" })), "");
227
+ });
228
+ });