@tokenoftrust/cli 1.4.0-rc.14 → 1.4.0-rc.16

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.
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `tot sync` — the common candidate CONFLICT-RECOVERY path (branch-lifecycle
3
+ * contract, docs/architecture/branch-lifecycle-and-integration-preview.md,
4
+ * "Two developers touch the same file" / P1 item 12):
5
+ *
6
+ * Both candidate previews may be green in isolation. After the first
7
+ * integrates, the second may become conflicted. The queue blocks that PR
8
+ * and prints the conflicting paths. The second developer syncs from
9
+ * `preview`, resolves locally, reruns `tot preview`, and obtains evidence
10
+ * for the new head. Prior approval is stale and must not carry forward
11
+ * automatically.
12
+ *
13
+ * `tot sync` fetches the protected `preview` branch and MERGES it into the
14
+ * developer's current local branch — this repo's own canonical policy for
15
+ * bringing a protected line into a working branch (see playbook/merge.md:
16
+ * `git fetch` + `git merge --no-edit`, never a history-rewriting rebase of
17
+ * shared history). It never writes `preview`/`main` (fetch is read-only on
18
+ * the remote), never force-pushes anything, and never runs `git add -A` — on
19
+ * a conflict it stops immediately and leaves the merge in progress so the
20
+ * developer resolves it the normal git way (`git add <file>` + `git commit`,
21
+ * or `git merge --abort` to back out).
22
+ *
23
+ * Distinct from `tot preview`/`tot accept`: sync touches ONLY the developer's
24
+ * own local branch. It never pushes, never opens/updates a candidate, and
25
+ * never touches the shared `preview` aggregate or live. A successful sync
26
+ * moves local HEAD, which makes any prior preview/approval stale by
27
+ * definition (they were evidence for the OLD head) — so this always closes
28
+ * by pointing the developer at a fresh `tot preview`.
29
+ *
30
+ * Dependency-free (global `git`, no MCP/network beyond `git fetch`).
31
+ */
32
+ import { execFileSync } from "node:child_process";
33
+ import { fail } from "../errors.mjs";
34
+
35
+ /** The protected branch `tot sync` fetches + merges from by default. */
36
+ export const DEFAULT_SYNC_BRANCH = "preview";
37
+
38
+ const USAGE = `tot sync — fetch \`preview\` and merge it into your local branch
39
+
40
+ tot sync fetch origin/${DEFAULT_SYNC_BRANCH} and merge it into your current branch
41
+ tot sync --branch <name> sync against a different protected branch (default: ${DEFAULT_SYNC_BRANCH})
42
+ tot sync --help show this help
43
+
44
+ The common conflict-recovery path: after another candidate integrates first,
45
+ yours may conflict on \`tot accept\`. \`tot sync\` fetches the protected
46
+ \`${DEFAULT_SYNC_BRANCH}\` branch and merges it into your local branch so you can resolve the
47
+ conflict locally, THEN re-run \`tot preview\` for a fresh candidate head.
48
+
49
+ On a conflict, sync stops SAFELY: it prints the conflicting paths and leaves
50
+ the merge in progress for you to resolve by hand — it never force-pushes
51
+ \`${DEFAULT_SYNC_BRANCH}\`/main and never runs \`git add -A\`.
52
+
53
+ A successful sync moves your local HEAD, so any prior preview/approval — it
54
+ was evidence for the OLD head — is now stale. Re-run \`tot preview\` before
55
+ your next \`tot accept\`.`;
56
+
57
+ /** Parse `tot sync` argv. Pure — unit-testable. */
58
+ export function parseArgs(argv) {
59
+ const a = { branch: null, help: false };
60
+ for (let i = 0; i < argv.length; i++) {
61
+ const t = argv[i];
62
+ if (t === "--branch") a.branch = argv[++i];
63
+ else if (t === "--help" || t === "-h") a.help = true;
64
+ }
65
+ return a;
66
+ }
67
+
68
+ /** Unmerged (conflicting) paths from `git diff --name-only --diff-filter=U`.
69
+ * Pure — unit-tested without git. */
70
+ export function parseConflictPaths(text) {
71
+ return String(text)
72
+ .split("\n")
73
+ .map((l) => l.trim())
74
+ .filter(Boolean);
75
+ }
76
+
77
+ /**
78
+ * Fetch `origin/<branch>` and merge it into the current branch. Never throws
79
+ * for an ordinary merge conflict — git's own nonzero exit on `merge` IS the
80
+ * conflict signal, and we turn it into a `"conflict"` result with the
81
+ * conflicting paths; a merge failure that ISN'T an ordinary conflict (no
82
+ * unmerged paths found) rethrows so the caller reports the real failure
83
+ * instead of a misleading "conflict".
84
+ *
85
+ * @param {(cargs:string[]) => string} git a THROWING `git -C <workspace>` runner
86
+ * (execFileSync-backed) — throws carry `.stderr`/`.message` like execFileSync.
87
+ * @param {{ branch?: string }} [opts]
88
+ * @returns {{ state: "up-to-date" } | { state: "synced", sha: string } | { state: "conflict", paths: string[] }}
89
+ */
90
+ export function syncWithBranch(git, { branch = DEFAULT_SYNC_BRANCH } = {}) {
91
+ git(["fetch", "origin", branch]);
92
+
93
+ // How many commits on origin/<branch> the local branch is missing. 0 means
94
+ // local HEAD already contains everything from the protected branch —
95
+ // nothing to merge, regardless of how far ahead the local branch itself is.
96
+ const ahead = git(["rev-list", "--count", `HEAD..origin/${branch}`]).trim();
97
+ if (ahead === "0") return { state: "up-to-date" };
98
+
99
+ try {
100
+ git(["merge", "--no-edit", `origin/${branch}`]);
101
+ } catch (e) {
102
+ const paths = parseConflictPaths(git(["diff", "--name-only", "--diff-filter=U"]));
103
+ if (paths.length === 0) throw e; // not an ordinary conflict — surface the real failure
104
+ return { state: "conflict", paths };
105
+ }
106
+ const sha = git(["rev-parse", "HEAD"]).trim();
107
+ return { state: "synced", sha };
108
+ }
109
+
110
+ /** Render a `syncWithBranch` result in house style; returns the process exit
111
+ * code. Pure given its inputs. */
112
+ export function reportSync(result, { branch }) {
113
+ if (result.state === "up-to-date") {
114
+ console.log(`\n ✓ already in sync with origin/${branch} — nothing to merge.`);
115
+ return 0;
116
+ }
117
+ if (result.state === "synced") {
118
+ console.log(`\n ✓ synced with origin/${branch} — new HEAD ${result.sha.slice(0, 9)}.`);
119
+ console.log(` → next: any prior preview/approval was evidence for the OLD head and is now stale.`);
120
+ console.log(` re-run \`tot preview\` to get a fresh candidate + evidence for this head.`);
121
+ return 0;
122
+ }
123
+ // conflict — stop safely; the merge is left in progress for the developer.
124
+ console.error(fail(`sync conflicts with origin/${branch} — ${result.paths.length} file(s)`) + "\n");
125
+ for (const p of result.paths) console.error(` ✗ conflict: ${p}`);
126
+ console.error(`\n → next: resolve each conflict above, then \`git add <file>\` and \`git commit\` to finish the merge`);
127
+ console.error(` (or \`git merge --abort\` to back out). Once resolved, re-run \`tot preview\` for a fresh candidate.`);
128
+ return 1;
129
+ }
130
+
131
+ /**
132
+ * @param {string[]} argv
133
+ * @param {any} ctx
134
+ */
135
+ export async function run(argv, ctx) {
136
+ const args = parseArgs(argv);
137
+ if (args.help) {
138
+ console.log(USAGE);
139
+ return 0;
140
+ }
141
+ if (ctx.mode !== "checkout") {
142
+ console.error(
143
+ fail(
144
+ "`tot sync` runs from inside a tenant checkout",
145
+ "tot clone <tenant> <dir> (then `cd` in, and re-run)",
146
+ ),
147
+ );
148
+ return 2;
149
+ }
150
+
151
+ const workspace = ctx.workspacePath;
152
+ const branch = (args.branch || DEFAULT_SYNC_BRANCH).trim();
153
+ const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
154
+ const gitSafe = (cargs) => {
155
+ try {
156
+ return git(cargs);
157
+ } catch {
158
+ return "";
159
+ }
160
+ };
161
+
162
+ // Refuse a dirty tree up front — a merge on top of uncommitted edits is how
163
+ // local work gets silently entangled with the merge, and we never sweep
164
+ // anything in with `git add -A`. Commit or stash first, then re-run.
165
+ const status = gitSafe(["status", "--porcelain", "--untracked-files=all"]);
166
+ if (status.trim()) {
167
+ console.error(
168
+ fail(
169
+ "your working tree has uncommitted changes",
170
+ "commit them, or `git stash --include-untracked`, then re-run `tot sync`",
171
+ ) + "\n",
172
+ );
173
+ for (const line of status.trim().split("\n")) console.error(` ${line}`);
174
+ return 2;
175
+ }
176
+
177
+ console.error(`~ fetching origin/${branch}…`);
178
+ let result;
179
+ try {
180
+ result = syncWithBranch(git, { branch });
181
+ } catch (e) {
182
+ console.error(
183
+ fail(
184
+ `sync failed: ${String(e.stderr || e.message || e)}`,
185
+ "check your network / that the checkout's remote is reachable, then re-run",
186
+ ),
187
+ );
188
+ return 1;
189
+ }
190
+
191
+ return reportSync(result, { branch });
192
+ }
package/src/plan.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * The shared "operation plan" affordance (unit U10) — the load-bearing
3
3
  * cross-cutting requirement from decision `operator-verb-and-hosting-model`:
