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

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,6 +42,7 @@ 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";
@@ -52,6 +53,7 @@ import {
52
53
  writeActiveChangeId,
53
54
  mintFreshChangeId,
54
55
  isTerminalCandidateState,
56
+ isDefaultBranch,
55
57
  } from "../candidate-state.mjs";
56
58
 
57
59
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
@@ -345,22 +347,165 @@ export function repoNameFromRemote(remoteUrl) {
345
347
  }
346
348
  }
347
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
+
348
402
  /**
349
- * A STABLE per-developer-per-tenant candidate handle so repeat `tot submit`
350
- * runs update the SAME PR instead of opening a new one each time
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
+
465
+ /**
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
351
468
  * (`candidate_open` is idempotent on `changeId`). No local state file needed:
352
- * it's a deterministic hash of the tenant + the acting identity, recomputed
353
- * fresh every run. Two different developers submitting to the same tenant get
354
- * 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.
355
480
  * @param {string} tenant
356
481
  * @param {string} actorKey
482
+ * @param {string|null} [branch] current git branch; default/null ⇒ today's id
357
483
  * @returns {string}
358
484
  */
359
- export function deriveChangeId(tenant, actorKey) {
360
- 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);
361
488
  return `local-${hash}`;
362
489
  }
363
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
+
364
509
  /** The stable identity key behind `deriveChangeId` — the signed-in developer's
365
510
  * email, falling back to the token, then a generic label. Single-plane: the only
366
511
  * identity `tot` carries is the developer's own OAuth session. */
@@ -538,10 +683,68 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
538
683
  const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
539
684
  const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
540
685
 
