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

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.
@@ -42,16 +42,25 @@ import { createHash } from "node:crypto";
42
42
  import { setTimeout as delay } from "node:timers/promises";
43
43
  import { createMcpClient } from "../mcp.mjs";
44
44
  import { establishSession, AuthUnavailableError } from "../auth.mjs";
45
+ import { checkoutTenant } from "./clone.mjs";
45
46
  import { validateTenant, ERROR } from "../validate.mjs";
46
47
  import { openBrowser } from "../open.mjs";
47
48
  import { startProgress } from "../progress.mjs";
48
49
  import { fail } from "../errors.mjs";
50
+ import {
51
+ defaultCandidateStatePath,
52
+ readActiveChangeId,
53
+ writeActiveChangeId,
54
+ mintFreshChangeId,
55
+ isTerminalCandidateState,
56
+ isDefaultBranch,
57
+ } from "../candidate-state.mjs";
49
58
 
50
59
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
51
60
  const DEFAULT_REF = "preview";
52
61
 
53
62
  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 };
63
+ 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
64
  for (let i = 0; i < argv.length; i++) {
56
65
  const t = argv[i];
57
66
  if (t === "--mcp") a.mcp = argv[++i];
@@ -60,28 +69,48 @@ export function parseArgs(argv) {
60
69
  else if (t === "-m" || t === "--message") a.message = argv[++i];
61
70
  else if (t === "--summary") a.summary = argv[++i];
62
71
  else if (t === "--skip-validate") a.skipValidate = true;
72
+ else if (t === "--no-commit") a.noCommit = true;
63
73
  else if (t === "--no-wait") a.noWait = true;
64
74
  else if (t === "--watch") a.watch = true;
65
75
  else if (t === "--no-open") a.noOpen = true;
76
+ else if (t === "--new") a.new = true;
66
77
  else if (t === "--help" || t === "-h") a.help = true;
67
78
  }
68
79
  return a;
69
80
  }
70
81
 
71
- const USAGE = `tot submit — submit your store for preview
82
+ /**
83
+ * Render the usage block for whichever verb invoked this flow. `preview` is the
84
+ * first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
85
+ * the help they print names the verb the developer actually typed (see preview.mjs).
86
+ * @param {string} [verb]
87
+ */
88
+ export function renderUsage(verb = "preview") {
89
+ return `tot ${verb} — submit your store for preview
90
+
91
+ tot ${verb} validate → push the preview ref → stream the result
92
+ tot ${verb} --new open a NEW candidate PR instead of updating your open one
93
+ tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
94
+ tot ${verb} --skip-validate push without the local lint (not recommended)
95
+ tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
96
+ tot ${verb} --ref <name> push ref (default: ${DEFAULT_REF})
97
+ tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
98
+ tot ${verb} --summary "<text>" longer description to accompany the title
99
+ tot ${verb} --no-wait push and exit without polling for the reconcile result
100
+ tot ${verb} --no-open don't open the preview URL in the browser on success
101
+ tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
72
102
 
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)
103
+ By default a re-run UPDATES your open candidate PR (like pushing more commits
104
+ to a GitHub PR), rather than opening a new one each time. Use --new to fork a
105
+ fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
106
+ open candidates with \`tot pr\` (list / view / close). If your candidate was already
107
+ merged or closed, a re-run automatically opens a fresh one.
108
+
109
+ Once a preview reconciles cleanly, \`tot ship\` promotes it live.
82
110
 
83
111
  If you omit -m, a summary is generated from git (commit subject + the diff vs
84
112
  what's live in preview) so the change record the approver reviews is never blank.`;
113
+ }
85
114
 
86
115
  // Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
87
116
  // --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
@@ -123,6 +152,126 @@ function printChangeSummary({ title, body, autoTitle }) {
123
152
  if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
124
153
  }
125
154
 
