@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21

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.
Files changed (47) hide show
  1. package/README.md +12 -9
  2. package/bin/tot.mjs +219 -44
  3. package/package.json +7 -2
  4. package/src/activity.mjs +379 -0
  5. package/src/app-scaffold.mjs +2 -2
  6. package/src/auth.mjs +13 -5
  7. package/src/candidate-state.mjs +137 -0
  8. package/src/commands/accept.mjs +736 -0
  9. package/src/commands/app/dev.mjs +7 -3
  10. package/src/commands/app/index.mjs +2 -2
  11. package/src/commands/branches.mjs +297 -0
  12. package/src/commands/cleanup.mjs +269 -0
  13. package/src/commands/clone.mjs +713 -0
  14. package/src/commands/dev.mjs +441 -93
  15. package/src/commands/doctor.mjs +4 -3
  16. package/src/commands/git-credential.mjs +180 -0
  17. package/src/commands/go-live.mjs +486 -0
  18. package/src/commands/grants.mjs +14 -7
  19. package/src/commands/hotfix.mjs +428 -0
  20. package/src/commands/link.mjs +225 -0
  21. package/src/commands/login.mjs +12 -8
  22. package/src/commands/pr.mjs +425 -0
  23. package/src/commands/preview-build.mjs +225 -0
  24. package/src/commands/preview.mjs +80 -0
  25. package/src/commands/retire.mjs +203 -0
  26. package/src/commands/revert.mjs +322 -0
  27. package/src/commands/rollback.mjs +403 -0
  28. package/src/commands/ship.mjs +517 -0
  29. package/src/commands/start.mjs +91 -29
  30. package/src/commands/submit.mjs +1360 -131
  31. package/src/commands/sync.mjs +203 -0
  32. package/src/commands/validate.mjs +11 -5
  33. package/src/commands/whoami.mjs +6 -2
  34. package/src/context.mjs +2 -2
  35. package/src/dev-heartbeat.mjs +2 -1
  36. package/src/errors.mjs +8 -4
  37. package/src/git-credential.mjs +185 -0
  38. package/src/mcp.mjs +6 -1
  39. package/src/no-gitea-links.test.mjs +55 -0
  40. package/src/oauth.mjs +26 -11
  41. package/src/obstacle-beacon.cjs +3 -3
  42. package/src/obstacle.mjs +1 -1
  43. package/src/plan.mjs +262 -0
  44. package/src/sample.mjs +30 -4
  45. package/src/validate.mjs +56 -0
  46. package/src/viewer-session.mjs +118 -0
  47. package/src/commands/checkout.mjs +0 -330