541
- 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)`);
542
745
  try {
543
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
544
- 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()));
545
748
  } catch (e) {
546
749
  console.error(
547
750
  fail(
@@ -555,16 +758,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
555
758
  printChangeSummary(changeSummary);
556
759
 
557
760
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
558
- // compliance + preview URL from the MCP (both graceful seams, same session).
559
- const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
560
- const client = createMcpClient(baseUrl);
761
+ // compliance + preview URL from the MCP reusing the session established above.
561
762
  let progress = null;
562
763
  try {
563
- // Attach auth before the first server call (developer bearer pre-initialize,
564
- // operator credential_validate post-initialize) see establishSession.
565
- const session = await establishSession(client, { env, prefer: args.identity || undefined });
566
- // Set the active tenant so preview_status/candidate_open read the right scope
567
- // (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).
568
766
  await client.callTool("client_switch", { tenant });
569
767
 
570
768
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
@@ -576,12 +774,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
576
774
  // otherwise → the remembered active candidate (from a prior --new /
577
775
  // roll), else the STABLE per-dev-per-tenant default.
578
776
  // 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());
777
+ // so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
581
778
  const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
582
779
  const statePath = defaultCandidateStatePath(env);
583
- const stableId = deriveChangeId(tenant, actorKeyFor(session));
584
- const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo }) : null;
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;
585
786
  let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
586
787
  // Persist when we diverge from the stable default (a --new fork, or a
587
788
  // previously-remembered active pointer) so the next plain submit follows it.
@@ -601,7 +802,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
601
802
  // never let a state-write failure break the submit).
602
803
  if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
603
804
  try {
604
- writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
805
+ writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
605
806
  } catch { /* best-effort local hint — a miss just re-derives the stable id */ }
606
807
  }
607
808
 
@@ -722,6 +923,38 @@ function reportComplianceCheck(c) {
722
923
  if (c.hint) console.log(` → fix: ${c.hint}`);
723
924
  }
724
925
 
926
+ /**
927
+ * Build the printed lines for the headline "share this with your reviewer" block —
928
+ * the whole point of U14: on a successful preview, the SHAREABLE deep link
929
+ * (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
930
+ * by the reconcile report and threaded through as `previewUrl`) is what a developer
931
+ * hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
932
+ * in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
933
+ * reference. Labeled prominently + paired with an honest auth caveat: the share
934
+ * target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
935
+ * public/anonymous link.
936
+ *
937
+ * Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
938
+ * MCP, or a reconcile that hasn't finished minting it — with a note instead of a
939
+ * crash or a silent blank. Pure — unit-tested.
940
+ * @param {{ previewUrl?: string|null, status?: string }|null} s
941
+ * @param {string} tenant
942
+ * @returns {string[]}
943
+ */
944
+ export function formatShareableUrlBlock(s, tenant) {
945
+ if (s?.previewUrl) {
946
+ return [
947
+ `\n ✓ Preview ready — share this with your reviewer:`,
948
+ ` ${s.previewUrl}`,
949
+ ` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
950
+ ];
951
+ }
952
+ if (s?.status === "reconciled") {
953
+ return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
954
+ }
955
+ return [];
956
+ }
957
+
725
958
  /**
726
959
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
727
960
  * preview URL, open it in the browser (unless opts.open === false).
@@ -755,10 +988,8 @@ function reportStatus(s, tenant, { open = true } = {}) {
755
988
  } else if (s.status === "reconciled") {
756
989
  console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
757
990
  }
758
- if (s.previewUrl) {
759
- console.log(`\n Preview: ${s.previewUrl}`);
760
- if (open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
761
- console.log(" (opened in your browser)");
762
- }
991
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
992
+ if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
993
+ console.log(" (opened in your browser)");
763
994
  }
764
995
  }
package/src/plan.mjs ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The shared "operation plan" affordance (unit U10) — the load-bearing
3
+ * cross-cutting requirement from decision `operator-verb-and-hosting-model`:
4
+ * every MUTATING operator verb (build / accept / ship / retire) must STATE
5
+ * EXACTLY what it will do — which PR moves to main, which deploy targets
6
+ * (preview / live) are touched, and their URLs — and get an explicit confirm
7
+ * before acting. No silent multi-step mutations, in either surface (the CLI
8
+ * here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
9
+ * shape of plan text server-side/inline).
10
+ *
11
+ * `planForAction` is PURE (no I/O, no prompt) so it's trivially unit-tested
12
+ * and reusable anywhere a plan needs to be rendered (CLI stdout, an admin
13
+ * confirm() dialog, a future dry-run flag). `printPlanAndConfirm` is the CLI
14
+ * half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
15
+ * TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
16
+ *
17
+ * SHIP is context-dependent (decision `ship-context-dependent-semantics`):
18
+ * run by the developer in their own checkout, the candidate is still open, so
19
+ * shipping means accept-then-deploy (merge PR → main, THEN deploy main to
20
+ * preview + live). Run by an operator already targeting a PR that's merged,
21
+ * shipping means just deploy (main → preview + live) — there's nothing left
22
+ * to merge. `planForAction` takes an explicit `context` so the caller (which
23
+ * knows which situation it's in) picks the right narration; it never guesses.
24
+ *
25
+ * Dependency-free (no imports besides the sibling `prompt.mjs`).
26
+ */
27
+ import { isInteractive, promptYesNo } from "./prompt.mjs";
28
+
29
+ /**
30
+ * A human-readable label for the thing an action targets: "PR #N" when a PR
31
+ * number is known, else the change id, else a neutral fallback. Pure.
32
+ * @param {{ pr?: number|string|null, changeId?: string|null }} p
33
+ * @returns {string}
34
+ */
35
+ function targetLabel({ pr, changeId }) {
36
+ if (pr != null && `${pr}`.trim()) return `PR #${pr}`;
37
+ if (changeId) return changeId;
38
+ return "this change";
39
+ }
40
+
41
+ /**
42
+ * Build the EXACT plan for a mutating operator verb — structured lines ready
43
+ * to print verbatim (CLI) or join for a confirm() dialog (admin). Pure: no
44
+ * console output, no network, no prompting.
45
+ *
46
+ * @param {{
47
+ * action: "build"|"accept"|"ship"|"retire",
48
+ * tenant: string,
49
+ * pr?: number|string|null,
50
+ * changeId?: string|null,
51
+ * headSha?: string|null,
52
+ * endpoint?: string|null,
53
+ * targets?: { preview?: string|null, live?: string|null },
54
+ * context?: "developer"|"operator",
55
+ * }} params
56
+ * @returns {string[]} plan lines (no leading/trailing blank line)
57
+ */
58
+ export function planForAction({
59
+ action,
60
+ tenant,
61
+ pr = null,
62
+ changeId = null,
63
+ headSha = null,
64
+ endpoint = null,
65
+ targets = {},
66
+ context = "operator",
67
+ }) {
68
+ const label = targetLabel({ pr, changeId });
69
+ const lines = [`${titleFor(action)} plan:`];
70
+ if (tenant) lines.push(` tenant: ${tenant}`);
71
+ if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
72
+ if (changeId) lines.push(` change id: ${changeId}`);
73
+ if (headSha) lines.push(` head sha: ${headSha}`);
74
+ if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
75
+
76
+ switch (action) {
77
+ case "build": {
78
+ lines.push(
79
+ ` effect: materialize ${label}'s candidate preview — NO merge, NO go-live, NO channel flip.`,
80
+ );
81
+ if (targets.preview) lines.push(` viewable: ${targets.preview}`);
82
+ break;
83
+ }
84
+ case "accept": {
85
+ lines.push(` effect: merge ${label} into main.`);
86
+ break;
87
+ }
88
+ case "ship": {
89
+ const deployTargets = deployTargetsLine(targets);
90
+ if (context === "developer") {
91
+ // The candidate is still open in the developer's own checkout — ship
92
+ // is accept-then-deploy in one gated step.
93
+ lines.push(
94
+ ` effect: merge ${label} into main, then deploy main → ${deployTargets}.`,
95
+ );
96
+ } else {
97
+ // Operator targeting a PR that's already merged — nothing left to
98
+ // merge, so ship is just the deploy half.
99
+ lines.push(` effect: deploy main → ${deployTargets}.`);
100
+ }
101
+ break;
102
+ }
103
+ case "retire": {
104
+ lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
105
+ break;
106
+ }
107
+ default: {
108
+ lines.push(` effect: ${action} ${label}.`);
109
+ break;
110
+ }
111
+ }
112
+ return lines;
113
+ }
114
+
115
+ /** "build" → "Build-on-demand", "accept" → "Accept", "ship" → "Ship", "retire" → "Retire". Pure. */
116
+ function titleFor(action) {
117
+ if (action === "build") return "Build-on-demand";
118
+ if (action === "accept") return "Accept";
119
+ if (action === "ship") return "Ship";
120
+ if (action === "retire") return "Retire";
121
+ return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
122
+ }
123
+
124
+ /** Render "preview (<url>) + live (<url>)", degrading gracefully when a URL is unknown. Pure. */
125
+ function deployTargetsLine(targets = {}) {
126
+ const preview = targets.preview ? `preview (${targets.preview})` : "preview";
127
+ const live = targets.live ? `live (${targets.live})` : "live";
128
+ return `${preview} + ${live}`;
129
+ }
130
+
131
+ /**
132
+ * Print a plan and gate on an explicit confirm — the CLI half of the shared
133
+ * affordance. Prints every line, a trailing blank line, then:
134
+ *
135
+ * - `yes: true` → confirmed immediately, no prompt (the verb's `--yes`).
136
+ * - a non-TTY (CI, piped) → refuses without prompting (never silently acts).
137
+ * - otherwise → asks `question` via `promptYesNo` (default NO unless the
138
+ * caller opts in with `defaultYes`).
139
+ *
140
+ * Returns a reason alongside the boolean so the caller can render its own
141
+ * house-style refusal/abort message (verbs differ: "nothing was built" vs
142
+ * "nothing shipped" etc.) — this helper only owns the plan + the gate.
143
+ *
144
+ * @param {string[]} planLines
145
+ * @param {{ yes?: boolean, question?: string, defaultYes?: boolean }} [opts]
146
+ * @returns {Promise<{ confirmed: boolean, reason: "yes-flag"|"confirmed"|"declined"|"non-tty" }>}
147
+ */
148
+ export async function printPlanAndConfirm(planLines, { yes = false, question = "Proceed?", defaultYes = false } = {}) {
149
+ for (const line of planLines) console.log(line);
150
+ console.log("");
151
+
152
+ if (yes) return { confirmed: true, reason: "yes-flag" };
153
+
154
+ if (!isInteractive()) {
155
+ return { confirmed: false, reason: "non-tty" };
156
+ }
157
+
158
+ const ok = await promptYesNo(question, defaultYes);
159
+ return { confirmed: ok, reason: ok ? "confirmed" : "declined" };
160
+ }
package/src/sample.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  * Dependency-free (node:fs + node:path only).
27
27
  */