155
+ // ─── auto-commit the known content trees (unit u2) ───────────────────────────────
156
+
157
+ /**
158
+ * The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
159
+ * these. A store's reviewable content lives here; anything a developer edits
160
+ * OUTSIDE these (src/, config, stray files) is out of scope for an automatic
161
+ * commit and is REFUSED rather than silently swept in. Explicit paths only —
162
+ * never `git add -A`/`git add .`.
163
+ */
164
+ export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
165
+ export const KNOWN_CONTENT_FILES = ["theme.json"];
166
+
167
+ /** Is this repo-relative path inside a known content tree (or the one known
168
+ * top-level file)? Pure — unit-tested. */
169
+ export function isKnownContentPath(path) {
170
+ const p = String(path).replace(/^\.\//, "");
171
+ return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
172
+ }
173
+
174
+ /**
175
+ * Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
176
+ * dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
177
+ * `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
178
+ * from its tree too, so it's part of the scope decision). Run with
179
+ * `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
180
+ * unit-tested.
181
+ * @param {string} text
182
+ * @returns {string[]}
183
+ */
184
+ export function parsePorcelainPaths(text) {
185
+ const seen = new Set();
186
+ const out = [];
187
+ for (const line of String(text).split("\n")) {
188
+ if (line.length < 4) continue; // "XY p" is the shortest real entry
189
+ const rest = line.slice(3);
190
+ const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
191
+ for (const raw of parts) {
192
+ const p = raw.trim();
193
+ if (p && !seen.has(p)) {
194
+ seen.add(p);
195
+ out.push(p);
196
+ }
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * Partition dirty paths into the known content trees vs everything else. Pure —
204
+ * unit-tested.
205
+ * @param {string[]} paths
206
+ * @returns {{ known: string[], unknown: string[] }}
207
+ */
208
+ export function classifyDirtyPaths(paths) {
209
+ const known = [];
210
+ const unknown = [];
211
+ for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
212
+ return { known, unknown };
213
+ }
214
+
215
+ /** A concise auto commit subject when the developer gave no -m — derived from the
216
+ * staged files so the commit is never a blank "(untitled change)". Pure. */
217
+ export function autoCommitSubject(files = []) {
218
+ if (files.length === 0) return "update store content";
219
+ if (files.length === 1) return `update ${files[0]}`;
220
+ return `update ${files.length} content files`;
221
+ }
222
+
223
+ /**
224
+ * The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
225
+ * as the subject; otherwise autoCommitSubject derives one from the staged files.
226
+ * The body (shortstat + file list) is built by the SAME buildChangeSummary the
227
+ * approver summary uses, so a commit and the change record it later feeds read
228
+ * consistently. Returns a git commit message (subject, blank line, body). Pure —
229
+ * unit-tested.
230
+ * @param {{ message?: string|null, files?: string[], statLine?: string }} input
231
+ * @returns {string}
232
+ */
233
+ export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
234
+ const subject = (message && message.trim()) || autoCommitSubject(files);
235
+ const { body } = buildChangeSummary({ message: subject, files, statLine });
236
+ return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
237
+ }
238
+
239
+ /**
240
+ * Auto-commit the known content trees before previewing (unit u2). On a DIRTY
241
+ * tree `tot preview` commits your content edits for you, so a preview always
242
+ * reflects your working changes — but ONLY the known trees (content/, public/,
243
+ * theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
244
+ * dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
245
+ * never silently swept into a store commit). --no-commit opts out entirely
246
+ * (preview whatever's already committed — u1's behavior).
247
+ *
248
+ * Returns exactly one of:
249
+ * { skipped: true } — --no-commit.
250
+ * { clean: true } — nothing dirty; preview HEAD as-is.
251
+ * { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
252
+ * { committed: true, sha, files } — staged the known dirty paths and committed.
253
+ *
254
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
255
+ * @param {{ message?: string|null, noCommit?: boolean }} [opts]
256
+ */
257
+ export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
258
+ if (noCommit) return { skipped: true };
259
+ const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
260
+ const dirty = parsePorcelainPaths(status);
261
+ if (dirty.length === 0) return { clean: true };
262
+ const { known, unknown } = classifyDirtyPaths(dirty);
263
+ if (unknown.length) return { refused: true, unknown, known };
264
+ // Stage ONLY the known dirty paths, each by explicit path — this records adds,
265
+ // modifications AND deletions within them, and can never reach outside the set.
266
+ git(["add", "--", ...known]);
267
+ const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
268
+ const statLine = git(["diff", "--cached", "--shortstat"]).trim();
269
+ const msg = buildAutoCommitMessage({ message, files, statLine });
270
+ git(["commit", "--no-verify", "-m", msg]);
271
+ const sha = git(["rev-parse", "HEAD"]).trim();
272
+ return { committed: true, sha, files };
273
+ }
274
+
126
275
  // ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
127
276
 
128
277
  /**
@@ -198,22 +347,165 @@ export function repoNameFromRemote(remoteUrl) {
198
347
  }
199
348
  }
200
349
 
350
+ // ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
351
+
352
+ /**
353
+ * Split an authenticated forge remote URL (basic-auth `user:token@host`, as the
354
+ * MCP mints it via `tenant_checkout`) into its tokenless public URL + the embedded
355
+ * credential, so the token can be handed to git EPHEMERALLY for one push instead of
356
+ * being persisted in `.git/config`. Returns null when the URL won't parse or carries
357
+ * no token — the caller then falls back to the checkout's existing remote. Pure —
358
+ * unit-tested.
359
+ * @param {string} remoteUrl
360
+ * @returns {{ publicUrl: string, username: string, token: string }|null}
361
+ */
362
+ export function splitAuthedRemote(remoteUrl) {
363
+ try {
364
+ const u = new URL(String(remoteUrl));
365
+ const token = u.password ? decodeURIComponent(u.password) : "";
366
+ if (!token) return null;
367
+ const username = u.username ? decodeURIComponent(u.username) : "";
368
+ return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
369
+ } catch {
370
+ return null;
371
+ }
372
+ }
373
+
374
+ /**
375
+ * The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
376
+ * invocation (base64 of `user:token`) — so a freshly-minted forge token
377
+ * authenticates one push without ever being written to `.git/config`. Pure —
378
+ * unit-tested.
379
+ * @param {string} username
380
+ * @param {string} token
381
+ * @returns {string}
382
+ */
383
+ export function basicAuthExtraHeader(username, token) {
384
+ const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
385
+ return `Authorization: Basic ${b64}`;
386
+ }
387
+
388
+ /**
389
+ * Recognise a forge auth failure (expired / invalid push token) in a failed git
390
+ * push's stderr, so `tot preview` can re-mint a fresh credential and retry once
391
+ * rather than dead-ending on a stale token (the belt-and-suspenders half of
392
+ * decision B). Pure — unit-tested.
393
+ * @param {string} text
394
+ * @returns {boolean}
395
+ */
396
+ export function isForgeAuthError(text) {
397
+ return /\b40[13]\b|failed to authenticate|authentication failed|invalid credentials|access denied/i.test(
398
+ String(text || ""),
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Derive the checkout's forge tag from its repo name (`"<tenant>-<tag>"`), so a
404
+ * re-mint targets the SAME repo the checkout points at. Defaults to "main" when the
405
+ * repo is bare (`"<tenant>"`, post-8425) or the `<tenant>-` prefix doesn't match, so
406
+ * the mint degrades to the clone default rather than a wrong tag. Pure —
407
+ * unit-tested.
408
+ * @param {string|null} repoName
409
+ * @param {string} tenant
410
+ * @returns {string}
411
+ */
412
+ export function tagFromRepoName(repoName, tenant) {
413
+ const r = String(repoName || "");
414
+ const prefix = `${tenant}-`;
415
+ return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
416
+ }
417
+
418
+ /**
419
+ * Push the preview ref (decision B): mint a FRESH, short-lived forge credential
420
+ * right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
421
+ * a per-invocation `-c` — never written to `.git/config`), re-minting once on an
422
+ * auth failure. The push credential the MCP baked into `.git/config` at clone time
423
+ * expires within hours; reusing that stale embedded token is the invited-dev
424
+ * "`tot preview` → Gitea 401 dead-end". We push over the named `origin` remote with
425
+ * its URL overridden to the tokenless public URL for this one invocation, so the
426
+ * remote-tracking ref still updates while no long-lived secret lands on disk.
427
+ *
428
+ * When the mint is unavailable (older MCP, transient failure — `mintRemote` returns
429
+ * null) or the minted URL carries no parseable token, it falls back to pushing over
430
+ * the checkout's EXISTING remote (pre-B behavior) — no regression.
431
+ *
432
+ * @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
433
+ * @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
434
+ * @param {{ ref: string }} opts
435
+ * @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
436
+ */
437
+ export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
438
+ const attempt = (remote) => {
439
+ const cred = splitAuthedRemote(remote);
440
+ if (!cred) {
441
+ // No fresh credential to hand over — push over the checkout's existing remote.
442
+ return git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
443
+ }
444
+ // Ephemeral auth: override the remote URL to the tokenless public URL and supply
445
+ // the credential as a one-shot Authorization header, with any OS credential
446
+ // helper disabled — none of this touches `.git/config`.
447
+ return git([
448
+ "-c", `remote.origin.url=${cred.publicUrl}`,
449
+ "-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
450
+ "-c", "credential.helper=",
451
+ "push", "-f", "origin", `HEAD:refs/heads/${ref}`,
452
+ ]);
453
+ };
454
+
455
+ const remote = await mintRemote();
456
+ try {
457
+ return { out: attempt(remote) };
458
+ } catch (e) {
459
+ if (!isForgeAuthError(e?.stderr || e?.message || e)) throw e;
460
+ // Belt-and-suspenders: an auth failure re-mints a fresh credential and retries once.
461
+ return { out: attempt(await mintRemote()) };
462
+ }
463
+ }
464
+
201
465
  /**
202
- * A STABLE per-developer-per-tenant candidate handle — so repeat `tot submit`
203
- * runs update the SAME PR instead of opening a new one each time
466
+ * A STABLE per-developer-per-tenant(-per-branch) candidate handle — so repeat
467
+ * `tot submit` runs update the SAME PR instead of opening a new one each time
204
468
  * (`candidate_open` is idempotent on `changeId`). No local state file needed:
205
- * it's a deterministic hash of the tenant + the acting identity, recomputed
206
- * fresh every run. Two different developers submitting to the same tenant get
207
- * two different (non-colliding) candidates. Pure — unit-tested.
469
+ * it's a deterministic hash of the tenant + the acting identity (+ the git branch
470
+ * on a non-default branch), recomputed fresh every run. Two different developers
471
+ * submitting to the same tenant get two different (non-colliding) candidates; so
472
+ * do the SAME developer on two different feature branches (u4 — branch-bound
473
+ * candidates), so `git checkout` acts as the PR switcher.
474
+ *
475
+ * ZERO MIGRATION: on the DEFAULT branch (main/master, or an unresolvable branch)
476
+ * the hash material is `tenant|actorKey` — byte-identical to the pre-u4 id — so an
477
+ * existing dev's current candidate keeps working untouched. A non-default branch
478
+ * folds the branch into the material (`tenant|actorKey|branch`) for its own id.
479
+ * Pure — unit-tested.
208
480
  * @param {string} tenant
209
481
  * @param {string} actorKey
482
+ * @param {string|null} [branch] current git branch; default/null ⇒ today's id
210
483
  * @returns {string}
211
484
  */
212
- export function deriveChangeId(tenant, actorKey) {
213
- const hash = createHash("sha256").update(`${tenant}|${actorKey}`).digest("hex").slice(0, 16);
485
+ export function deriveChangeId(tenant, actorKey, branch = null) {
486
+ const material = isDefaultBranch(branch) ? `${tenant}|${actorKey}` : `${tenant}|${actorKey}|${branch}`;
487
+ const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
214
488
  return `local-${hash}`;
215
489
  }
216
490
 
491
+ /**
492
+ * The current git branch in `workspace`, or null when it can't be resolved (a
493
+ * detached HEAD reports "HEAD", and any git failure is swallowed) — null is read
494
+ * by `isDefaultBranch` as the default branch, so an unresolvable branch keeps
495
+ * today's (branch-less) candidate rather than minting a spurious namespace. `git`
496
+ * is injected (a `(args:string[])=>string` runner) so it's testable. Best-effort.
497
+ * @param {(args:string[]) => string} git
498
+ * @returns {string|null}
499
+ */
500
+ export function currentBranch(git) {
501
+ try {
502
+ const b = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
503
+ return b && b !== "HEAD" ? b : null;
504
+ } catch {
505
+ return null;
506
+ }
507
+ }
508
+
217
509
  /** The stable identity key behind `deriveChangeId` — the signed-in developer's
218
510
  * email, falling back to the token, then a generic label. Single-plane: the only
219
511
  * identity `tot` carries is the developer's own OAuth session. */
@@ -277,19 +569,26 @@ function reportCandidate(result, changeId) {
277
569
  console.log(` ~ PR-backed candidate not opened: ${msg}`);
278
570
  }
279
571
 
280
- /** @param {string[]} argv @param {any} ctx */
281
- export async function run(argv, ctx) {
572
+ /**
573
+ * The preview flow — validate, push the preview ref, open/update the PR-backed
574
+ * candidate, and stream back the reconcile/compliance/preview result. Reached by
575
+ * `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
576
+ * wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
577
+ * copy (usage + the not-in-checkout error) with whatever the developer typed.
578
+ * @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
579
+ */
580
+ export async function run(argv, ctx, { verb = "preview" } = {}) {
282
581
  const env = process.env;
283
582
  const args = parseArgs(argv);
284
583
  if (args.help) {
285
- console.log(USAGE);
584
+ console.log(renderUsage(verb));
286
585
  return 0;
287
586
  }
288
587
  if (ctx.mode !== "checkout") {
289
588
  console.error(
290
589
  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)",
590
+ `\`tot ${verb}\` runs from inside a tenant checkout`,
591
+ "tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
293
592
  ),
294
593
  );
295
594
  return 2;
@@ -298,6 +597,41 @@ export async function run(argv, ctx) {
298
597
  const tenant = ctx.tenant;
299
598
  const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
300
599
 
600
+ // 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
601
+ // content edits BEFORE previewing so the preview reflects your working changes.
602
+ // Only content/, public/, theme.json, .tot/ (staged by explicit path, never
603
+ // `git add -A`); anything dirty outside those is refused, not silently swept in.
604
+ // --no-commit opts out (preview whatever's already committed).
605
+ let auto;
606
+ try {
607
+ auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
608
+ } catch (e) {
609
+ console.error(
610
+ fail(
611
+ `auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
612
+ "commit your content manually (git add / git commit), or re-run with --no-commit",
613
+ ),
614
+ );
615
+ return 1;
616
+ }
617
+ if (auto.refused) {
618
+ console.error(
619
+ fail(
620
+ `${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
621
+ "commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
622
+ ) + "\n",
623
+ );
624
+ for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
625
+ console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
626
+ if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
627
+ return 1;
628
+ }
629
+ if (auto.committed) {
630
+ console.error(
631
+ `~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
632
+ );
633
+ }
634
+
301
635
  // 1. validate locally — refuse on errors.
302
636
  if (!args.skipValidate) {
303
637
  const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
@@ -349,10 +683,68 @@ export async function run(argv, ctx) {
349
683
  const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
350
684
  const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
351
685
 
352
- console.error(`~ pushing ${short} ${args.ref} (origin)`);
686
+ // The MCP session is needed BOTH to mint a fresh forge push credential (decision
687
+ // B — right below) and for the candidate/preview read-back after, so establish it
688
+ // ONCE, up front, and reuse it for the whole flow.
689
+ const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
690
+ const client = createMcpClient(baseUrl);
691
+ const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
692
+
693
+ let session;
694
+ try {
695
+ session = await establishSession(client, { env, prefer: args.identity || undefined });
696
+ } catch (e) {
697
+ // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
698
+ // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
699
+ // no worse than before) and skip the read-back that needs a session. The push
700
+ // still lands if that embedded token is live.
701
+ console.error(`~ pushing ${short} → ${args.ref} (origin)`);
702
+ try {
703
+ const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
704
+ if (out.trim()) console.error(redactUrl(out.trim()));
705
+ } catch (pushErr) {
706
+ console.error(
707
+ fail(
708
+ `push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`,
709
+ "check your commit and that the checkout's remote is reachable, then re-run",
710
+ ),
711
+ );
712
+ return 1;
713
+ }
714
+ console.log(`\n+ submitted ${short} to ${args.ref}.`);
715
+ printChangeSummary(changeSummary);
716
+ if (e instanceof AuthUnavailableError) {
717
+ console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
718
+ } else {
719
+ console.log(` (couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)})`);
720
+ }
721
+ console.log(` Your push is in; the preview updates once reconcile completes.`);
722
+ return 0;
723
+ }
724
+
725
+ // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
726
+ // (decision B). The token baked into `.git/config` at clone time expires within
727
+ // hours, so we re-mint right before the push and hand it to git ephemerally
728
+ // (never persisted to `.git/config`), re-minting once on an auth failure.
729
+ // `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
730
+ // binding the tenant scope the candidate/preview read-back below reads.
731
+ const tag = tagFromRepoName(repo, tenant);
732
+ const mintRemote = async () => {
733
+ try {
734
+ const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
735
+ return res.gitRemote || null;
736
+ } catch (e) {
737
+ console.error(
738
+ `~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
739
+ );
740
+ return null;
741
+ }
742
+ };
743
+
744
+ console.error(`~ pushing ${short} → ${args.ref} (origin, fresh credential)`);
353
745
  try {
354
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
355
- if (out.trim()) console.error(redactUrl(out.trim()));
746
+ const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
747
+ if (out && out.trim()) console.error(redactUrl(out.trim()));
356
748
  } catch (e) {
357
749
  console.error(
358
750
  fail(
@@ -366,28 +758,53 @@ export async function run(argv, ctx) {
366
758
  printChangeSummary(changeSummary);
367
759
 
368
760
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
369
- // compliance + preview URL from the MCP (both graceful seams, same session).
370
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
371
- const client = createMcpClient(baseUrl);
761
+ // compliance + preview URL from the MCP reusing the session established above.
372
762
  let progress = null;
373
763
  try {
374
- // Attach auth before the first server call (developer bearer pre-initialize,
375
- // operator credential_validate post-initialize) see establishSession.
376
- const session = await establishSession(client, { env, prefer: args.identity || undefined });
377
- // Set the active tenant so preview_status/candidate_open read the right scope
378
- // (both key on the session's bound tenant/app — no tenant arg of their own).
764
+ // Bind the active tenant so preview_status/candidate_open read the right scope
765
+ // (idempotent checkoutTenant already switched when the fresh mint succeeded).
379
766
  await client.callTool("client_switch", { tenant });
380
767
 
381
768
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
382
769
  // failure here (older MCP, VC not configured, preview-access capability) is
383
770
  // 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
- });
771
+ //
772
+ // Which candidate this submit lands on (gh-pr-like):
773
+ // --new → fork a FRESH candidate and remember it as active;
774
+ // otherwise → the remembered active candidate (from a prior --new /
775
+ // roll), else the STABLE per-dev-per-tenant default.
776
+ // If the chosen candidate turns out to be merged/closed, roll to a fresh one
777
+ // so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
778
+ const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
779
+ const statePath = defaultCandidateStatePath(env);
780
+ // Branch-bound (u4): the candidate handle + active-pointer namespace fold in the
781
+ // current git branch on a non-default branch, so a feature branch gets its OWN
782
+ // candidate; the default branch keeps today's exact id (zero migration).
783
+ const branch = currentBranch(gitSafe);
784
+ const stableId = deriveChangeId(tenant, actorKeyFor(session), branch);
785
+ const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
786
+ let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
787
+ // Persist when we diverge from the stable default (a --new fork, or a
788
+ // previously-remembered active pointer) so the next plain submit follows it.
789
+ let persist = args.new || (!!active && active !== stableId);
790
+
791
+ let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
792
+
793
+ if (candidate && isTerminalCandidateState(candidate.state)) {
794
+ const rolled = mintFreshChangeId(stableId);
795
+ console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
796
+ changeId = rolled;
797
+ persist = true;
798
+ candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
799
+ }
800
+
801
+ // Remember the active candidate only on a real, non-terminal open (best-effort;
802
+ // never let a state-write failure break the submit).
803
+ if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
804
+ try {
805
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
806
+ } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
807
+ }
391
808
 
392
809
  let status;
393
810
  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