@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.10

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.
@@ -46,12 +46,19 @@ import { validateTenant, ERROR } from "../validate.mjs";
46
46
  import { openBrowser } from "../open.mjs";
47
47
  import { startProgress } from "../progress.mjs";
48
48
  import { fail } from "../errors.mjs";
49
+ import {
50
+ defaultCandidateStatePath,
51
+ readActiveChangeId,
52
+ writeActiveChangeId,
53
+ mintFreshChangeId,
54
+ isTerminalCandidateState,
55
+ } from "../candidate-state.mjs";
49
56
 
50
57
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
51
58
  const DEFAULT_REF = "preview";
52
59
 
53
60
  export function parseArgs(argv) {
54
- const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, help: false };
61
+ const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
55
62
  for (let i = 0; i < argv.length; i++) {
56
63
  const t = argv[i];
57
64
  if (t === "--mcp") a.mcp = argv[++i];
@@ -60,28 +67,48 @@ export function parseArgs(argv) {
60
67
  else if (t === "-m" || t === "--message") a.message = argv[++i];
61
68
  else if (t === "--summary") a.summary = argv[++i];
62
69
  else if (t === "--skip-validate") a.skipValidate = true;
70
+ else if (t === "--no-commit") a.noCommit = true;
63
71
  else if (t === "--no-wait") a.noWait = true;
64
72
  else if (t === "--watch") a.watch = true;
65
73
  else if (t === "--no-open") a.noOpen = true;
74
+ else if (t === "--new") a.new = true;
66
75
  else if (t === "--help" || t === "-h") a.help = true;
67
76
  }
68
77
  return a;
69
78
  }
70
79
 
71
- const USAGE = `tot submit — submit your store for preview
80
+ /**
81
+ * Render the usage block for whichever verb invoked this flow. `preview` is the
82
+ * first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
83
+ * the help they print names the verb the developer actually typed (see preview.mjs).
84
+ * @param {string} [verb]
85
+ */
86
+ export function renderUsage(verb = "preview") {
87
+ return `tot ${verb} — submit your store for preview
88
+
89
+ tot ${verb} validate → push the preview ref → stream the result
90
+ tot ${verb} --new open a NEW candidate PR instead of updating your open one
91
+ tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
92
+ tot ${verb} --skip-validate push without the local lint (not recommended)
93
+ tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
94
+ tot ${verb} --ref <name> push ref (default: ${DEFAULT_REF})
95
+ tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
96
+ tot ${verb} --summary "<text>" longer description to accompany the title
97
+ tot ${verb} --no-wait push and exit without polling for the reconcile result
98
+ tot ${verb} --no-open don't open the preview URL in the browser on success
99
+ tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
100
+
101
+ By default a re-run UPDATES your open candidate PR (like pushing more commits
102
+ to a GitHub PR), rather than opening a new one each time. Use --new to fork a
103
+ fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
104
+ open candidates with \`tot pr\` (list / view / close). If your candidate was already
105
+ merged or closed, a re-run automatically opens a fresh one.
72
106
 
73
- tot submit validate push the preview ref stream the result
74
- tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
75
- tot submit --skip-validate push without the local lint (not recommended)
76
- tot submit --ref <name> push ref (default: ${DEFAULT_REF})
77
- tot submit -m "<title>" one-line summary of what changed (the approver sees this)
78
- tot submit --summary "<text>" longer description to accompany the title
79
- tot submit --no-wait push and exit without polling for the reconcile result
80
- tot submit --no-open don't open the preview URL in the browser on success
81
- tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
107
+ Once a preview reconciles cleanly, \`tot ship\` promotes it live.
82
108
 
83
109
  If you omit -m, a summary is generated from git (commit subject + the diff vs
84
110
  what's live in preview) so the change record the approver reviews is never blank.`;
111
+ }
85
112
 
86
113
  // Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
87
114
  // --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
@@ -123,6 +150,126 @@ function printChangeSummary({ title, body, autoTitle }) {
123
150
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
124
151
  }
