run402 4.26.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.
- package/core-dist/profile-state.js +46 -0
- package/lib/claims.mjs +3 -1
- package/lib/command-manifest.mjs +5 -0
- package/lib/init.mjs +23 -0
- package/lib/org-context.mjs +298 -17
- package/lib/org.mjs +165 -0
- package/lib/projects.mjs +6 -1
- package/lib/rooms-context.mjs +77 -14
- package/lib/rooms.mjs +8 -0
- package/lib/wallet-context.mjs +62 -8
- package/lib/wallets.mjs +10 -6
- package/package.json +1 -1
- package/sdk/core-dist/profile-state.js +46 -0
|
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
|
|
|
126
126
|
saveProfileState(state, p);
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Selected organization (add-cli-current-org, design D3).
|
|
131
|
+
//
|
|
132
|
+
// Scoped exactly like the active project id — by api_base, profile, and
|
|
133
|
+
// principal — and stored in the SAME per-profile state.json, never in the
|
|
134
|
+
// base-level config.json beside `active_wallet`. The chain is
|
|
135
|
+
// wallet -> principal -> memberships, so a globally-selected org survives
|
|
136
|
+
// `wallets use other` and then either 403s or, worse, silently resolves to a
|
|
137
|
+
// valid-but-wrong org when both principals are members. Per-profile makes the
|
|
138
|
+
// failure mode "this profile has no current org", which the ORG_REQUIRED
|
|
139
|
+
// envelope already knows how to explain.
|
|
140
|
+
//
|
|
141
|
+
// There is deliberately no top-level `active_org_id` mirror: the project one
|
|
142
|
+
// exists only to carry pre-scoping installs forward, and this key is new.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
export function getActiveOrgId(path, scope = {}) {
|
|
145
|
+
const state = loadProfileState(path);
|
|
146
|
+
return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
|
|
147
|
+
}
|
|
148
|
+
export function setActiveOrgId(orgId, path, scope = {}) {
|
|
149
|
+
const p = path ?? getProfileStatePath();
|
|
150
|
+
withFileLock(p, () => {
|
|
151
|
+
const state = loadProfileState(p);
|
|
152
|
+
const key = activeProjectScopeKey(scope);
|
|
153
|
+
const resolved = defaultActiveProjectScope(scope);
|
|
154
|
+
state.active_orgs = state.active_orgs ?? {};
|
|
155
|
+
state.active_orgs[key] = {
|
|
156
|
+
org_id: orgId,
|
|
157
|
+
api_base: resolved.api_base,
|
|
158
|
+
principal: resolved.principal ?? null,
|
|
159
|
+
profile: resolved.profile,
|
|
160
|
+
updated_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
saveProfileState(state, p);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function clearActiveOrgId(path, scope = {}) {
|
|
166
|
+
const p = path ?? getProfileStatePath();
|
|
167
|
+
withFileLock(p, () => {
|
|
168
|
+
const state = loadProfileState(p);
|
|
169
|
+
const key = activeProjectScopeKey(scope);
|
|
170
|
+
if (state.active_orgs?.[key])
|
|
171
|
+
delete state.active_orgs[key];
|
|
172
|
+
saveProfileState(state, p);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
129
175
|
export function recordMigration(marker, value, path) {
|
|
130
176
|
const p = path ?? getProfileStatePath();
|
|
131
177
|
withFileLock(p, () => {
|
package/lib/claims.mjs
CHANGED
|
@@ -51,7 +51,9 @@ Advisory, always:
|
|
|
51
51
|
Room addressing:
|
|
52
52
|
Default room of the active project with no flags; --project <id> for another
|
|
53
53
|
project; --org <org_id> --room <key> (or RUN402_ROOM=<org_id>/<key>) for a
|
|
54
|
-
named org room.
|
|
54
|
+
named org room. --room <key> alone works when the org resolves on its own
|
|
55
|
+
(--org / RUN402_ORG / the 'org' key of the nearest .run402.json / 'org use'),
|
|
56
|
+
and {"org":"...","room":"..."} in .run402.json binds a whole checkout.
|
|
55
57
|
|
|
56
58
|
Options:
|
|
57
59
|
--mode <m> exclusive (default) — one worker; shared — parallel-safe.
|
package/lib/command-manifest.mjs
CHANGED
|
@@ -199,6 +199,11 @@ export const COMMAND_MANIFEST = [
|
|
|
199
199
|
{ path: ["org", "payout-wallet"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
|
|
200
200
|
{ path: ["org", "whoami"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
|
|
201
201
|
{ path: ["org", "audit"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
|
|
202
|
+
{ path: ["org", "use"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["11111111-2222-3333-4444-555555555555"] },
|
|
203
|
+
{ path: ["org", "current"], positionals: [], projectScoped: false, legacyPositionalProject: false, minimalArgs: [] },
|
|
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: [] },
|
|
202
207
|
{ path: ["org", "member", "list"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1"] },
|
|
203
208
|
{ path: ["org", "member", "add"], positionals: [p("org_id")], projectScoped: false, legacyPositionalProject: false, minimalArgs: ["org_gate1", "--wallet", "0x1111111111111111111111111111111111111111"] },
|
|
204
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("");
|
package/lib/org-context.mjs
CHANGED
|
@@ -1,29 +1,310 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared organization resolution for org-scoped CLI families
|
|
3
|
-
* (attention-architecture Wave E — one resolver, not one per family
|
|
3
|
+
* (attention-architecture Wave E — one resolver, not one per family;
|
|
4
|
+
* extended by add-cli-current-org with the local-context classes).
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* checkout needs no flags and the precedence cannot drift per family.
|
|
6
|
+
* The org is the ownership root — it owns projects, holds memberships, and is
|
|
7
|
+
* the scope for rooms, escalations, members, grants, and audit. This is the one
|
|
8
|
+
* place org precedence is expressed.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Precedence is four INTENT CLASSES, highest first, and inside each class an
|
|
11
|
+
* organization named directly outranks one derived from a project named in that
|
|
12
|
+
* same class:
|
|
13
|
+
*
|
|
14
|
+
* 1. flag --org <org_id> else --project <id> -> its org
|
|
15
|
+
* 2. environment RUN402_ORG, else the org else RUN402_PROJECT_ID -> its org
|
|
16
|
+
* half of RUN402_ROOM
|
|
17
|
+
* 3. binding the `org` key of the nearest .run402(.local).json
|
|
18
|
+
* 4. profile state the profile's selected org else its active project -> org
|
|
19
|
+
*
|
|
20
|
+
* The class rule is what makes `--project X` behave: naming a project IS naming
|
|
21
|
+
* its organization, so a stale profile selection must not outrank it. Classes 3
|
|
22
|
+
* and 4 are what let an org that owns NO project be addressed at all — every
|
|
23
|
+
* earlier rung needed an argument, a variable, or a deployed project.
|
|
24
|
+
*
|
|
25
|
+
* `rooms`/`claims` resolve a PAIR ({orgId, roomKey}) with their own
|
|
26
|
+
* `RUN402_ROOM` env form — see `rooms-context.mjs`. They are not forced through
|
|
27
|
+
* this single-value resolver: the pair keeps its own shape and its own
|
|
28
|
+
* precedence, and only the ORG HALF of its fallback delegates here. One
|
|
29
|
+
* resolver per shape, composed for the half they share.
|
|
30
|
+
*
|
|
31
|
+
* Two things this deliberately does NOT do:
|
|
32
|
+
* - Infer the org from the caller's memberships, at any count. Membership is
|
|
33
|
+
* server state that changes without the caller acting, so a heuristic that is
|
|
34
|
+
* right today silently changes meaning the day they are invited elsewhere.
|
|
35
|
+
* - Validate membership locally. A well-formed id goes to the server and the
|
|
36
|
+
* server's answer is surfaced as returned — never rewritten into a local
|
|
37
|
+
* not-found (that would invent the existence oracle the gateway refuses to
|
|
38
|
+
* provide) and never retried against a lower class (that would act on a
|
|
39
|
+
* different organization than the caller named).
|
|
13
40
|
*/
|
|
14
41
|
import { getSdk } from "./sdk.mjs";
|
|
15
42
|
import { flagValue } from "./argparse.mjs";
|
|
43
|
+
import { findBindingKey } from "./wallet-context.mjs";
|
|
44
|
+
import { fail } from "./sdk-errors.mjs";
|
|
45
|
+
import { nextAction } from "./next-actions.mjs";
|
|
46
|
+
import {
|
|
47
|
+
getActiveOrgId as coreGetActiveOrgId,
|
|
48
|
+
setActiveOrgId as coreSetActiveOrgId,
|
|
49
|
+
clearActiveOrgId as coreClearActiveOrgId,
|
|
50
|
+
} from "../core-dist/profile-state.js";
|
|
51
|
+
import { getActiveProjectId } from "../core-dist/keystore.js";
|
|
16
52
|
import { getProject, resolveProjectId, updateProject } from "./config.mjs";
|
|
17
53
|
|
|
18
|
-
|
|
19
|
-
export
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
54
|
+
export const ORG_ENV = "RUN402_ORG";
|
|
55
|
+
export const ROOM_ENV = "RUN402_ROOM";
|
|
56
|
+
export const PROJECT_ENV = "RUN402_PROJECT_ID";
|
|
57
|
+
|
|
58
|
+
/** Command groups that must stay usable while selection is ambiguous. */
|
|
59
|
+
const CONFLICT_EXEMPT = new Set(["org", "wallets", "doctor"]);
|
|
60
|
+
|
|
61
|
+
/** `org_id` is a UUID at every API boundary (`uuidParam` on every route). */
|
|
62
|
+
const ORG_ID_RE = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
63
|
+
|
|
64
|
+
const trimmed = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shape-validate an organization id supplied by a local source. Membership is
|
|
68
|
+
* NEVER checked here — only that the value could be an org id at all.
|
|
69
|
+
*/
|
|
70
|
+
function assertOrgIdShape(orgId, origin) {
|
|
71
|
+
if (ORG_ID_RE.test(orgId)) return orgId;
|
|
72
|
+
fail({
|
|
73
|
+
code: "BAD_ORG_ID",
|
|
74
|
+
message: `Invalid organization id ${JSON.stringify(orgId)} (from ${origin}).`,
|
|
75
|
+
hint: "An org_id is a UUID. Run 'run402 org list' to see the organizations you belong to.",
|
|
76
|
+
details: { org_id: orgId, origin },
|
|
77
|
+
next_actions: [listOrgsAction()],
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The org half of `RUN402_ROOM=<org_id>/<room_key>`, or null. */
|
|
82
|
+
function orgFromRoomEnv(env) {
|
|
83
|
+
const raw = trimmed(env[ROOM_ENV]);
|
|
84
|
+
if (!raw) return null;
|
|
85
|
+
const slash = raw.indexOf("/");
|
|
86
|
+
if (slash <= 0 || slash === raw.length - 1) return null; // rooms-context reports the malformed form
|
|
87
|
+
return raw.slice(0, slash);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function listOrgsAction() {
|
|
91
|
+
return nextAction("edit_request", {
|
|
92
|
+
command: "run402 org list",
|
|
93
|
+
why: "List the organizations this wallet belongs to.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function orgRequiredActions() {
|
|
98
|
+
return [
|
|
99
|
+
nextAction("edit_request", {
|
|
100
|
+
command: "run402 org use <org_id>",
|
|
101
|
+
why: "Select a current organization for this wallet profile.",
|
|
102
|
+
}),
|
|
103
|
+
listOrgsAction(),
|
|
104
|
+
nextAction("edit_request", {
|
|
105
|
+
command: "run402 <command> --org <org_id>",
|
|
106
|
+
why: "Name the organization on this one call.",
|
|
107
|
+
}),
|
|
108
|
+
nextAction("edit_request", {
|
|
109
|
+
command: "export RUN402_ORG=<org_id>",
|
|
110
|
+
why: "Name the organization for every call in this shell (harness wiring).",
|
|
111
|
+
}),
|
|
112
|
+
nextAction("edit_request", {
|
|
113
|
+
command: `echo '{"org":"<org_id>"}' > .run402.json`,
|
|
114
|
+
why: "Bind this checkout to an organization so every agent in it inherits the same one.",
|
|
115
|
+
}),
|
|
116
|
+
nextAction("edit_request", {
|
|
117
|
+
command: "run402 projects use <project_id>",
|
|
118
|
+
why: "Select a project; its owning organization becomes the current one.",
|
|
119
|
+
}),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Resolve a project's owning org (one GET). Returns null when unresolvable. */
|
|
124
|
+
async function orgOfProject(projectId, { required }) {
|
|
125
|
+
// Upstream's per-project cache answers the same question for free when it is
|
|
126
|
+
// warm. It is NOT used as the whole implementation: it returns null on
|
|
127
|
+
// failure, and an explicitly named project must be able to HARD STOP rather
|
|
128
|
+
// than fall through to a lower class.
|
|
129
|
+
const cached = getProject(projectId)?.org_id;
|
|
130
|
+
if (typeof cached === "string" && cached.length > 0) return cached;
|
|
131
|
+
try {
|
|
132
|
+
const scoped = await getSdk().rooms.forProject(projectId);
|
|
133
|
+
return scoped.orgId;
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// An EXPLICITLY named project that cannot be resolved is a hard stop — the
|
|
136
|
+
// caller named it, so falling to a lower class would act on a different
|
|
137
|
+
// organization than the one they asked for. An implicitly selected project
|
|
138
|
+
// (profile state) may simply be stale; skip the class.
|
|
139
|
+
if (!required) return null;
|
|
140
|
+
throw err;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resolve the organization an org-scoped command acts on.
|
|
146
|
+
*
|
|
147
|
+
* Accepts either normalized argv (the Wave E call shape, still used by
|
|
148
|
+
* `escalations`) or an options object for callers that already hold values.
|
|
149
|
+
*
|
|
150
|
+
* @param {string[]|object} input normalized argv, or { org, project }
|
|
151
|
+
* @param {object} [opts]
|
|
152
|
+
* @param {string} [opts.cmd] command group, for the conflict exemption
|
|
153
|
+
* @param {object} [opts.env] environment (injectable for tests)
|
|
154
|
+
* @param {string} [opts.cwd] directory to walk up from (injectable)
|
|
155
|
+
* @param {boolean} [opts.optional] return null instead of failing when absent
|
|
156
|
+
* @returns {Promise<{orgId: string, source: string, sourceDetail: string}|null>}
|
|
157
|
+
*/
|
|
158
|
+
export async function resolveOrg(input = {}, opts = {}) {
|
|
159
|
+
const { cmd, env = process.env, cwd = process.cwd(), optional = false } = opts;
|
|
160
|
+
const org = Array.isArray(input) ? flagValue(input, "--org") : input.org;
|
|
161
|
+
const project = Array.isArray(input) ? flagValue(input, "--project") : input.project;
|
|
162
|
+
|
|
163
|
+
// --- Class 1: flag -------------------------------------------------------
|
|
164
|
+
const orgFlag = trimmed(org);
|
|
165
|
+
if (orgFlag) {
|
|
166
|
+
return { orgId: assertOrgIdShape(orgFlag, "--org"), source: "flag", sourceDetail: "--org" };
|
|
167
|
+
}
|
|
168
|
+
const projectFlag = trimmed(project);
|
|
169
|
+
if (projectFlag) {
|
|
170
|
+
const orgId = await orgOfProject(projectFlag, { required: true });
|
|
171
|
+
if (orgId) return { orgId, source: "flag", sourceDetail: "--project" };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// --- Class 2 and 3: environment vs binding (the conflict pair) ------------
|
|
175
|
+
const bindingHit = findBindingKey(cwd, "org");
|
|
176
|
+
const bindingOrg = bindingHit ? assertOrgIdShape(bindingHit.value, bindingHit.file) : null;
|
|
177
|
+
|
|
178
|
+
const envDirect = trimmed(env[ORG_ENV]);
|
|
179
|
+
const envRoomOrg = envDirect ? null : orgFromRoomEnv(env);
|
|
180
|
+
const envProject = envDirect || envRoomOrg ? null : trimmed(env[PROJECT_ENV]);
|
|
181
|
+
|
|
182
|
+
let envOrg = null;
|
|
183
|
+
let envDetail = null;
|
|
184
|
+
if (envDirect) {
|
|
185
|
+
envOrg = assertOrgIdShape(envDirect, ORG_ENV);
|
|
186
|
+
envDetail = ORG_ENV;
|
|
187
|
+
} else if (envRoomOrg) {
|
|
188
|
+
envOrg = assertOrgIdShape(envRoomOrg, ROOM_ENV);
|
|
189
|
+
envDetail = ROOM_ENV;
|
|
190
|
+
} else if (envProject && bindingOrg) {
|
|
191
|
+
// Only worth a lookup when a binding exists to disagree with; otherwise the
|
|
192
|
+
// env class wins uncontested and the lookup happens once, below.
|
|
193
|
+
envOrg = await orgOfProject(envProject, { required: false });
|
|
194
|
+
envDetail = PROJECT_ENV;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (envOrg && bindingOrg && envOrg !== bindingOrg && !CONFLICT_EXEMPT.has(cmd)) {
|
|
198
|
+
// The wallet tier's rule, scoped to the same pair. A binding deliberately
|
|
199
|
+
// declared by a checkout outranks the profile's selection SILENTLY — that
|
|
200
|
+
// is what a binding is for — but an ambient env var disagreeing with a
|
|
201
|
+
// committed file is exactly the surprise worth stopping on.
|
|
202
|
+
fail({
|
|
203
|
+
code: "AMBIGUOUS_ORG",
|
|
204
|
+
message: `Ambiguous organization: ${envDetail}=${envOrg} but ${bindingHit.file} binds ${bindingOrg}.`,
|
|
205
|
+
hint: `Resolve with one of: pass --org <org_id>, unset ${envDetail}, or edit the binding file.`,
|
|
206
|
+
details: {
|
|
207
|
+
candidates: [
|
|
208
|
+
{ org_id: envOrg, source: "env", source_detail: envDetail },
|
|
209
|
+
{ org_id: bindingOrg, source: "binding", source_detail: bindingHit.file },
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
next_actions: [
|
|
213
|
+
nextAction("edit_request", {
|
|
214
|
+
command: "run402 <command> --org <org_id>",
|
|
215
|
+
why: "The flag resolves the conflict for this call.",
|
|
216
|
+
}),
|
|
217
|
+
],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (envOrg) return { orgId: envOrg, source: "env", sourceDetail: envDetail };
|
|
222
|
+
if (envProject) {
|
|
223
|
+
const orgId = await orgOfProject(envProject, { required: false });
|
|
224
|
+
if (orgId) return { orgId, source: "env", sourceDetail: PROJECT_ENV };
|
|
225
|
+
}
|
|
226
|
+
if (bindingOrg) return { orgId: bindingOrg, source: "binding", sourceDetail: bindingHit.file };
|
|
227
|
+
|
|
228
|
+
// --- Class 4: profile state ----------------------------------------------
|
|
229
|
+
const selected = trimmed(coreGetActiveOrgId());
|
|
230
|
+
if (selected) {
|
|
231
|
+
return { orgId: assertOrgIdShape(selected, "org use"), source: "profile", sourceDetail: "org use" };
|
|
232
|
+
}
|
|
233
|
+
const activeProject = trimmed(getActiveProjectId());
|
|
234
|
+
if (activeProject) {
|
|
235
|
+
const orgId = await orgOfProject(activeProject, { required: false });
|
|
236
|
+
if (orgId) return { orgId, source: "profile", sourceDetail: "projects use" };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (optional) return null;
|
|
240
|
+
fail({
|
|
241
|
+
code: "ORG_REQUIRED",
|
|
242
|
+
message: "No organization specified and no current organization set.",
|
|
243
|
+
hint: `Pass --org <org_id>, set ${ORG_ENV}, bind this directory in .run402.json, or run: run402 org use <org_id>`,
|
|
244
|
+
next_actions: orgRequiredActions(),
|
|
245
|
+
});
|
|
246
|
+
return null; // unreachable — fail() exits
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Resolve the addressed organization id from normalized argv.
|
|
251
|
+
*
|
|
252
|
+
* The Wave E signature, preserved: callers that only need the id keep passing
|
|
253
|
+
* argv and getting a string back. Use {@link resolveOrg} when the provenance
|
|
254
|
+
* pair is wanted too.
|
|
255
|
+
*/
|
|
256
|
+
export async function resolveOrgId(a, opts = {}) {
|
|
257
|
+
const resolved = await resolveOrg(a, opts);
|
|
258
|
+
return resolved ? resolved.orgId : null;
|
|
259
|
+
}
|
|
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
|
+
|
|
266
|
+
/** The profile's selected organization, without running the chain. */
|
|
267
|
+
export function getSelectedOrgId() {
|
|
268
|
+
return trimmed(coreGetActiveOrgId());
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Record the profile's selected organization. */
|
|
272
|
+
export function setSelectedOrgId(orgId, origin = "org use") {
|
|
273
|
+
coreSetActiveOrgId(assertOrgIdShape(orgId, origin));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Clear the profile's selected organization. */
|
|
277
|
+
export function clearSelectedOrgId() {
|
|
278
|
+
coreClearActiveOrgId();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Stamp a project's owning organization as the profile selection.
|
|
283
|
+
*
|
|
284
|
+
* Called by `projects use`: a project determines its organization
|
|
285
|
+
* unambiguously, so selecting one keeps the two lowest classes from ever
|
|
286
|
+
* disagreeing in practice — and gives an existing user a current org without
|
|
287
|
+
* reading a changelog. Best-effort: a failure here must never fail the project
|
|
288
|
+
* selection the caller actually asked for.
|
|
289
|
+
*/
|
|
290
|
+
export async function stampOrgFromProject(projectId) {
|
|
291
|
+
try {
|
|
292
|
+
const orgId = await orgOfProject(projectId, { required: false });
|
|
293
|
+
if (orgId) {
|
|
294
|
+
coreSetActiveOrgId(orgId);
|
|
295
|
+
return orgId;
|
|
296
|
+
}
|
|
297
|
+
} catch {
|
|
298
|
+
/* best-effort */
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Provenance pair for command output: bounded, never a resolution trace. */
|
|
304
|
+
export function orgProvenance(resolved) {
|
|
305
|
+
return resolved
|
|
306
|
+
? { org_id: resolved.orgId, org_source: resolved.source, org_source_detail: resolved.sourceDetail }
|
|
307
|
+
: { org_id: null, org_source: null, org_source_detail: null };
|
|
27
308
|
}
|
|
28
309
|
|
|
29
310
|
/**
|
package/lib/org.mjs
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
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";
|
|
5
|
+
import {
|
|
6
|
+
requireOrgIdShape,
|
|
7
|
+
resolveOrg,
|
|
8
|
+
orgProvenance,
|
|
9
|
+
getSelectedOrgId,
|
|
10
|
+
setSelectedOrgId,
|
|
11
|
+
clearSelectedOrgId,
|
|
12
|
+
} from "./org-context.mjs";
|
|
3
13
|
import {
|
|
4
14
|
normalizeArgv,
|
|
5
15
|
assertKnownFlags,
|
|
6
16
|
flagValue,
|
|
17
|
+
positionalArgs,
|
|
7
18
|
parseIntegerFlag,
|
|
8
19
|
requirePositionalCount,
|
|
9
20
|
failUnknownSubcommand,
|
|
@@ -20,6 +31,11 @@ Usage:
|
|
|
20
31
|
run402 org rename <org_id> --name <display_name> (or: --clear to remove the label)
|
|
21
32
|
run402 org payout-wallet <org_id> --wallet <wallet_address> (or: --clear to remove the explicit default)
|
|
22
33
|
run402 org whoami
|
|
34
|
+
run402 org use <org_id>
|
|
35
|
+
run402 org current
|
|
36
|
+
run402 org clear
|
|
37
|
+
run402 org bind [--org <org_id>] [--room <key>]
|
|
38
|
+
run402 org unbind
|
|
23
39
|
run402 org audit <org_id> [--limit N] [--after <cursor>] [--before <cursor>]
|
|
24
40
|
run402 org member list <org_id>
|
|
25
41
|
run402 org member add <org_id> --wallet <wallet_address> [--role <role>]
|
|
@@ -37,6 +53,11 @@ Subcommands:
|
|
|
37
53
|
list Orgs you are a member of
|
|
38
54
|
get Read one org (label + tier/lease + your role)
|
|
39
55
|
rename Set or clear an org's display label (owner-only)
|
|
56
|
+
use Select the current org for this wallet profile
|
|
57
|
+
current Report the resolved current org and where it came from
|
|
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
|
|
40
61
|
payout-wallet Set or clear the tenant route payout wallet (admin+)
|
|
41
62
|
whoami Resolved principal + org memberships (GET /agent/v1/whoami)
|
|
42
63
|
member Manage members (list, add, role, rm) — mutations require owner
|
|
@@ -198,6 +219,145 @@ async function whoami(args) {
|
|
|
198
219
|
}
|
|
199
220
|
}
|
|
200
221
|
|
|
222
|
+
// ── Current organization (add-cli-current-org) ─────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// The selection is per WALLET PROFILE, not global: the chain is
|
|
225
|
+
// wallet -> principal -> memberships, so a global selection survives
|
|
226
|
+
// `wallets use other` and then either 403s or silently resolves to a
|
|
227
|
+
// valid-but-wrong org when both principals are members.
|
|
228
|
+
|
|
229
|
+
async function use(args) {
|
|
230
|
+
const a = normalizeArgv(args);
|
|
231
|
+
assertKnownFlags(a, ["--help", "-h"]);
|
|
232
|
+
requirePositionalCount(a, [], {
|
|
233
|
+
min: 1, max: 1, command: "run402 org use <org_id>", missing: "<org_id>",
|
|
234
|
+
});
|
|
235
|
+
const orgId = positionalArgs(a, [])[0];
|
|
236
|
+
setSelectedOrgId(orgId);
|
|
237
|
+
console.log(JSON.stringify({ org_id: orgId, selected: true, scope: "wallet_profile" }, null, 2));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function clear(args) {
|
|
241
|
+
const a = normalizeArgv(args);
|
|
242
|
+
assertKnownFlags(a, ["--help", "-h"]);
|
|
243
|
+
requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org clear" });
|
|
244
|
+
const previous = getSelectedOrgId();
|
|
245
|
+
clearSelectedOrgId();
|
|
246
|
+
console.log(JSON.stringify({ org_id: null, selected: false, previous_org_id: previous ?? null }, null, 2));
|
|
247
|
+
}
|
|
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
|
+
|
|
342
|
+
async function current(args) {
|
|
343
|
+
const a = normalizeArgv(args);
|
|
344
|
+
assertKnownFlags(a, ["--help", "-h"]);
|
|
345
|
+
requirePositionalCount(a, [], { min: 0, max: 0, command: "run402 org current" });
|
|
346
|
+
try {
|
|
347
|
+
// `cmd: "org"` exempts this from the ambiguity error on purpose: the
|
|
348
|
+
// command that reports the selection must stay usable while it is ambiguous.
|
|
349
|
+
// `optional` keeps an empty selection an explicit null state rather than a
|
|
350
|
+
// failure — reporting is not acting.
|
|
351
|
+
const resolved = await resolveOrg([], { cmd: "org", optional: true });
|
|
352
|
+
console.log(JSON.stringify({
|
|
353
|
+
...orgProvenance(resolved),
|
|
354
|
+
selected_org_id: getSelectedOrgId() ?? null,
|
|
355
|
+
}, null, 2));
|
|
356
|
+
} catch (err) {
|
|
357
|
+
reportSdkError(err);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
201
361
|
async function get(args) {
|
|
202
362
|
const a = normalizeArgv(args);
|
|
203
363
|
assertKnownFlags(a, ["--help", "-h"]);
|
|
@@ -471,6 +631,11 @@ export async function run(sub, args) {
|
|
|
471
631
|
case "rename": await rename(args); break;
|
|
472
632
|
case "payout-wallet": await payoutWallet(args); break;
|
|
473
633
|
case "whoami": await whoami(args); break;
|
|
634
|
+
case "use": await use(args); break;
|
|
635
|
+
case "current": await current(args); break;
|
|
636
|
+
case "clear": await clear(args); break;
|
|
637
|
+
case "bind": await bind(args); break;
|
|
638
|
+
case "unbind": await unbind(args); break;
|
|
474
639
|
case "audit": await audit(args); break;
|
|
475
640
|
default:
|
|
476
641
|
failUnknownSubcommand("org", sub);
|
package/lib/projects.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import { loadLiveControlPlaneSession } from "../core-dist/control-plane-session.
|
|
|
5
5
|
import { withAutoApprove } from "./operator.mjs";
|
|
6
6
|
import { getSdk } from "./sdk.mjs";
|
|
7
7
|
import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
|
|
8
|
+
import { stampOrgFromProject } from "./org-context.mjs";
|
|
8
9
|
import { assertKnownFlags, failBadProjectId, flagValue, hasHelp, normalizeArgv, positionalArgs, resolveProjectSelector, validateRegularFile, failUnknownSubcommand } from "./argparse.mjs";
|
|
9
10
|
|
|
10
11
|
const HELP = `run402 projects — Manage your deployed Run402 projects
|
|
@@ -802,7 +803,11 @@ async function use(projectId) {
|
|
|
802
803
|
}
|
|
803
804
|
try {
|
|
804
805
|
await getSdk().projects.use(projectId);
|
|
805
|
-
|
|
806
|
+
// A project determines its org unambiguously, so selecting one also makes
|
|
807
|
+
// that org current (add-cli-current-org D4). Best-effort by construction:
|
|
808
|
+
// the project selection the caller asked for must not fail on this.
|
|
809
|
+
const orgId = await stampOrgFromProject(projectId);
|
|
810
|
+
console.log(JSON.stringify({ active_project_id: projectId, set: true, org_id: orgId ?? null }));
|
|
806
811
|
} catch (err) {
|
|
807
812
|
reportSdkError(err);
|
|
808
813
|
}
|
package/lib/rooms-context.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Room addressing (precedence):
|
|
6
6
|
* --org <org_id> + --room <key> explicit (named org rooms)
|
|
7
7
|
* RUN402_ROOM=<org_id>/<key> env form of the same
|
|
8
|
+
* <room key> + the shared chain a room key from --room or the binding
|
|
9
|
+
* file's `room`, with the ORG resolved by
|
|
10
|
+
* `org-context.mjs` — this is what reaches a
|
|
11
|
+
* named room in a checkout with no project
|
|
8
12
|
* (default) the project's DEFAULT room — the room key
|
|
9
13
|
* IS the project id; org resolved via the
|
|
10
14
|
* project overview (`rooms.forProject`).
|
|
@@ -21,8 +25,11 @@
|
|
|
21
25
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
22
26
|
import { join } from "node:path";
|
|
23
27
|
import { fail } from "./sdk-errors.mjs";
|
|
24
|
-
import {
|
|
28
|
+
import { getActiveProjectId } from "./config.mjs";
|
|
25
29
|
import { getSdk } from "./sdk.mjs";
|
|
30
|
+
import { resolveOrg } from "./org-context.mjs";
|
|
31
|
+
import { findBindingKey } from "./wallet-context.mjs";
|
|
32
|
+
import { nextAction } from "./next-actions.mjs";
|
|
26
33
|
|
|
27
34
|
export const ROOM_ENV = "RUN402_ROOM";
|
|
28
35
|
export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
|
|
@@ -30,16 +37,22 @@ export const PRESENCE_ENV = "RUN402_PRESENCE_ID";
|
|
|
30
37
|
const STATE_DIR = ".run402";
|
|
31
38
|
const STATE_FILE = "messaging.json";
|
|
32
39
|
|
|
33
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the addressed room to { orgId, roomKey } (one SDK lookup at most).
|
|
42
|
+
*
|
|
43
|
+
* A room is a PAIR, and this function owns the pair. What it no longer owns is
|
|
44
|
+
* the org half of its own fallback: below the explicit forms, the org comes
|
|
45
|
+
* from the shared chain in `org-context.mjs` (flag → env → binding → profile
|
|
46
|
+
* selection → active project) while the room key comes from `--room`, the
|
|
47
|
+
* binding file's `room` key, or the project's default room. That is what makes
|
|
48
|
+
* a named room reachable in a checkout with no project link at all.
|
|
49
|
+
*/
|
|
34
50
|
export async function resolveRoom({ org, room, project } = {}) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
if (org && room) return { orgId: org, roomKey: room };
|
|
51
|
+
// 1. Both halves named explicitly.
|
|
52
|
+
if (org && room) return { orgId: org, roomKey: room, orgSource: "flag", orgSourceDetail: "--org" };
|
|
53
|
+
|
|
54
|
+
// 2. The compound env form names both, and keeps outranking everything below
|
|
55
|
+
// it — a harness wired with RUN402_ROOM selects exactly the room it names.
|
|
43
56
|
const envRoom = (process.env[ROOM_ENV] ?? "").trim();
|
|
44
57
|
if (envRoom) {
|
|
45
58
|
const slash = envRoom.indexOf("/");
|
|
@@ -50,11 +63,61 @@ export async function resolveRoom({ org, room, project } = {}) {
|
|
|
50
63
|
details: { value: envRoom },
|
|
51
64
|
});
|
|
52
65
|
}
|
|
53
|
-
return {
|
|
66
|
+
return {
|
|
67
|
+
orgId: envRoom.slice(0, slash),
|
|
68
|
+
roomKey: envRoom.slice(slash + 1),
|
|
69
|
+
orgSource: "env",
|
|
70
|
+
orgSourceDetail: ROOM_ENV,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 3. A room key without an org: the org resolves through the shared chain.
|
|
75
|
+
// (`--room` alone used to be a hard error; it now works whenever the org
|
|
76
|
+
// resolves, and fails with ORG_REQUIRED — naming every way to supply one —
|
|
77
|
+
// when it does not.)
|
|
78
|
+
const bindingRoom = findBindingKey(process.cwd(), "room");
|
|
79
|
+
const roomKey = room ?? bindingRoom?.value ?? null;
|
|
80
|
+
if (roomKey) {
|
|
81
|
+
const resolved = await resolveOrg({ org, project }, { cmd: "rooms" });
|
|
82
|
+
return {
|
|
83
|
+
orgId: resolved.orgId,
|
|
84
|
+
roomKey,
|
|
85
|
+
orgSource: resolved.source,
|
|
86
|
+
orgSourceDetail: resolved.sourceDetail,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 4. No room key anywhere: the project's DEFAULT room, whose key IS the
|
|
91
|
+
// project id — the zero-config rendezvous carried by run402.config.json.
|
|
92
|
+
const effectiveProject = project || (process.env.RUN402_PROJECT_ID ?? "").trim() || getActiveProjectId();
|
|
93
|
+
if (!effectiveProject) {
|
|
94
|
+
fail({
|
|
95
|
+
code: "ROOM_REQUIRED",
|
|
96
|
+
message: "No room addressed and no current project to take a default room from.",
|
|
97
|
+
hint: `Pass --org <org_id> --room <key>, set ${ROOM_ENV}="<org_id>/<room_key>", add a "room" key to .run402.json, or select a project with: run402 projects use <project_id>`,
|
|
98
|
+
next_actions: [
|
|
99
|
+
nextAction("edit_request", {
|
|
100
|
+
command: "run402 rooms who --org <org_id> --room <key>",
|
|
101
|
+
why: "Name the organization and room on this one call.",
|
|
102
|
+
}),
|
|
103
|
+
nextAction("edit_request", {
|
|
104
|
+
command: `echo '{"org":"<org_id>","room":"<key>"}' > .run402.json`,
|
|
105
|
+
why: "Bind this checkout so every agent in it lands in the same room with no flags.",
|
|
106
|
+
}),
|
|
107
|
+
nextAction("edit_request", {
|
|
108
|
+
command: "run402 projects use <project_id>",
|
|
109
|
+
why: "Select a project to use its default room (the room key IS the project id).",
|
|
110
|
+
}),
|
|
111
|
+
],
|
|
112
|
+
});
|
|
54
113
|
}
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
114
|
+
const scoped = await getSdk().rooms.forProject(effectiveProject);
|
|
115
|
+
return {
|
|
116
|
+
orgId: scoped.orgId,
|
|
117
|
+
roomKey: scoped.roomKey,
|
|
118
|
+
orgSource: "profile",
|
|
119
|
+
orgSourceDetail: "projects use",
|
|
120
|
+
};
|
|
58
121
|
}
|
|
59
122
|
|
|
60
123
|
function statePath() {
|
package/lib/rooms.mjs
CHANGED
|
@@ -50,6 +50,12 @@ Room addressing (all subcommands):
|
|
|
50
50
|
--org <org_id> --room <key>
|
|
51
51
|
A named org room (multi-repo products); also
|
|
52
52
|
RUN402_ROOM=<org_id>/<key>.
|
|
53
|
+
--room <key> A named org room whose ORG comes from the current-org
|
|
54
|
+
chain: --org / RUN402_ORG / the 'org' key of the nearest
|
|
55
|
+
.run402.json / 'run402 org use'. A checkout that hosts
|
|
56
|
+
nothing on run402 reaches a room this way.
|
|
57
|
+
.run402.json {"org":"<org_id>","room":"<key>"} binds a checkout, so
|
|
58
|
+
every agent in it lands in the same room with no flags.
|
|
53
59
|
|
|
54
60
|
Subcommands:
|
|
55
61
|
who Who is live in the room (name, task, active claims). Registers your
|
|
@@ -155,6 +161,8 @@ async function who(args) {
|
|
|
155
161
|
});
|
|
156
162
|
console.log(JSON.stringify({
|
|
157
163
|
org_id: room.orgId,
|
|
164
|
+
org_source: room.orgSource ?? null,
|
|
165
|
+
org_source_detail: room.orgSourceDetail ?? null,
|
|
158
166
|
room_key: room.roomKey,
|
|
159
167
|
you: me,
|
|
160
168
|
...page,
|
package/lib/wallet-context.mjs
CHANGED
|
@@ -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
|
|
@@ -69,14 +70,14 @@ export function splitWalletFlag(rawArgv = []) {
|
|
|
69
70
|
return { argv, walletFlag: flag };
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
function
|
|
73
|
+
function readBindingKeyFrom(dir, key) {
|
|
73
74
|
// .run402.local.json (gitignored personal override) beats .run402.json.
|
|
74
75
|
for (const fname of [".run402.local.json", ".run402.json"]) {
|
|
75
76
|
const p = join(dir, fname);
|
|
76
77
|
try {
|
|
77
78
|
const parsed = JSON.parse(readFileSync(p, "utf8"));
|
|
78
|
-
const
|
|
79
|
-
if (typeof
|
|
79
|
+
const v = parsed?.[key];
|
|
80
|
+
if (typeof v === "string" && v.trim()) return { value: v.trim(), file: p };
|
|
80
81
|
} catch {
|
|
81
82
|
/* missing / unreadable / malformed → skip */
|
|
82
83
|
}
|
|
@@ -84,18 +85,71 @@ function readBindingFrom(dir) {
|
|
|
84
85
|
return null;
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
/**
|
|
88
|
-
export function
|
|
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
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Nearest binding carrying `key`, walking up from `startDir` to the root.
|
|
129
|
+
*
|
|
130
|
+
* Keys bind INDEPENDENTLY (add-cli-current-org, design D7): a file declaring
|
|
131
|
+
* only `org` leaves wallet resolution untouched, and each key resolves at the
|
|
132
|
+
* nearest file that carries it — so `/work/.run402.json` may supply the org
|
|
133
|
+
* while `/work/api/.run402.json` supplies the wallet. Unknown keys are ignored,
|
|
134
|
+
* which is what makes older CLIs forward-compatible with these files.
|
|
135
|
+
*/
|
|
136
|
+
export function findBindingKey(startDir, key) {
|
|
89
137
|
let dir = resolve(startDir);
|
|
90
138
|
for (;;) {
|
|
91
|
-
const
|
|
92
|
-
if (
|
|
139
|
+
const hit = readBindingKeyFrom(dir, key);
|
|
140
|
+
if (hit) return hit;
|
|
93
141
|
const parent = dirname(dir);
|
|
94
142
|
if (parent === dir) return null;
|
|
95
143
|
dir = parent;
|
|
96
144
|
}
|
|
97
145
|
}
|
|
98
146
|
|
|
147
|
+
/** Nearest wallet binding walking up from `startDir` to the filesystem root. */
|
|
148
|
+
export function findBinding(startDir) {
|
|
149
|
+
const hit = findBindingKey(startDir, "wallet");
|
|
150
|
+
return hit ? { wallet: hit.value, file: hit.file } : null;
|
|
151
|
+
}
|
|
152
|
+
|
|
99
153
|
function assertValidName(name, origin) {
|
|
100
154
|
if (name === DEFAULT || isValidProfileName(name)) return;
|
|
101
155
|
fail({
|
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
|
-
|
|
212
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
@@ -126,6 +126,52 @@ export function clearActiveProjectId(projectId, path, scope = {}) {
|
|
|
126
126
|
saveProfileState(state, p);
|
|
127
127
|
});
|
|
128
128
|
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Selected organization (add-cli-current-org, design D3).
|
|
131
|
+
//
|
|
132
|
+
// Scoped exactly like the active project id — by api_base, profile, and
|
|
133
|
+
// principal — and stored in the SAME per-profile state.json, never in the
|
|
134
|
+
// base-level config.json beside `active_wallet`. The chain is
|
|
135
|
+
// wallet -> principal -> memberships, so a globally-selected org survives
|
|
136
|
+
// `wallets use other` and then either 403s or, worse, silently resolves to a
|
|
137
|
+
// valid-but-wrong org when both principals are members. Per-profile makes the
|
|
138
|
+
// failure mode "this profile has no current org", which the ORG_REQUIRED
|
|
139
|
+
// envelope already knows how to explain.
|
|
140
|
+
//
|
|
141
|
+
// There is deliberately no top-level `active_org_id` mirror: the project one
|
|
142
|
+
// exists only to carry pre-scoping installs forward, and this key is new.
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
export function getActiveOrgId(path, scope = {}) {
|
|
145
|
+
const state = loadProfileState(path);
|
|
146
|
+
return state.active_orgs?.[activeProjectScopeKey(scope)]?.org_id;
|
|
147
|
+
}
|
|
148
|
+
export function setActiveOrgId(orgId, path, scope = {}) {
|
|
149
|
+
const p = path ?? getProfileStatePath();
|
|
150
|
+
withFileLock(p, () => {
|
|
151
|
+
const state = loadProfileState(p);
|
|
152
|
+
const key = activeProjectScopeKey(scope);
|
|
153
|
+
const resolved = defaultActiveProjectScope(scope);
|
|
154
|
+
state.active_orgs = state.active_orgs ?? {};
|
|
155
|
+
state.active_orgs[key] = {
|
|
156
|
+
org_id: orgId,
|
|
157
|
+
api_base: resolved.api_base,
|
|
158
|
+
principal: resolved.principal ?? null,
|
|
159
|
+
profile: resolved.profile,
|
|
160
|
+
updated_at: new Date().toISOString(),
|
|
161
|
+
};
|
|
162
|
+
saveProfileState(state, p);
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
export function clearActiveOrgId(path, scope = {}) {
|
|
166
|
+
const p = path ?? getProfileStatePath();
|
|
167
|
+
withFileLock(p, () => {
|
|
168
|
+
const state = loadProfileState(p);
|
|
169
|
+
const key = activeProjectScopeKey(scope);
|
|
170
|
+
if (state.active_orgs?.[key])
|
|
171
|
+
delete state.active_orgs[key];
|
|
172
|
+
saveProfileState(state, p);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
129
175
|
export function recordMigration(marker, value, path) {
|
|
130
176
|
const p = path ?? getProfileStatePath();
|
|
131
177
|
withFileLock(p, () => {
|