run402 4.27.0 → 4.28.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.
@@ -202,6 +202,8 @@ export const COMMAND_MANIFEST = [
202
202
  { path: ["org", "use"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["11111111-2222-3333-4444-555555555555"] },
203
203
  { path: ["org", "current"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
204
204
  { path: ["org", "clear"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
205
+ { path: ["org", "bind"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["--org", "11111111-2222-3333-4444-555555555555"] },
206
+ { path: ["org", "unbind"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
205
207
  { path: ["org", "member", "list"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
206
208
  { path: ["org", "member", "add"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
207
209
  { path: ["org", "member", "role"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--principal", "prn_gate1", "--role", "viewer"] },
package/lib/init.mjs CHANGED
@@ -627,6 +627,29 @@ export async function run(args = []) {
627
627
  }
628
628
  }
629
629
 
630
+ // 5b. The org. `init` already materializes it (the tier read above
631
+ // authenticates, which provisions the wallet's org-of-one), so NOT reporting
632
+ // it forced every agent that wanted to coordinate to go find it with a second
633
+ // command and copy a UUID by hand. It is an identifier, not a credential.
634
+ try {
635
+ const orgs = await getSdk().orgs.list();
636
+ const rows = Array.isArray(orgs) ? orgs : (orgs?.orgs ?? []);
637
+ summary.orgs = rows.map((o) => ({
638
+ org_id: o.org_id,
639
+ display_name: o.display_name ?? null,
640
+ role: o.role ?? null,
641
+ }));
642
+ if (rows.length === 1) {
643
+ line("Org", `${rows[0].org_id}${rows[0].display_name ? ` (${rows[0].display_name})` : ""}`);
644
+ } else if (rows.length > 1) {
645
+ line("Org", `${rows.length} organizations — run402 org list`);
646
+ }
647
+ } catch {
648
+ // Best-effort, exactly like the billing read: a listing failure must never
649
+ // fail setup.
650
+ summary.orgs = null;
651
+ }
652
+
630
653
  // 6. Next step — canonical typed action(s); `next_step` is the back-compat
631
654
  // string mirror of the first action's command (one spelling, surface-wide).
632
655
  write("");
@@ -258,6 +258,11 @@ export async function resolveOrgId(a, opts = {}) {
258
258
  return resolved ? resolved.orgId : null;
259
259
  }
260
260
 
261
+ /** Validate an org id supplied by a human, naming the origin. Throws via fail(). */
262
+ export function requireOrgIdShape(orgId, origin = "--org") {
263
+ return assertOrgIdShape(orgId, origin);
264
+ }
265
+
261
266
  /** The profile's selected organization, without running the chain. */
262
267
  export function getSelectedOrgId() {
263
268
  return trimmed(coreGetActiveOrgId());
package/lib/org.mjs CHANGED
@@ -1,6 +1,9 @@
1
1
  import { getSdk } from "./sdk.mjs";
2
2
  import { reportSdkError, fail } from "./sdk-errors.mjs";
3
+ import { readBindingFile, updateBindingFile } from "./wallet-context.mjs";
4
+ import { nextAction } from "./next-actions.mjs";
3
5
  import {
6
+ requireOrgIdShape,
4
7
  resolveOrg,
5
8
  orgProvenance,
6
9
  getSelectedOrgId,
@@ -31,6 +34,8 @@ Usage:
31
34
  run402 org use <org_id>
32
35
  run402 org current
33
36
  run402 org clear
37
+ run402 org bind [--org <org_id>] [--room <key>]
38
+ run402 org unbind
34
39
  run402 org audit <org_id> [--limit N] [--after <cursor>] [--before <cursor>]
35
40
  run402 org member list <org_id>
36
41
  run402 org member add <org_id> --wallet <wallet_address> [--role <role>]
@@ -51,6 +56,8 @@ Subcommands:
51
56
  use Select the current org for this wallet profile
52
57
  current Report the resolved current org and where it came from
53
58
  clear Clear this wallet profile's org selection
59
+ bind Write this checkout's org (+room) into .run402.json — commit it
60
+ unbind Remove the org/room keys from .run402.json
54
61
  payout-wallet Set or clear the tenant route payout wallet (admin+)
55
62
  whoami Resolved principal + org memberships (GET /agent/v1/whoami)
56
63
  member Manage members (list, add, role, rm) — mutations require owner
@@ -239,6 +246,99 @@ async function clear(args) {
239
246
  console.log(JSON.stringify({ org_id: null, selected: false, previous_org_id: previous ?? null }, null, 2));
240
247
  }
241
248
 
249
+ /**
250
+ * Slugify a directory name into a legal room key.
251
+ * Room keys match /^[a-z0-9][a-z0-9._-]{0,63}$/ (the DB CHECK on every
252
+ * agent-messaging table), so the repo's own name has to be coerced, not trusted.
253
+ */
254
+ function roomKeyFromDir(dir) {
255
+ const base = dir.split("/").filter(Boolean).pop() ?? "";
256
+ const slug = base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+/, "").slice(0, 64);
257
+ return /^[a-z0-9]/.test(slug) ? slug : null;
258
+ }
259
+
260
+ /**
261
+ * Write this checkout's org (and room) into `.run402.json`.
262
+ *
263
+ * WHY THIS PICKS FOR YOU WHEN YOU OWN EXACTLY ONE ORG, while the resolution
264
+ * chain never does: they are different acts. The chain runs on EVERY command
265
+ * and must not change meaning the day you are invited to a second org — so it
266
+ * refuses to infer. This runs ONCE, because you asked it to, and it WRITES THE
267
+ * ANSWER DOWN. Nothing is inferred afterwards; the file is read verbatim
268
+ * forever after. An explicit act with a recorded result is not a heuristic.
269
+ *
270
+ * With two or more orgs there is nothing to pick, so it lists them and stops.
271
+ */
272
+ async function bind(args) {
273
+ const a = normalizeArgv(args);
274
+ const valueFlags = ["--org", "--room"];
275
+ assertKnownFlags(a, [...valueFlags, "--help", "-h"], valueFlags);
276
+ requirePositionalCount(a, valueFlags, { min: 0, max: 0, command: "run402 org bind [--org <org_id>] [--room <key>]" });
277
+
278
+ let orgId = flagValue(a, "--org");
279
+ let picked = "flag";
280
+ if (!orgId) {
281
+ let orgs;
282
+ try {
283
+ orgs = await getSdk().orgs.list();
284
+ } catch (err) {
285
+ reportSdkError(err);
286
+ return;
287
+ }
288
+ const rows = Array.isArray(orgs) ? orgs : (orgs?.orgs ?? []);
289
+ if (rows.length === 0) {
290
+ fail({
291
+ code: "NO_ORGS",
292
+ message: "This wallet is a member of no organization yet.",
293
+ hint: "Run 'run402 init' to provision one, or ask an owner to add you with 'run402 org member add'.",
294
+ next_actions: [nextAction("initialize_wallet", { command: "run402 init", why: "Provision this wallet's organization, then retry." })],
295
+ });
296
+ }
297
+ if (rows.length > 1) {
298
+ fail({
299
+ code: "AMBIGUOUS_ORG",
300
+ message: `This wallet belongs to ${rows.length} organizations — name the one to bind.`,
301
+ hint: "run402 org bind --org <org_id>",
302
+ details: { orgs: rows.map((o) => ({ org_id: o.org_id, display_name: o.display_name ?? null, role: o.role ?? null })) },
303
+ next_actions: [nextAction("edit_request", { command: "run402 org bind --org <org_id>", why: "Name which organization this checkout coordinates in." })],
304
+ });
305
+ }
306
+ orgId = rows[0].org_id;
307
+ picked = "sole_membership";
308
+ }
309
+
310
+ const room = flagValue(a, "--room") ?? roomKeyFromDir(process.cwd());
311
+ const { contents, file } = updateBindingFile(process.cwd(), {
312
+ org: requireOrgIdShape(orgId, picked === "flag" ? "--org" : "org list"),
313
+ ...(room ? { room } : {}),
314
+ });
315
+ console.log(JSON.stringify({
316
+ org_id: orgId,
317
+ room_key: room ?? null,
318
+ org_source: picked,
319
+ file: ".run402.json",
320
+ path: file,
321
+ bound: true,
322
+ safe_to_commit: true,
323
+ note: "Safe to commit — an org id is an identifier, not a credential; authorization stays server-side.",
324
+ binding: contents,
325
+ }, null, 2));
326
+ }
327
+
328
+ async function unbind(args) {
329
+ const a = normalizeArgv(args);
330
+ assertKnownFlags(a, ["--help", "-h"]);
331
+ requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org unbind" });
332
+ const previous = readBindingFile(process.cwd());
333
+ const { contents, removed } = updateBindingFile(process.cwd(), { org: null, room: null });
334
+ console.log(JSON.stringify({
335
+ file: ".run402.json",
336
+ unbound: previous.org !== undefined || previous.room !== undefined,
337
+ removed,
338
+ binding: contents,
339
+ }, null, 2));
340
+ }
341
+
242
342
  async function current(args) {
243
343
  const a = normalizeArgv(args);
244
344
  assertKnownFlags(a, ["--help", "-h"]);
@@ -534,6 +634,8 @@ export async function run(sub, args) {
534
634
  case "use": await use(args); break;
535
635
  case "current": await current(args); break;
536
636
  case "clear": await clear(args); break;
637
+ case "bind": await bind(args); break;
638
+ case "unbind": await unbind(args); break;
537
639
  case "audit": await audit(args); break;
538
640
  default:
539
641
  failUnknownSubcommand("org", sub);
@@ -19,7 +19,7 @@
19
19
  * wallets and no flag is given, that is a hard error (not a silent pick).
20
20
  */
21
21
 
22
- import { readFileSync } from "node:fs";
22
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
23
23
  import { join, dirname, resolve } from "node:path";
24
24
  import { fail } from "./sdk-errors.mjs";
25
25
  import { isValidProfileName } from "../core-dist/config.js";
@@ -27,6 +27,7 @@ import { getDefaultWallet, profileExists, readMeta, profileDir } from "../core-d
27
27
  import { readAllowance } from "../core-dist/allowance.js";
28
28
 
29
29
  const DEFAULT = "default";
30
+ const BINDING_FILE = ".run402.json";
30
31
  const GLOBAL_FLAGS = new Set(["--wallet", "--profile"]);
31
32
  // The `wallets` group is the management + escape surface — it must work even
32
33
  // when selection is ambiguous (so you can `wallets unbind`), and it validates
@@ -84,6 +85,45 @@ function readBindingKeyFrom(dir, key) {
84
85
  return null;
85
86
  }
86
87
 
88
+ /** Path of the committed binding file for a directory. */
89
+ export function bindingFilePath(dir = process.cwd()) {
90
+ return join(dir, BINDING_FILE);
91
+ }
92
+
93
+ /** Parse a directory's committed binding file, or `{}` when absent/unreadable. */
94
+ export function readBindingFile(dir = process.cwd()) {
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(bindingFilePath(dir), "utf8"));
97
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
98
+ } catch {
99
+ return {};
100
+ }
101
+ }
102
+
103
+ /**
104
+ * MERGE keys into a directory's binding file. A `null` value removes its key;
105
+ * a file left with no keys is deleted rather than committed empty.
106
+ *
107
+ * The file is shared by tiers (`wallet` from `wallets bind`, `org`/`room` from
108
+ * `org bind`) and unknown keys are preserved, so one tier can never clobber
109
+ * another's binding — which a whole-file write did until this existed.
110
+ */
111
+ export function updateBindingFile(dir, patch) {
112
+ const file = bindingFilePath(dir);
113
+ const next = { ...readBindingFile(dir) };
114
+ for (const [k, v] of Object.entries(patch)) {
115
+ if (v === null || v === undefined) delete next[k];
116
+ else next[k] = v;
117
+ }
118
+ if (Object.keys(next).length === 0) {
119
+ const existed = existsSync(file);
120
+ if (existed) rmSync(file, { force: true });
121
+ return { file, contents: null, removed: existed };
122
+ }
123
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
124
+ return { file, contents: next, removed: false };
125
+ }
126
+
87
127
  /**
88
128
  * Nearest binding carrying `key`, walking up from `startDir` to the root.
89
129
  *
package/lib/wallets.mjs CHANGED
@@ -32,6 +32,7 @@ import {
32
32
  } from "../core-dist/profiles.js";
33
33
  import { readAllowance, saveAllowance } from "../core-dist/allowance.js";
34
34
  import { getSdk } from "./sdk.mjs";
35
+ import { readBindingFile, updateBindingFile } from "./wallet-context.mjs";
35
36
 
36
37
  const DEFAULT = "default";
37
38
  const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
@@ -208,14 +209,15 @@ async function cmdRename(args) {
208
209
  function cmdBind(args) {
209
210
  let name = args.find((a) => a && !a.startsWith("-"));
210
211
  name = name ? requireName(name) : getActiveProfile();
211
- const file = join(process.cwd(), ".run402.json");
212
- writeFileSync(file, JSON.stringify({ wallet: name }, null, 2) + "\n");
212
+ // MERGE, never clobber: the same file carries `org`/`room` from `org bind`.
213
+ const { contents } = updateBindingFile(process.cwd(), { wallet: name });
213
214
  const result = {
214
215
  wallet: name,
215
216
  file: ".run402.json",
216
217
  bound: true,
217
218
  safe_to_commit: true,
218
219
  note: "Safe to commit — contains no secrets, only the wallet name.",
220
+ binding: contents,
219
221
  };
220
222
  if (name !== DEFAULT && !profileExists(name)) {
221
223
  result.warning = `No local wallet named '${name}' yet — create it with 'run402 wallets new ${name}'.`;
@@ -224,10 +226,12 @@ function cmdBind(args) {
224
226
  }
225
227
 
226
228
  function cmdUnbind() {
227
- const file = join(process.cwd(), ".run402.json");
228
- const existed = existsSync(file);
229
- if (existed) rmSync(file, { force: true });
230
- out({ file: ".run402.json", unbound: existed });
229
+ // Removes only the WALLET key. An `org`/`room` binding in the same file
230
+ // belongs to another tier and must survive; the file is deleted only when
231
+ // unbinding leaves nothing behind.
232
+ const had = readBindingFile(process.cwd()).wallet !== undefined;
233
+ const { contents, removed } = updateBindingFile(process.cwd(), { wallet: null });
234
+ out({ file: ".run402.json", unbound: had, removed, binding: contents });
231
235
  }
232
236
 
233
237
  async function cmdImport(args) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "run402",
3
- "version": "4.27.0",
3
+ "version": "4.28.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": {