@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/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