run402 2.39.4 → 2.41.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/cli.mjs +1 -1
- package/core-dist/control-plane-session.js +114 -0
- package/lib/operator.mjs +203 -12
- package/lib/org.mjs +205 -103
- package/lib/transfer.mjs +94 -27
- package/package.json +1 -1
- package/sdk/core-dist/control-plane-session.js +114 -0
- package/sdk/dist/control-plane-credentials.d.ts +45 -0
- package/sdk/dist/control-plane-credentials.d.ts.map +1 -0
- package/sdk/dist/control-plane-credentials.js +57 -0
- package/sdk/dist/control-plane-credentials.js.map +1 -0
- package/sdk/dist/errors.d.ts +31 -1
- package/sdk/dist/errors.d.ts.map +1 -1
- package/sdk/dist/errors.js +59 -0
- package/sdk/dist/errors.js.map +1 -1
- package/sdk/dist/index.d.ts +6 -2
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +4 -2
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/kernel.d.ts.map +1 -1
- package/sdk/dist/kernel.js +4 -1
- package/sdk/dist/kernel.js.map +1 -1
- package/sdk/dist/namespaces/operator-session.d.ts +223 -0
- package/sdk/dist/namespaces/operator-session.d.ts.map +1 -0
- package/sdk/dist/namespaces/operator-session.js +230 -0
- package/sdk/dist/namespaces/operator-session.js.map +1 -0
- package/sdk/dist/namespaces/operator.d.ts +63 -0
- package/sdk/dist/namespaces/operator.d.ts.map +1 -1
- package/sdk/dist/namespaces/operator.js +51 -0
- package/sdk/dist/namespaces/operator.js.map +1 -1
- package/sdk/dist/namespaces/org.d.ts +55 -23
- package/sdk/dist/namespaces/org.d.ts.map +1 -1
- package/sdk/dist/namespaces/org.js +117 -52
- package/sdk/dist/namespaces/org.js.map +1 -1
- package/sdk/dist/namespaces/org.types.d.ts +37 -1
- package/sdk/dist/namespaces/org.types.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.d.ts +58 -0
- package/sdk/dist/namespaces/transfers.d.ts.map +1 -1
- package/sdk/dist/namespaces/transfers.js +40 -0
- package/sdk/dist/namespaces/transfers.js.map +1 -1
package/cli.mjs
CHANGED
|
@@ -30,7 +30,7 @@ Commands:
|
|
|
30
30
|
deploy Unified deploy operations (requires active tier)
|
|
31
31
|
ci Link GitHub Actions OIDC deploy bindings
|
|
32
32
|
transfer Two-party project transfer (init, preview, list, accept, cancel)
|
|
33
|
-
org Org membership
|
|
33
|
+
org Org membership, invites & audit (whoami, list, member, invite, audit)
|
|
34
34
|
grants Per-project capability grants for agent/CI principals (create, revoke)
|
|
35
35
|
jobs Submit and inspect fixed platform-managed jobs
|
|
36
36
|
functions Manage serverless functions (deploy, invoke, logs, list, delete)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync, rmSync, } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { getConfigBaseDir } from "./config.js";
|
|
5
|
+
/**
|
|
6
|
+
* Path to the cached control-plane session: `{base}/control-plane-session.json`.
|
|
7
|
+
* `RUN402_CONTROL_PLANE_SESSION_PATH` overrides for testing.
|
|
8
|
+
*/
|
|
9
|
+
export function getControlPlaneSessionPath() {
|
|
10
|
+
return (process.env.RUN402_CONTROL_PLANE_SESSION_PATH ||
|
|
11
|
+
join(getConfigBaseDir(), "control-plane-session.json"));
|
|
12
|
+
}
|
|
13
|
+
/** Tighten 0600 if group/other-readable, warning once. Best-effort, POSIX-only. */
|
|
14
|
+
function selfHealPermissions(p) {
|
|
15
|
+
if (process.platform === "win32")
|
|
16
|
+
return;
|
|
17
|
+
try {
|
|
18
|
+
const mode = statSync(p).mode & 0o777;
|
|
19
|
+
if ((mode & 0o077) !== 0) {
|
|
20
|
+
chmodSync(p, 0o600);
|
|
21
|
+
process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Best-effort; never block a read on a chmod/stat failure.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Load the cached control-plane session. Returns `null` for the "no session"
|
|
30
|
+
* cases (absent, unreadable, unparseable). Throws when the file parses as JSON
|
|
31
|
+
* but the shape is wrong, so a corrupted cache surfaces a clear fix-it.
|
|
32
|
+
*/
|
|
33
|
+
export function readControlPlaneSession(path) {
|
|
34
|
+
const p = path ?? getControlPlaneSessionPath();
|
|
35
|
+
if (!existsSync(p))
|
|
36
|
+
return null;
|
|
37
|
+
selfHealPermissions(p);
|
|
38
|
+
let raw;
|
|
39
|
+
try {
|
|
40
|
+
raw = readFileSync(p, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
let parsed;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(raw);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
53
|
+
throw new Error("control-plane-session.json must contain a JSON object. Delete it and run 'run402 operator login --loopback' to recreate it.");
|
|
54
|
+
}
|
|
55
|
+
const data = parsed;
|
|
56
|
+
if (typeof data.control_plane_session_token !== "string" ||
|
|
57
|
+
data.control_plane_session_token.length === 0) {
|
|
58
|
+
throw new Error("control-plane-session.json missing valid 'control_plane_session_token'. Run 'run402 operator login --loopback' to refresh it.");
|
|
59
|
+
}
|
|
60
|
+
if (typeof data.expires_at !== "number" || !Number.isFinite(data.expires_at)) {
|
|
61
|
+
throw new Error("control-plane-session.json missing valid 'expires_at'. Run 'run402 operator login --loopback' to refresh it.");
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
control_plane_session_token: data.control_plane_session_token,
|
|
65
|
+
token_type: typeof data.token_type === "string" ? data.token_type : "Bearer",
|
|
66
|
+
provenance: typeof data.provenance === "string" ? data.provenance : "loopback_pkce",
|
|
67
|
+
principal_id: typeof data.principal_id === "string" ? data.principal_id : "",
|
|
68
|
+
amr: Array.isArray(data.amr) ? data.amr.filter((a) => typeof a === "string") : [],
|
|
69
|
+
expires_at: data.expires_at,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Persist a control-plane session atomically (temp-file + rename), mode 0600. */
|
|
73
|
+
export function saveControlPlaneSession(data, path) {
|
|
74
|
+
const p = path ?? getControlPlaneSessionPath();
|
|
75
|
+
const dir = dirname(p);
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
const tmp = join(dir, `.control-plane-session.${randomBytes(4).toString("hex")}.tmp`);
|
|
78
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
79
|
+
renameSync(tmp, p);
|
|
80
|
+
chmodSync(p, 0o600);
|
|
81
|
+
}
|
|
82
|
+
/** Delete the cached control-plane session — local half of `operator logout`. Idempotent. */
|
|
83
|
+
export function clearControlPlaneSession(path) {
|
|
84
|
+
const p = path ?? getControlPlaneSessionPath();
|
|
85
|
+
try {
|
|
86
|
+
rmSync(p, { force: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
// Best-effort: a failed unlink should never crash logout.
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/** Whether a cached session is past its usable life (with a small skew buffer). */
|
|
93
|
+
export function isControlPlaneSessionExpired(session, nowMs = Date.now(), skewMs = 10_000) {
|
|
94
|
+
return nowMs + skewMs >= session.expires_at;
|
|
95
|
+
}
|
|
96
|
+
/** Read the cached session and return it only if still usable; `null` if absent or expired. */
|
|
97
|
+
export function loadLiveControlPlaneSession(path, nowMs = Date.now()) {
|
|
98
|
+
const s = readControlPlaneSession(path);
|
|
99
|
+
if (!s)
|
|
100
|
+
return null;
|
|
101
|
+
return isControlPlaneSessionExpired(s, nowMs) ? null : s;
|
|
102
|
+
}
|
|
103
|
+
/** Map a gateway token payload (relative `expires_in`) into the cached shape (absolute `expires_at`). */
|
|
104
|
+
export function controlPlaneSessionFromTokenResponse(resp, nowMs = Date.now()) {
|
|
105
|
+
return {
|
|
106
|
+
control_plane_session_token: resp.control_plane_session_token,
|
|
107
|
+
token_type: resp.token_type ?? "Bearer",
|
|
108
|
+
provenance: resp.provenance ?? "loopback_pkce",
|
|
109
|
+
principal_id: resp.principal_id ?? "",
|
|
110
|
+
amr: Array.isArray(resp.amr) ? resp.amr.filter((a) => typeof a === "string") : [],
|
|
111
|
+
expires_at: nowMs + (typeof resp.expires_in === "number" ? resp.expires_in : 0) * 1000,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
//# sourceMappingURL=control-plane-session.js.map
|
package/lib/operator.mjs
CHANGED
|
@@ -19,6 +19,8 @@
|
|
|
19
19
|
|
|
20
20
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
21
21
|
import { spawn } from "node:child_process";
|
|
22
|
+
import { createServer } from "node:http";
|
|
23
|
+
import { randomBytes, createHash } from "node:crypto";
|
|
22
24
|
import { fail, reportSdkError } from "./sdk-errors.mjs";
|
|
23
25
|
import { getSdk } from "./sdk.mjs";
|
|
24
26
|
import { normalizeArgv, hasHelp, assertKnownFlags } from "./argparse.mjs";
|
|
@@ -30,6 +32,13 @@ import {
|
|
|
30
32
|
isOperatorSessionExpired,
|
|
31
33
|
operatorSessionFromTokenResponse,
|
|
32
34
|
} from "../core-dist/operator-session.js";
|
|
35
|
+
import {
|
|
36
|
+
saveControlPlaneSession,
|
|
37
|
+
clearControlPlaneSession,
|
|
38
|
+
readControlPlaneSession,
|
|
39
|
+
isControlPlaneSessionExpired,
|
|
40
|
+
controlPlaneSessionFromTokenResponse,
|
|
41
|
+
} from "../core-dist/control-plane-session.js";
|
|
33
42
|
|
|
34
43
|
const CLIENT_NAME = "run402 CLI";
|
|
35
44
|
|
|
@@ -40,19 +49,25 @@ The operator is YOU, the human, identified by email — distinct from the agent
|
|
|
40
49
|
For a single wallet's account state, use 'run402 status'.
|
|
41
50
|
|
|
42
51
|
Usage:
|
|
43
|
-
run402 operator login [--no-open]
|
|
52
|
+
run402 operator login [--no-open] (read session, device-flow)
|
|
53
|
+
run402 operator login --loopback [--no-open] (write session, loopback-PKCE)
|
|
54
|
+
run402 operator login --step-up (fresh write session for high-stakes ops)
|
|
44
55
|
run402 operator overview
|
|
45
56
|
run402 operator whoami
|
|
46
57
|
run402 operator logout
|
|
47
58
|
|
|
48
59
|
Subcommands:
|
|
49
|
-
login Sign in via the browser
|
|
60
|
+
login Sign in via the browser. Default = device-flow READ session (powers
|
|
61
|
+
'overview'). --loopback = write-capable control-plane session
|
|
62
|
+
(aws-sso-style, passkey-fresh). --step-up = re-mint a fresh write session.
|
|
50
63
|
overview Account view across ALL wallets controlling your email (requires login)
|
|
51
|
-
whoami Show the cached session
|
|
52
|
-
logout Revoke the session server-side and clear the local cache
|
|
64
|
+
whoami Show the cached session(s) — local, no network
|
|
65
|
+
logout Revoke the session server-side and clear the local cache(s)
|
|
53
66
|
|
|
54
67
|
Options:
|
|
55
|
-
--no-open (login) Do not auto-open the browser; just print the URL
|
|
68
|
+
--no-open (login) Do not auto-open the browser; just print the URL.
|
|
69
|
+
--loopback (login) Use the loopback-PKCE write login instead of the device flow.
|
|
70
|
+
--step-up (login) Re-mint a fresh write session (implies --loopback).
|
|
56
71
|
|
|
57
72
|
Notes:
|
|
58
73
|
- The session is cached at the base config dir, shared across named wallets.
|
|
@@ -96,8 +111,173 @@ function openBrowser(url) {
|
|
|
96
111
|
}
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/** Output shape for the write (control-plane) session. NEVER includes the token. */
|
|
115
|
+
function controlPlaneView(session, nowMs = Date.now()) {
|
|
116
|
+
return {
|
|
117
|
+
logged_in: true,
|
|
118
|
+
kind: "control_plane_session",
|
|
119
|
+
provenance: session.provenance,
|
|
120
|
+
principal_id: session.principal_id || null,
|
|
121
|
+
amr: session.amr,
|
|
122
|
+
expires_at: new Date(session.expires_at).toISOString(),
|
|
123
|
+
expires_in_seconds: Math.max(0, Math.round((session.expires_at - nowMs) / 1000)),
|
|
124
|
+
write_capable: true,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const base64url = (buf) => buf.toString("base64url");
|
|
129
|
+
|
|
130
|
+
/** Generate PKCE (S256) + CSRF state + replay nonce for the loopback flow. */
|
|
131
|
+
function pkce() {
|
|
132
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
133
|
+
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
134
|
+
return { codeVerifier, codeChallenge, state: base64url(randomBytes(16)), nonce: base64url(randomBytes(16)) };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Start a 127.0.0.1 loopback server (RFC 8252) that captures exactly one
|
|
139
|
+
* redirect. Returns the bound port (via `ready`), a promise for the auth code,
|
|
140
|
+
* and a `close()`. State is validated here to reject CSRF before exchange.
|
|
141
|
+
*/
|
|
142
|
+
function startLoopbackServer({ expectedState, timeoutMs }) {
|
|
143
|
+
let resolveCode;
|
|
144
|
+
let rejectCode;
|
|
145
|
+
const codePromise = new Promise((res, rej) => {
|
|
146
|
+
resolveCode = res;
|
|
147
|
+
rejectCode = rej;
|
|
148
|
+
});
|
|
149
|
+
let timer;
|
|
150
|
+
const server = createServer((req, res) => {
|
|
151
|
+
let u;
|
|
152
|
+
try {
|
|
153
|
+
u = new URL(req.url, "http://127.0.0.1");
|
|
154
|
+
} catch {
|
|
155
|
+
res.writeHead(400).end("bad request");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (u.pathname !== "/callback") {
|
|
159
|
+
res.writeHead(404).end("not found");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const code = u.searchParams.get("code");
|
|
163
|
+
const gotState = u.searchParams.get("state");
|
|
164
|
+
const errParam = u.searchParams.get("error");
|
|
165
|
+
res.writeHead(200, { "content-type": "text/html" });
|
|
166
|
+
res.end(
|
|
167
|
+
"<!doctype html><html><body style=\"font-family:system-ui;padding:3rem\">" +
|
|
168
|
+
"<h2>run402 - you're signed in.</h2><p>You can close this window and return to your terminal.</p></body></html>",
|
|
169
|
+
);
|
|
170
|
+
cleanup();
|
|
171
|
+
if (errParam) rejectCode(new Error(`authorization error: ${errParam}`));
|
|
172
|
+
else if (!code) rejectCode(new Error("no authorization code on the loopback redirect"));
|
|
173
|
+
else if (gotState !== expectedState) rejectCode(new Error("state mismatch on the loopback redirect (possible CSRF) - aborted"));
|
|
174
|
+
else resolveCode(code);
|
|
175
|
+
});
|
|
176
|
+
function cleanup() {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
try {
|
|
179
|
+
server.close();
|
|
180
|
+
} catch {
|
|
181
|
+
// already closing
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
timer = setTimeout(() => {
|
|
185
|
+
cleanup();
|
|
186
|
+
rejectCode(new Error("timed out waiting for browser approval"));
|
|
187
|
+
}, timeoutMs);
|
|
188
|
+
server.on("error", (e) => {
|
|
189
|
+
cleanup();
|
|
190
|
+
rejectCode(e);
|
|
191
|
+
});
|
|
192
|
+
const ready = new Promise((res, rej) => {
|
|
193
|
+
server.once("error", rej);
|
|
194
|
+
server.listen(0, "127.0.0.1", () => res(server.address().port));
|
|
195
|
+
});
|
|
196
|
+
return { ready, codePromise, close: cleanup };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Loopback-PKCE write-login (RFC 8252, the aws-sso-style flow). Mints a
|
|
201
|
+
* write-capable control-plane session via the browser passkey ceremony, caches
|
|
202
|
+
* it (mode 0600), and prints metadata only - never the token.
|
|
203
|
+
*/
|
|
204
|
+
async function loopbackLogin(args, { stepUp }) {
|
|
205
|
+
const noOpen = args.includes("--no-open");
|
|
206
|
+
const sdk = getSdk();
|
|
207
|
+
const { codeVerifier, codeChallenge, state, nonce } = pkce();
|
|
208
|
+
const { ready, codePromise, close } = startLoopbackServer({ expectedState: state, timeoutMs: 300_000 });
|
|
209
|
+
|
|
210
|
+
let port;
|
|
211
|
+
try {
|
|
212
|
+
port = await ready;
|
|
213
|
+
} catch (err) {
|
|
214
|
+
close();
|
|
215
|
+
return fail({ code: "OPERATOR_LOOPBACK_FAILED", message: `Could not start the loopback server: ${err.message}` });
|
|
216
|
+
}
|
|
217
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
218
|
+
const authorizeUrl = sdk.operator.buildCliAuthorizeUrl({ redirectUri, codeChallenge, state, nonce });
|
|
219
|
+
|
|
220
|
+
process.stderr.write(
|
|
221
|
+
`\nTo ${stepUp ? "re-authenticate (step-up)" : "sign in (write-capable)"}, open:\n ${authorizeUrl}\n\n`,
|
|
222
|
+
);
|
|
223
|
+
if (!noOpen && process.stderr.isTTY) {
|
|
224
|
+
openBrowser(authorizeUrl);
|
|
225
|
+
process.stderr.write("(opening your browser…)\n\n");
|
|
226
|
+
}
|
|
227
|
+
process.stderr.write("Waiting for approval…\n");
|
|
228
|
+
|
|
229
|
+
let code;
|
|
230
|
+
try {
|
|
231
|
+
code = await codePromise;
|
|
232
|
+
} catch (err) {
|
|
233
|
+
close();
|
|
234
|
+
return fail({
|
|
235
|
+
code: "OPERATOR_LOGIN_FAILED",
|
|
236
|
+
message: err.message,
|
|
237
|
+
hint: "Run 'run402 operator login --loopback' to try again.",
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let session;
|
|
242
|
+
try {
|
|
243
|
+
session = await sdk.operator.exchangeCliToken({ code, codeVerifier, redirectUri, state });
|
|
244
|
+
} catch (err) {
|
|
245
|
+
return reportSdkError(err);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const cached = controlPlaneSessionFromTokenResponse(session);
|
|
249
|
+
saveControlPlaneSession(cached);
|
|
250
|
+
process.stderr.write(`\nSigned in (write-capable, provenance=${cached.provenance}).\n`);
|
|
251
|
+
|
|
252
|
+
// Surface org memberships — newly-active rows are invites auto-claimed at this
|
|
253
|
+
// login (owner/admin invites only claim once a passkey is enrolled). Best-effort:
|
|
254
|
+
// login already succeeded, so a whoami hiccup must not fail the command.
|
|
255
|
+
const view = controlPlaneView(cached);
|
|
256
|
+
try {
|
|
257
|
+
const who = await sdk.operator.session.whoami({ token: session.control_plane_session_token });
|
|
258
|
+
const memberships = Array.isArray(who?.memberships) ? who.memberships : [];
|
|
259
|
+
view.memberships = memberships;
|
|
260
|
+
if (memberships.length) {
|
|
261
|
+
process.stderr.write(
|
|
262
|
+
`Member of ${memberships.length} org(s):\n` +
|
|
263
|
+
memberships.map((m) => ` - ${m.billing_account_id} (${m.role}, ${m.status})`).join("\n") +
|
|
264
|
+
"\n",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
} catch {
|
|
268
|
+
/* best-effort — the session is valid regardless of the whoami result */
|
|
269
|
+
}
|
|
270
|
+
console.log(JSON.stringify(view));
|
|
271
|
+
}
|
|
272
|
+
|
|
99
273
|
async function login(args) {
|
|
100
|
-
assertKnownFlags(args, ["--help", "-h", "--no-open"]);
|
|
274
|
+
assertKnownFlags(args, ["--help", "-h", "--no-open", "--loopback", "--device", "--step-up"]);
|
|
275
|
+
// Loopback-PKCE = the write-capable control-plane login (--loopback/--step-up).
|
|
276
|
+
// The default stays the device-flow READ session (which powers 'overview');
|
|
277
|
+
// --device forces it explicitly.
|
|
278
|
+
if (args.includes("--loopback") || args.includes("--step-up")) {
|
|
279
|
+
return loopbackLogin(args, { stepUp: args.includes("--step-up") });
|
|
280
|
+
}
|
|
101
281
|
const noOpen = args.includes("--no-open");
|
|
102
282
|
const sdk = getSdk();
|
|
103
283
|
|
|
@@ -184,6 +364,7 @@ async function logout(args) {
|
|
|
184
364
|
}
|
|
185
365
|
}
|
|
186
366
|
clearOperatorSession();
|
|
367
|
+
clearControlPlaneSession();
|
|
187
368
|
console.log(JSON.stringify({ revoked, cleared: true }));
|
|
188
369
|
}
|
|
189
370
|
|
|
@@ -219,17 +400,27 @@ async function whoami(args) {
|
|
|
219
400
|
assertKnownFlags(args, ["--help", "-h"]);
|
|
220
401
|
const now = Date.now();
|
|
221
402
|
const session = readOperatorSession();
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
403
|
+
const cp = readControlPlaneSession();
|
|
404
|
+
const liveCp = cp && !isControlPlaneSessionExpired(cp, now) ? cp : null;
|
|
405
|
+
|
|
406
|
+
if (session && !isOperatorSessionExpired(session, now)) {
|
|
407
|
+
const view = sessionView(session, now);
|
|
408
|
+
if (liveCp) view.control_plane = controlPlaneView(liveCp, now);
|
|
409
|
+
console.log(JSON.stringify(view));
|
|
225
410
|
return;
|
|
226
411
|
}
|
|
227
|
-
|
|
228
|
-
|
|
412
|
+
// No live device-flow READ session — fall back to the write session if present.
|
|
413
|
+
if (liveCp) {
|
|
414
|
+
console.log(JSON.stringify(controlPlaneView(liveCp, now)));
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!session) {
|
|
418
|
+
console.log(JSON.stringify({ logged_in: false, reason: "no_session", hint: "Run 'run402 operator login' to sign in." }));
|
|
229
419
|
process.exitCode = 1;
|
|
230
420
|
return;
|
|
231
421
|
}
|
|
232
|
-
console.log(JSON.stringify(
|
|
422
|
+
console.log(JSON.stringify({ logged_in: false, reason: "expired", email: session.email, hint: "Run 'run402 operator login' to sign in again." }));
|
|
423
|
+
process.exitCode = 1;
|
|
233
424
|
}
|
|
234
425
|
|
|
235
426
|
export async function run(sub, args = []) {
|