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

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.
@@ -3,7 +3,9 @@
3
3
  *
4
4
  * From inside a tenant checkout:
5
5
  * 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
6
- * 2. push your committed work to the tenant repo's `preview` ref (triggers reconcile),
6
+ * 2. push your committed work to YOUR OWN isolated candidate ref `candidate/<changeId>`
7
+ * (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
8
+ * target a literal ref name for back-compat) — which triggers reconcile,
7
9
  * 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
8
10
  * `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
9
11
  * Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
@@ -57,10 +59,25 @@ import {
57
59
  } from "../candidate-state.mjs";
58
60
 
59
61
  const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
60
- const DEFAULT_REF = "preview";
62
+
63
+ // The git ref prefix an isolated candidate push lands under (b03 — stop
64
+ // force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
65
+ // provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
66
+ // which already names its PR-backed branch `candidate/<changeId>` (see
67
+ // submitCandidate, below): the raw git push here and the PR-backed candidate it
68
+ // opens always target the SAME branch, never two.
69
+ export const CANDIDATE_REF_PREFIX = "candidate/";
70
+
71
+ /** The isolated git ref a candidate's preview push lands under. Pure. */
72
+ export function candidateRefFor(changeId) {
73
+ return `${CANDIDATE_REF_PREFIX}${changeId}`;
74
+ }
61
75
 
62
76
  export function parseArgs(argv) {
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 };
77
+ // `ref: null` an explicit `--ref` always wins; otherwise the push target is
78
+ // derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
79
+ // never a fixed shared default.
80
+ const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
64
81
  for (let i = 0; i < argv.length; i++) {
65
82
  const t = argv[i];
66
83
  if (t === "--mcp") a.mcp = argv[++i];
@@ -93,7 +110,7 @@ export function renderUsage(verb = "preview") {
93
110
  tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
94
111
  tot ${verb} --skip-validate push without the local lint (not recommended)
95
112
  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})
113
+ tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
97
114
  tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
98
115
  tot ${verb} --summary "<text>" longer description to accompany the title
99
116
  tot ${verb} --no-wait push and exit without polling for the reconcile result
@@ -513,6 +530,44 @@ export function actorKeyFor(session) {
513
530
  return session?.email || session?.token || "developer";
514
531
  }
515
532
 
533
+ /**
534
+ * Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
535
+ * network call, because it also determines the isolated git ref we push to
536
+ * (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
537
+ * `--new` forks a fresh one.
538
+ * --new → fork a FRESH candidate id;
539
+ * otherwise → the remembered active candidate (from a prior --new / terminal
540
+ * roll), else the STABLE per-dev-per-tenant(-per-branch) default.
541
+ * `persist` reports whether the choice diverges from the stable default, so the
542
+ * caller knows whether to remember it as the new active pointer. `mint` is
543
+ * injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
544
+ * Pure — unit-tested.
545
+ * @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
546
+ * isNew?: boolean, mint?: (baseId: string) => string }} opts
547
+ * @returns {{ changeId: string, stableId: string, persist: boolean }}
548
+ */
549
+ export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
550
+ const stableId = deriveChangeId(tenant, actorKey, branch);
551
+ const changeId = isNew ? mint(stableId) : (active || stableId);
552
+ const persist = isNew || (!!active && active !== stableId);
553
+ return { changeId, stableId, persist };
554
+ }
555
+
556
+ /**
557
+ * The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
558
+ * SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
559
+ * back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
560
+ * any tooling that still reads it by that literal name); otherwise it's YOUR OWN
561
+ * isolated candidate ref, so two developers — or the same developer on two
562
+ * branches — never force-push over each other or each other's preview. Pure —
563
+ * unit-tested.
564
+ * @param {{ ref?: string|null, changeId: string }} opts
565
+ * @returns {string}
566
+ */
567
+ export function resolvePushRef({ ref, changeId }) {
568
+ return ref || candidateRefFor(changeId);
569
+ }
570
+
516
571
  /**
517
572
  * Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
518
573
  * unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
@@ -663,6 +718,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
663
718
  // same diff (name-status, so candidate_open also knows adds/deletes/renames)
664
719
  // doubles as the source of the PR-backed candidate's file patch (step 2b, below)
665
720
  // — one git read, two consumers, so the PR always matches what's printed here.
721
+ // Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
722
+ // candidate ref's own tracking ref, not a shared one, so the summary always reads
723
+ // "vs your own last push" once the push ref is known (computed just below).
666
724
  const gitSafe = (cargs) => {
667
725
  try {
668
726
  return git(cargs);
@@ -670,18 +728,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
670
728
  return "";
671
729
  }
672
730
  };
673
- const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
674
- const trackingRef = `refs/remotes/origin/${args.ref}`;
675
- const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
676
- ? trackingRef
677
- : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
678
- ? "HEAD~1"
679
- : "";
680
- const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
681
- const patchEntries = parseNameStatus(gitSafe(statusCmd));
682
- const files = patchEntries.map((e) => e.path);
683
- const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
684
- const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
731
+ function buildSummaryAndPatch(ref) {
732
+ const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
733
+ const trackingRef = `refs/remotes/origin/${ref}`;
734
+ const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
735
+ ? trackingRef
736
+ : gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
737
+ ? "HEAD~1"
738
+ : "";
739
+ const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
740
+ const patchEntries = parseNameStatus(gitSafe(statusCmd));
741
+ const files = patchEntries.map((e) => e.path);
742
+ const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
743
+ const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
744
+ return { changeSummary, patchEntries };
745
+ }
685
746
 
686
747
  // The MCP session is needed BOTH to mint a fresh forge push credential (decision
687
748
  // B — right below) and for the candidate/preview read-back after, so establish it
@@ -690,6 +751,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
690
751
  const client = createMcpClient(baseUrl);
691
752
  const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
692
753
 
754
+ // Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
755
+ // since the push target itself depends on it — so the raw git push and the
756
+ // PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
757
+ // `statePath` are local filesystem reads (candidate-state.mjs) — no session
758
+ // needed — so they're available even on the no-session fallback path below.
759
+ const branch = currentBranch(gitSafe);
760
+ const statePath = defaultCandidateStatePath(env);
761
+ const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
762
+
693
763
  let session;
694
764
  try {
695
765
  session = await establishSession(client, { env, prefer: args.identity || undefined });
@@ -697,10 +767,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
697
767
  // Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
698
768
  // back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
699
769
  // 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)`);
