@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.20
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.
- package/README.md +12 -9
- package/bin/tot.mjs +191 -14
- package/package.json +2 -2
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +313 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +682 -0
- package/src/commands/dev.mjs +414 -84
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/grants.mjs +8 -3
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +9 -4
- package/src/commands/pr.mjs +424 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +35 -21
- package/src/commands/submit.mjs +1129 -129
- package/src/commands/sync.mjs +192 -0
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +14 -3
- package/src/obstacle-beacon.cjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/commands/checkout.mjs +0 -330
|
@@ -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
|
+
}
|
|
@@ -68,11 +68,11 @@ export function run(argv, ctx) {
|
|
|
68
68
|
|
|
69
69
|
const target = resolveTarget(args, ctx);
|
|
70
70
|
if (target.error) {
|
|
71
|
-
console.error(fail(target.error, "tot
|
|
71
|
+
console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
|
|
72
72
|
return 2;
|
|
73
73
|
}
|
|
74
74
|
if (!existsSync(target.dir)) {
|
|
75
|
-
console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot
|
|
75
|
+
console.error(fail(`no tenant directory at ${target.dir}`, "confirm the path, or `tot clone <tenant>`"));
|
|
76
76
|
return 2;
|
|
77
77
|
}
|
|
78
78
|
|
package/src/commands/whoami.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { defaultCredentialsPath, readCredentials, isExpired, activeProfile } from "../token-store.mjs";
|
|
12
12
|
import { establishSession } from "../auth.mjs";
|
|
13
13
|
import { createMcpClient } from "../mcp.mjs";
|
|
14
|
-
import { normalizeStores, storeListError } from "./
|
|
14
|
+
import { normalizeStores, storeListError, noStoresGuidance } from "./clone.mjs";
|
|
15
15
|
import { recordServerPolicy } from "../update-check.mjs";
|
|
16
16
|
|
|
17
17
|
/**
|
|
@@ -67,7 +67,11 @@ export async function run(argv, _ctx) {
|
|
|
67
67
|
} else if (listErr) {
|
|
68
68
|
console.log(` (couldn't list your stores: ${listErr} — try \`tot login\` again)`);
|
|
69
69
|
} else {
|
|
70
|
-
|
|
70
|
+
// Status-aware (card c2): an UNLINKED identity is told to link (not "may
|
|
71
|
+
// still be propagating" — that's only the genuine zero-grants case).
|
|
72
|
+
const g = noStoresGuidance(listResp);
|
|
73
|
+
console.log(` (${g.headline})`);
|
|
74
|
+
console.log(` → next: ${g.next}`);
|
|
71
75
|
}
|
|
72
76
|
} catch {
|
|
73
77
|
console.log(" (couldn't reach the MCP to list your stores right now — your cached session is above)");
|
package/src/context.mjs
CHANGED
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* monorepo — inside a full storefront checkout (pnpm-workspace.yaml +
|
|
8
8
|
* apps/storefront + tenants/). This is us / a platform dev.
|
|
9
|
-
* `tot dev` here runs the in-tree astro dev; `tot
|
|
9
|
+
* `tot dev` here runs the in-tree astro dev; `tot clone`
|
|
10
10
|
* can suggest cloning a sibling dir.
|
|
11
11
|
* checkout — inside a STANDALONE tenant checkout: the flat, content-only
|
|
12
12
|
* shape `content/ public/ theme.json .tot/config.json` a
|
|
13
13
|
* developer clones. The tenant is read from .tot/config.json.
|
|
14
14
|
* `tot dev` here boots the bundled runner against this dir.
|
|
15
|
-
* loose — anywhere else. `tot
|
|
15
|
+
* loose — anywhere else. `tot clone <tenant>` still works (it's how
|
|
16
16
|
* you GET a checkout); commands that need a workspace say so.
|
|
17
17
|
*
|
|
18
18
|
* Detection walks UP from the cwd so `tot` works from any subdirectory of a
|
|
@@ -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/oauth.mjs
CHANGED
|
@@ -84,8 +84,15 @@ export async function registerClient(
|
|
|
84
84
|
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
|
85
85
|
body: JSON.stringify({
|
|
86
86
|
client_name: CLIENT_NAME,
|
|
87
|
+
// Include the device-code grant: the browserless rendezvous + B3 device flows
|
|
88
|
+
// redeem their device_code at /oauth/token with this grant, so a client
|
|
89
|
+
// registered WITHOUT it gets "unauthorized_client: grant_type is invalid".
|
|
90
|
+
grant_types: [
|
|
91
|
+
"authorization_code",
|
|
92
|
+
"refresh_token",
|
|
93
|
+
"urn:ietf:params:oauth:grant-type:device_code",
|
|
94
|
+
],
|
|
87
95
|
redirect_uris: [redirectUri],
|
|
88
|
-
grant_types: ["authorization_code", "refresh_token"],
|
|
89
96
|
response_types: ["code"],
|
|
90
97
|
token_endpoint_auth_method: "none",
|
|
91
98
|
scope: SCOPE,
|
|
@@ -355,7 +362,6 @@ export async function attachRendezvous(attachEndpoint, { rendezvousCode, clientI
|
|
|
355
362
|
*/
|
|
356
363
|
export async function rendezvousLoginFlow({
|
|
357
364
|
mcpUrl,
|
|
358
|
-
clientId,
|
|
359
365
|
code,
|
|
360
366
|
fetchImpl = fetch,
|
|
361
367
|
log = () => {},
|
|
@@ -363,7 +369,12 @@ export async function rendezvousLoginFlow({
|
|
|
363
369
|
now = () => Date.now(),
|
|
364
370
|
}) {
|
|
365
371
|
const meta = await discoverMetadata(mcpUrl, fetchImpl);
|
|
366
|
-
|
|
372
|
+
// Always register a FRESH client for a rendezvous sign-in — do NOT reuse a cached
|
|
373
|
+
// clientId. A client cached by an older CLI build was registered without the
|
|
374
|
+
// device-code grant, so /oauth/token would reject the device grant with
|
|
375
|
+
// "unauthorized_client". Registration is cheap and a rendezvous has no consent
|
|
376
|
+
// screen to re-trigger (unlike the loopback/device flows, which reuse the cache).
|
|
377
|
+
const resolvedClientId = await registerClient(meta.registration_endpoint, LOOPBACK_REDIRECT, fetchImpl);
|
|
367
378
|
const { verifier, challenge } = generatePkce();
|
|
368
379
|
|
|
369
380
|
const attach = await attachRendezvous(
|
package/src/obstacle-beacon.cjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The obstacle beacon — a fire-and-forget POST that tells the hosted cockpit a
|
|
3
|
-
* `tot start` / `tot
|
|
3
|
+
* `tot start` / `tot clone` failed, so it can show the exact fix in the bridge
|
|
4
4
|
* strip (obstacle lane, server side already shipped). Best-effort telemetry that
|
|
5
5
|
* rides ALONGSIDE the house-style `✗ … → next:` error; it must NEVER change,
|
|
6
6
|
* delay past its timeout, or fail that error path.
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
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 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
|
|
9
|
+
* here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
|
|
10
|
+
* shape of plan text server-side/inline).
|
|
11
|
+
*
|
|
12
|
+
* `planForAction` is PURE (no I/O, no prompt) so it's trivially unit-tested
|
|
13
|
+
* and reusable anywhere a plan needs to be rendered (CLI stdout, an admin
|
|
14
|
+
* confirm() dialog, a future dry-run flag). `printPlanAndConfirm` is the CLI
|
|
15
|
+
* half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
|
|
16
|
+
* TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
|
|
17
|
+
*
|
|
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.
|
|
26
|
+
*
|
|
27
|
+
* Dependency-free (no imports besides the sibling `prompt.mjs`).
|
|
28
|
+
*/
|
|
29
|
+
import { isInteractive, promptYesNo } from "./prompt.mjs";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A human-readable label for the thing an action targets: "PR #N" when a PR
|
|
33
|
+
* number is known, else the change id, else a neutral fallback. Pure.
|
|
34
|
+
* @param {{ pr?: number|string|null, changeId?: string|null }} p
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
function targetLabel({ pr, changeId }) {
|
|
38
|
+
if (pr != null && `${pr}`.trim()) return `PR #${pr}`;
|
|
39
|
+
if (changeId) return changeId;
|
|
40
|
+
return "this change";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the EXACT plan for a mutating operator verb — structured lines ready
|
|
45
|
+
* to print verbatim (CLI) or join for a confirm() dialog (admin). Pure: no
|
|
46
|
+
* console output, no network, no prompting.
|
|
47
|
+
*
|
|
48
|
+
* @param {{
|
|
49
|
+
* action: "build"|"accept"|"ship"|"retire"|"revert"|"cleanup"|"hotfix",
|
|
50
|
+
* tenant: string,
|
|
51
|
+
* pr?: number|string|null,
|
|
52
|
+
* changeId?: string|null,
|
|
53
|
+
* headSha?: string|null,
|
|
54
|
+
* integrationSha?: string|null,
|
|
55
|
+
* endpoint?: string|null,
|
|
56
|
+
* targets?: { preview?: string|null, live?: string|null },
|
|
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,
|
|
66
|
+
* }} params
|
|
67
|
+
* @returns {string[]} plan lines (no leading/trailing blank line)
|
|
68
|
+
*/
|
|
69
|
+
export function planForAction({
|
|
70
|
+
action,
|
|
71
|
+
tenant,
|
|
72
|
+
pr = null,
|
|
73
|
+
changeId = null,
|
|
74
|
+
headSha = null,
|
|
75
|
+
integrationSha = null,
|
|
76
|
+
endpoint = null,
|
|
77
|
+
targets = {},
|
|
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,
|
|
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).
|
|
90
|
+
const label = targetLabel({ pr, changeId });
|
|
91
|
+
const lines = [`${titleFor(action)} plan:`];
|
|
92
|
+
if (tenant) lines.push(` tenant: ${tenant}`);
|
|
93
|
+
if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
|
|
94
|
+
if (changeId) lines.push(` change id: ${changeId}`);
|
|
95
|
+
if (headSha) lines.push(` head sha: ${headSha}`);
|
|
96
|
+
if (integrationSha) lines.push(` integration sha: ${integrationSha}`);
|
|
97
|
+
if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
|
|
98
|
+
|
|
99
|
+
switch (action) {
|
|
100
|
+
case "build": {
|
|
101
|
+
lines.push(
|
|
102
|
+
` effect: materialize ${label}'s candidate preview — NO merge, NO go-live, NO channel flip.`,
|
|
103
|
+
);
|
|
104
|
+
if (targets.preview) lines.push(` viewable: ${targets.preview}`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
case "accept": {
|
|
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
|
+
);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "ship": {
|
|
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"}`);
|
|
145
|
+
}
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
case "retire": {
|
|
149
|
+
lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
|
|
150
|
+
break;
|
|
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
|
+
}
|
|
213
|
+
default: {
|
|
214
|
+
lines.push(` effect: ${action} ${label}.`);
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return lines;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** "build" → "Build-on-demand", "accept" → "Accept", … "hotfix" → "Hotfix". Pure. */
|
|
222
|
+
function titleFor(action) {
|
|
223
|
+
if (action === "build") return "Build-on-demand";
|
|
224
|
+
if (action === "accept") return "Accept";
|
|
225
|
+
if (action === "ship") return "Ship";
|
|
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)";
|
|
230
|
+
return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Print a plan and gate on an explicit confirm — the CLI half of the shared
|
|
235
|
+
* affordance. Prints every line, a trailing blank line, then:
|
|
236
|
+
*
|
|
237
|
+
* - `yes: true` → confirmed immediately, no prompt (the verb's `--yes`).
|
|
238
|
+
* - a non-TTY (CI, piped) → refuses without prompting (never silently acts).
|
|
239
|
+
* - otherwise → asks `question` via `promptYesNo` (default NO unless the
|
|
240
|
+
* caller opts in with `defaultYes`).
|
|
241
|
+
*
|
|
242
|
+
* Returns a reason alongside the boolean so the caller can render its own
|
|
243
|
+
* house-style refusal/abort message (verbs differ: "nothing was built" vs
|
|
244
|
+
* "nothing shipped" etc.) — this helper only owns the plan + the gate.
|
|
245
|
+
*
|
|
246
|
+
* @param {string[]} planLines
|
|
247
|
+
* @param {{ yes?: boolean, question?: string, defaultYes?: boolean }} [opts]
|
|
248
|
+
* @returns {Promise<{ confirmed: boolean, reason: "yes-flag"|"confirmed"|"declined"|"non-tty" }>}
|
|
249
|
+
*/
|
|
250
|
+
export async function printPlanAndConfirm(planLines, { yes = false, question = "Proceed?", defaultYes = false } = {}) {
|
|
251
|
+
for (const line of planLines) console.log(line);
|
|
252
|
+
console.log("");
|
|
253
|
+
|
|
254
|
+
if (yes) return { confirmed: true, reason: "yes-flag" };
|
|
255
|
+
|
|
256
|
+
if (!isInteractive()) {
|
|
257
|
+
return { confirmed: false, reason: "non-tty" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const ok = await promptYesNo(question, defaultYes);
|
|
261
|
+
return { confirmed: ok, reason: ok ? "confirmed" : "declined" };
|
|
262
|
+
}
|
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
|