@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21
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 +219 -44
- package/package.json +7 -2
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +736 -0
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +269 -0
- package/src/commands/clone.mjs +713 -0
- package/src/commands/dev.mjs +441 -93
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +486 -0
- package/src/commands/grants.mjs +14 -7
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +12 -8
- package/src/commands/pr.mjs +425 -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 +403 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +91 -29
- package/src/commands/submit.mjs +1360 -131
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +11 -5
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +26 -11
- package/src/obstacle-beacon.cjs +3 -3
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +30 -4
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
- package/src/commands/checkout.mjs +0 -330
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.
|
|
@@ -103,9 +103,9 @@ function beacon(opts, done) {
|
|
|
103
103
|
|
|
104
104
|
/** Promise wrapper for the ESM side (src/obstacle.mjs) so a failure path can await delivery. */
|
|
105
105
|
function beaconAsync(opts) {
|
|
106
|
-
return new Promise(function (resolve) {
|
|
106
|
+
return /** @type {Promise<void>} */ (new Promise(function (resolve) {
|
|
107
107
|
try { beacon(opts, resolve); } catch (e) { resolve(); }
|
|
108
|
-
});
|
|
108
|
+
}));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
module.exports = { parseActivityArgs: parseActivityArgs, beacon: beacon, beaconAsync: beaconAsync };
|
package/src/obstacle.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { defaultCredentialsPath, readCredentials } from "./token-store.mjs";
|
|
|
19
19
|
* Best-effort obstacle beacon for a post-login failure. No-op (silent) when no
|
|
20
20
|
* bridge credential is cached — the developer signed in with a build that didn't
|
|
21
21
|
* carry the activity flags, or ran a bare `tot login`.
|
|
22
|
-
* @param {"pnpm-missing"|"install-failed"|"clone-failed"} kind
|
|
22
|
+
* @param {"pnpm-missing"|"install-failed"|"clone-failed"|"renderer-native-bindings-missing"} kind
|
|
23
23
|
* @param {{ have?: string, need?: string, env?: NodeJS.ProcessEnv }} [opts]
|
|
24
24
|
*/
|
|
25
25
|
export async function emitObstacle(kind, { have, need, env = process.env } = {}) {
|
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 }>|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 }>|null,
|
|
64
|
+
* bypassedPrs?: Array<{ prNumber?: number|null, changeId?: string|null, headSha?: string|null }>|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
|
|
@@ -68,7 +94,7 @@ export function pickNvmrcVersion(env = process.env) {
|
|
|
68
94
|
const best = readdirSync(root)
|
|
69
95
|
.map((name) => /^v(\d+)\.(\d+)\.(\d+)$/.exec(name))
|
|
70
96
|
.filter((m) => m && nodeMeetsFloor(m.slice(1).join(".")))
|
|
71
|
-
.map((m) => m.slice(1).map(Number))
|
|
97
|
+
.map((m) => /** @type {RegExpExecArray} */ (m).slice(1).map(Number))
|
|
72
98
|
.sort((a, b) => b[0] - a[0] || b[1] - a[1] || b[2] - a[2])[0];
|
|
73
99
|
if (best) return best.join(".");
|
|
74
100
|
} catch {
|
|
@@ -151,7 +177,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
|
|
|
151
177
|
const err = new Error(
|
|
152
178
|
`${dir} isn't empty and isn't a sample checkout — scaffold into an empty directory (or pass a new --workspace)`,
|
|
153
179
|
);
|
|
154
|
-
err.code =
|
|
180
|
+
/** @type {any} */ (err).code ="ENOTEMPTY_SAMPLE";
|
|
155
181
|
throw err;
|
|
156
182
|
}
|
|
157
183
|
|
|
@@ -161,7 +187,7 @@ export function scaffoldSample(destDir, { force = false, log = () => {} } = {})
|
|
|
161
187
|
"the sample store isn't available in this release yet — it's coming soon. " +
|
|
162
188
|
"To build a real store now: `tot login --code <invite>` then `tot start`.",
|
|
163
189
|
);
|
|
164
|
-
err.code =
|
|
190
|
+
/** @type {any} */ (err).code ="SAMPLE_UNAVAILABLE";
|
|
165
191
|
throw err;
|
|
166
192
|
}
|
|
167
193
|
|
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
|
}
|
|
@@ -555,6 +607,10 @@ function buildPageTargetSet(contentDir, pagesDir) {
|
|
|
555
607
|
return set;
|
|
556
608
|
}
|
|
557
609
|
|
|
610
|
+
/**
|
|
611
|
+
* @param {any} href @param {any} file @param {any} scope @param {any} pageTargets
|
|
612
|
+
* @param {(path: string) => boolean} [ownsPlatformRoute]
|
|
613
|
+
*/
|
|
558
614
|
function checkLink(href, file, scope, pageTargets, ownsPlatformRoute = () => false) {
|
|
559
615
|
const out = [];
|
|
560
616
|
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return out;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewer-session transport for the ship surface — the NO-operator-secret path.
|
|
3
|
+
*
|
|
4
|
+
* An invited developer holds a `tot login` MCP session but no operator secret and no
|
|
5
|
+
* storefront cookie, so the old ship-surface transport dead-ended on them. This mints
|
|
6
|
+
* a storefront VIEWER session from the developer's OWN MCP token (POST
|
|
7
|
+
* /api/dev/cli-session on the tenant's own host, which resolves WHO the opaque token
|
|
8
|
+
* is via the MCP and hands back a `tot_session` cookie), then hands the caller a
|
|
9
|
+
* cookie-based transport pointed at the tenant host. Authorization is still the
|
|
10
|
+
* developer's live `ship-on-behalf` grant, enforced server-side at the ship route —
|
|
11
|
+
* this only carries their identity, it grants nothing.
|
|
12
|
+
*
|
|
13
|
+
* Minted FRESH per call (no disk cache): a `tot accept` is interactive + infrequent,
|
|
14
|
+
* and minting-per-call means a revoked session/grant is never honored past its life.
|
|
15
|
+
*/
|
|
16
|
+
import { resolveDeveloperSession, AuthUnavailableError } from "./auth.mjs";
|
|
17
|
+
|
|
18
|
+
const SESSION_COOKIE = "tot_session";
|
|
19
|
+
|
|
20
|
+
/** The tenant's own storefront host — the dev-viewer admission derives the tenant
|
|
21
|
+
* from the request host, so the session + integrate MUST target it (not the generic
|
|
22
|
+
* storefront origin + X-Tot-Owner, which only steers the operator-secret path). */
|
|
23
|
+
function tenantBase(tenant) {
|
|
24
|
+
return `https://${String(tenant || "").trim().toLowerCase()}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Pull `tot_session=<id>` out of a (possibly comma-folded) Set-Cookie header. The
|
|
28
|
+
* id is base64url — no comma/semicolon — so a non-greedy stop-set is unambiguous. */
|
|
29
|
+
export function parseSessionCookie(setCookie) {
|
|
30
|
+
if (!setCookie) return null;
|
|
31
|
+
const m = new RegExp(`${SESSION_COOKIE}=([^;,\\s]+)`).exec(setCookie);
|
|
32
|
+
return m ? m[1] : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Resolve a viewer-session transport for `tenant`. Returns
|
|
37
|
+
* { ok:true, base, authHeaders } — base = tenant host; cookie transport
|
|
38
|
+
* { ok:false, message, hint } — a clean, actionable refusal
|
|
39
|
+
* Never throws.
|
|
40
|
+
*
|
|
41
|
+
* @param {{ tenant:string, env?:NodeJS.ProcessEnv, fetchImpl?:typeof fetch,
|
|
42
|
+
* resolveDev?:typeof resolveDeveloperSession }} params
|
|
43
|
+
* @returns {Promise<
|
|
44
|
+
* { ok:true, base:string, authHeaders:Record<string,string> } |
|
|
45
|
+
* { ok:false, message:string, hint:string }
|
|
46
|
+
* >}
|
|
47
|
+
*/
|
|
48
|
+
export async function resolveViewerTransport({
|
|
49
|
+
tenant,
|
|
50
|
+
env = process.env,
|
|
51
|
+
fetchImpl = fetch,
|
|
52
|
+
resolveDev = resolveDeveloperSession,
|
|
53
|
+
}) {
|
|
54
|
+
// The developer's OWN MCP token (read + silently refreshed by the resolver). No
|
|
55
|
+
// client needed — resolveDeveloperSession tolerates a null client.
|
|
56
|
+
let dev;
|
|
57
|
+
try {
|
|
58
|
+
dev = await resolveDev(null, env, { fetchImpl });
|
|
59
|
+
} catch (e) {
|
|
60
|
+
if (e instanceof AuthUnavailableError) {
|
|
61
|
+
return { ok: false, message: e.message, hint: e.hint || "run `tot login`, then re-run." };
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
message: `couldn't read your Token of Trust session: ${e?.message || e}`,
|
|
66
|
+
hint: "run `tot login`, then re-run.",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const base = tenantBase(tenant);
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetchImpl(`${base}/api/dev/cli-session`, {
|
|
74
|
+
method: "POST",
|
|
75
|
+
headers: { authorization: `Bearer ${dev.token}` },
|
|
76
|
+
});
|
|
77
|
+
} catch (e) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: `couldn't reach ${base} to start a session: ${e?.message || e}`,
|
|
81
|
+
hint: "check the --tenant domain / your network, then re-run.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (res.status === 401) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
message: "your `tot` session wasn't recognized for this store.",
|
|
88
|
+
hint: "run `tot login`, then re-run.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
message: `couldn't start a session at ${base} (HTTP ${res.status}).`,
|
|
95
|
+
hint: "retry shortly; if it persists, this store may not be set up for CLI publishing yet.",
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cookie = parseSessionCookie(res.headers.get("set-cookie"));
|
|
100
|
+
if (!cookie) {
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
message: "the store started a session but returned no session cookie.",
|
|
104
|
+
hint: "re-run; if it persists, report it via `tot` feedback.",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
ok: true,
|
|
110
|
+
base,
|
|
111
|
+
authHeaders: {
|
|
112
|
+
cookie: `${SESSION_COOKIE}=${cookie}`,
|
|
113
|
+
// Matches the admin browser client; the server IGNORES this hint and re-reads
|
|
114
|
+
// the live ship-on-behalf grant, so it authorizes nothing on its own.
|
|
115
|
+
"x-tot-capability": "ship-on-behalf",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|