770
+ // still lands if that embedded token is live. actorKeyFor(null) degrades to the
771
+ // generic "developer" key still isolated PER BRANCH (never the shared ref),
772
+ // just not per-developer until sign-in succeeds.
773
+ const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
774
+ const ref = resolvePushRef({ ref: args.ref, changeId });
775
+ const { changeSummary } = buildSummaryAndPatch(ref);
776
+ console.error(`~ pushing ${short} → ${ref} (origin)`);
702
777
  try {
703
- const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
778
+ const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
704
779
  if (out.trim()) console.error(redactUrl(out.trim()));
705
780
  } catch (pushErr) {
706
781
  console.error(
@@ -711,7 +786,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
711
786
  );
712
787
  return 1;
713
788
  }
714
- console.log(`\n+ submitted ${short} to ${args.ref}.`);
789
+ console.log(`\n+ submitted ${short} to ${ref}.`);
715
790
  printChangeSummary(changeSummary);
716
791
  if (e instanceof AuthUnavailableError) {
717
792
  console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
@@ -722,6 +797,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
722
797
  return 0;
723
798
  }
724
799
 
800
+ // Which candidate (and therefore which isolated ref, b03) this submit targets —
801
+ // decided now, with a real session, so the SAME id backs both the raw git push
802
+ // (right below) and the PR-backed candidate (step 2b): the two never point at
803
+ // different branches. See chooseChangeId's doc for the --new / active-pointer
804
+ // rules.
805
+ let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
806
+ const ref = resolvePushRef({ ref: args.ref, changeId });
807
+ const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
808
+
725
809
  // 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