@@ -0,0 +1,428 @@
1
+ /**
2
+ * `tot hotfix` (unit b22) — the OWNER-ONLY EXCEPTION LANE: release an urgent
3
+ * production fix from `main` to live WHILE `preview` still contains other unshipped
4
+ * work, EXCLUDING that unshipped preview head, then automatically forward-integrate
5
+ * `main` into `preview` and re-validate.
6
+ *
7
+ * tot dev / preview build + review the fix (a `main`-based candidate)
8
+ * tot hotfix --pr N release THAT reviewed fix from main to live, bypassing preview ← you are here
9
+ *
10
+ * This is a DELIBERATELY DISTINCT verb — NEVER a `--base main` flag on `tot ship`.
11
+ * `tot ship` publishes the tenant's current green PREVIEW aggregate; `tot hotfix`
12
+ * does the opposite in the one case that warrants it: it ships a `main`-based fix
13
+ * that does NOT include the in-flight preview work, and only then carries `main`
14
+ * forward into `preview`. Because rewinding what goes live around the normal queue
15
+ * is a serious exception, it ALWAYS shows the exact plan — including the unshipped
16
+ * preview work it BYPASSES — and requires an explicit confirm.
17
+ *
18
+ * THE FLOW (mirrors `tot ship`'s plan → confirm → act → honest-terminal-state):
19
+ * 1. GET the read-only PLAN from `/api/changes/hotfix` — the candidate, the
20
+ * current live rollback target, the unshipped preview work bypassed, and the
21
+ * paywall verdict. Zero side effects.
22
+ * 2. Print the EXACT plan and require ONE explicit confirm (default NO). `--yes`
23
+ * confirms non-interactively; a non-TTY without `--yes` REFUSES.
24
+ * 3. On confirm, POST `/api/changes/hotfix` with `confirmGoLive: true`.
25
+ * 4. Report the orchestrator's HONEST terminal state verbatim — `shipped`
26
+ * (live VERIFIED on the fix; the ONLY "shipped" state), `refused`,
27
+ * `merge_failed` / `build_failed` / `promote_failed` / `record_failed`
28
+ * (recoverable, never a false live) — PLUS the automatic forward-integration
29
+ * outcome (green, or red + a fix-forward nudge; a red forward-integration
30
+ * never un-ships the live fix).
31
+ *
32
+ * TRANSPORT: the operator-secret Bearer + `X-Tot-Owner`, the SAME transport as
33
+ * `tot ship` / `tot revert`. Dependency-free (global fetch + the shared plan module).
34
+ */
35
+ import { fail } from "../errors.mjs";
36
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
37
+ import { startProgress } from "../progress.mjs";
38
+ import { openBrowser } from "../open.mjs";
39
+ // Reuse `tot ship`'s operator-secret precedence + live-url derivation verbatim so
40
+ // ship + hotfix speak ONE operator-auth contract.
41
+ import { resolveOperatorSecret, liveUrlFor } from "./ship.mjs";
42
+
43
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
44
+
45
+ const USAGE = `tot hotfix — OWNER-ONLY: release an urgent fix from main to live, bypassing preview
46
+
47
+ tot hotfix --pr <N> --tenant <t>
48
+ tot hotfix --change <changeId> --tenant <t>
49
+
50
+ The EXPLICIT EXCEPTION lane for an urgent production fix while \`preview\` holds
51
+ other unshipped work. It releases the reviewed, main-based fix to live WITHOUT the
52
+ unshipped preview head, then automatically forward-integrates main → preview and
53
+ re-validates the aggregate. It is NOT \`tot ship\` with a flag — \`tot ship\`
54
+ publishes the current green preview aggregate; \`tot hotfix\` deliberately excludes it.
55
+
56
+ It ALWAYS prints the exact plan — including the unshipped preview work it BYPASSES —
57
+ and asks for an explicit confirm. A non-TTY without --yes is refused.
58
+
59
+ Options:
60
+ --pr <N> The reviewed hotfix candidate's PR number.
61
+ --change <changeId> The reviewed hotfix candidate's change id (alternative to --pr).
62
+ --tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
63
+ current checkout's tenant when run inside one.
64
+ --message <msg> Optional merge-commit message.
65
+ --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
66
+ --secret <s> operator secret (prefer the env vars below)
67
+ --yes, -y Skip the interactive confirm (still an explicit affirmative).
68
+ --no-open Don't open the live URL in your browser.
69
+ --help, -h Show this help.
70
+
71
+ For the ordinary release of the whole green preview aggregate, use \`tot ship\`.
72
+ To undo something already LIVE, use \`tot rollback\`.
73
+
74
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
75
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
76
+
77
+ /** Parse `tot hotfix` argv. Pure — unit-testable. */
78
+ export function parseHotfixArgs(argv) {
79
+ const a = {
80
+ pr: null,
81
+ change: null,
82
+ tenant: null,
83
+ message: null,
84
+ url: null,
85
+ secret: null,
86
+ yes: false,
87
+ noOpen: false,
88
+ help: false,
89
+ };
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const t = argv[i];
92
+ if (t === "--pr") a.pr = argv[++i];
93
+ else if (t === "--change" || t === "--changeId") a.change = argv[++i];
94
+ else if (t === "--tenant") a.tenant = argv[++i];
95
+ else if (t === "--message" || t === "-m") a.message = argv[++i];
96
+ else if (t === "--url") a.url = argv[++i];
97
+ else if (t === "--secret") a.secret = argv[++i];
98
+ else if (t === "--yes" || t === "-y") a.yes = true;
99
+ else if (t === "--no-open") a.noOpen = true;
100
+ else if (t === "--help" || t === "-h") a.help = true;
101
+ }
102
+ return a;
103
+ }
104
+
105
+ /** Normalise a `?pr` value to a positive integer, or null. Pure. */
106
+ export function parsePrNumber(raw) {
107
+ const t = (raw == null ? "" : `${raw}`).trim().replace(/^#/, "");
108
+ return /^\d+$/.test(t) ? Number(t) : null;
109
+ }
110
+
111
+ /**
112
+ * Normalise a `GET /api/changes/hotfix` body — the {@link HotfixReleasePlan} (an
113
+ * exception ready to confirm) or a refusal. Read defensively (crossed the wire as
114
+ * JSON). Pure — unit-tested.
115
+ * @param {any} data
116
+ */
117
+ export function normalizeHotfixPlan(data) {
118
+ const o = data && typeof data === "object" ? data : {};
119
+ if (o.ok === true) {
120
+ return {
121
+ ok: true,
122
+ tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
123
+ changeId: typeof o.changeId === "string" ? o.changeId : null,
124
+ prNumber: typeof o.prNumber === "number" ? o.prNumber : null,
125
+ bypassedPreviewSha: typeof o.bypassedPreviewSha === "string" ? o.bypassedPreviewSha : null,
126
+ bypassedPrs: Array.isArray(o.bypassedPrs) ? o.bypassedPrs : [],
127
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
128
+ paywall:
129
+ o.paywall && typeof o.paywall === "object"
130
+ ? { allowed: o.paywall.allowed === true, message: typeof o.paywall.message === "string" ? o.paywall.message : null }
131
+ : { allowed: true, message: null },
132
+ };
133
+ }
134
+ return {
135
+ ok: false,
136
+ reason: typeof o.reason === "string" ? o.reason : "unknown",
137
+ message: typeof o.message === "string" ? o.message : "the hotfix plan was refused for an unknown reason",
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Normalise a `POST /api/changes/hotfix` body — the {@link HotfixReleaseResult}.
143
+ * `state` is the ONLY honest terminal authority: render "shipped live" for
144
+ * `"shipped"` and nothing else. Pure — unit-tested.
145
+ * @param {any} data
146
+ */
147
+ export function normalizeHotfixResult(data) {
148
+ const o = data && typeof data === "object" ? data : {};
149
+ const fwd = o.forwardIntegration && typeof o.forwardIntegration === "object" ? o.forwardIntegration : null;
150
+ return {
151
+ ok: o.ok === true,
152
+ state: typeof o.state === "string" ? o.state : "unknown",
153
+ reason: typeof o.reason === "string" ? o.reason : null,
154
+ message: typeof o.message === "string" ? o.message : "",
155
+ mainSha: typeof o.mainSha === "string" ? o.mainSha : null,
156
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
157
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
158
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
159
+ forwardIntegration: fwd
160
+ ? {
161
+ ok: fwd.ok === true,
162
+ queueState: typeof fwd.queueState === "string" ? fwd.queueState : null,
163
+ runState: typeof fwd.runState === "string" ? fwd.runState : null,
164
+ reason: typeof fwd.reason === "string" ? fwd.reason : null,
165
+ aggregateSha: typeof fwd.aggregateSha === "string" ? fwd.aggregateSha : null,
166
+ statusMessage: typeof fwd.statusMessage === "string" ? fwd.statusMessage : null,
167
+ }
168
+ : null,
169
+ };
170
+ }
171
+
172
+ /** A clear next step per hotfix plan/release refusal reason. Pure. */
173
+ export function hotfixNextStep(reason) {
174
+ switch (reason) {
175
+ case "no_candidate":
176
+ return "pass --pr <N> (or --change <id>) naming the reviewed hotfix candidate";
177
+ case "paywall":
178
+ return "upgrade the storefront subscription to enable go-live";
179
+ case "golive_unconfirmed":
180
+ return "re-run `tot hotfix` and confirm the plan";
181
+ case "digest_mismatch":
182
+ return "main moved under review — re-preview the hotfix against current main, then re-run `tot hotfix`";
183
+ default:
184
+ return "re-run `tot hotfix`";
185
+ }
186
+ }
187
+
188
+ /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
189
+ async function readJsonSafe(res) {
190
+ try {
191
+ return await res.json();
192
+ } catch {
193
+ return {};
194
+ }
195
+ }
196
+
197
+ /**
198
+ * The hotfix flow: GET the plan, print it + confirm, POST to release, report the
199
+ * honest terminal state + the forward-integration outcome. `fetch`/`confirmPlan`/
200
+ * `openUrl`/`progress` are injected so it's unit-tested with no live network/TTY.
201
+ *
202
+ * @param {{ tenant:string, prNumber:number|null, changeId:string|null,
203
+ * message:string|null, secret:string, storefrontUrl?:string|null,
204
+ * yes?:boolean, noOpen?:boolean }} params
205
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
206
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
207
+ * @returns {Promise<number>} process exit code
208
+ */
209
+ export async function runHotfix(
210
+ { tenant, prNumber, changeId, message = null, secret, storefrontUrl = null, yes = false, noOpen = false },
211
+ deps = {},
212
+ ) {
213
+ const fetchImpl = deps.fetch || globalThis.fetch;
214
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
215
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
216
+ const label = prNumber != null ? `PR #${prNumber}` : changeId ? changeId : "the hotfix candidate";
217
+
218
+ if (!secret) {
219
+ console.error(
220
+ fail(
221
+ "a hotfix is an OWNER action — it needs an operator secret",
222
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
223
+ ),
224
+ );
225
+ return 2;
226
+ }
227
+
228
+ const authHeaders = {
229
+ authorization: `Bearer ${secret}`,
230
+ "x-tot-owner": tenant,
231
+ "x-tot-capability": "ship-on-behalf",
232
+ };
233
+
234
+ // 1. GET the read-only plan (candidate + bypassed preview work + rollback + paywall).
235
+ const query = prNumber != null ? `pr=${encodeURIComponent(prNumber)}` : `changeId=${encodeURIComponent(changeId || "")}`;
236
+ let planRes;
237
+ try {
238
+ planRes = await fetchImpl(`${base}/api/changes/hotfix?${query}`, { method: "GET", headers: authHeaders });
239
+ } catch (e) {
240
+ console.error(
241
+ fail(`couldn't reach the hotfix plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
242
+ );
243
+ return 1;
244
+ }
245
+ const planData = await readJsonSafe(planRes);
246
+ if (!planRes.ok && planData?.ok !== false) {
247
+ const msg = planData?.error || `HTTP ${planRes.status}`;
248
+ console.error(
249
+ fail(
250
+ `the hotfix plan was refused: ${msg}`,
251
+ planRes.status === 401 || planRes.status === 403
252
+ ? "check the operator secret and that it's authorised for this tenant"
253
+ : "check --tenant / --url / --pr, then re-run",
254
+ ),
255
+ );
256
+ return 1;
257
+ }
258
+ const plan = normalizeHotfixPlan(planData);
259
+ if (!plan.ok) {
260
+ console.error(fail(plan.message, hotfixNextStep(plan.reason)));
261
+ return 1;
262
+ }
263
+
264
+ // 2. Print the EXACT plan — including the unshipped preview work it BYPASSES —
265
+ // then require ONE explicit confirm (default NO).
266
+ const liveUrl = liveUrlFor(tenant);
267
+ const planLines = planForAction({
268
+ action: "hotfix",
269
+ tenant,
270
+ pr: plan.prNumber,
271
+ changeId: plan.changeId,
272
+ bypassedPrs: plan.bypassedPrs,
273
+ bypassedPreviewSha: plan.bypassedPreviewSha,
274
+ rollbackTarget: plan.rollbackTarget,
275
+ paywall: plan.paywall,
276
+ targets: { live: liveUrl },
277
+ });
278
+ const { confirmed, reason } = await confirmPlan(planLines, {
279
+ yes,
280
+ question: `Release ${label} from main to live for ${tenant}, BYPASSING the unshipped preview work?`,
281
+ });
282
+ if (!confirmed) {
283
+ if (reason === "non-tty") {
284
+ console.error(
285
+ fail(
286
+ "`tot hotfix` needs an interactive terminal to confirm this live exception",
287
+ "run it from a terminal, or pass --yes to confirm non-interactively",
288
+ ),
289
+ );
290
+ return 2;
291
+ }
292
+ console.log(" Hotfix cancelled — nothing changed.");
293
+ return 0;
294
+ }
295
+
296
+ // 3. RELEASE — the human go-live gate (confirmGoLive).
297
+ const progress = deps.progress === false ? null : startProgress("releasing hotfix…");
298
+ let res;
299
+ try {
300
+ res = await fetchImpl(`${base}/api/changes/hotfix`, {
301
+ method: "POST",
302
+ headers: { "content-type": "application/json", ...authHeaders },
303
+ body: JSON.stringify({
304
+ confirmGoLive: true,
305
+ ...(prNumber != null ? { prNumber } : {}),
306
+ ...(plan.changeId ? { changeId: plan.changeId } : {}),
307
+ ...(message ? { message } : {}),
308
+ }),
309
+ });
310
+ } catch (e) {
311
+ progress?.stop();
312
+ console.error(
313
+ fail(`couldn't reach the hotfix endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
314
+ );
315
+ return 1;
316
+ }
317
+ const resultData = await readJsonSafe(res);
318
+ progress?.stop();
319
+
320
+ return reportHotfixResult(normalizeHotfixResult(resultData), { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
321
+ }
322
+
323
+ /**
324
+ * Report the orchestrator's HONEST terminal state + the automatic forward-integration
325
+ * outcome. `shipped` is the ONLY state rendered as "shipped live"; a red
326
+ * forward-integration is surfaced as a fix-forward nudge but never un-ships live.
327
+ * @returns {number} process exit code
328
+ */
329
+ export function reportHotfixResult(result, { tenant, liveUrl, noOpen, openUrl }) {
330
+ if (result.state === "shipped") {
331
+ console.log(`\n ✓ shipped hotfix live for ${tenant} — the unshipped preview work was NOT included.`);
332
+ if (result.mainSha) console.log(` main: ${result.mainSha}`);
333
+ if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
334
+ if (liveUrl) {
335
+ console.log(` Live: ${liveUrl}`);
336
+ if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
337
+ }
338
+ // The automatic forward-integration: honest whether green or red.
339
+ const fwd = result.forwardIntegration;
340
+ if (fwd && fwd.ok) {
341
+ console.log(` forward-integrated main → preview (green${fwd.aggregateSha ? `, ${fwd.aggregateSha}` : ""}).`);
342
+ } else if (fwd) {
343
+ console.log(
344
+ ` ⚠ forward-integration main → preview is ${fwd.queueState ?? "?"}/${fwd.runState ?? "—"}` +
345
+ `${fwd.reason ? ` (${fwd.reason})` : ""} — the live hotfix is fine, but preview needs a fix-forward.`,
346
+ );
347
+ if (fwd.statusMessage) console.log(` why: ${fwd.statusMessage}`);
348
+ console.log(" → next: resolve preview against main, then `tot preview`.");
349
+ }
350
+ return 0;
351
+ }
352
+ if (result.state === "refused") {
353
+ console.error(fail(result.message || `hotfix refused (${result.reason})`, hotfixNextStep(result.reason)));
354
+ return 1;
355
+ }
356
+ if (
357
+ result.state === "merge_failed" ||
358
+ result.state === "build_failed" ||
359
+ result.state === "promote_failed" ||
360
+ result.state === "record_failed"
361
+ ) {
362
+ console.error(
363
+ fail(
364
+ result.message || `hotfix ${result.state.replace("_", " ")}`,
365
+ "re-run `tot hotfix` — the release is idempotent and safe to retry",
366
+ ),
367
+ );
368
+ return 1;
369
+ }
370
+ console.error(
371
+ fail(
372
+ `unexpected hotfix response (state: ${result.state})`,
373
+ "re-run `tot hotfix`; check the storefront logs if it persists",
374
+ ),
375
+ );
376
+ return 1;
377
+ }
378
+
379
+ /**
380
+ * @param {string[]} argv
381
+ * @param {any} ctx
382
+ */
383
+ export async function run(argv, ctx) {
384
+ const env = process.env;
385
+ const args = parseHotfixArgs(argv);
386
+ if (args.help) {
387
+ console.log(USAGE);
388
+ return 0;
389
+ }
390
+
391
+ const tenant = (args.tenant || (ctx?.mode === "checkout" ? ctx.tenant : null) || ctx?.tenant || "").trim();
392
+ if (!tenant) {
393
+ console.error(
394
+ fail(
395
+ "`tot hotfix` needs a target tenant",
396
+ "pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
397
+ ),
398
+ );
399
+ return 2;
400
+ }
401
+
402
+ const prNumber = parsePrNumber(args.pr);
403
+ const changeId = (args.change || "").trim() || null;
404
+ if (prNumber == null && !changeId) {
405
+ console.error(
406
+ fail(
407
+ "`tot hotfix` needs the reviewed hotfix candidate",
408
+ "pass --pr <N> (a PR number) or --change <changeId>.",
409
+ ),
410
+ );
411
+ return 2;
412
+ }
413
+
414
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
415
+ return await runHotfix(
416
+ {
417
+ tenant,
418
+ prNumber,
419
+ changeId,
420
+ message: (args.message || "").trim() || null,
421
+ secret: resolveOperatorSecret(args.secret, env),
422
+ storefrontUrl,
423
+ yes: args.yes,
424
+ noOpen: args.noOpen,
425
+ },
426
+ { openUrl: (u) => openBrowser(u) },
427
+ );
428
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `tot link` — link your signed-in Token of Trust identity to the ToT identity
3
+ * broker so your store scope can be resolved.
4
+ *
5
+ * Some developers sign in fine (a valid ToT OAuth session) but their identity
6
+ * isn't yet linked to the broker that maps a person → the tenants they can build
7
+ * on — so `tot start` / `tot clone` / `tot whoami` resolve ZERO stores. That's
8
+ * NOT a "you weren't invited" problem and NOT "the invite is still propagating";
9
+ * it's a one-time link step. This command drives it end-to-end from the terminal:
10
+ *
11
+ * identity_link_begin → the MCP returns an authUrl + a poll handle
12
+ * open the authUrl → you approve the link in the browser
13
+ * identity_link_poll(handle) → we poll until it's linked, then confirm scope
14
+ *
15
+ * It reuses the SAME auth ceremony as the rest of the CLI (establishSession — the
16
+ * cached `tot login` session, offering an inline sign-in when there's none and
17
+ * we're on a TTY), so a not-signed-in developer isn't dead-ended.
18
+ *
19
+ * Dependency-free (node built-ins via mcp.mjs / auth.mjs / open.mjs).
20
+ */
21
+ import { setTimeout as delay } from "node:timers/promises";
22
+ import { createMcpClient } from "../mcp.mjs";
23
+ import { establishSession, AuthUnavailableError } from "../auth.mjs";
24
+ import { offerSignIn } from "./login.mjs";
25
+ import { openBrowser } from "../open.mjs";
26
+ import { CliError, fail, formatError } from "../errors.mjs";
27
+ import { normalizeStores } from "./clone.mjs";
28
+
29
+ const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
30
+
31
+ function parseArgs(argv) {
32
+ const a = { mcp: null, help: false };
33
+ for (let i = 0; i < argv.length; i++) {
34
+ const t = argv[i];
35
+ if (t === "--mcp") a.mcp = argv[++i];
36
+ else if (t === "--help" || t === "-h") a.help = true;
37
+ }
38
+ return a;
39
+ }
40
+
41
+ const USAGE = `tot link — link your Token of Trust identity so your stores resolve
42
+
43
+ tot link open the browser, approve the link, confirm your scope
44
+ tot link --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
45
+
46
+ Run this when \`tot whoami\` / \`tot start\` say your identity isn't linked yet.`;
47
+
48
+ /**
49
+ * Tolerant extraction of the fields `identity_link_begin` returns. The link URL
50
+ * and poll handle field names can vary by server version, so probe the known
51
+ * candidates (the `pollHandle` name is fixed by the identity_link_poll contract).
52
+ * Pure + exported so it's unit-tested without any I/O.
53
+ * @param {unknown} res
54
+ * @returns {{ authUrl: string|null, pollHandle: string|null }}
55
+ */
56
+ export function linkBeginFields(res) {
57
+ const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
58
+ const authUrl =
59
+ c.authUrl ||
60
+ c.url ||
61
+ c.verificationUrl ||
62
+ c.verificationUriComplete ||
63
+ c.verification_uri_complete ||
64
+ null;
65
+ const pollHandle = c.pollHandle || c.handle || c.poll_handle || c.pollHandleId || null;
66
+ return {
67
+ authUrl: typeof authUrl === "string" && authUrl ? authUrl : null,
68
+ pollHandle: typeof pollHandle === "string" && pollHandle ? pollHandle : null,
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Classify an `identity_link_poll` result into 'linked' | 'pending' | a raw error
74
+ * status. Tolerant of the shape (a boolean flag, or a status/state string) so a
75
+ * server-version drift doesn't strand the poll. Pure + exported for unit tests.
76
+ * @param {unknown} res
77
+ * @returns {"linked"|"pending"|string}
78
+ */
79
+ export function linkPollStatus(res) {
80
+ const c = /** @type {any} */ (res && typeof res === "object" && !Array.isArray(res) ? res : {});
81
+ if (c.linked === true || c.done === true || c.complete === true) return "linked";
82
+ const raw =
83
+ (typeof c.status === "string" && c.status) || (typeof c.state === "string" && c.state) || "";
84
+ const s = raw.toLowerCase();
85
+ if (!s) return "pending";
86
+ // The broker namespaces its poll status with a `link_` prefix (e.g. `link_pending`,
87
+ // `link_approved`, `link_denied`) — strip it before matching so a normal "still
88
+ // waiting" state isn't misread as an unknown/error status and doesn't abort the poll.
89
+ // (Regression: `link_pending` fell through to the error branch and killed `tot link`
90
+ // the instant the developer hadn't yet finished the browser step.)
91
+ const bare = s.replace(/^link[_-]/, "");
92
+ if (/^(linked|link|complete|completed|done|ok|success|succeeded|active|approved|granted)$/.test(bare))
93
+ return "linked";
94
+ if (/^(pending|waiting|in_?progress|processing|started|created|authorizing|polling)$/.test(bare))
95
+ return "pending";
96
+ return s; // a terminal error status (link_denied, link_expired, …) — surfaced to the caller
97
+ }
98
+
99
+ /** @param {string[]} argv @param {any} _ctx */
100
+ export async function run(argv, _ctx) {
101
+ const env = process.env;
102
+ const args = parseArgs(argv);
103
+ if (args.help) {
104
+ console.log(USAGE);
105
+ return 0;
106
+ }
107
+
108
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
109
+ const client = createMcpClient(baseUrl);
110
+
111
+ try {
112
+ // Same auth ordering as checkout/start — developer bearer BEFORE initialize —
113
+ // and the same inline sign-in offer so a not-signed-in dev isn't dead-ended.
114
+ try {
115
+ await establishSession(client, { env });
116
+ } catch (e) {
117
+ if (e instanceof AuthUnavailableError && e.reason === "missing") {
118
+ const signedIn = await offerSignIn(client.mcpUrl, env, {});
119
+ if (!signedIn) throw e;
120
+ await establishSession(client, { env });
121
+ } else {
122
+ throw e;
123
+ }
124
+ }
125
+ console.error(`~ signed in → ${client.mcpUrl}`);
126
+
127
+ console.error("~ identity_link_begin — asking Token of Trust to start the link");
128
+ let begin;
129
+ try {
130
+ begin = await client.callTool("identity_link_begin", {});
131
+ } catch (e) {
132
+ throw new CliError(`couldn't start the identity link: ${String(e?.message || e)}`, {
133
+ next: "your MCP may not support `tot link` yet — run `tot whoami` for the current guidance",
134
+ });
135
+ }
136
+ const { authUrl, pollHandle } = linkBeginFields(begin);
137
+ if (!pollHandle) {
138
+ throw new CliError("Token of Trust didn't return a link handle to poll", {
139
+ next: "re-run `tot link`, or run `tot whoami` for the current guidance",
140
+ });
141
+ }
142
+
143
+ if (authUrl) {
144
+ const opened = openBrowser(authUrl);
145
+ console.log(
146
+ opened
147
+ ? `\n+ opening your browser to finish linking:\n ${authUrl}`
148
+ : `\nOpen this URL to finish linking your identity:\n ${authUrl}`,
149
+ );
150
+ } else {
151
+ console.log("\n+ finishing the link — no approval step needed …");
152
+ }
153
+
154
+ const linked = await pollLink(client, pollHandle, {
155
+ log: (m) => console.error(m),
156
+ });
157
+ if (!linked) {
158
+ throw new CliError("the identity link didn't complete in time", {
159
+ next: "finish the approval in your browser, then re-run `tot link`",
160
+ });
161
+ }
162
+
163
+ console.log("\n+ your identity is linked.");
164
+ // Confirm scope now resolves — best-effort, so a transient list hiccup doesn't
165
+ // fail an otherwise-successful link.
166
+ try {
167
+ const list = await client.callTool("client_list", {});
168
+ const stores = normalizeStores(list);
169
+ if (stores.length) {
170
+ console.log(` stores you can build on: ${stores.map((s) => s.id).join(", ")}`);
171
+ console.log(" Next: `tot start` (or `tot clone <tenant>`).");
172
+ } else {
173
+ console.log(" Next: `tot start` — if it still shows no stores, ask your ToT contact for a store invite.");
174
+ }
175
+ } catch {
176
+ console.log(" Next: `tot start` to build your store.");
177
+ }
178
+ return 0;
179
+ } catch (e) {
180
+ if (e instanceof AuthUnavailableError || e instanceof CliError) {
181
+ console.error(formatError(e));
182
+ return e instanceof CliError ? (e.exitCode ?? 1) : 1;
183
+ }
184
+ console.error(fail(`link failed: ${String(e?.message || e)}`));
185
+ return 1;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Poll `identity_link_poll(pollHandle)` until the link is complete, or give up
191
+ * after `timeoutMs`. Returns true on 'linked', false on timeout; throws a CliError
192
+ * on a definite error status the server reports. Injectable clock/interval keep it
193
+ * unit-testable, but the default path is the live poll.
194
+ * @param {ReturnType<import("../mcp.mjs").createMcpClient>} client
195
+ * @param {string} pollHandle
196
+ * @param {{ timeoutMs?: number, intervalMs?: number, log?: (m:string)=>void }} [opts]
197
+ * @returns {Promise<boolean>}
198
+ */
199
+ export async function pollLink(client, pollHandle, { timeoutMs = 120000, intervalMs = 2500, log = () => {} } = {}) {
200
+ const deadline = Date.now() + timeoutMs;
201
+ let announced = false;
202
+ while (Date.now() < deadline) {
203
+ let res;
204
+ try {
205
+ res = await client.callTool("identity_link_poll", { pollHandle });
206
+ } catch (e) {
207
+ // A transient poll error isn't fatal — keep trying until the deadline.
208
+ await delay(intervalMs);
209
+ continue;
210
+ }
211
+ const status = linkPollStatus(res);
212
+ if (status === "linked") return true;
213
+ if (status !== "pending") {
214
+ throw new CliError(`Token of Trust reported the link couldn't complete (${status})`, {
215
+ next: "re-run `tot link`, or run `tot whoami` for the current guidance",
216
+ });
217
+ }
218
+ if (!announced) {
219
+ log("~ waiting for you to approve the link in your browser …");
220
+ announced = true;
221
+ }
222
+ await delay(intervalMs);
223
+ }
224
+ return false;
225
+ }