@tokenoftrust/cli 1.4.0-rc.1 → 1.4.0-rc.11
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 +51 -11
- package/package.json +2 -2
- package/src/candidate-state.mjs +137 -0
- package/src/commands/{checkout.mjs → clone.mjs} +129 -28
- package/src/commands/dev.mjs +110 -15
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/grants.mjs +8 -3
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +19 -12
- package/src/commands/pr.mjs +214 -0
- package/src/commands/preview.mjs +71 -0
- package/src/commands/ship.mjs +667 -0
- package/src/commands/start.mjs +34 -14
- package/src/commands/submit.mjs +458 -41
- package/src/commands/validate.mjs +2 -2
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/oauth.mjs +92 -42
- package/src/obstacle-beacon.cjs +1 -1
package/src/commands/submit.mjs
CHANGED
|
@@ -42,16 +42,25 @@ import { createHash } from "node:crypto";
|
|
|
42
42
|
import { setTimeout as delay } from "node:timers/promises";
|
|
43
43
|
import { createMcpClient } from "../mcp.mjs";
|
|
44
44
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
45
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
45
46
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
46
47
|
import { openBrowser } from "../open.mjs";
|
|
47
48
|
import { startProgress } from "../progress.mjs";
|
|
48
49
|
import { fail } from "../errors.mjs";
|
|
50
|
+
import {
|
|
51
|
+
defaultCandidateStatePath,
|
|
52
|
+
readActiveChangeId,
|
|
53
|
+
writeActiveChangeId,
|
|
54
|
+
mintFreshChangeId,
|
|
55
|
+
isTerminalCandidateState,
|
|
56
|
+
isDefaultBranch,
|
|
57
|
+
} from "../candidate-state.mjs";
|
|
49
58
|
|
|
50
59
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
51
60
|
const DEFAULT_REF = "preview";
|
|
52
61
|
|
|
53
62
|
export function parseArgs(argv) {
|
|
54
|
-
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, help: false };
|
|
63
|
+
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
|
|
55
64
|
for (let i = 0; i < argv.length; i++) {
|
|
56
65
|
const t = argv[i];
|
|
57
66
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -60,28 +69,48 @@ export function parseArgs(argv) {
|
|
|
60
69
|
else if (t === "-m" || t === "--message") a.message = argv[++i];
|
|
61
70
|
else if (t === "--summary") a.summary = argv[++i];
|
|
62
71
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
72
|
+
else if (t === "--no-commit") a.noCommit = true;
|
|
63
73
|
else if (t === "--no-wait") a.noWait = true;
|
|
64
74
|
else if (t === "--watch") a.watch = true;
|
|
65
75
|
else if (t === "--no-open") a.noOpen = true;
|
|
76
|
+
else if (t === "--new") a.new = true;
|
|
66
77
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
67
78
|
}
|
|
68
79
|
return a;
|
|
69
80
|
}
|
|
70
81
|
|
|
71
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Render the usage block for whichever verb invoked this flow. `preview` is the
|
|
84
|
+
* first-class verb; `submit`/`deploy` reach the same flow as teaching aliases, so
|
|
85
|
+
* the help they print names the verb the developer actually typed (see preview.mjs).
|
|
86
|
+
* @param {string} [verb]
|
|
87
|
+
*/
|
|
88
|
+
export function renderUsage(verb = "preview") {
|
|
89
|
+
return `tot ${verb} — submit your store for preview
|
|
90
|
+
|
|
91
|
+
tot ${verb} validate → push the preview ref → stream the result
|
|
92
|
+
tot ${verb} --new open a NEW candidate PR instead of updating your open one
|
|
93
|
+
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
94
|
+
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
95
|
+
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
96
|
+
tot ${verb} --ref <name> push ref (default: ${DEFAULT_REF})
|
|
97
|
+
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
98
|
+
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
99
|
+
tot ${verb} --no-wait push and exit without polling for the reconcile result
|
|
100
|
+
tot ${verb} --no-open don't open the preview URL in the browser on success
|
|
101
|
+
tot ${verb} --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
72
102
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
tot
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
tot submit --no-open don't open the preview URL in the browser on success
|
|
81
|
-
tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
103
|
+
By default a re-run UPDATES your open candidate PR (like pushing more commits
|
|
104
|
+
to a GitHub PR), rather than opening a new one each time. Use --new to fork a
|
|
105
|
+
fresh candidate PR; the next plain \`tot ${verb}\` then updates THAT one. Manage your
|
|
106
|
+
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
107
|
+
merged or closed, a re-run automatically opens a fresh one.
|
|
108
|
+
|
|
109
|
+
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
82
110
|
|
|
83
111
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
84
112
|
what's live in preview) so the change record the approver reviews is never blank.`;
|
|
113
|
+
}
|
|
85
114
|
|
|
86
115
|
// Default bounded wait (~20s, matching the pre-E2 fixed poll's total budget) vs.
|
|
87
116
|
// --watch's longer per-call long-poll + more attempts (~8 min ceiling) for a dev
|
|
@@ -123,6 +152,126 @@ function printChangeSummary({ title, body, autoTitle }) {
|
|
|
123
152
|
if (autoTitle) console.log(` (auto-generated from git — pass -m "…" / --summary "…" to refine)`);
|
|
124
153
|
}
|
|
125
154
|
|
|
155
|
+
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The content trees `tot preview` may auto-commit on a dirty tree — and ONLY
|
|
159
|
+
* these. A store's reviewable content lives here; anything a developer edits
|
|
160
|
+
* OUTSIDE these (src/, config, stray files) is out of scope for an automatic
|
|
161
|
+
* commit and is REFUSED rather than silently swept in. Explicit paths only —
|
|
162
|
+
* never `git add -A`/`git add .`.
|
|
163
|
+
*/
|
|
164
|
+
export const KNOWN_CONTENT_TREES = ["content/", "public/", ".tot/"];
|
|
165
|
+
export const KNOWN_CONTENT_FILES = ["theme.json"];
|
|
166
|
+
|
|
167
|
+
/** Is this repo-relative path inside a known content tree (or the one known
|
|
168
|
+
* top-level file)? Pure — unit-tested. */
|
|
169
|
+
export function isKnownContentPath(path) {
|
|
170
|
+
const p = String(path).replace(/^\.\//, "");
|
|
171
|
+
return KNOWN_CONTENT_FILES.includes(p) || KNOWN_CONTENT_TREES.some((t) => p.startsWith(t));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Flatten `git status --porcelain` (v1) output to the ordered, deduped set of
|
|
176
|
+
* dirty repo-relative paths. Each line is `XY <path>`; a rename/copy is
|
|
177
|
+
* `XY <old> -> <new>` and contributes BOTH paths (the old one is being removed
|
|
178
|
+
* from its tree too, so it's part of the scope decision). Run with
|
|
179
|
+
* `core.quotePath=false` upstream so unicode paths arrive literal. Pure —
|
|
180
|
+
* unit-tested.
|
|
181
|
+
* @param {string} text
|
|
182
|
+
* @returns {string[]}
|
|
183
|
+
*/
|
|
184
|
+
export function parsePorcelainPaths(text) {
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
const out = [];
|
|
187
|
+
for (const line of String(text).split("\n")) {
|
|
188
|
+
if (line.length < 4) continue; // "XY p" is the shortest real entry
|
|
189
|
+
const rest = line.slice(3);
|
|
190
|
+
const parts = rest.includes(" -> ") ? rest.split(" -> ") : [rest];
|
|
191
|
+
for (const raw of parts) {
|
|
192
|
+
const p = raw.trim();
|
|
193
|
+
if (p && !seen.has(p)) {
|
|
194
|
+
seen.add(p);
|
|
195
|
+
out.push(p);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Partition dirty paths into the known content trees vs everything else. Pure —
|
|
204
|
+
* unit-tested.
|
|
205
|
+
* @param {string[]} paths
|
|
206
|
+
* @returns {{ known: string[], unknown: string[] }}
|
|
207
|
+
*/
|
|
208
|
+
export function classifyDirtyPaths(paths) {
|
|
209
|
+
const known = [];
|
|
210
|
+
const unknown = [];
|
|
211
|
+
for (const p of paths) (isKnownContentPath(p) ? known : unknown).push(p);
|
|
212
|
+
return { known, unknown };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A concise auto commit subject when the developer gave no -m — derived from the
|
|
216
|
+
* staged files so the commit is never a blank "(untitled change)". Pure. */
|
|
217
|
+
export function autoCommitSubject(files = []) {
|
|
218
|
+
if (files.length === 0) return "update store content";
|
|
219
|
+
if (files.length === 1) return `update ${files[0]}`;
|
|
220
|
+
return `update ${files.length} content files`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The message for `tot preview`'s pre-preview auto-commit. An explicit `-m` wins
|
|
225
|
+
* as the subject; otherwise autoCommitSubject derives one from the staged files.
|
|
226
|
+
* The body (shortstat + file list) is built by the SAME buildChangeSummary the
|
|
227
|
+
* approver summary uses, so a commit and the change record it later feeds read
|
|
228
|
+
* consistently. Returns a git commit message (subject, blank line, body). Pure —
|
|
229
|
+
* unit-tested.
|
|
230
|
+
* @param {{ message?: string|null, files?: string[], statLine?: string }} input
|
|
231
|
+
* @returns {string}
|
|
232
|
+
*/
|
|
233
|
+
export function buildAutoCommitMessage({ message, files = [], statLine = "" } = {}) {
|
|
234
|
+
const subject = (message && message.trim()) || autoCommitSubject(files);
|
|
235
|
+
const { body } = buildChangeSummary({ message: subject, files, statLine });
|
|
236
|
+
return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Auto-commit the known content trees before previewing (unit u2). On a DIRTY
|
|
241
|
+
* tree `tot preview` commits your content edits for you, so a preview always
|
|
242
|
+
* reflects your working changes — but ONLY the known trees (content/, public/,
|
|
243
|
+
* theme.json, .tot/), staged by EXPLICIT path (never `git add -A`). If anything
|
|
244
|
+
* dirty falls OUTSIDE those, it REFUSES (out-of-scope src/config/stray edits are
|
|
245
|
+
* never silently swept into a store commit). --no-commit opts out entirely
|
|
246
|
+
* (preview whatever's already committed — u1's behavior).
|
|
247
|
+
*
|
|
248
|
+
* Returns exactly one of:
|
|
249
|
+
* { skipped: true } — --no-commit.
|
|
250
|
+
* { clean: true } — nothing dirty; preview HEAD as-is.
|
|
251
|
+
* { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
|
|
252
|
+
* { committed: true, sha, files } — staged the known dirty paths and committed.
|
|
253
|
+
*
|
|
254
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
255
|
+
* @param {{ message?: string|null, noCommit?: boolean }} [opts]
|
|
256
|
+
*/
|
|
257
|
+
export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
|
|
258
|
+
if (noCommit) return { skipped: true };
|
|
259
|
+
const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
|
|
260
|
+
const dirty = parsePorcelainPaths(status);
|
|
261
|
+
if (dirty.length === 0) return { clean: true };
|
|
262
|
+
const { known, unknown } = classifyDirtyPaths(dirty);
|
|
263
|
+
if (unknown.length) return { refused: true, unknown, known };
|
|
264
|
+
// Stage ONLY the known dirty paths, each by explicit path — this records adds,
|
|
265
|
+
// modifications AND deletions within them, and can never reach outside the set.
|
|
266
|
+
git(["add", "--", ...known]);
|
|
267
|
+
const files = parseNameStatus(git(["diff", "--cached", "--name-status"])).map((e) => e.path);
|
|
268
|
+
const statLine = git(["diff", "--cached", "--shortstat"]).trim();
|
|
269
|
+
const msg = buildAutoCommitMessage({ message, files, statLine });
|
|
270
|
+
git(["commit", "--no-verify", "-m", msg]);
|
|
271
|
+
const sha = git(["rev-parse", "HEAD"]).trim();
|
|
272
|
+
return { committed: true, sha, files };
|
|
273
|
+
}
|
|
274
|
+
|
|
126
275
|
// ─── PR-backed candidate (g1b candidate_open, unit c1) ──────────────────────────
|
|
127
276
|
|
|
128
277
|
/**
|
|
@@ -198,22 +347,165 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
198
347
|
}
|
|
199
348
|
}
|
|
200
349
|
|
|
350
|
+
// ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Split an authenticated forge remote URL (basic-auth `user:token@host`, as the
|
|
354
|
+
* MCP mints it via `tenant_checkout`) into its tokenless public URL + the embedded
|
|
355
|
+
* credential, so the token can be handed to git EPHEMERALLY for one push instead of
|
|
356
|
+
* being persisted in `.git/config`. Returns null when the URL won't parse or carries
|
|
357
|
+
* no token — the caller then falls back to the checkout's existing remote. Pure —
|
|
358
|
+
* unit-tested.
|
|
359
|
+
* @param {string} remoteUrl
|
|
360
|
+
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
361
|
+
*/
|
|
362
|
+
export function splitAuthedRemote(remoteUrl) {
|
|
363
|
+
try {
|
|
364
|
+
const u = new URL(String(remoteUrl));
|
|
365
|
+
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
366
|
+
if (!token) return null;
|
|
367
|
+
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
368
|
+
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
|
|
376
|
+
* invocation (base64 of `user:token`) — so a freshly-minted forge token
|
|
377
|
+
* authenticates one push without ever being written to `.git/config`. Pure —
|
|
378
|
+
* unit-tested.
|
|
379
|
+
* @param {string} username
|
|
380
|
+
* @param {string} token
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
export function basicAuthExtraHeader(username, token) {
|
|
384
|
+
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
385
|
+
return `Authorization: Basic ${b64}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Recognise a forge auth failure (expired / invalid push token) in a failed git
|
|
390
|
+
* push's stderr, so `tot preview` can re-mint a fresh credential and retry once
|
|
391
|
+
* rather than dead-ending on a stale token (the belt-and-suspenders half of
|
|
392
|
+
* decision B). Pure — unit-tested.
|
|
393
|
+
* @param {string} text
|
|
394
|
+
* @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
export function isForgeAuthError(text) {
|
|
397
|
+
return /\b40[13]\b|failed to authenticate|authentication failed|invalid credentials|access denied/i.test(
|
|
398
|
+
String(text || ""),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Derive the checkout's forge tag from its repo name (`"<tenant>-<tag>"`), so a
|
|
404
|
+
* re-mint targets the SAME repo the checkout points at. Defaults to "main" when the
|
|
405
|
+
* repo is bare (`"<tenant>"`, post-8425) or the `<tenant>-` prefix doesn't match, so
|
|
406
|
+
* the mint degrades to the clone default rather than a wrong tag. Pure —
|
|
407
|
+
* unit-tested.
|
|
408
|
+
* @param {string|null} repoName
|
|
409
|
+
* @param {string} tenant
|
|
410
|
+
* @returns {string}
|
|
411
|
+
*/
|
|
412
|
+
export function tagFromRepoName(repoName, tenant) {
|
|
413
|
+
const r = String(repoName || "");
|
|
414
|
+
const prefix = `${tenant}-`;
|
|
415
|
+
return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Push the preview ref (decision B): mint a FRESH, short-lived forge credential
|
|
420
|
+
* right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
|
|
421
|
+
* a per-invocation `-c` — never written to `.git/config`), re-minting once on an
|
|
422
|
+
* auth failure. The push credential the MCP baked into `.git/config` at clone time
|
|
423
|
+
* expires within hours; reusing that stale embedded token is the invited-dev
|
|
424
|
+
* "`tot preview` → Gitea 401 dead-end". We push over the named `origin` remote with
|
|
425
|
+
* its URL overridden to the tokenless public URL for this one invocation, so the
|
|
426
|
+
* remote-tracking ref still updates while no long-lived secret lands on disk.
|
|
427
|
+
*
|
|
428
|
+
* When the mint is unavailable (older MCP, transient failure — `mintRemote` returns
|
|
429
|
+
* null) or the minted URL carries no parseable token, it falls back to pushing over
|
|
430
|
+
* the checkout's EXISTING remote (pre-B behavior) — no regression.
|
|
431
|
+
*
|
|
432
|
+
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
433
|
+
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
434
|
+
* @param {{ ref: string }} opts
|
|
435
|
+
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
436
|
+
*/
|
|
437
|
+
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
438
|
+
const attempt = (remote) => {
|
|
439
|
+
const cred = splitAuthedRemote(remote);
|
|
440
|
+
if (!cred) {
|
|
441
|
+
// No fresh credential to hand over — push over the checkout's existing remote.
|
|
442
|
+
return git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
443
|
+
}
|
|
444
|
+
// Ephemeral auth: override the remote URL to the tokenless public URL and supply
|
|
445
|
+
// the credential as a one-shot Authorization header, with any OS credential
|
|
446
|
+
// helper disabled — none of this touches `.git/config`.
|
|
447
|
+
return git([
|
|
448
|
+
"-c", `remote.origin.url=${cred.publicUrl}`,
|
|
449
|
+
"-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
|
|
450
|
+
"-c", "credential.helper=",
|
|
451
|
+
"push", "-f", "origin", `HEAD:refs/heads/${ref}`,
|
|
452
|
+
]);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const remote = await mintRemote();
|
|
456
|
+
try {
|
|
457
|
+
return { out: attempt(remote) };
|
|
458
|
+
} catch (e) {
|
|
459
|
+
if (!isForgeAuthError(e?.stderr || e?.message || e)) throw e;
|
|
460
|
+
// Belt-and-suspenders: an auth failure re-mints a fresh credential and retries once.
|
|
461
|
+
return { out: attempt(await mintRemote()) };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
201
465
|
/**
|
|
202
|
-
* A STABLE per-developer-per-tenant candidate handle — so repeat
|
|
203
|
-
* runs update the SAME PR instead of opening a new one each time
|
|
466
|
+
* A STABLE per-developer-per-tenant(-per-branch) candidate handle — so repeat
|
|
467
|
+
* `tot submit` runs update the SAME PR instead of opening a new one each time
|
|
204
468
|
* (`candidate_open` is idempotent on `changeId`). No local state file needed:
|
|
205
|
-
* it's a deterministic hash of the tenant + the acting identity
|
|
206
|
-
* fresh every run. Two different developers
|
|
207
|
-
* two different (non-colliding) candidates
|
|
469
|
+
* it's a deterministic hash of the tenant + the acting identity (+ the git branch
|
|
470
|
+
* on a non-default branch), recomputed fresh every run. Two different developers
|
|
471
|
+
* submitting to the same tenant get two different (non-colliding) candidates; so
|
|
472
|
+
* do the SAME developer on two different feature branches (u4 — branch-bound
|
|
473
|
+
* candidates), so `git checkout` acts as the PR switcher.
|
|
474
|
+
*
|
|
475
|
+
* ZERO MIGRATION: on the DEFAULT branch (main/master, or an unresolvable branch)
|
|
476
|
+
* the hash material is `tenant|actorKey` — byte-identical to the pre-u4 id — so an
|
|
477
|
+
* existing dev's current candidate keeps working untouched. A non-default branch
|
|
478
|
+
* folds the branch into the material (`tenant|actorKey|branch`) for its own id.
|
|
479
|
+
* Pure — unit-tested.
|
|
208
480
|
* @param {string} tenant
|
|
209
481
|
* @param {string} actorKey
|
|
482
|
+
* @param {string|null} [branch] current git branch; default/null ⇒ today's id
|
|
210
483
|
* @returns {string}
|
|
211
484
|
*/
|
|
212
|
-
export function deriveChangeId(tenant, actorKey) {
|
|
213
|
-
const
|
|
485
|
+
export function deriveChangeId(tenant, actorKey, branch = null) {
|
|
486
|
+
const material = isDefaultBranch(branch) ? `${tenant}|${actorKey}` : `${tenant}|${actorKey}|${branch}`;
|
|
487
|
+
const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
214
488
|
return `local-${hash}`;
|
|
215
489
|
}
|
|
216
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The current git branch in `workspace`, or null when it can't be resolved (a
|
|
493
|
+
* detached HEAD reports "HEAD", and any git failure is swallowed) — null is read
|
|
494
|
+
* by `isDefaultBranch` as the default branch, so an unresolvable branch keeps
|
|
495
|
+
* today's (branch-less) candidate rather than minting a spurious namespace. `git`
|
|
496
|
+
* is injected (a `(args:string[])=>string` runner) so it's testable. Best-effort.
|
|
497
|
+
* @param {(args:string[]) => string} git
|
|
498
|
+
* @returns {string|null}
|
|
499
|
+
*/
|
|
500
|
+
export function currentBranch(git) {
|
|
501
|
+
try {
|
|
502
|
+
const b = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
503
|
+
return b && b !== "HEAD" ? b : null;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
217
509
|
/** The stable identity key behind `deriveChangeId` — the signed-in developer's
|
|
218
510
|
* email, falling back to the token, then a generic label. Single-plane: the only
|
|
219
511
|
* identity `tot` carries is the developer's own OAuth session. */
|
|
@@ -277,19 +569,26 @@ function reportCandidate(result, changeId) {
|
|
|
277
569
|
console.log(` ~ PR-backed candidate not opened: ${msg}`);
|
|
278
570
|
}
|
|
279
571
|
|
|
280
|
-
/**
|
|
281
|
-
|
|
572
|
+
/**
|
|
573
|
+
* The preview flow — validate, push the preview ref, open/update the PR-backed
|
|
574
|
+
* candidate, and stream back the reconcile/compliance/preview result. Reached by
|
|
575
|
+
* `tot preview` and, as teaching aliases, `tot submit` / `tot deploy` (preview.mjs
|
|
576
|
+
* wraps this and adds the verb-teaching hints). `verb` only brands the user-facing
|
|
577
|
+
* copy (usage + the not-in-checkout error) with whatever the developer typed.
|
|
578
|
+
* @param {string[]} argv @param {any} ctx @param {{ verb?: string }} [opts]
|
|
579
|
+
*/
|
|
580
|
+
export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
282
581
|
const env = process.env;
|
|
283
582
|
const args = parseArgs(argv);
|
|
284
583
|
if (args.help) {
|
|
285
|
-
console.log(
|
|
584
|
+
console.log(renderUsage(verb));
|
|
286
585
|
return 0;
|
|
287
586
|
}
|
|
288
587
|
if (ctx.mode !== "checkout") {
|
|
289
588
|
console.error(
|
|
290
589
|
fail(
|
|
291
|
-
|
|
292
|
-
"tot
|
|
590
|
+
`\`tot ${verb}\` runs from inside a tenant checkout`,
|
|
591
|
+
"tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
|
|
293
592
|
),
|
|
294
593
|
);
|
|
295
594
|
return 2;
|
|
@@ -298,6 +597,41 @@ export async function run(argv, ctx) {
|
|
|
298
597
|
const tenant = ctx.tenant;
|
|
299
598
|
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
300
599
|
|
|
600
|
+
// 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
|
|
601
|
+
// content edits BEFORE previewing so the preview reflects your working changes.
|
|
602
|
+
// Only content/, public/, theme.json, .tot/ (staged by explicit path, never
|
|
603
|
+
// `git add -A`); anything dirty outside those is refused, not silently swept in.
|
|
604
|
+
// --no-commit opts out (preview whatever's already committed).
|
|
605
|
+
let auto;
|
|
606
|
+
try {
|
|
607
|
+
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
608
|
+
} catch (e) {
|
|
609
|
+
console.error(
|
|
610
|
+
fail(
|
|
611
|
+
`auto-commit failed: ${String(e?.stderr || e?.message || e)}`,
|
|
612
|
+
"commit your content manually (git add / git commit), or re-run with --no-commit",
|
|
613
|
+
),
|
|
614
|
+
);
|
|
615
|
+
return 1;
|
|
616
|
+
}
|
|
617
|
+
if (auto.refused) {
|
|
618
|
+
console.error(
|
|
619
|
+
fail(
|
|
620
|
+
`${auto.unknown.length} change(s) are outside the store content trees — refusing to auto-commit`,
|
|
621
|
+
"commit (or stash/revert) those yourself, then re-run — or use --no-commit to preview only what's already committed",
|
|
622
|
+
) + "\n",
|
|
623
|
+
);
|
|
624
|
+
for (const p of auto.unknown) console.error(` ✗ out of scope: ${p}`);
|
|
625
|
+
console.error(`\n \`tot ${verb}\` auto-commits only: ${[...KNOWN_CONTENT_TREES, ...KNOWN_CONTENT_FILES].join(", ")}`);
|
|
626
|
+
if (auto.known.length) console.error(` (in scope, would have been committed: ${auto.known.join(", ")})`);
|
|
627
|
+
return 1;
|
|
628
|
+
}
|
|
629
|
+
if (auto.committed) {
|
|
630
|
+
console.error(
|
|
631
|
+
`~ auto-committed ${auto.sha.slice(0, 9)} (${auto.files.length} content file(s)) — pass -m "…" to set the message, --no-commit to skip`,
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
|
|
301
635
|
// 1. validate locally — refuse on errors.
|
|
302
636
|
if (!args.skipValidate) {
|
|
303
637
|
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
@@ -349,10 +683,68 @@ export async function run(argv, ctx) {
|
|
|
349
683
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
350
684
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
351
685
|
|
|
352
|
-
|
|
686
|
+
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
687
|
+
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
688
|
+
// ONCE, up front, and reuse it for the whole flow.
|
|
689
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
690
|
+
const client = createMcpClient(baseUrl);
|
|
691
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
692
|
+
|
|
693
|
+
let session;
|
|
694
|
+
try {
|
|
695
|
+
session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
696
|
+
} catch (e) {
|
|
697
|
+
// Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
|
|
698
|
+
// back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
|
|
699
|
+
// no worse than before) and skip the read-back that needs a session. The push
|
|
700
|
+
// still lands if that embedded token is live.
|
|
701
|
+
console.error(`~ pushing ${short} → ${args.ref} (origin)`);
|
|
702
|
+
try {
|
|
703
|
+
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
|
|
704
|
+
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
705
|
+
} catch (pushErr) {
|
|
706
|
+
console.error(
|
|
707
|
+
fail(
|
|
708
|
+
`push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`,
|
|
709
|
+
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
710
|
+
),
|
|
711
|
+
);
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
console.log(`\n+ submitted ${short} to ${args.ref}.`);
|
|
715
|
+
printChangeSummary(changeSummary);
|
|
716
|
+
if (e instanceof AuthUnavailableError) {
|
|
717
|
+
console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
|
|
718
|
+
} else {
|
|
719
|
+
console.log(` (couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)})`);
|
|
720
|
+
}
|
|
721
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
722
|
+
return 0;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
726
|
+
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
727
|
+
// hours, so we re-mint right before the push and hand it to git ephemerally
|
|
728
|
+
// (never persisted to `.git/config`), re-minting once on an auth failure.
|
|
729
|
+
// `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
|
|
730
|
+
// binding the tenant scope the candidate/preview read-back below reads.
|
|
731
|
+
const tag = tagFromRepoName(repo, tenant);
|
|
732
|
+
const mintRemote = async () => {
|
|
733
|
+
try {
|
|
734
|
+
const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
735
|
+
return res.gitRemote || null;
|
|
736
|
+
} catch (e) {
|
|
737
|
+
console.error(
|
|
738
|
+
`~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
|
|
739
|
+
);
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
console.error(`~ pushing ${short} → ${args.ref} (origin, fresh credential)`);
|
|
353
745
|
try {
|
|
354
|
-
const out = git
|
|
355
|
-
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
746
|
+
const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
|
|
747
|
+
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
356
748
|
} catch (e) {
|
|
357
749
|
console.error(
|
|
358
750
|
fail(
|
|
@@ -366,28 +758,53 @@ export async function run(argv, ctx) {
|
|
|
366
758
|
printChangeSummary(changeSummary);
|
|
367
759
|
|
|
368
760
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
369
|
-
// compliance + preview URL from the MCP
|
|
370
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
371
|
-
const client = createMcpClient(baseUrl);
|
|
761
|
+
// compliance + preview URL from the MCP — reusing the session established above.
|
|
372
762
|
let progress = null;
|
|
373
763
|
try {
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
377
|
-
// Set the active tenant so preview_status/candidate_open read the right scope
|
|
378
|
-
// (both key on the session's bound tenant/app — no tenant arg of their own).
|
|
764
|
+
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
765
|
+
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
379
766
|
await client.callTool("client_switch", { tenant });
|
|
380
767
|
|
|
381
768
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
382
769
|
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
383
770
|
// reported and swallowed, never blocking the preview push that already landed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
771
|
+
//
|
|
772
|
+
// Which candidate this submit lands on (gh-pr-like):
|
|
773
|
+
// --new → fork a FRESH candidate and remember it as active;
|
|
774
|
+
// otherwise → the remembered active candidate (from a prior --new /
|
|
775
|
+
// roll), else the STABLE per-dev-per-tenant default.
|
|
776
|
+
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
777
|
+
// so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
|
|
778
|
+
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
779
|
+
const statePath = defaultCandidateStatePath(env);
|
|
780
|
+
// Branch-bound (u4): the candidate handle + active-pointer namespace fold in the
|
|
781
|
+
// current git branch on a non-default branch, so a feature branch gets its OWN
|
|
782
|
+
// candidate; the default branch keeps today's exact id (zero migration).
|
|
783
|
+
const branch = currentBranch(gitSafe);
|
|
784
|
+
const stableId = deriveChangeId(tenant, actorKeyFor(session), branch);
|
|
785
|
+
const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
786
|
+
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
787
|
+
// Persist when we diverge from the stable default (a --new fork, or a
|
|
788
|
+
// previously-remembered active pointer) so the next plain submit follows it.
|
|
789
|
+
let persist = args.new || (!!active && active !== stableId);
|
|
790
|
+
|
|
791
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
792
|
+
|
|
793
|
+
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
794
|
+
const rolled = mintFreshChangeId(stableId);
|
|
795
|
+
console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
796
|
+
changeId = rolled;
|
|
797
|
+
persist = true;
|
|
798
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
802
|
+
// never let a state-write failure break the submit).
|
|
803
|
+
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
804
|
+
try {
|
|
805
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
806
|
+
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
807
|
+
}
|
|
391
808
|
|
|
392
809
|
let status;
|
|
393
810
|
if (args.noWait) {
|
|
@@ -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
|