28
28
  import {
29
- cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
29
+ appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
30
30
  } from "node:fs";
31
31
  import { homedir } from "node:os";
32
32
  import { fileURLToPath } from "node:url";
@@ -41,6 +41,11 @@ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
41
41
  * version-manager shell hooks land on a supported Node just by cd-ing into
42
42
  * their store, and a plain `nvm use` works with no argument. Best-effort:
43
43
  * never fails the checkout.
44
+ *
45
+ * When `dir` is a git working copy (true for `tot clone`, not for the
46
+ * non-git sample scaffold), the dropped file is also excluded LOCALLY
47
+ * (`.git/info/exclude`) so it doesn't leave a fresh `tot clone` dirty —
48
+ * `git status` right after cloning must read clean. See `excludeLocally`.
44
49
  * @param {string} dir @param {NodeJS.ProcessEnv} [env]
45
50
  */
46
51
  export function writeNvmrc(dir, env = process.env) {
@@ -48,11 +53,32 @@ export function writeNvmrc(dir, env = process.env) {
48
53
  const p = join(dir, ".nvmrc");
49
54
  if (existsSync(p)) return; // the store repo's own pin wins
50
55
  writeFileSync(p, pickNvmrcVersion(env) + "\n");
56
+ excludeLocally(dir, ".nvmrc");
51
57
  } catch {
52
58
  /* a missing .nvmrc never blocks the loop */
53
59
  }
54
60
  }