4
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
5
+ * EXACTLY what it will do — which PR is queued/integrated/deployed, which
6
+ * deploy targets (preview / live) are touched, and their URLs — and get an
7
+ * explicit confirm before acting. (`accept` now queue-integrates a PR into the
8
+ * `preview` aggregate — unit b08 — rather than merging it to main.) No silent multi-step mutations, in either surface (the CLI
8
9
  * here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
9
10
  * shape of plan text server-side/inline).
10
11
  *
@@ -14,13 +15,14 @@
14
15
  * half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
15
16
  * TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
16
17
  *
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.
18
+ * SHIP has ONE meaning (unit b10 — supersedes the retired context-dependent
19
+ * ship, decision `ship-context-dependent-semantics`): it publishes the
20
+ * tenant's CURRENT GREEN AGGREGATE the batch of PRs that integrated
21
+ * cleanly to live. No merge, no PR/candidate targeting, no developer-vs-
22
+ * operator branching. The plan states the pinned aggregate sha, its
23
+ * content-addressed artifact digest, every included PR, the rollback target,
24
+ * and the go-live paywall verdict, so the human reviews EXACTLY what "green"
25
+ * means before confirming.
24
26
  *
25
27
  * Dependency-free (no imports besides the sibling `prompt.mjs`).
26
28
  */