125
152
 
153
+ // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
154
+
155
+ /**
156
+ * The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
157
+ * these. A store's reviewable content lives here; anything a developer edits
158
+ * OUTSIDE these (src/, config, stray files) is out of scope for an automatic
159
+ * commit and is REFUSED rather than silently swept in. Explicit paths only —
160
+ * never `git add -A`/`git add .`.
161
+ */
162
+ export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
163
+ export const KNOWN_CONTENT_FILES = ["theme.json"];
164
+
165
+ /** Is this repo-relative path inside a known content tree (or the one known
166
+ * top-level file)? Pure — unit-tested. */
167
+ export function isKnownContentPath(path) {
168
+ const p = String(path).replace(/^\.\//, "");
169
+ return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
170
+ }
171
+
172
+ /**
173
+ * Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
174
+ * dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
175
+ * `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
176
+ * from its tree too, so it's part of the scope decision). Run with
177
+ * `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
178
+ * unit-tested.
179
+ * @param {string} text
180
+ * @returns {string[]}
181
+ */
182
+ export function parsePorcelainPaths(text) {
183
+ const seen = new Set();
184
+ const out = [];
185
+ for (const line of String(text).split("\n")) {
186
+ if (line.length < 4) continue; // "XY p" is the shortest real entry
187
+ const rest = line.slice(3);
188
+ const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
189
+ for (const raw of parts) {
190
+ const p = raw.trim();
191
+ if (p && !seen.has(p)) {
192
+ seen.add(p);
193
+ out.push(p);
194
+ }
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ /**
201
+ * Partition dirty paths into the known content trees vs everything else. Pure —
202
+ * unit-tested.
203
+ * @param {string[]} paths
204
+ * @returns {{ known: string[], unknown: string[] }}
205
+ */
206
+ export function classifyDirtyPaths(paths) {
207
+ const known = [];
208
+ const unknown = [];
209
+ for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
210
+ return { known, unknown };
211
+ }
212
+
213
+ /** A concise auto commit subject when the developer gave no -m — derived from the
214
+ * staged files so the commit is never a blank "(untitled change)". Pure. */
215
+ export function autoCommitSubject(files = []) {
216
+ if (files.length === 0) return "update store content";
217
+ if (files.length === 1) return `update ${files[0]}`;
218
+ return `update ${files.length} content files`;
219
+ }
220
+
221
+ /**
222
+ * The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
223
+ * as the subject; otherwise autoCommitSubject derives one from the staged files.
224
+ * The body (shortstat + file list) is built by the SAME buildChangeSummary the
225
+ * approver summary uses, so a commit and the change record it later feeds read
226
+ * consistently. Returns a git commit message (subject, blank line, body). Pure —
227
+ * unit-tested.
228
+ * @param {{ message?: string|null, files?: string[], statLine?: string }} input
229
+ * @returns {string}
230
+ */
231
+ export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
232
+ const subject = (message && message.trim()) || autoCommitSubject(files);
233
+ const { body } = buildChangeSummary({ message: subject, files, statLine });
234
+ return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
235
+ }
236
+
237
+ /**
238
+ * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
239
+ * tree `tot preview` commits your content edits for you, so a preview always
240
+ * reflects your working changes — but ONLY the known trees (content/, public/,
241
+ * theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
242
+ * dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
243
+ * never silently swept into a store commit). --no-commit opts out entirely
244
+ * (preview whatever's already committed — u1's behavior).
245
+ *
246
+ * Returns exactly one of:
247
+ * { skipped: true } — --no-commit.
248
+ * { clean: true } — nothing dirty; preview HEAD as-is.
249
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
250
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
251
+ *
252
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
253
+ * @param {{ message?: string|null, noCommit?: boolean }} [opts]
254
+ */
255
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
256
+ if (noCommit) return { skipped: true };
257
+ const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
258
+ const dirty = parsePorcelainPaths(status);
259
+ if (dirty.length === 0) return { clean: true };
260
+ const { known, unknown } = classifyDirtyPaths(dirty);
261
+ if (unknown.length) return { refused: true, unknown, known };
262
+ // Stage ONLY the known dirty paths, each by explicit path — this records adds,
263
+ // modifications AND deletions within them, and can never reach outside the set.
264
+ git(["add", "--", ...known]);
265
+ const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
266
+ const statLine = git(["diff", "--cached", "--shortstat"]).trim();
267
+ const msg = buildAutoCommitMessage({ message, files, statLine });
268
+ git(["commit", "--no-verify", "-m", msg]);
269
+ const sha = git(["rev-parse", "HEAD"]).trim();
270
+ return { committed: true, sha, files };
271
+ }
272
+
126
273
  // ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
127
274
 
128
275
  /**
@@ -277,19 +424,26 @@ function reportCandidate(result, changeId) {
277
424
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
278
425
  }
279
426
 
280
- /** @param {string[]} argv @param {any} ctx */
281
- export async function run(argv, ctx) {
427
+ /**
428
+ * The preview flow — validate, push the preview ref, open/update the PR-backed
429
+ * candidate, and stream back the reconcile/compliance/preview result. Reached by
430
+ * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
431
+ * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
432
+ * copy (usage + the not-in-checkout error) with whatever the developer typed.
433
+ * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
434
+ */
435
+ export async function run(argv, ctx, { verb = "preview" } = {}) {
282
436
  const env = process.env;
283
437
  const args = parseArgs(argv);
284
438
  if (args.help) {
285
- console.log(USAGE);
439
+ console.log(renderUsage(verb));
286
440
  return 0;
287
441
  }
288
442
  if (ctx.mode !== "checkout") {
289
443
  console.error(
290
444
  fail(
291
- "`tot submit` runs from inside a tenant checkout",
292
- "tot checkout <tenant> --clone <dir> (then `cd` in, commit your work, and re-run)",
445
+ `\`tot ${verb}\` runs from inside a tenant checkout`,
446
+ "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
293
447
  ),
294
448
  );
295
449
  return 2;
@@ -298,6 +452,41 @@ export async function run(argv, ctx) {
298
452
  const tenant = ctx.tenant;
299
453
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
300
454
 
455
+ // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
456
+ // content edits BEFORE previewing so the preview reflects your working changes.
457
+ // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
458
+ // `git add -A`); anything dirty outside those is refused, not silently swept in.
459
+ // --no-commit opts out (preview whatever's already committed).
460
+ let auto;
461
+ try {
462
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
463
+ } catch (e) {
464
+ console.error(
465
+ fail(
466
+ `auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
467
+ "commit your content manually (git add / git commit), or re-run with --no-commit",
468
+ ),
469
+ );
470
+ return 1;
471
+ }
472
+ if (auto.refused) {
473
+ console.error(
474
+ fail(
475
+ `${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
476
+ "commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
477
+ ) + "\n",
478
+ );
479
+ for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
480
+ console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
481
+ if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
482
+ return 1;
483
+ }
484
+ if (auto.committed) {
485
+ console.error(
486
+ `~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
487
+ );
488
+ }
489
+
301
490
  // 1. validate locally — refuse on errors.
302
491
  if (!args.skipValidate) {
303
492
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
@@ -381,13 +570,40 @@ export async function run(argv, ctx) {
381
570
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
382
571
  // failure here (older MCP, VC not configured, preview-access capability) is
383
572
  // reported and swallowed, never blocking the preview push that already landed.
384
- await submitCandidate(client, {
385
- repo: repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim()),
386
- changeId: deriveChangeId(tenant, actorKeyFor(session)),
387
- changeSummary,
388
- patchEntries,
389
- readBlob: (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] }),
390
- });
573
+ //
574
+ // Which candidate this submit lands on (gh-pr-like):
575
+ // --new → fork a FRESH candidate and remember it as active;
576
+ // otherwise → the remembered active candidate (from a prior --new /
577
+ // roll), else the STABLE per-dev-per-tenant default.
578
+ // If the chosen candidate turns out to be merged/closed, roll to a fresh one
579
+ // so a re-submit is never wedged on a dead PR.
580
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
581
+ const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
582
+ const statePath = defaultCandidateStatePath(env);
583
+ const stableId = deriveChangeId(tenant, actorKeyFor(session));
584
+ const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo }) : null;
585
+ let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
586
+ // Persist when we diverge from the stable default (a --new fork, or a
587
+ // previously-remembered active pointer) so the next plain submit follows it.
588
+ let persist = args.new || (!!active && active !== stableId);
589
+
590
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
591
+
592
+ if (candidate && isTerminalCandidateState(candidate.state)) {
593
+ const rolled = mintFreshChangeId(stableId);
594
+ console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
595
+ changeId = rolled;
596
+ persist = true;
597
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
598
+ }
599
+
600
+ // Remember the active candidate only on a real, non-terminal open (best-effort;
601
+ // never let a state-write failure break the submit).
602
+ if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
603
+ try {
604
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
605
+ } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
606
+ }
391
607
 
392
608
  let status;
393
609
  if (args.noWait) {
@@ -68,11 +68,11 @@ export function run(argv, ctx) {
68
68
 
69
69
  const target = resolveTarget(args, ctx);
70
70
  if (target.error) {
71
- console.error(fail(target.error, "tot checkout <tenant> --clone <dir>, or pass --workspace <dir>"));
71
+ console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
72
72
  return 2;
73
73
  }
74
74
  if (!existsSync(target.dir)) {
75
- console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot checkout <tenant> --clone <dir>`"));
75
+ console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot clone <tenant>`"));
76
76
  return 2;
77
77
  }
78
78
 
@@ -11,7 +11,7 @@
11
11
  import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
12
12
  import { establishSession } from "../auth.mjs";
13
13
  import { createMcpClient } from "../mcp.mjs";
14
- import { normalizeStores, storeListError } from "./checkout.mjs";
14
+ import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
15
15
  import { recordServerPolicy } from "../update-check.mjs";
16
16
 
17
17
  /**
@@ -67,7 +67,11 @@ export async function run(argv, _ctx) {
67
67
  } else if (listErr) {
68
68
  console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
69
69
  } else {
70
- console.log(" (no stores resolved yet if you were just invited, it may still be propagating)");
70
+ // Status-aware (card c2): an UNLINKED identity is told to link (not "may
71
+ // still be propagating" — that's only the genuine zero-grants case).
72
+ const g = noStoresGuidance(listResp);
73
+ console.log(` (${g.headline})`);
74
+ console.log(` → next: ${g.next}`);
71
75
  }
72
76
  } catch {
73
77
  console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
package/src/context.mjs CHANGED
@@ -6,13 +6,13 @@
6
6
  *
7
7
  * monorepo — inside a full storefront checkout (pnpm-workspace.yaml +
8
8
  * apps/storefront + tenants/). This is us / a platform dev.
9
- * `tot dev` here runs the in-tree astro dev; `tot checkout`
9
+ * `tot dev` here runs the in-tree astro dev; `tot clone`
10
10
  * can suggest cloning a sibling dir.
11
11
  * checkout — inside a STANDALONE tenant checkout: the flat, content-only
12
12
  * shape `content/ public/ theme.json .tot/config.json` a
13
13
  * developer clones. The tenant is read from .tot/config.json.
14
14
  * `tot dev` here boots the bundled runner against this dir.
15
- * loose — anywhere else. `tot checkout <tenant>` still works (it's how
15
+ * loose — anywhere else. `tot clone <tenant>` still works (it's how
16
16
  * you GET a checkout); commands that need a workspace say so.
17
17
  *
18
18
  * Detection walks UP from the cwd so `tot` works from any subdirectory of a
package/src/oauth.mjs CHANGED
@@ -84,8 +84,15 @@ export async function registerClient(
84
84
  headers: { "Content-Type": "application/json", Accept: "application/json" },
85
85
  body: JSON.stringify({
86
86
  client_name: CLIENT_NAME,
87
+ // Include the device-code grant: the browserless rendezvous + B3 device flows
88
+ // redeem their device_code at /oauth/token with this grant, so a client
89
+ // registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
90
+ grant_types: [
91
+ "authorization_code",
92
+ "refresh_token",
93
+ "urn:ietf:params:oauth:grant-type:device_code",
94
+ ],
87
95
  redirect_uris: [redirectUri],
88
- grant_types: ["authorization_code", "refresh_token"],
89
96
  response_types: ["code"],
90
97
  token_endpoint_auth_method: "none",
91
98
  scope: SCOPE,
@@ -285,41 +292,50 @@ export async function loginFlow({
285
292
  }
286
293
  }
287
294
 
288
- // ── WS2b: browserless invite-code redemption (`tot login --code <token>`) ─────
295
+ // ── Browserless CLI sign-in RFC 8628 RENDEZVOUS (`tot login --code <handle>`) ──
289
296
  //
290
- // The invited developer pastes the single-use sign-in token their invite minted;
291
- // we hand it to the MCP's redeem endpoint, which validates + BURNS it via ToT and
292
- // returns a normal MCP OAuth grant. No browser, no loopback, no OTP the token IS
293
- // the operator's authorization. Same credentials shape as loginFlow/deviceLoginFlow,
294
- // and the grant is bound to our registered client_id so later silent refreshes go
295
- // through the standard token endpoint like any other session.
297
+ // The invited developer pastes the NON-SECRET rendezvous handle their cockpit
298
+ // rendered. Unlike the retired bearer-redeem (a secret received elsewhere, replayed
299
+ // once), the terminal generates its OWN PKCE key here, ATTACHES only the public
300
+ // challenge to the pending rendezvous, prints a human-verifiable fingerprint, and
301
+ // polls the standard token endpoint (device_code grant + PKCE) until the developer
302
+ // approves that fingerprint in the cockpit. Same credentials shape as
303
+ // loginFlow/deviceLoginFlow, same dynamically-registered client_id, so later silent
304
+ // refreshes go through the standard token endpoint like any other session.
296
305
  //
297
- // Wire contract (CLI → MCP):
298
- // POST {mcp-origin}/oauth/redeem-code (application/json)
299
- // { code: "<invite sign-in token>", client_id, scope? }
300
- // → 200 { access_token, refresh_token, token_type, expires_in, scope }
301
- // → 400 { error, error_description } (invalid_grant | invalid_client)
306
+ // Wire contract (CLI → MCP), all off the MCP origin (from the AS token endpoint):
307
+ // POST /oauth/device/attach (application/json)
308
+ // { rendezvous_code, client_id, code_challenge, code_challenge_method: "S256" }
309
+ // → 200 { device_code, user_fingerprint, interval, expires_in }
310
+ // → 400 { error, error_description } (invalid_grant | invalid_client)
311
+ // POST /oauth/token (form) grant_type=…:device_code, device_code, code_verifier,
312
+ // client_id → RFC 8628 polling until approved/denied/expired.
302
313
  //
303
- // The token→CLI binding is inherent to the single request: the tokens are returned
304
- // only in the direct TLS response to the client that presented the code, and the
305
- // grant is pinned to our dynamically-registered client_id (only that client can
306
- // refresh it). A separate PKCE nonce would add nothing here — there is no second
307
- // exchange step at which a verifier could be presented.
308
-
309
- /** The MCP's redeem endpoint the MCP origin (from the AS token endpoint) + a
310
- * fixed path. Kept beside the flow so the path lives in exactly one place. */
311
- export function redeemCodeEndpoint(mcpUrl, meta) {
314
+ // Device-bound by construction: the pasted handle attaches only a PUBLIC PKCE
315
+ // challenge (intercepting it grants nothing an interceptor's terminal shows a
316
+ // DIFFERENT fingerprint the developer won't approve), and only the terminal holding
317
+ // the matching verifier can redeem the device_code at /oauth/token.
318
+
319
+ /** An MCP device/rendezvous endpoint — the MCP origin (from the AS token endpoint)
320
+ * + a fixed path. Kept beside the flow so the paths live in exactly one place. */
321
+ export function deviceEndpoint(mcpUrl, meta, path) {
312
322
  const origin = meta?.token_endpoint ? new URL(meta.token_endpoint) : new URL(mcpUrl);
313
- return new URL("/oauth/redeem-code", origin).toString();
323
+ return new URL(path, origin).toString();
314
324
  }
315
325
 
316
- /** POST the invite token to the MCP redeem endpoint and return the raw token
317
- * response (→ credentialsFromToken). Throws a clear, non-stack error on rejection. */
318
- export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope = SCOPE }, fetchImpl = fetch) {
319
- const res = await fetchImpl(redeemEndpoint, {
326
+ /** Attach the terminal's PKCE challenge to a pending rendezvous. Returns the raw
327
+ * response { device_code, user_fingerprint, interval, expires_in }. Throws a clear,
328
+ * non-stack error on rejection (an expired / already-used handle). */
329
+ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientId, challenge }, fetchImpl = fetch) {
330
+ const res = await fetchImpl(attachEndpoint, {
320
331
  method: "POST",
321
332
  headers: { "Content-Type": "application/json", Accept: "application/json" },
322
- body: JSON.stringify({ code, client_id: clientId, scope }),
333
+ body: JSON.stringify({
334
+ rendezvous_code: rendezvousCode,
335
+ client_id: clientId,
336
+ code_challenge: challenge,
337
+ code_challenge_method: "S256",
338
+ }),
323
339
  });
324
340
  const text = await res.text();
325
341
  let body;
@@ -328,31 +344,61 @@ export async function redeemInviteCode(redeemEndpoint, { code, clientId, scope =
328
344
  const detail = [body.error, body.error_description].filter(Boolean).join(" — ");
329
345
  throw new Error(`your sign-in code was not accepted (HTTP ${res.status}${detail ? `: ${detail}` : ""})`);
330
346
  }
331
- if (!body.access_token) throw new Error("the redeem endpoint returned no access_token");
347
+ if (!body.device_code || !body.user_fingerprint) {
348
+ throw new Error("the attach endpoint returned no device_code/fingerprint");
349
+ }
332
350
  return body;
333
351
  }
334
352
 
335
353
  /**
336
- * Run the full browserless redemption and return a persistable credentials record
337
- * — the invite-token sibling of loginFlow()/deviceLoginFlow(), same credentials
338
- * shape, same dynamically-registered client_id (reused when the caller cached one
339
- * for this MCP). Injectable (`fetchImpl`, `now`) so it's testable with no network.
354
+ * Run the full browserless rendezvous sign-in and return a persistable credentials
355
+ * record — the invite sibling of loginFlow()/deviceLoginFlow(), same credentials
356
+ * shape + dynamically-registered client_id (reused when the caller cached one for
357
+ * this MCP). The terminal generates its own PKCE key, attaches, surfaces the
358
+ * fingerprint via `log` for the developer to confirm in the cockpit, then polls the
359
+ * token endpoint until approved. Injectable (`fetchImpl`, `log`, `sleep`, `now`) so
360
+ * it's testable with no network and no real waiting.
340
361
  * @returns {Promise<object>} credentials to hand to writeCredentials()
341
362
  */
342
- export async function redeemCodeFlow({
363
+ export async function rendezvousLoginFlow({
343
364
  mcpUrl,
344
- clientId,
345
365
  code,
346
366
  fetchImpl = fetch,
367
+ log = () => {},
368
+ sleep = delay,
347
369
  now = () => Date.now(),
348
370
  }) {
349
371
  const meta = await discoverMetadata(mcpUrl, fetchImpl);
350
- const resolvedClientId = clientId || (await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl));
351
- const token = await redeemInviteCode(
352
- redeemCodeEndpoint(mcpUrl, meta),
353
- { code, clientId: resolvedClientId },
372
+ // Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
373
+ // clientId. A client cached by an older CLI build was registered without the
374
+ // device-code grant, so /oauth/token would reject the device grant with
375
+ // "unauthorized_client". Registration is cheap and a rendezvous has no consent
376
+ // screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
377
+ const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
378
+ const { verifier, challenge } = generatePkce();
379
+
380
+ const attach = await attachRendezvous(
381
+ deviceEndpoint(mcpUrl, meta, "/oauth/device/attach"),
382
+ { rendezvousCode: code, clientId: resolvedClientId, challenge },
354
383
  fetchImpl,
355
384
  );
385
+
386
+ log("");
387
+ log(` Confirm this code in your browser to finish signing in: ${attach.user_fingerprint}`);
388
+ log(" Waiting for you to approve it in Token of Trust …");
389
+
390
+ const token = await pollDeviceToken(
391
+ meta.token_endpoint,
392
+ {
393
+ deviceCode: attach.device_code,
394
+ clientId: resolvedClientId,
395
+ codeVerifier: verifier,
396
+ intervalSec: attach.interval,
397
+ expiresInSec: attach.expires_in,
398
+ },
399
+ fetchImpl,
400
+ { sleep, now },
401
+ );
356
402
  return credentialsFromToken({
357
403
  mcpUrl,
358
404
  clientId: resolvedClientId,
@@ -395,7 +441,7 @@ export async function deviceAuthorize(deviceAuthorizationEndpoint, { clientId, s
395
441
  * normal "keep waiting" responses — so this resolves `{ pending: true }`
396
442
  * (with `slowDown` set) for those instead of throwing.
397
443
  */
398
- async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl) {
444
+ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl) {
399
445
  const res = await fetchImpl(tokenEndpoint, {
400
446
  method: "POST",
401
447
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
@@ -403,6 +449,10 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
403
449
  grant_type: "urn:ietf:params:oauth:grant-type:device_code",
404
450
  device_code: deviceCode,
405
451
  client_id: clientId,
452
+ // The rendezvous flow binds the grant to the terminal's PKCE key: the
453
+ // verifier proves this is the same terminal that attached the challenge.
454
+ // Absent for the plain RFC 8628 device flow (no PKCE) — omitted then.
455
+ ...(codeVerifier ? { code_verifier: codeVerifier } : {}),
406
456
  }).toString(),
407
457
  });
408
458
  const text = await res.text();
@@ -428,7 +478,7 @@ async function deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImp
428
478
  */
429
479
  export async function pollDeviceToken(
430
480
  tokenEndpoint,
431
- { deviceCode, clientId, intervalSec, expiresInSec },
481
+ { deviceCode, clientId, codeVerifier, intervalSec, expiresInSec },
432
482
  fetchImpl = fetch,
433
483
  { sleep = delay, now = () => Date.now() } = {},
434
484
  ) {
@@ -437,7 +487,7 @@ export async function pollDeviceToken(
437
487
  for (;;) {
438
488
  await sleep(intervalMs);
439
489
  if (now() >= deadline) throw new Error("the device code expired before it was approved");
440
- const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId }, fetchImpl);
490
+ const r = await deviceTokenPoll(tokenEndpoint, { deviceCode, clientId, codeVerifier }, fetchImpl);
441
491
  if (!r.pending) return r.token;
442
492
  if (r.slowDown) intervalMs += 5000;
443
493
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
3
- * `tot start` / `tot checkout` failed, so it can show the exact fix in the bridge
3
+ * `tot start` / `tot clone` failed, so it can show the exact fix in the bridge
4
4
  * strip (obstacle lane, server side already shipped). Best-effort telemetry that
5
5
  * rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
6
6
  * delay past its timeout, or fail that error path.