@tokenoftrust/cli 1.4.0 → 1.4.1

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,203 @@
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
+ import { ensureTokenlessRemote } from "../git-credential.mjs";
35
+
36
+ /** The protected branch `tot sync` fetches + merges from by default. */
37
+ export const DEFAULT_SYNC_BRANCH = "preview";
38
+
39
+ const USAGE = `tot sync — fetch \`preview\` and merge it into your local branch
40
+
41
+ tot sync fetch origin/${DEFAULT_SYNC_BRANCH} and merge it into your current branch
42
+ tot sync --branch <name> sync against a different protected branch (default: ${DEFAULT_SYNC_BRANCH})
43
+ tot sync --help show this help
44
+
45
+ The common conflict-recovery path: after another candidate integrates first,
46
+ yours may conflict on \`tot accept\`. \`tot sync\` fetches the protected
47
+ \`${DEFAULT_SYNC_BRANCH}\` branch and merges it into your local branch so you can resolve the
48
+ conflict locally, THEN re-run \`tot preview\` for a fresh candidate head.
49
+
50
+ On a conflict, sync stops SAFELY: it prints the conflicting paths and leaves
51
+ the merge in progress for you to resolve by hand — it never force-pushes
52
+ \`${DEFAULT_SYNC_BRANCH}\`/main and never runs \`git add -A\`.
53
+
54
+ A successful sync moves your local HEAD, so any prior preview/approval — it
55
+ was evidence for the OLD head — is now stale. Re-run \`tot preview\` before
56
+ your next \`tot accept\`.`;
57
+
58
+ /** Parse `tot sync` argv. Pure — unit-testable. */
59
+ export function parseArgs(argv) {
60
+ const a = { branch: null, help: false };
61
+ for (let i = 0; i < argv.length; i++) {
62
+ const t = argv[i];
63
+ if (t === "--branch") a.branch = argv[++i];
64
+ else if (t === "--help" || t === "-h") a.help = true;
65
+ }
66
+ return a;
67
+ }
68
+
69
+ /** Unmerged (conflicting) paths from `git diff --name-only --diff-filter=U`.
70
+ * Pure — unit-tested without git. */
71
+ export function parseConflictPaths(text) {
72
+ return String(text)
73
+ .split("\n")
74
+ .map((l) => l.trim())
75
+ .filter(Boolean);
76
+ }
77
+
78
+ /**
79
+ * Fetch `origin/<branch>` and merge it into the current branch. Never throws
80
+ * for an ordinary merge conflict — git's own nonzero exit on `merge` IS the
81
+ * conflict signal, and we turn it into a `"conflict"` result with the
82
+ * conflicting paths; a merge failure that ISN'T an ordinary conflict (no
83
+ * unmerged paths found) rethrows so the caller reports the real failure
84
+ * instead of a misleading "conflict".
85
+ *
86
+ * @param {(cargs:string[]) => string} git a THROWING `git -C <workspace>` runner
87
+ * (execFileSync-backed) — throws carry `.stderr`/`.message` like execFileSync.
88
+ * @param {{ branch?: string }} [opts]
89
+ * @returns {{ state: "up-to-date" } | { state: "synced", sha: string } | { state: "conflict", paths: string[] }}
90
+ */
91
+ export function syncWithBranch(git, { branch = DEFAULT_SYNC_BRANCH } = {}) {
92
+ git(["fetch", "origin", branch]);
93
+
94
+ // How many commits on origin/<branch> the local branch is missing. 0 means
95
+ // local HEAD already contains everything from the protected branch —
96
+ // nothing to merge, regardless of how far ahead the local branch itself is.
97
+ const ahead = git(["rev-list", "--count", `HEAD..origin/${branch}`]).trim();
98
+ if (ahead === "0") return { state: "up-to-date" };
99
+
100
+ try {
101
+ git(["merge", "--no-edit", `origin/${branch}`]);
102
+ } catch (e) {
103
+ const paths = parseConflictPaths(git(["diff", "--name-only", "--diff-filter=U"]));
104
+ if (paths.length === 0) throw e; // not an ordinary conflict — surface the real failure
105
+ return { state: "conflict", paths };
106
+ }
107
+ const sha = git(["rev-parse", "HEAD"]).trim();
108
+ return { state: "synced", sha };
109
+ }
110
+
111
+ /** Render a `syncWithBranch` result in house style; returns the process exit
112
+ * code. Pure given its inputs. */
113
+ export function reportSync(result, { branch }) {
114
+ if (result.state === "up-to-date") {
115
+ console.log(`\n ✓ already in sync with origin/${branch} — nothing to merge.`);
116
+ return 0;
117
+ }
118
+ if (result.state === "synced") {
119
+ console.log(`\n ✓ synced with origin/${branch} — new HEAD ${result.sha.slice(0, 9)}.`);
120
+ console.log(` → next: any prior preview/approval was evidence for the OLD head and is now stale.`);
121
+ console.log(` re-run \`tot preview\` to get a fresh candidate + evidence for this head.`);
122
+ return 0;
123
+ }
124
+ // conflict — stop safely; the merge is left in progress for the developer.
125
+ console.error(fail(`sync conflicts with origin/${branch} — ${result.paths.length} file(s)`) + "\n");
126
+ for (const p of result.paths) console.error(` ✗ conflict: ${p}`);
127
+ console.error(`\n → next: resolve each conflict above, then \`git add <file>\` and \`git commit\` to finish the merge`);
128
+ console.error(` (or \`git merge --abort\` to back out). Once resolved, re-run \`tot preview\` for a fresh candidate.`);
129
+ return 1;
130
+ }
131
+
132
+ /**
133
+ * @param {string[]} argv
134
+ * @param {any} ctx
135
+ */
136
+ export async function run(argv, ctx) {
137
+ const args = parseArgs(argv);
138
+ if (args.help) {
139
+ console.log(USAGE);
140
+ return 0;
141
+ }
142
+ if (ctx.mode !== "checkout") {
143
+ console.error(
144
+ fail(
145
+ "`tot sync` runs from inside a tenant checkout",
146
+ "tot clone <tenant> <dir> (then `cd` in, and re-run)",
147
+ ),
148
+ );
149
+ return 2;
150
+ }
151
+
152
+ const workspace = ctx.workspacePath;
153
+ const branch = (args.branch || DEFAULT_SYNC_BRANCH).trim();
154
+ const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
155
+ const gitSafe = (cargs) => {
156
+ try {
157
+ return git(cargs);
158
+ } catch {
159
+ return "";
160
+ }
161
+ };
162
+
163
+ // Self-heal a LEGACY checkout (unit u10, absorbs u7): `tot login` refreshes
164
+ // this CLI's own session, never the token baked into a checkout's remote at
165
+ // clone time — the exact reason a stale checkout's `git fetch origin` (below)
166
+ // used to 401 even right after signing back in. Best-effort, never blocks sync.
167
+ try {
168
+ ensureTokenlessRemote(git);
169
+ } catch {
170
+ /* best-effort — see above */
171
+ }
172
+
173
+ // Refuse a dirty tree up front — a merge on top of uncommitted edits is how
174
+ // local work gets silently entangled with the merge, and we never sweep
175
+ // anything in with `git add -A`. Commit or stash first, then re-run.
176
+ const status = gitSafe(["status", "--porcelain", "--untracked-files=all"]);
177
+ if (status.trim()) {
178
+ console.error(
179
+ fail(
180
+ "your working tree has uncommitted changes",
181
+ "commit them, or `git stash --include-untracked`, then re-run `tot sync`",
182
+ ) + "\n",
183
+ );
184
+ for (const line of status.trim().split("\n")) console.error(` ${line}`);
185
+ return 2;
186
+ }
187
+
188
+ console.error(`~ fetching origin/${branch}…`);
189
+ let result;
190
+ try {
191
+ result = syncWithBranch(git, { branch });
192
+ } catch (e) {
193
+ console.error(
194
+ fail(
195
+ `sync failed: ${String(e.stderr || e.message || e)}`,
196
+ "check your network / that the checkout's remote is reachable, then re-run",
197
+ ),
198
+ );
199
+ return 1;
200
+ }
201
+
202
+ return reportSync(result, { branch });
203
+ }
@@ -92,7 +92,12 @@ export function run(argv, ctx) {
92
92
 
93
93
  const errors = findings.filter((f) => f.level === ERROR);
94
94
  const warns = findings.filter((f) => f.level === WARN);
95
- console.log(`\ntot validate${target.tenantId ?? target.dir}\n`);
95
+ // Never present the checkout PATH as if it were the tenant name when the
96
+ // tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
97
+ // the findings below say why; the header should say so too, not disguise
98
+ // a directory as a domain.
99
+ const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
100
+ console.log(`\ntot validate — ${label}\n`);
96
101
  for (const f of findings) {
97
102
  const tag = f.level === ERROR ? "✗" : "⚠";
98
103
  console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shared plumbing for `tot` acting as a git credential helper (unit u10,
3
+ * workstream tot-merge-conflict-resolution-ux — absorbs u7's `tot fetch`
4
+ * self-heal idea) — so a checkout never needs a LIVE forge token baked into
5
+ * `.git/config`'s remote URL. `tot clone` configures a fresh checkout's
6
+ * `credential.helper` to `CREDENTIAL_HELPER` (git's own extension point for
7
+ * exactly this — see `git help gitcredentials`), which git then invokes with
8
+ * `get`/`store`/`erase` on every network operation instead of reading a
9
+ * persisted secret. This module holds the pieces BOTH the `git-credential`
10
+ * command (src/commands/git-credential.mjs, which mints fresh tokens via the
11
+ * MCP) and the self-heal migration (called at the top of every command that
12
+ * touches git — submit/sync/… — so ANY CLI touch of a legacy checkout
13
+ * migrates it) share: parsing/formatting git's credential protocol, the
14
+ * on-disk credential cache, and rewriting a checkout's remote to drop its
15
+ * embedded token.
16
+ *
17
+ * Dependency-free — node:fs/os/path/crypto only. The MCP mint itself (real
18
+ * network I/O) lives in the command layer, which this module never imports.
19
+ */
20
+ import { homedir } from "node:os";
21
+ import { createHash } from "node:crypto";
22
+ import { join } from "node:path";
23
+ import { readCredentials, writeCredentials } from "./token-store.mjs";
24
+
25
+ /** The git config value that routes credential requests through `tot` — the
26
+ * `!` tells git to run this as a shell command (git appends the operation,
27
+ * e.g. `get`, as the final argument) — the same convention `gh auth
28
+ * git-credential` uses. Repo-LOCAL only (never --global): a checkout not
29
+ * built with `tot` must never have its credential resolution silently
30
+ * redirected. */
31
+ export const CREDENTIAL_HELPER = "!tot git-credential";
32
+
33
+ /** How long a minted credential is trusted before `tot git-credential get`
34
+ * mints a fresh one — comfortably under the forge push token's own
35
+ * multi-hour expiry (see pushPreviewRef in submit.mjs), so a long-running
36
+ * session still self-refreshes well before the cached one goes stale. */
37
+ export const CREDENTIAL_TTL_MS = 20 * 60 * 1000;
38
+
39
+ /**
40
+ * Split an authenticated forge remote URL (basic-auth `user:token@host`, as
41
+ * the MCP mints it via `tenant_checkout`) into its tokenless public URL +
42
+ * the embedded credential, so the token can be handed to git EPHEMERALLY for
43
+ * one operation instead of being persisted in `.git/config`. Returns null
44
+ * when the URL won't parse or carries no token — the caller then falls back
45
+ * to the checkout's existing remote. Pure — unit-tested.
46
+ * @param {string} remoteUrl
47
+ * @returns {{ publicUrl: string, username: string, token: string }|null}
48
+ */
49
+ export function splitAuthedRemote(remoteUrl) {
50
+ try {
51
+ const u = new URL(String(remoteUrl));
52
+ const token = u.password ? decodeURIComponent(u.password) : "";
53
+ if (!token) return null;
54
+ const username = u.username ? decodeURIComponent(u.username) : "";
55
+ return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The `http.extraheader` value that hands a basic-auth credential to a
63
+ * SINGLE git invocation (base64 of `user:token`) — so a freshly-minted forge
64
+ * token authenticates one operation without ever being written to
65
+ * `.git/config`. Pure — unit-tested.
66
+ * @param {string} username
67
+ * @param {string} token
68
+ * @returns {string}
69
+ */
70
+ export function basicAuthExtraHeader(username, token) {
71
+ const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
72
+ return `Authorization: Basic ${b64}`;
73
+ }
74
+
75
+ /**
76
+ * Parse git's credential-helper protocol (key=value lines, terminated by a
77
+ * blank line or EOF) into a plain object. A malformed line is skipped rather
78
+ * than throwing — git's own helpers are lenient the same way. Pure.
79
+ * @param {string} text
80
+ * @returns {Record<string,string>}
81
+ */
82
+ export function parseCredentialInput(text) {
83
+ const out = {};
84
+ for (const line of String(text).split("\n")) {
85
+ const trimmed = line.trim();
86
+ if (!trimmed) continue;
87
+ const i = trimmed.indexOf("=");
88
+ if (i <= 0) continue;
89
+ out[trimmed.slice(0, i)] = trimmed.slice(i + 1);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Format a credential response for git's `get` operation — only the fields
96
+ * present are emitted (git only needs `username`/`password` filled in;
97
+ * echoing `protocol`/`host` back is harmless and conventional). Pure.
98
+ * @param {Record<string,string|undefined|null>} fields
99
+ * @returns {string}
100
+ */
101
+ export function formatCredentialOutput(fields) {
102
+ const lines = [];
103
+ for (const key of ["protocol", "host", "path", "username", "password"]) {
104
+ if (fields[key] !== undefined && fields[key] !== null) lines.push(`${key}=${fields[key]}`);
105
+ }
106
+ return `${lines.join("\n")}\n`;
107
+ }
108
+
109
+ /** A filesystem-safe, collision-resistant cache key for one (tenant, tag) —
110
+ * hashed (not the raw tenant string) so an unusual tenant name can never
111
+ * escape `~/.tot/git-credentials/` or collide across tags. Pure. */
112
+ export function credentialCacheKey(tenant, tag) {
113
+ return createHash("sha256").update(`${tenant}|${tag}`).digest("hex").slice(0, 32);
114
+ }
115
+
116
+ /** Absolute path to one (tenant, tag)'s cached credential — same `~/.tot`
117
+ * root (and `TOT_HOME` override) as the OAuth session cache. */
118
+ export function credentialCachePath(tenant, tag, env = process.env) {
119
+ const home = env.TOT_HOME || homedir();
120
+ return join(home, ".tot", "git-credentials", `${credentialCacheKey(tenant, tag)}.json`);
121
+ }
122
+
123
+ /** Is a cached credential still trusted? A cache with no `mintedAt` is
124
+ * treated as stale (mint fresh rather than trust an unknown age). Pure. */
125
+ export function isFreshCredential(cred, { now = Date.now(), ttlMs = CREDENTIAL_TTL_MS } = {}) {
126
+ return Boolean(cred && cred.username && cred.password && cred.mintedAt && now - cred.mintedAt < ttlMs);
127
+ }
128
+
129
+ /** Read a cached credential (reusing token-store.mjs's generic reader), or
130
+ * null if absent/unreadable/malformed/stale. Never throws. */
131
+ export function readCachedCredential(filePath, opts = {}) {
132
+ const cred = readCredentials(filePath);
133
+ return isFreshCredential(cred, opts) ? cred : null;
134
+ }
135
+
136
+ /** Cache a freshly-minted credential — atomic write, owner-only permissions
137
+ * (0600 in a 0700 dir, via token-store.mjs's writer): this file holds a LIVE
138
+ * forge push token, same security bar as the OAuth session cache. */
139
+ export function writeCachedCredential(filePath, { username, password }, { now = Date.now() } = {}) {
140
+ writeCredentials(filePath, { username, password, mintedAt: now });
141
+ }
142
+
143
+ /**
144
+ * Self-heal a LEGACY checkout: if `origin`'s remote still carries an
145
+ * embedded token (the pre-u10 `tot clone` shape, or one predating `tot
146
+ * login` entirely), strip it — rewriting the remote to the tokenless public
147
+ * URL — and install the credential helper so future git operations mint
148
+ * fresh creds through `tot` instead of relying on a token that silently
149
+ * expires. Meant to be called at the top of every command that touches git,
150
+ * best-effort (the caller decides how to handle a thrown error — this never
151
+ * blocks the actual command on a migration hiccup). A no-op on an
152
+ * already-migrated, tokenless, or non-http(s) (e.g. ssh) remote.
153
+ * @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
154
+ * @returns {{ migrated: boolean }}
155
+ */
156
+ export function ensureTokenlessRemote(git) {
157
+ let remote;
158
+ try {
159
+ remote = git(["remote", "get-url", "origin"]).trim();
160
+ } catch {
161
+ return { migrated: false }; // no `origin` (or not a git repo) — nothing to migrate
162
+ }
163
+ let migrated = false;
164
+ try {
165
+ const u = new URL(remote);
166
+ if (u.password) {
167
+ git(["remote", "set-url", "origin", `${u.protocol}//${u.host}${u.pathname}`]);
168
+ migrated = true;
169
+ }
170
+ } catch {
171
+ return { migrated }; // not a parseable URL (e.g. an ssh remote) — leave it alone entirely
172
+ }
173
+ let helper = "";
174
+ try {
175
+ helper = git(["config", "--local", "--get", "credential.helper"]).trim();
176
+ } catch {
177
+ /* unset — falls through to configuring it below */
178
+ }
179
+ if (helper !== CREDENTIAL_HELPER) {
180
+ git(["config", "--local", "credential.helper", CREDENTIAL_HELPER]);
181
+ migrated = true;
182
+ }
183
+ return { migrated };
184
+ }
@@ -0,0 +1,55 @@
1
+ // Regression guard: the `tot` CLI must never print/reference a raw Gitea
2
+ // forge URL to a developer's terminal. Gitea's API returns `html_url` when a
3
+ // PR is opened (e.g. `https://git.tokenoftrust.com/storefront/<repo>/pulls/<n>`)
4
+ // -- the CLI must surface the storefront-owned `/preview/<tenant>/pr/<n>` link
5
+ // instead (see apps/storefront's matching noGiteaLinks.test.ts and its header
6
+ // for the full architecture reasoning: apps/CLI never expose the forge
7
+ // directly, the MCP proxies every read/write). Precipitating incident
8
+ // (2026-08-18): a raw git.tokenoftrust.com PR URL reached an owner reviewing
9
+ // tokenoftrust.com. Test fixtures are exempt (they legitimately mock a Gitea
10
+ // URL to test the forge client), everything else in `src` must stay clean.
11
+ import { readdirSync, readFileSync, statSync } from "node:fs";
12
+ import { join, relative, dirname } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+ import { test } from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ const SELF = fileURLToPath(import.meta.url);
18
+ const SRC_ROOT = dirname(SELF);
19
+
20
+ const TEST_FILE_RE = /\.test\.[cm]?js$/;
21
+ const FORGE_HOST_RE = /\bgit\.tokenoftrust\.com\b/i;
22
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git"]);
23
+
24
+ function walk(dir, out = []) {
25
+ for (const entry of readdirSync(dir)) {
26
+ if (SKIP_DIRS.has(entry)) continue;
27
+ const full = join(dir, entry);
28
+ const st = statSync(full);
29
+ if (st.isDirectory()) {
30
+ walk(full, out);
31
+ } else if (/\.[cm]?js$/.test(entry)) {
32
+ out.push(full);
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+ test("no Gitea forge links in the CLI source outside test fixtures", () => {
39
+ const offenders = [];
40
+ for (const file of walk(SRC_ROOT)) {
41
+ if (TEST_FILE_RE.test(file)) continue;
42
+ if (file === SELF) continue;
43
+ const content = readFileSync(file, "utf8");
44
+ content.split("\n").forEach((line, i) => {
45
+ if (FORGE_HOST_RE.test(line)) {
46
+ offenders.push(`${relative(SRC_ROOT, file)}:${i + 1}: ${line.trim()}`);
47
+ }
48
+ });
49
+ }
50
+ assert.deepEqual(
51
+ offenders,
52
+ [],
53
+ `Found Gitea forge links in non-test CLI source:\n${offenders.join("\n")}`,
54
+ );
55
+ });
package/src/plan.mjs CHANGED
@@ -46,11 +46,12 @@ function targetLabel({ pr, changeId }) {
46
46
  * console output, no network, no prompting.
47
47
  *
48
48
  * @param {{
49
- * action: "build"|"accept"|"ship"|"retire",
49
+ * action: "build"|"accept"|"ship"|"retire"|"revert"|"cleanup"|"hotfix",
50
50
  * tenant: string,
51
51
  * pr?: number|string|null,
52
52
  * changeId?: string|null,
53
53
  * headSha?: string|null,
54
+ * integrationSha?: string|null,
54
55
  * endpoint?: string|null,
55
56
  * targets?: { preview?: string|null, live?: string|null },
56
57
  * context?: "developer"|"operator",
@@ -59,6 +60,9 @@ function targetLabel({ pr, changeId }) {
59
60
  * includedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>,
60
61
  * rollbackTarget?: { receiptId: string, aggregateSha: string } | null,
61
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,
62
66
  * }} params
63
67
  * @returns {string[]} plan lines (no leading/trailing blank line)
64
68
  */
@@ -68,6 +72,7 @@ export function planForAction({
68
72
  pr = null,
69
73
  changeId = null,
70
74
  headSha = null,
75
+ integrationSha = null,
71
76
  endpoint = null,
72
77
  targets = {},
73
78
  context = "operator",
@@ -76,6 +81,9 @@ export function planForAction({
76
81
  includedPrs = null,
77
82
  rollbackTarget = null,
78
83
  paywall = null,
84
+ refs = null,
85
+ bypassedPrs = null,
86
+ bypassedPreviewSha = null,
79
87
  }) {
80
88
  void context; // retained param — no action currently branches on it (ship, the
81
89
  // last one that did, is now ONE meaning; kept so a future action can opt in).
@@ -85,6 +93,7 @@ export function planForAction({
85
93
  if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
86
94
  if (changeId) lines.push(` change id: ${changeId}`);
87
95
  if (headSha) lines.push(` head sha: ${headSha}`);
96
+ if (integrationSha) lines.push(` integration sha: ${integrationSha}`);
88
97
  if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
89
98
 
90
99
  switch (action) {
@@ -140,6 +149,67 @@ export function planForAction({
140
149
  lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
141
150
  break;
142
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
+ }
143
213
  default: {
144
214
  lines.push(` effect: ${action} ${label}.`);
145
215
  break;
@@ -148,12 +218,15 @@ export function planForAction({
148
218
  return lines;
149
219
  }
150
220
 
151
- /** "build" → "Build-on-demand", "accept" → "Accept", "ship" "Ship", "retire" → "Retire". Pure. */
221
+ /** "build" → "Build-on-demand", "accept" → "Accept", "hotfix" → "Hotfix". Pure. */
152
222
  function titleFor(action) {
153
223
  if (action === "build") return "Build-on-demand";
154
224
  if (action === "accept") return "Accept";
155
225
  if (action === "ship") return "Ship";
156
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)";
157
230
  return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
158
231
  }
159
232
 
package/src/validate.mjs CHANGED
@@ -27,6 +27,38 @@ function mk(level, rule, file, message, fix) {
27
27
  return { level, rule, file, message, fix };
28
28
  }
29
29
 
30
+ // --- git conflict markers ----------------------------------------------------
31
+ // A half-resolved merge/rebase can commit literal conflict markers into content
32
+ // (the incident: `<<<<<<<`/`=======`/`>>>>>>>` in content/home.html slipped past
33
+ // preview as "validated"). These are the default and diff3 marker lines, anchored
34
+ // at line start and exactly 7 chars with a trailing boundary — precise enough that
35
+ // real content never matches. `=======` / `|||||||` ALONE are NOT flagged (a lone
36
+ // `=======` is a common markdown/prose horizontal rule); only the START (`<<<<<<<`)
37
+ // and END (`>>>>>>>`) markers trigger — either one is a near-certain conflict, so
38
+ // we err false-negative-averse and flag on either.
39
+ const CONFLICT_START = /^<{7}(?=[ \t]|$)/;
40
+ const CONFLICT_END = /^>{7}(?=[ \t]|$)/;
41
+
42
+ /**
43
+ * Line numbers (1-based) of git conflict markers in `content`. Empty ⇒ none.
44
+ * Pure — exported for focused testing.
45
+ * @param {string} content
46
+ * @returns {number[]}
47
+ */
48
+ export function detectConflictMarkers(content) {
49
+ if (typeof content !== "string" || (!content.includes("<<<<<<<") && !content.includes(">>>>>>>"))) return [];
50
+ const lines = content.split(/\r?\n/);
51
+ const hits = [];
52
+ for (let i = 0; i < lines.length; i++) {
53
+ if (CONFLICT_START.test(lines[i]) || CONFLICT_END.test(lines[i])) hits.push(i + 1);
54
+ }
55
+ return hits;
56
+ }
57
+
58
+ // Text artifacts a conflict marker can hide in (images/fonts live in public/, not scanned).
59
+ const CONFLICT_SCAN_EXT = new Set([".html", ".htm", ".json", ".md", ".txt", ".css", ".js", ".mjs", ".svg", ".xml"]);
60
+ const hasScanExt = (p) => CONFLICT_SCAN_EXT.has((p.match(/\.[^./\\]+$/) || [""])[0].toLowerCase());
61
+
30
62
  // --- canonical `.tot/config.json` shape (the #24/#25 regression guard) --------
31
63
  const KNOWN_KINDS = new Set(["file", "tree"]);
32
64
  const REQUIRED_WORKSPACES = ["content/", "public/", "theme.json"];
@@ -539,6 +571,26 @@ export function validateTenant(tenantDir, opts = {}) {
539
571
  }
540
572
  }
541
573
 
574
+ // 5. git conflict markers — advisory (never blocks), but LOUD: a half-resolved
575
+ // merge/rebase must not slip past as "validated". Scans text artifacts under
576
+ // content/ plus the root config files.
577
+ const conflictScanFiles = [
578
+ ...walk(contentDir, hasScanExt),
579
+ ...["theme.json", "capabilities.json", "scripts.json", join(".tot", "config.json")]
580
+ .map((f) => join(tenantDir, f))
581
+ .filter((p) => existsSync(p)),
582
+ ];
583
+ for (const p of conflictScanFiles) {
584
+ const lines = detectConflictMarkers(readFileSync(p, "utf8"));
585
+ if (lines.length) {
586
+ findings.push(
587
+ mk(WARN, "git-conflict-markers", rel(p),
588
+ `git conflict markers at line(s) ${lines.join(", ")} — looks like an unfinished merge/rebase (the page would still build/serve broken)`,
589
+ "resolve the conflict and remove the <<<<<<< / ======= / >>>>>>> lines before submitting"),
590
+ );
591
+ }
592
+ }
593
+
542
594
  const ok = !findings.some((f) => f.level === ERROR);
543
595
  return { ok, findings };
544
596
  }