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.
- package/README.md +15 -1
- package/cli.mjs +33 -6
- package/core-dist/allowance.js +24 -1
- package/core-dist/config.js +75 -3
- package/core-dist/profiles.js +196 -0
- package/lib/allowance.mjs +3 -3
- package/lib/config.mjs +16 -1
- package/lib/doctor.mjs +47 -2
- package/lib/functions.mjs +128 -0
- package/lib/init.mjs +5 -1
- package/lib/status.mjs +13 -0
- package/lib/wallet-context.mjs +223 -0
- package/lib/wallet-context.test.mjs +228 -0
- package/lib/wallets.mjs +344 -0
- package/package.json +1 -1
- package/sdk/core-dist/allowance.js +24 -1
- package/sdk/core-dist/config.js +75 -3
- package/sdk/core-dist/profiles.js +196 -0
- package/sdk/dist/credentials.d.ts +16 -0
- package/sdk/dist/credentials.d.ts.map +1 -1
- package/sdk/dist/index.d.ts +24 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +30 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/admin.d.ts +15 -0
- package/sdk/dist/namespaces/admin.d.ts.map +1 -1
- package/sdk/dist/namespaces/admin.js.map +1 -1
- package/sdk/dist/namespaces/functions.d.ts +42 -2
- package/sdk/dist/namespaces/functions.d.ts.map +1 -1
- package/sdk/dist/namespaces/functions.js +52 -1
- package/sdk/dist/namespaces/functions.js.map +1 -1
- package/sdk/dist/namespaces/functions.types.d.ts +54 -0
- package/sdk/dist/namespaces/functions.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/wallets.d.ts +35 -0
- package/sdk/dist/namespaces/wallets.d.ts.map +1 -0
- package/sdk/dist/namespaces/wallets.js +50 -0
- package/sdk/dist/namespaces/wallets.js.map +1 -0
- package/sdk/dist/node/credentials.d.ts +2 -1
- package/sdk/dist/node/credentials.d.ts.map +1 -1
- package/sdk/dist/node/credentials.js +11 -1
- package/sdk/dist/node/credentials.js.map +1 -1
- package/sdk/dist/scoped.d.ts +3 -1
- package/sdk/dist/scoped.d.ts.map +1 -1
- package/sdk/dist/scoped.js +6 -0
- 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,
|
|
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
|
+
});
|