@@ -44,14 +46,23 @@ function targetLabel({ pr, changeId }) {
44
46
  * console output, no network, no prompting.
45
47
  *
46
48
  * @param {{
47
- * action: "build"|"accept"|"ship"|"retire",
49
+ * action: "build"|"accept"|"ship"|"retire"|"revert"|"cleanup"|"hotfix",
48
50
  * tenant: string,
49
51
  * pr?: number|string|null,
50
52
  * changeId?: string|null,
51
53
  * headSha?: string|null,
54
+ * integrationSha?: string|null,
52
55
  * endpoint?: string|null,
53
56
  * targets?: { preview?: string|null, live?: string|null },
54
57
  * context?: "developer"|"operator",
58
+ * pinnedSha?: string|null,
59
+ * artifactDigest?: string|null,
60
+ * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
61
+ * rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
62
+ * paywall?: { allowed: boolean, message?: string|null } | null,
63
+ * refs?: Array<{ ref: string, sha?: string|null, reason?: string|null }>,
64
+ * bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
65
+ * bypassedPreviewSha?: string|null,
55
66
  * }} params
56
67
  * @returns {string[]} plan lines (no leading/trailing blank line)
57
68
  */
@@ -61,16 +72,28 @@ export function planForAction({
61
72
  pr = null,
62
73
  changeId = null,
63
74
  headSha = null,
75
+ integrationSha = null,
64
76
  endpoint = null,
65
77
  targets = {},
66
78
  context = "operator",
79
+ pinnedSha = null,
80
+ artifactDigest = null,
81
+ includedPrs = null,
82
+ rollbackTarget = null,
83
+ paywall = null,
84
+ refs = null,
85
+ bypassedPrs = null,
86
+ bypassedPreviewSha = null,
67
87
  }) {
88
+ void context; // retained param — no action currently branches on it (ship, the
89
+ // last one that did, is now ONE meaning; kept so a future action can opt in).
68
90
  const label = targetLabel({ pr, changeId });
69
91
  const lines = [`${titleFor(action)} plan:`];
70
92
  if (tenant) lines.push(` tenant: ${tenant}`);
71
93
  if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
72
94
  if (changeId) lines.push(` change id: ${changeId}`);
73
95
  if (headSha) lines.push(` head sha: ${headSha}`);
96
+ if (integrationSha) lines.push(` integration sha: ${integrationSha}`);
74
97
  if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
75
98
 
76
99
  switch (action) {
@@ -82,21 +105,43 @@ export function planForAction({
82
105
  break;
83
106
  }
84
107
  case "accept": {
85
- lines.push(` effect: merge ${label} into main.`);
108
+ // Accept now means QUEUE-INTEGRATE-INTO-PREVIEW (unit b08), NOT merge-to-main:
109
+ // the candidate lands in the protected `preview` aggregate (serialized merge →
110
+ // rebuild → combined-evidence gate), and the aggregate goes live only later via
111
+ // `tot ship`. So the plan states the integration, never a merge or a go-live.
112
+ const into = tenant ? `${tenant}'s preview aggregate` : "the preview aggregate";
113
+ lines.push(
114
+ ` effect: queue ${label} for integration into ${into} — NO merge to main, NO go-live.`,
115
+ );
86
116
  break;
87
117
  }
88
118
  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}.`);
119
+ // ONE meaning (b10): publish the tenant's CURRENT GREEN AGGREGATE to live.
120
+ // No merge b09's orchestrator ships the already-materialized, already-
121
+ // reviewed digest. State exactly WHAT that is: the pinned sha, the
122
+ // content-addressed artifact digest, every included PR, the rollback
123
+ // target, and the go-live paywall verdict — so the human reviews the
124
+ // real content of "green" before confirming.
125
+ lines.push(
126
+ ` effect: publish the current green aggregate to live${targets.live ? ` (${targets.live})` : ""} — no merge.`,
127
+ );
128
+ if (pinnedSha) lines.push(` pinned sha: ${pinnedSha}`);
129
+ if (artifactDigest) lines.push(` artifact digest: ${artifactDigest}`);
130
+ if (Array.isArray(includedPrs)) {
131
+ lines.push(` included PRs (${includedPrs.length}):`);
132
+ for (const p of includedPrs) {
133
+ const prLabel = p && p.prNumber != null ? `#${p.prNumber}` : p?.changeId || "(no PR)";
134
+ const shortSha = p && p.headSha ? ` ${String(p.headSha).slice(0, 8)}` : "";
135
+ lines.push(` - ${prLabel}${shortSha}`);
136
+ }
137
+ }
138
+ lines.push(
139
+ rollbackTarget
140
+ ? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})`
141
+ : " rollback to: (none — first-ever ship)",
142
+ );
143
+ if (paywall && paywall.allowed === false) {
144
+ lines.push(` ⚠ paywall: ${paywall.message || "go-live is blocked by the storefront subscription gate"}`);
100
145
  }
101
146
  break;
102
147
  }
@@ -104,6 +149,67 @@ export function planForAction({
104
149
  lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
105
150
  break;
106
151
  }
152
+ case "cleanup": {
153
+ // Branch GC (P1 items 9/10): delete ONLY the exact terminal refs a fresh
154
+ // server-side classification (candidate_list) marked eligible — never by
155
+ // age alone, never main/preview, never an orphan. State the exact set so
156
+ // the human confirms precisely what will be removed, not "some branches".
157
+ const list = Array.isArray(refs) ? refs : [];
158
+ lines.push(
159
+ ` effect: delete ${list.length} terminal candidate branch(es) — never main/preview, ` +
160
+ "never by age alone, never a quarantined orphan.",
161
+ );
162
+ for (const r of list) {
163
+ const shortSha = r?.sha ? ` ${String(r.sha).slice(0, 8)}` : "";
164
+ lines.push(` - ${r?.ref}${shortSha}${r?.reason ? ` — ${r.reason}` : ""}`);
165
+ }
166
+ break;
167
+ }
168
+ case "revert": {
169
+ // Revert (b21): REMOVE already-integrated content from the protected `preview`
170
+ // aggregate by creating a NEW auditable revert commit — never a force-reset,
171
+ // never a branch delete. The aggregate rebuilds and ships only when green
172
+ // again. State exactly that: preview-only, a new commit, NO touch to main.
173
+ const from = tenant ? `${tenant}'s preview aggregate` : "the preview aggregate";
174
+ const what = integrationSha ? `integration ${integrationSha}` : label;
175
+ lines.push(
176
+ ` effect: revert ${what} out of ${from} — a NEW revert commit, NO force-reset, NO merge to main, NO go-live.`,
177
+ );
178
+ break;
179
+ }
180
+ case "hotfix": {
181
+ // Hotfix (b22): the EXPLICIT EXCEPTION lane. Release the reviewed fix from
182
+ // `main` to live, EXCLUDING the unshipped `preview` work — then automatically
183
+ // forward-integrate `main` into `preview` and re-validate. State exactly that,
184
+ // and — critically — list the unshipped preview work this deliberately BYPASSES,
185
+ // so the human confirms an unmistakable exception, not an ordinary ship.
186
+ lines.push(
187
+ ` effect: OWNER HOTFIX — release ${label} from main to live${targets.live ? ` (${targets.live})` : ""}, ` +
188
+ `EXCLUDING the unshipped preview head, then forward-integrate main → preview + revalidate.`,
189
+ );
190
+ if (Array.isArray(bypassedPrs)) {
191
+ if (bypassedPrs.length === 0) {
192
+ lines.push(" bypasses: (nothing — preview has no unshipped work)");
193
+ } else {
194
+ lines.push(` ⚠ BYPASSES the unshipped preview work (${bypassedPrs.length}) — NOT included in this hotfix:`);
195
+ for (const p of bypassedPrs) {
196
+ const prLabel = p && p.prNumber != null ? `#${p.prNumber}` : p?.changeId || "(no PR)";
197
+ const shortSha = p && p.headSha ? ` ${String(p.headSha).slice(0, 8)}` : "";
198
+ lines.push(` - ${prLabel}${shortSha}`);
199
+ }
200
+ }
201
+ }
202
+ if (bypassedPreviewSha) lines.push(` preview head (bypassed): ${bypassedPreviewSha}`);
203
+ lines.push(
204
+ rollbackTarget
205
+ ? ` rollback to: ${rollbackTarget.aggregateSha} (receipt ${rollbackTarget.receiptId})`
206
+ : " rollback to: (none — first-ever ship)",
207
+ );
208
+ if (paywall && paywall.allowed === false) {
209
+ lines.push(` ⚠ paywall: ${paywall.message || "go-live is blocked by the storefront subscription gate"}`);
210
+ }
211
+ break;
212
+ }
107
213
  default: {
108
214
  lines.push(` effect: ${action} ${label}.`);
109
215
  break;
@@ -112,22 +218,18 @@ export function planForAction({
112
218
  return lines;
113
219
  }
114
220
 
115
- /** "build" → "Build-on-demand", "accept" → "Accept", "ship" "Ship", "retire" → "Retire". Pure. */
221
+ /** "build" → "Build-on-demand", "accept" → "Accept", "hotfix" → "Hotfix". Pure. */
116
222
  function titleFor(action) {
117
223
  if (action === "build") return "Build-on-demand";
118
224
  if (action === "accept") return "Accept";
119
225
  if (action === "ship") return "Ship";
120
226
  if (action === "retire") return "Retire";
227
+ if (action === "revert") return "Revert";
228
+ if (action === "cleanup") return "Cleanup";
229
+ if (action === "hotfix") return "Hotfix (owner-only exception lane)";
121
230
  return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
122
231
  }
123
232
 
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
233
  /**
132
234
  * Print a plan and gate on an explicit confirm — the CLI half of the shared
133
235
  * affordance. Prints every line, a trailing blank line, then: