@tokenoftrust/cli 1.4.0-rc.11 → 1.4.0-rc.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tot.mjs +33 -0
- package/package.json +1 -1
- package/src/commands/accept.mjs +247 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +9 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +389 -41
- package/src/commands/submit.mjs +35 -5
- package/src/plan.mjs +160 -0
- package/src/sample.mjs +27 -1
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
|