55
61
 
62
+ /**
63
+ * Add `pattern` to `<dir>/.git/info/exclude` — a LOCAL-only ignore list that
64
+ * never touches the repo's own committed `.gitignore` (so we don't mutate a
65
+ * tenant's tracked files just to keep our own convenience drop-in out of
66
+ * their way). No-op when `dir` isn't a git working copy, or `pattern` is
67
+ * already excluded (a repeat `tot clone` into the same dir, or the repo's
68
+ * own `.gitignore` already covering it — appending again would just be
69
+ * redundant, not wrong). Best-effort: never throws past its caller's `try`.
70
+ * @param {string} dir @param {string} pattern
71
+ */
72
+ function excludeLocally(dir, pattern) {
73
+ const gitDir = join(dir, ".git");
74
+ if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return; // no .git, or a submodule-style .git FILE — skip
75
+ const excludePath = join(gitDir, "info", "exclude");
76
+ const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
77
+ if (existing.split("\n").some((l) => l.trim() === pattern)) return; // already excluded
78
+ mkdirSync(join(gitDir, "info"), { recursive: true });
79
+ appendFileSync(excludePath, (existing && !existing.endsWith("\n") ? "\n" : "") + pattern + "\n");
80
+ }
81
+
56
82
  /**
57
83
  * The version `.nvmrc` should pin: the NEWEST Node the developer ALREADY has
58
84
  * installed under nvm that meets the floor — so `nvm use` succeeds with zero