726
810
  // (decision B). The token baked into `.git/config` at clone time expires within
727
811
  // hours, so we re-mint right before the push and hand it to git ephemerally
@@ -741,9 +825,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
741
825
  }
742
826
  };
743
827
 
744
- console.error(`~ pushing ${short} → ${args.ref} (origin, fresh credential)`);
828
+ console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
745
829
  try {
746
- const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
830
+ const { out } = await pushPreviewRef(git, mintRemote, { ref });
747
831
  if (out && out.trim()) console.error(redactUrl(out.trim()));
748
832
  } catch (e) {
749
833
  console.error(
@@ -754,7 +838,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
754
838
  );
755
839
  return 1;
756
840
  }
757
- console.log(`\n+ submitted ${short} to ${args.ref}.`);
841
+ console.log(`\n+ submitted ${short} to ${ref}.`);
758
842
  printChangeSummary(changeSummary);
759
843
 
760
844
  // 2b + 3. open/update the PR-backed candidate, then report reconcile +
@@ -768,25 +852,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
768
852
  // 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
769
853
  // failure here (older MCP, VC not configured, preview-access capability) is
770
854
  // reported and swallowed, never blocking the preview push that already landed.
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.)
855
+ // `changeId`/`stableId`/`persist` were already decided above (they picked the
856
+ // push ref too); if the chosen candidate turns out to be merged/closed, roll to
857
+ // a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
858
+ // above.)
778
859
  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
860
 
791
861
  let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
792
862
 
@@ -923,6 +993,38 @@ function reportComplianceCheck(c) {
923
993
  if (c.hint) console.log(` → fix: ${c.hint}`);
924
994
  }
925
995
 
996
+ /**
997
+ * Build the printed lines for the headline "share this with your reviewer" block —
998
+ * the whole point of U14: on a successful preview, the SHAREABLE deep link
999
+ * (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
1000
+ * by the reconcile report and threaded through as `previewUrl`) is what a developer
1001
+ * hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
1002
+ * in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
1003
+ * reference. Labeled prominently + paired with an honest auth caveat: the share
1004
+ * target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
1005
+ * public/anonymous link.
1006
+ *
1007
+ * Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
1008
+ * MCP, or a reconcile that hasn't finished minting it — with a note instead of a
1009
+ * crash or a silent blank. Pure — unit-tested.
1010
+ * @param {{ previewUrl?: string|null, status?: string }|null} s
1011
+ * @param {string} tenant
1012
+ * @returns {string[]}
1013
+ */
1014
+ export function formatShareableUrlBlock(s, tenant) {
1015
+ if (s?.previewUrl) {
1016
+ return [
1017
+ `\n ✓ Preview ready — share this with your reviewer:`,
1018
+ ` ${s.previewUrl}`,
1019
+ ` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
1020
+ ];
1021
+ }
1022
+ if (s?.status === "reconciled") {
1023
+ return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
1024
+ }
1025
+ return [];
1026
+ }
1027
+
926
1028
  /**
927
1029
  * Print the reconcile/compliance/preview result and, on a clean reconcile with a
928
1030
  * preview URL, open it in the browser (unless opts.open === false).
@@ -956,10 +1058,8 @@ function reportStatus(s, tenant, { open = true } = {}) {
956
1058
  } else if (s.status === "reconciled") {
957
1059
  console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
958
1060
  }
959
- if (s.previewUrl) {
960
- console.log(`\n Preview: ${s.previewUrl}`);
961
- if (open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
962
- console.log(" (opened in your browser)");
963
- }
1061
+ for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
1062
+ if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
1063
+ console.log(" (opened in your browser)");
964
1064
  }
965
1065
  }
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