@tokenoftrust/cli 1.3.4 → 1.4.0-rc.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 +17 -3
- package/bin/tot.mjs +7 -0
- package/package.json +1 -1
- package/src/auth.mjs +55 -77
- package/src/commands/checkout.mjs +24 -17
- package/src/commands/dev.mjs +2 -2
- package/src/commands/doctor.mjs +18 -12
- package/src/commands/feedback.mjs +4 -4
- package/src/commands/grants.mjs +264 -0
- package/src/commands/login.mjs +69 -13
- package/src/commands/start.mjs +30 -32
- package/src/commands/submit.mjs +195 -17
- package/src/commands/whoami.mjs +9 -12
- package/src/mcp.mjs +2 -2
- package/src/prompt.mjs +32 -0
- package/src/token-store.mjs +22 -2
- package/src/validate.mjs +8 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot grants` — what can the signed-in identity actually do on each store?
|
|
3
|
+
*
|
|
4
|
+
* A diagnostic sibling of `tot whoami`: where whoami answers "who am I and which
|
|
5
|
+
* stores can I touch", `grants` drills into the DEVELOPER-ACCESS GRANT behind each
|
|
6
|
+
* of those stores — its capability, ship-on-behalf tier, expiry, and whether it's
|
|
7
|
+
* been revoked. This is the RFC 7662-shaped introspection the MCP already runs
|
|
8
|
+
* internally on every `tenant_checkout` (tot-mcp: operator/grant-introspection.ts,
|
|
9
|
+
* `GrantIntrospector.introspect({developer, tenant}) -> {active, capability, tier,
|
|
10
|
+
* exp, revokedAt}`) — surfaced to the developer so a "why can't I check this store
|
|
11
|
+
* out?" is answerable without an operator.
|
|
12
|
+
*
|
|
13
|
+
* Forward-compatible by design: it PREFERS a server-side `grant_introspect` tool
|
|
14
|
+
* (the follow-up that exposes the introspector per the signed-in session's stores)
|
|
15
|
+
* and, until that tool ships, FALLS BACK to `client_list` — which today returns only
|
|
16
|
+
* the store ids + environment, no grant detail. In the fallback it renders the store
|
|
17
|
+
* list and says plainly that the capability/tier/expiry/revocation picture needs the
|
|
18
|
+
* server-side tool, rather than inventing numbers it can't see. The moment
|
|
19
|
+
* `grant_introspect` lands, the same command consumes it with no further CLI change.
|
|
20
|
+
*
|
|
21
|
+
* Auth/session and MCP transport are reused verbatim from the doctor/whoami pattern
|
|
22
|
+
* (establishSession attaches the developer bearer BEFORE the handshake so the server
|
|
23
|
+
* binds this identity — the same ordering that fixes the "no stores" dead-end).
|
|
24
|
+
* Dependency-free.
|
|
25
|
+
*/
|
|
26
|
+
import { defaultCredentialsPath, readCredentials } from "../token-store.mjs";
|
|
27
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
28
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
29
|
+
import { storeListError } from "./checkout.mjs";
|
|
30
|
+
import { offerSignIn } from "./login.mjs";
|
|
31
|
+
import { recordServerPolicy } from "../update-check.mjs";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
34
|
+
|
|
35
|
+
const USAGE = `tot grants — capability / tier / expiry / revocation for the stores you can act on
|
|
36
|
+
|
|
37
|
+
tot grants show each store's developer-access grant
|
|
38
|
+
tot grants --json machine-readable output`;
|
|
39
|
+
|
|
40
|
+
function parseArgs(argv) {
|
|
41
|
+
const a = { json: false, help: false };
|
|
42
|
+
for (const t of argv) {
|
|
43
|
+
if (t === "--json") a.json = true;
|
|
44
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
45
|
+
}
|
|
46
|
+
return a;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Normalize the grant source into plain rows, tolerant of the two shapes we may
|
|
51
|
+
* read: the introspector-backed `grant_introspect` result (`{ activeTenant?, grants:
|
|
52
|
+
* [{ tenant, environment, active, capability, tier, exp, revokedAt }] }`) and the
|
|
53
|
+
* `client_list` fallback (`{ activeTenant?, clients: [{ tenant, environment, active
|
|
54
|
+
* }] }` or a bare array). The introspector's `active` means "grant currently valid";
|
|
55
|
+
* client_list's `active` means "the currently-SELECTED tenant" — two different bits,
|
|
56
|
+
* so they're kept apart here (`grantActive` vs `selected`) and never conflated.
|
|
57
|
+
*
|
|
58
|
+
* @param {unknown} resp
|
|
59
|
+
* @returns {{ introspected: boolean, rows: Array<{ tenant: string, environment: string|null,
|
|
60
|
+
* selected: boolean, grantActive: boolean|null, capability: string|null, tier: string|null,
|
|
61
|
+
* exp: number|null, revokedAt: string|null }> }}
|
|
62
|
+
*/
|
|
63
|
+
export function normalizeGrantRows(resp) {
|
|
64
|
+
if (resp && typeof resp === "object" && !Array.isArray(resp) && Array.isArray(resp.grants)) {
|
|
65
|
+
const activeTenant = typeof resp.activeTenant === "string" ? resp.activeTenant : null;
|
|
66
|
+
const rows = resp.grants
|
|
67
|
+
.map((g) => {
|
|
68
|
+
const tenant = g?.tenant ?? g?.id ?? null;
|
|
69
|
+
return {
|
|
70
|
+
tenant,
|
|
71
|
+
environment: g?.environment ?? null,
|
|
72
|
+
selected: activeTenant ? tenant === activeTenant : Boolean(g?.selected),
|
|
73
|
+
grantActive: typeof g?.active === "boolean" ? g.active : null,
|
|
74
|
+
capability: g?.capability ?? null,
|
|
75
|
+
tier: g?.tier ?? null,
|
|
76
|
+
exp: typeof g?.exp === "number" ? g.exp : null,
|
|
77
|
+
revokedAt: g?.revokedAt ?? null,
|
|
78
|
+
};
|
|
79
|
+
})
|
|
80
|
+
.filter((r) => r.tenant);
|
|
81
|
+
return { introspected: true, rows };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Fallback: client_list — store ids + environment + the selected marker only. No
|
|
85
|
+
// grant detail is knowable here (grantActive/capability/tier/exp/revokedAt stay null).
|
|
86
|
+
const clients = Array.isArray(resp) ? resp : resp?.clients || resp?.tenants || [];
|
|
87
|
+
const rows = (Array.isArray(clients) ? clients : [])
|
|
88
|
+
.map((c) => ({
|
|
89
|
+
tenant: c?.tenant ?? c?.id ?? c?.clientId ?? c?.appDomain ?? null,
|
|
90
|
+
environment: c?.environment ?? null,
|
|
91
|
+
selected: Boolean(c?.active),
|
|
92
|
+
grantActive: null,
|
|
93
|
+
capability: null,
|
|
94
|
+
tier: null,
|
|
95
|
+
exp: null,
|
|
96
|
+
revokedAt: null,
|
|
97
|
+
}))
|
|
98
|
+
.filter((r) => r.tenant);
|
|
99
|
+
return { introspected: false, rows };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Classify a grant row into a single status word. `unknown` is the honest answer
|
|
104
|
+
* for a fallback (client_list) row where the grant state simply isn't observable
|
|
105
|
+
* yet. For an introspected row it derives from grantActive + exp + revokedAt, and
|
|
106
|
+
* defensively downgrades an active-but-past-exp grant to `expired` even if the
|
|
107
|
+
* server hasn't flipped `active` (clock-skew / caching robustness).
|
|
108
|
+
*
|
|
109
|
+
* @param {{ grantActive: boolean|null, exp: number|null, revokedAt: string|null }} row
|
|
110
|
+
* @param {{ now?: number }} [opts]
|
|
111
|
+
* @returns {"active"|"revoked"|"expired"|"inactive"|"unknown"}
|
|
112
|
+
*/
|
|
113
|
+
export function grantStatus(row, { now = Date.now() } = {}) {
|
|
114
|
+
if (!row || row.grantActive === null || row.grantActive === undefined) return "unknown";
|
|
115
|
+
if (row.grantActive === false) return row.revokedAt ? "revoked" : "inactive";
|
|
116
|
+
if (typeof row.exp === "number" && row.exp * 1000 <= now) return "expired";
|
|
117
|
+
return "active";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Humanize an RFC 7662 `exp` (epoch SECONDS) into an ISO instant + a coarse relative
|
|
122
|
+
* hint. `no expiry` for a grant that never expires; `expired <iso>` once it's past.
|
|
123
|
+
* @param {number|null|undefined} exp
|
|
124
|
+
* @param {{ now?: number }} [opts]
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
export function formatExpiry(exp, { now = Date.now() } = {}) {
|
|
128
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return "no expiry";
|
|
129
|
+
const ms = exp * 1000;
|
|
130
|
+
const iso = new Date(ms).toISOString();
|
|
131
|
+
const deltaMs = ms - now;
|
|
132
|
+
if (deltaMs <= 0) return `expired ${iso}`;
|
|
133
|
+
const days = Math.floor(deltaMs / 86_400_000);
|
|
134
|
+
const hours = Math.floor((deltaMs % 86_400_000) / 3_600_000);
|
|
135
|
+
const rel = days > 0 ? `in ${days}d ${hours}h` : hours > 0 ? `in ${hours}h` : "in <1h";
|
|
136
|
+
return `${iso} (${rel})`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The one-line grant detail printed (and asserted in tests) under each store. Pure —
|
|
141
|
+
* no I/O — so the exact copy is unit-tested. An unknown (fallback) row says so instead
|
|
142
|
+
* of pretending; every other status is `status · capability=… · tier=… · <expiry|revoked>`.
|
|
143
|
+
* @param {ReturnType<typeof normalizeGrantRows>["rows"][number]} row
|
|
144
|
+
* @param {{ now?: number }} [opts]
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function describeGrantRow(row, { now = Date.now() } = {}) {
|
|
148
|
+
const status = grantStatus(row, { now });
|
|
149
|
+
if (status === "unknown") return "grant detail unavailable (server introspection not enabled)";
|
|
150
|
+
const parts = [status];
|
|
151
|
+
if (row.capability) parts.push(`capability=${row.capability}`);
|
|
152
|
+
if (row.tier) parts.push(`tier=${row.tier}`);
|
|
153
|
+
if (status === "revoked" && row.revokedAt) parts.push(`revoked ${row.revokedAt}`);
|
|
154
|
+
else if (row.grantActive) parts.push(`expiry ${formatExpiry(row.exp, { now })}`);
|
|
155
|
+
return parts.join(" · ");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Best-effort read of the introspector-backed tool. Returns the result only when it
|
|
160
|
+
* is a genuine introspection response (carries a `grants` array); anything else —
|
|
161
|
+
* including the JSON-RPC error a server that doesn't register `grant_introspect` yet
|
|
162
|
+
* throws — resolves to null so the caller falls back to `client_list`.
|
|
163
|
+
* @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
|
|
164
|
+
*/
|
|
165
|
+
async function tryIntrospect(client) {
|
|
166
|
+
try {
|
|
167
|
+
const r = await client.callTool("grant_introspect", {});
|
|
168
|
+
if (r && typeof r === "object" && Array.isArray(r.grants)) return r;
|
|
169
|
+
return null;
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** @param {string[]} argv @param {any} _ctx */
|
|
176
|
+
export async function run(argv, _ctx) {
|
|
177
|
+
const args = parseArgs(argv);
|
|
178
|
+
if (args.help) {
|
|
179
|
+
console.log(USAGE);
|
|
180
|
+
return 0;
|
|
181
|
+
}
|
|
182
|
+
const env = process.env;
|
|
183
|
+
const now = Date.now();
|
|
184
|
+
|
|
185
|
+
// Same MCP-URL resolution as whoami: the cached session's origin wins, then env
|
|
186
|
+
// overrides, then the public default.
|
|
187
|
+
const creds = readCredentials(defaultCredentialsPath(env));
|
|
188
|
+
const mcpUrl = creds?.mcpUrl || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
189
|
+
const client = createMcpClient(mcpUrl);
|
|
190
|
+
|
|
191
|
+
// Attach the developer bearer BEFORE the handshake (establishSession) so the server
|
|
192
|
+
// binds this identity — the grants belong to the signed-in developer. When there's
|
|
193
|
+
// no session yet and we're on a TTY, offer to sign in inline and retry once;
|
|
194
|
+
// otherwise the AuthUnavailableError surfaces house-style with its next step.
|
|
195
|
+
try {
|
|
196
|
+
await establishSession(client, { env });
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
199
|
+
const signedIn = await offerSignIn(mcpUrl, env, {});
|
|
200
|
+
if (!signedIn) throw e;
|
|
201
|
+
await establishSession(client, { env }); // retry once, in-flow
|
|
202
|
+
} else {
|
|
203
|
+
throw e;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Prefer the introspector-backed tool; fall back to client_list until it ships.
|
|
208
|
+
let resp = await tryIntrospect(client);
|
|
209
|
+
if (!resp) {
|
|
210
|
+
resp = await client.callTool("client_list", {});
|
|
211
|
+
// Update-awareness Layer 2: an authed response may carry a version-support policy.
|
|
212
|
+
recordServerPolicy(resp?.cliPolicy, env);
|
|
213
|
+
} else {
|
|
214
|
+
recordServerPolicy(resp?.cliPolicy, env);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const { introspected, rows } = normalizeGrantRows(resp);
|
|
218
|
+
const listErr = storeListError(resp);
|
|
219
|
+
|
|
220
|
+
if (args.json) {
|
|
221
|
+
console.log(
|
|
222
|
+
JSON.stringify(
|
|
223
|
+
{
|
|
224
|
+
introspected,
|
|
225
|
+
error: listErr || null,
|
|
226
|
+
grants: rows.map((r) => ({ ...r, status: grantStatus(r, { now }) })),
|
|
227
|
+
},
|
|
228
|
+
null,
|
|
229
|
+
2,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
return listErr && rows.length === 0 ? 1 : 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (listErr && rows.length === 0) {
|
|
236
|
+
console.log(`\nCouldn't read your grants: ${listErr}`);
|
|
237
|
+
console.log("Run `tot whoami` to check your session, or `tot login` again.");
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
if (rows.length === 0) {
|
|
241
|
+
console.log("\nNo stores you can act on yet for this identity.");
|
|
242
|
+
console.log("If you were just invited, it may still be propagating — try again in a minute.");
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log("\nDeveloper-access grants for the stores you can act on:\n");
|
|
247
|
+
for (const row of rows) {
|
|
248
|
+
const envLabel = row.environment ? ` [${row.environment}]` : "";
|
|
249
|
+
const marker = row.selected ? " (active tenant)" : "";
|
|
250
|
+
console.log(` ${row.tenant}${envLabel}${marker}`);
|
|
251
|
+
console.log(` ${describeGrantRow(row, { now })}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!introspected) {
|
|
255
|
+
console.log(
|
|
256
|
+
"\nNote: full grant detail (capability / tier / expiry / revocation) needs the",
|
|
257
|
+
);
|
|
258
|
+
console.log(
|
|
259
|
+
"server-side `grant_introspect` MCP tool, which this MCP doesn't expose yet —",
|
|
260
|
+
);
|
|
261
|
+
console.log("showing the stores you can act on only. Run `tot whoami` for session status.");
|
|
262
|
+
}
|
|
263
|
+
return 0;
|
|
264
|
+
}
|
package/src/commands/login.mjs
CHANGED
|
@@ -23,7 +23,8 @@ import { loginFlow, deviceLoginFlow, redeemCodeFlow, NoOpenerError } from "../oa
|
|
|
23
23
|
import { defaultCredentialsPath, readCredentials, writeCredentials } from "../token-store.mjs";
|
|
24
24
|
import { openBrowser } from "../open.mjs";
|
|
25
25
|
import { fail } from "../errors.mjs";
|
|
26
|
-
import { cockpitRecoveryUrl, normalizeEmailHint } from "../auth.mjs";
|
|
26
|
+
import { cockpitRecoveryUrl, normalizeEmailHint, redactEmailForHint, emailFromJwt } from "../auth.mjs";
|
|
27
|
+
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
27
28
|
|
|
28
29
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
29
30
|
|
|
@@ -140,6 +141,52 @@ export async function loginAndCache(mcpUrl, env = process.env, { log = () => {},
|
|
|
140
141
|
return merged;
|
|
141
142
|
}
|
|
142
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Interactive sign-in assist — the no-dead-end path. When a command hits "you're
|
|
146
|
+
* not signed in" on a TTY, don't tell the developer to go run `tot login` and come
|
|
147
|
+
* back: offer to sign them in RIGHT HERE, open the browser, and hand control back
|
|
148
|
+
* so they continue in the same flow (handoff 2026-08-02 v2 §UX bar — "the product
|
|
149
|
+
* taking their hand", no re-run, no OAuth jargon).
|
|
150
|
+
*
|
|
151
|
+
* Returns the cached credentials on success, or null when the offer is declined,
|
|
152
|
+
* skipped (non-interactive and not auto-yes — never hang CI), or the sign-in
|
|
153
|
+
* doesn't complete. A null return means "fall back to your own not-signed-in
|
|
154
|
+
* behavior" (the free sample preview for `tot start`, a crisp error for others).
|
|
155
|
+
*
|
|
156
|
+
* All I/O is injectable so this unit-tests with no real prompt or browser:
|
|
157
|
+
* interactive — is this a TTY we can prompt on (default: stdin+stdout are a TTY)
|
|
158
|
+
* yes — auto-accept, skip the prompt (the caller's --yes)
|
|
159
|
+
* prompt — async (question, defaultYes) => boolean
|
|
160
|
+
* login — async (mcpUrl, env, {log}) => creds (defaults to loginAndCache)
|
|
161
|
+
* out — where the human-facing lines go (default: stderr, like the rest)
|
|
162
|
+
*
|
|
163
|
+
* @returns {Promise<object|null>}
|
|
164
|
+
*/
|
|
165
|
+
export async function offerSignIn(mcpUrl, env = process.env, {
|
|
166
|
+
interactive = isInteractive(),
|
|
167
|
+
yes = false,
|
|
168
|
+
prompt = promptYesNo,
|
|
169
|
+
login = loginAndCache,
|
|
170
|
+
out = (m) => console.error(m),
|
|
171
|
+
} = {}) {
|
|
172
|
+
if (!interactive && !yes) return null; // non-TTY without --yes: never prompt/hang
|
|
173
|
+
if (!yes) {
|
|
174
|
+
out("");
|
|
175
|
+
out(" One quick step — sign in to Token of Trust and I'll take you straight to your store.");
|
|
176
|
+
const ok = await prompt(" Sign in now?", true);
|
|
177
|
+
if (!ok) return null;
|
|
178
|
+
}
|
|
179
|
+
out(" → opening your browser to sign in …");
|
|
180
|
+
try {
|
|
181
|
+
const creds = await login(mcpUrl, env, { log: out });
|
|
182
|
+
out(" ✓ signed in");
|
|
183
|
+
return creds;
|
|
184
|
+
} catch (e) {
|
|
185
|
+
out(` couldn't finish signing in (${e?.message || e}).`);
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
143
190
|
/**
|
|
144
191
|
* The core of `tot login --code`: run the browserless redemption (POST the invite
|
|
145
192
|
* token to the MCP, get a grant back) and cache it, reusing a previously-registered
|
|
@@ -164,20 +211,29 @@ export async function redeemAndCache(mcpUrl, code, env = process.env) {
|
|
|
164
211
|
* (a full-object overwrite, not a merge) — carry it forward when re-authing against
|
|
165
212
|
* the SAME mcpUrl (a different MCP means a different session; the old bridge
|
|
166
213
|
* credential and trace no longer apply).
|
|
214
|
+
*
|
|
215
|
+
* IDENTITY GUARD: only carry it when the new sign-in is the SAME person. If we can
|
|
216
|
+
* POSITIVELY tell it's a different identity — both the prior and the new token
|
|
217
|
+
* carry an email and they differ — we drop the prior bridge/trace/hint rather than
|
|
218
|
+
* bleed one identity's hosted /dev panel + feedback trace into another's (the
|
|
219
|
+
* staff↔developer re-login on one machine). When we can't tell (opaque token, no
|
|
220
|
+
* hint either side) we preserve the same-person carry, so this never regresses a
|
|
221
|
+
* routine re-login. For genuinely concurrent identities, prefer per-terminal
|
|
222
|
+
* `TOT_PROFILE` (separate credential files — no carry to guard at all).
|
|
167
223
|
*/
|
|
168
224
|
export function mergeActivityBridge(prior, mcpUrl, creds) {
|
|
169
|
-
if (prior?.mcpUrl
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
225
|
+
if (prior?.mcpUrl !== mcpUrl) return creds;
|
|
226
|
+
const priorHint = normalizeEmailHint(prior.emailHint) || normalizeEmailHint(prior.email);
|
|
227
|
+
const newHint = redactEmailForHint(emailFromJwt(creds?.accessToken || "") || "");
|
|
228
|
+
if (priorHint && newHint && priorHint !== newHint) return creds; // different identity → no bleed
|
|
229
|
+
return {
|
|
230
|
+
...creds,
|
|
231
|
+
...(prior.activityToken && prior.activityUrl
|
|
232
|
+
? { activityToken: prior.activityToken, activityUrl: prior.activityUrl }
|
|
233
|
+
: {}),
|
|
234
|
+
...(prior.traceId ? { traceId: prior.traceId } : {}),
|
|
235
|
+
...(priorHint ? { emailHint: priorHint } : {}),
|
|
236
|
+
};
|
|
181
237
|
}
|
|
182
238
|
|
|
183
239
|
export function inviteCodeRecoveryNext(activityUrl, emailHint = null) {
|
package/src/commands/start.mjs
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* It COMPOSES the other commands' cores in-process (no shelling out to `tot …`):
|
|
6
6
|
*
|
|
7
7
|
* preflight collectChecks() (F — self-healing where it can be)
|
|
8
|
-
* login
|
|
8
|
+
* login establishSession() (developer OAuth; offers inline sign-in when
|
|
9
|
+
* there's no session yet — no re-run — see tryResolveSession)
|
|
9
10
|
* (A2 — run concurrently: neither gates the other; wait on the slower)
|
|
10
11
|
* ↓
|
|
11
12
|
* store normalizeStores(client_list) → auto-pick if exactly one, else
|
|
@@ -69,19 +70,20 @@ import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
|
|
|
69
70
|
import { streamDevLogs } from "../dev-logs.mjs";
|
|
70
71
|
import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
|
|
71
72
|
import { IDEAS } from "./ideas.mjs";
|
|
73
|
+
import { offerSignIn } from "./login.mjs";
|
|
74
|
+
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
72
75
|
|
|
73
76
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
74
77
|
const CLAUDE_MCP_ARGS = ["mcp", "add", "--transport", "http", "tot", `${DEFAULT_MCP_URL}/mcp`];
|
|
75
78
|
|
|
76
79
|
function parseArgs(argv) {
|
|
77
80
|
const a = {
|
|
78
|
-
mcp: null,
|
|
81
|
+
mcp: null, tenant: null, port: "4321",
|
|
79
82
|
noOpen: false, noConnect: false, yes: false, docker: false, sample: false, help: false,
|
|
80
83
|
};
|
|
81
84
|
for (let i = 0; i < argv.length; i++) {
|
|
82
85
|
const t = argv[i];
|
|
83
86
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
84
|
-
else if (t === "--identity") a.identity = argv[++i];
|
|
85
87
|
else if (t === "--tenant") a.tenant = argv[++i];
|
|
86
88
|
else if (t === "--port") a.port = argv[++i];
|
|
87
89
|
else if (t === "--no-open") a.noOpen = true;
|
|
@@ -106,8 +108,7 @@ Options:
|
|
|
106
108
|
artifact can't be fetched
|
|
107
109
|
--no-open don't auto-open the browser
|
|
108
110
|
--no-connect skip the "Connect Claude for AI editing?" prompt
|
|
109
|
-
--yes, -y assume yes for prompts (non-interactive)
|
|
110
|
-
--identity <who> force "operator" or "developer" auth (default: auto)
|
|
111
|
+
--yes, -y assume yes for prompts (non-interactive; also auto-signs-in)
|
|
111
112
|
--mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
112
113
|
|
|
113
114
|
On a multi-store identity, the tenant you pick (or pass via --tenant) is
|
|
@@ -373,15 +374,14 @@ async function runSampleStart(args, ctx, env, startedAt) {
|
|
|
373
374
|
* MCP init + session resolve — the "login" half of preflight. Throws
|
|
374
375
|
* AuthUnavailableError (no creds) or a CliError (can't reach the MCP).
|
|
375
376
|
*/
|
|
376
|
-
async function loginStep(client, env,
|
|
377
|
+
async function loginStep(client, env, _args) {
|
|
377
378
|
// establishSession attaches auth in the right order relative to the handshake:
|
|
378
|
-
// the developer bearer BEFORE initialize
|
|
379
|
-
// initialize time — otherwise the session is anonymous and client_list is empty
|
|
380
|
-
//
|
|
381
|
-
//
|
|
379
|
+
// the developer bearer BEFORE initialize, so the server binds this identity at
|
|
380
|
+
// initialize time — otherwise the session is anonymous and client_list is empty.
|
|
381
|
+
// We inject our own initialize so the unreachable-MCP case still surfaces the
|
|
382
|
+
// actionable CliError below.
|
|
382
383
|
return establishSession(client, {
|
|
383
384
|
env,
|
|
384
|
-
prefer: args.identity || undefined,
|
|
385
385
|
initialize: async () => {
|
|
386
386
|
try {
|
|
387
387
|
await client.initialize();
|
|
@@ -398,12 +398,27 @@ async function loginStep(client, env, args) {
|
|
|
398
398
|
* Resolve a session but return null (instead of throwing) for the two conditions
|
|
399
399
|
* the free-taste fallback should absorb: no usable session, or the MCP being
|
|
400
400
|
* unreachable. A blocking machine problem (or any other error) still throws.
|
|
401
|
+
*
|
|
402
|
+
* When there's simply no session yet, we don't dead-end at "go run tot login" —
|
|
403
|
+
* on a TTY (or --yes) we OFFER to sign the developer in right here (browser opens),
|
|
404
|
+
* then continue straight into the run with no re-run (handoff 2026-08-02 v2). A
|
|
405
|
+
* declined/skipped offer falls through to the free sample preview as before.
|
|
401
406
|
*/
|
|
402
407
|
async function tryResolveSession(client, env, args) {
|
|
403
408
|
try {
|
|
404
409
|
return await loginStep(client, env, args);
|
|
405
410
|
} catch (e) {
|
|
406
|
-
if (e instanceof AuthUnavailableError && e.reason === "missing")
|
|
411
|
+
if (e instanceof AuthUnavailableError && e.reason === "missing") {
|
|
412
|
+
const signedIn = await offerSignIn(client.mcpUrl, env, { yes: args.yes });
|
|
413
|
+
if (!signedIn) return null; // declined/skipped → sample preview
|
|
414
|
+
try {
|
|
415
|
+
return await loginStep(client, env, args); // retry once, in-flow
|
|
416
|
+
} catch (e2) {
|
|
417
|
+
if (e2 instanceof AuthUnavailableError && e2.reason === "missing") return null;
|
|
418
|
+
if (e2 instanceof CliError && /can't reach the Token of Trust MCP/.test(e2.message)) return null;
|
|
419
|
+
throw e2;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
407
422
|
if (e instanceof CliError && /can't reach the Token of Trust MCP/.test(e.message)) return null;
|
|
408
423
|
throw e;
|
|
409
424
|
}
|
|
@@ -499,13 +514,10 @@ async function prefetchDockerLogin(client, devArgs, env) {
|
|
|
499
514
|
}
|
|
500
515
|
|
|
501
516
|
/** A human label for a resolved session — the email when we could read it from
|
|
502
|
-
* the OAuth token, else
|
|
517
|
+
* the OAuth token, else a generic developer label. */
|
|
503
518
|
function describeIdentity(session) {
|
|
504
519
|
if (!session) return "an unknown identity";
|
|
505
520
|
if (session.email) return session.email;
|
|
506
|
-
if (session.identity === "operator") {
|
|
507
|
-
return session.appDomain ? `operator (${session.appDomain})` : "operator";
|
|
508
|
-
}
|
|
509
521
|
return "your developer identity";
|
|
510
522
|
}
|
|
511
523
|
|
|
@@ -727,22 +739,8 @@ function connectClaude() {
|
|
|
727
739
|
}
|
|
728
740
|
|
|
729
741
|
// ── small prompt helpers (respect non-TTY so nothing hangs in CI) ────────────
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
async function promptYesNo(question, defaultYes) {
|
|
736
|
-
if (!isInteractive()) return defaultYes;
|
|
737
|
-
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
738
|
-
try {
|
|
739
|
-
const ans = (await rl.question(`${question} ${defaultYes ? "[Y/n]" : "[y/N]"} `)).trim().toLowerCase();
|
|
740
|
-
if (!ans) return defaultYes;
|
|
741
|
-
return ans === "y" || ans === "yes";
|
|
742
|
-
} finally {
|
|
743
|
-
rl.close();
|
|
744
|
-
}
|
|
745
|
-
}
|
|
742
|
+
// isInteractive + promptYesNo now live in ../prompt.mjs (shared with the sign-in
|
|
743
|
+
// offer); promptChoice stays here as it's only used by the store picker.
|
|
746
744
|
|
|
747
745
|
/** Prompt for a 1..n choice; returns a 0-based index (defaults to first). */
|
|
748
746
|
async function promptChoice(n) {
|