@tokenoftrust/cli 1.4.0-rc.20 → 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/bin/tot.mjs +52 -54
- package/package.json +6 -1
- package/src/activity.mjs +5 -4
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/commands/accept.mjs +473 -50
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +1 -0
- package/src/commands/cleanup.mjs +2 -1
- package/src/commands/clone.mjs +51 -20
- package/src/commands/dev.mjs +30 -12
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +6 -2
- package/src/commands/grants.mjs +6 -4
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +3 -4
- package/src/commands/pr.mjs +4 -3
- package/src/commands/preview.mjs +1 -1
- package/src/commands/rollback.mjs +6 -4
- package/src/commands/start.mjs +59 -11
- package/src/commands/submit.mjs +280 -51
- package/src/commands/sync.mjs +11 -0
- package/src/commands/validate.mjs +10 -4
- 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/oauth.mjs +12 -8
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +3 -3
- package/src/sample.mjs +3 -3
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
package/src/commands/submit.mjs
CHANGED
|
@@ -40,7 +40,8 @@
|
|
|
40
40
|
* Dependency-free (global fetch + `git`).
|
|
41
41
|
*/
|
|
42
42
|
import { execFileSync } from "node:child_process";
|
|
43
|
-
import { readFileSync } from "node:fs";
|
|
43
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
44
|
+
import { resolve as resolvePath } from "node:path";
|
|
44
45
|
import { createHash } from "node:crypto";
|
|
45
46
|
import { setTimeout as delay } from "node:timers/promises";
|
|
46
47
|
import { createMcpClient } from "../mcp.mjs";
|
|
@@ -55,6 +56,7 @@ import {
|
|
|
55
56
|
defaultCandidateStatePath,
|
|
56
57
|
readActiveChangeId,
|
|
57
58
|
writeActiveChangeId,
|
|
59
|
+
clearActiveChangeId,
|
|
58
60
|
mintFreshChangeId,
|
|
59
61
|
isTerminalCandidateState,
|
|
60
62
|
isDefaultBranch,
|
|
@@ -90,6 +92,8 @@ export function candidateRefFor(changeId) {
|
|
|
90
92
|
* value). Fire-and-forget best-effort: a silent no-op without a hosted-bridge
|
|
91
93
|
* credential, never awaited, never throws, never alters the command. `errorClass` is
|
|
92
94
|
* a low-cardinality class (never a raw git stderr, which can carry a token/path).
|
|
95
|
+
* @param {string} op @param {boolean} ok
|
|
96
|
+
* @param {{ command?: string, durationMs?: number, errorClass?: string }} [opts]
|
|
93
97
|
*/
|
|
94
98
|
function emitGitOp(op, ok, { command = "submit", durationMs, errorClass } = {}) {
|
|
95
99
|
void emitActivity({
|
|
@@ -103,7 +107,8 @@ export function parseArgs(argv) {
|
|
|
103
107
|
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
104
108
|
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
105
109
|
// never a fixed shared default.
|
|
106
|
-
|
|
110
|
+
/** @type {{ mcp: string|null, identity: string|null, ref: string|null, skipValidate: boolean, skipFreshness: boolean, noWait: boolean, watch: boolean, noOpen: boolean, noCommit: boolean, message: string|null, summary: string|null, summaryFile: string|null, json: boolean, new: boolean, help: boolean }} */
|
|
111
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, skipFreshness: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, summaryFile: null, json: false, new: false, help: false };
|
|
107
112
|
for (let i = 0; i < argv.length; i++) {
|
|
108
113
|
const t = argv[i];
|
|
109
114
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -114,6 +119,7 @@ export function parseArgs(argv) {
|
|
|
114
119
|
else if (t === "--summary-file") a.summaryFile = argv[++i];
|
|
115
120
|
else if (t === "--json") a.json = true;
|
|
116
121
|
else if (t === "--skip-validate") a.skipValidate = true;
|
|
122
|
+
else if (t === "--skip-freshness") a.skipFreshness = true;
|
|
117
123
|
else if (t === "--no-commit") a.noCommit = true;
|
|
118
124
|
else if (t === "--no-wait") a.noWait = true;
|
|
119
125
|
else if (t === "--watch") a.watch = true;
|
|
@@ -137,6 +143,8 @@ export function renderUsage(verb = "preview") {
|
|
|
137
143
|
tot ${verb} --new open a NEW candidate PR instead of updating your open one
|
|
138
144
|
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
139
145
|
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
146
|
+
tot ${verb} --skip-freshness skip the stale-base check (not recommended — may build a
|
|
147
|
+
candidate rooted in an already-superseded base)
|
|
140
148
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
141
149
|
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
142
150
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
@@ -159,6 +167,9 @@ export function renderUsage(verb = "preview") {
|
|
|
159
167
|
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
160
168
|
merged or closed, a re-run automatically opens a fresh one.
|
|
161
169
|
|
|
170
|
+
Working on one thing at a time? You don't need --new, a git branch, or any
|
|
171
|
+
branch management at all — just keep editing and re-running \`tot ${verb}\`.
|
|
172
|
+
|
|
162
173
|
Once a preview reconciles cleanly, \`tot ship\` promotes it live.
|
|
163
174
|
|
|
164
175
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
@@ -326,6 +337,55 @@ export function readSummaryFileContent(pathOrDash) {
|
|
|
326
337
|
return readFileSync(pathOrDash, "utf8");
|
|
327
338
|
}
|
|
328
339
|
|
|
340
|
+
// ─── stale-base freshness preflight (unit u16) ──────────────────────────────────
|
|
341
|
+
|
|
342
|
+
/** The candidate base branch every submit targets (mirrors tot-mcp's own
|
|
343
|
+
* `DEFAULT_CANDIDATE_BASE` and sync.mjs's `DEFAULT_SYNC_BRANCH` — the SAME
|
|
344
|
+
* protected branch by three different names in three different modules; kept
|
|
345
|
+
* a local constant here, not imported, since this file already resolves its
|
|
346
|
+
* own defaults independently of sync.mjs and candidate_open's server default). */
|
|
347
|
+
export const FRESHNESS_BASE_BRANCH = "preview";
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Detect a STALE local view of the base branch before minting a candidate —
|
|
351
|
+
* the live-repeat incident this guards against: the checkout's `origin/preview`
|
|
352
|
+
* tracking ref was stale (recorded before a just-merged PR moved it), so a
|
|
353
|
+
* fresh `tot preview --new` built a candidate rooted in the OLD tip and got an
|
|
354
|
+
* instant, entirely avoidable "not mergeable" the moment it was compared
|
|
355
|
+
* against the real, already-advanced `preview`.
|
|
356
|
+
*
|
|
357
|
+
* Compares what the LOCAL checkout believes the base's tip is
|
|
358
|
+
* (`refs/remotes/origin/<branch>`, only as fresh as the last explicit fetch)
|
|
359
|
+
* against its ACTUAL current tip on the forge (`git ls-remote`, a lightweight
|
|
360
|
+
* single-ref read — no full fetch, no local ref mutated). Returns the live
|
|
361
|
+
* remote sha when local's cached view is behind it, or `null` when: the two
|
|
362
|
+
* already agree, there is no local tracking ref to compare against yet (a
|
|
363
|
+
* checkout that has simply never fetched this branch — never a false block on
|
|
364
|
+
* that), or the remote can't be reached right now (a network hiccup must
|
|
365
|
+
* never block a submit that would otherwise succeed; the push itself is the
|
|
366
|
+
* real connectivity test). Pure git I/O via the injected runner — unit-tested.
|
|
367
|
+
* @param {(cargs:string[])=>string} git
|
|
368
|
+
* @param {string} branch
|
|
369
|
+
* @returns {string|null}
|
|
370
|
+
*/
|
|
371
|
+
export function detectStaleBase(git, branch) {
|
|
372
|
+
let localSha = "";
|
|
373
|
+
try {
|
|
374
|
+
localSha = git(["rev-parse", "-q", "--verify", `refs/remotes/origin/${branch}`]).trim();
|
|
375
|
+
} catch {
|
|
376
|
+
return null; // never fetched this branch locally — nothing cached to be stale
|
|
377
|
+
}
|
|
378
|
+
if (!localSha) return null;
|
|
379
|
+
let remoteSha = "";
|
|
380
|
+
try {
|
|
381
|
+
remoteSha = (git(["ls-remote", "origin", branch]).split(/\s+/)[0] || "").trim();
|
|
382
|
+
} catch {
|
|
383
|
+
return null; // can't reach the remote right now — don't block on a network hiccup
|
|
384
|
+
}
|
|
385
|
+
if (!remoteSha || remoteSha === localSha) return null;
|
|
386
|
+
return remoteSha;
|
|
387
|
+
}
|
|
388
|
+
|
|
329
389
|
// ─── auto-commit the known content trees (unit u2) ───────────────────────────────
|
|
330
390
|
|
|
331
391
|
/**
|
|
@@ -410,6 +470,59 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
|
|
|
410
470
|
return body.length ? `${subject}\n\n${body.join("\n")}` : subject;
|
|
411
471
|
}
|
|
412
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Detect an in-progress rebase, merge, or cherry-pick in the workspace (unit u8) —
|
|
475
|
+
* `tot preview`/`tot submit` must NEVER auto-commit over one of these. A rebase that
|
|
476
|
+
* stopped at "Could not apply" (or a merge/cherry-pick left with real conflicts) IS
|
|
477
|
+
* a dirty tree from `git status`'s point of view, so `autoCommitKnownTrees` would
|
|
478
|
+
* otherwise stage + commit the half-resolved content straight into a plain "content
|
|
479
|
+
* update" commit — silently finishing the git operation WRONG and losing whatever
|
|
480
|
+
* edit was still sitting in conflict markers or unresolved hunks (the live incident
|
|
481
|
+
* this guards against: a stalled rebase was never continued, `tot preview` ran
|
|
482
|
+
* anyway, and the developer's own edit was gone). Detection is worktree-safe:
|
|
483
|
+
* MERGE_HEAD/CHERRY_PICK_HEAD via plumbing refs (no direct `.git` path assumption),
|
|
484
|
+
* and rebase-merge/rebase-apply via `--git-path` (a linked worktree's git-dir lives
|
|
485
|
+
* OUTSIDE `<workspace>/.git`, so a literal `.git/rebase-merge` check would miss it).
|
|
486
|
+
* Returns which operation is in progress, or null when the tree is clean of one.
|
|
487
|
+
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
488
|
+
* @param {string} workspace absolute path `git` runs `-C` against — `--git-path`'s
|
|
489
|
+
* output may be relative, and Node's `existsSync` resolves relative paths against
|
|
490
|
+
* the CLI process's own cwd, not the checkout, so this pins the resolution base.
|
|
491
|
+
* @returns {"rebase"|"merge"|"cherry-pick"|null}
|
|
492
|
+
*/
|
|
493
|
+
export function detectInProgressGitOperation(git, workspace) {
|
|
494
|
+
const hasRef = (name) => {
|
|
495
|
+
try {
|
|
496
|
+
return git(["rev-parse", "-q", "--verify", name]).trim().length > 0;
|
|
497
|
+
} catch {
|
|
498
|
+
return false;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
if (hasRef("MERGE_HEAD")) return "merge";
|
|
502
|
+
if (hasRef("CHERRY_PICK_HEAD")) return "cherry-pick";
|
|
503
|
+
const hasGitPath = (name) => {
|
|
504
|
+
try {
|
|
505
|
+
const p = git(["rev-parse", "--git-path", name]).trim();
|
|
506
|
+
// An empty result should never happen for a real `--git-path` (it always
|
|
507
|
+
// echoes SOME path, existing or not) — but treat it as "absent" rather than
|
|
508
|
+
// resolving it, since `resolvePath(workspace, "")` degrades to `workspace`
|
|
509
|
+
// itself, which trivially always exists (a false "rebase in progress" on
|
|
510
|
+
// every call, not just an occasional false negative).
|
|
511
|
+
return p.length > 0 && existsSync(resolvePath(workspace, p));
|
|
512
|
+
} catch {
|
|
513
|
+
return false;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
if (hasGitPath("rebase-merge") || hasGitPath("rebase-apply")) return "rebase";
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** The abort command that recovers from each in-progress git operation, so the
|
|
521
|
+
* refusal below can tell a developer exactly what to run. Pure. */
|
|
522
|
+
export function abortCommandFor(op) {
|
|
523
|
+
return op === "merge" ? "git merge --abort" : op === "cherry-pick" ? "git cherry-pick --abort" : "git rebase --abort";
|
|
524
|
+
}
|
|
525
|
+
|
|
413
526
|
/**
|
|
414
527
|
* Auto-commit the known content trees before previewing (unit u2). On a DIRTY
|
|
415
528
|
* tree `tot preview` commits your content edits for you, so a preview always
|
|
@@ -420,16 +533,21 @@ export function buildAutoCommitMessage({ message, files = [], statLine = "" } =
|
|
|
420
533
|
* (preview whatever's already committed — u1's behavior).
|
|
421
534
|
*
|
|
422
535
|
* Returns exactly one of:
|
|
423
|
-
* { skipped: true }
|
|
424
|
-
* {
|
|
425
|
-
*
|
|
426
|
-
*
|
|
536
|
+
* { skipped: true } — --no-commit.
|
|
537
|
+
* { inProgress: "rebase"|… } — a rebase/merge/cherry-pick is unresolved;
|
|
538
|
+
* caller refuses rather than auto-committing
|
|
539
|
+
* over the developer's own half-resolved tree.
|
|
540
|
+
* { clean: true } — nothing dirty; preview HEAD as-is.
|
|
541
|
+
* { refused, unknown, known } — out-of-scope dirt; caller refuses + hints.
|
|
542
|
+
* { committed: true, sha, files } — staged the known dirty paths and committed.
|
|
427
543
|
*
|
|
428
544
|
* @param {(cargs:string[])=>string} git a `git -C <workspace>` runner
|
|
429
|
-
* @param {{ message?: string|null, noCommit?: boolean }} [opts]
|
|
545
|
+
* @param {{ message?: string|null, noCommit?: boolean, workspace?: string }} [opts]
|
|
430
546
|
*/
|
|
431
|
-
export function autoCommitKnownTrees(git, { message = null, noCommit = false } = {}) {
|
|
547
|
+
export function autoCommitKnownTrees(git, { message = null, noCommit = false, workspace = "." } = {}) {
|
|
432
548
|
if (noCommit) return { skipped: true };
|
|
549
|
+
const inProgress = detectInProgressGitOperation(git, workspace);
|
|
550
|
+
if (inProgress) return { inProgress };
|
|
433
551
|
const status = git(["-c", "core.quotePath=false", "status", "--porcelain", "--untracked-files=all"]);
|
|
434
552
|
const dirty = parsePorcelainPaths(status);
|
|
435
553
|
if (dirty.length === 0) return { clean: true };
|
|
@@ -484,16 +602,17 @@ export function parseNameStatus(text) {
|
|
|
484
602
|
* @returns {Array<{path: string, content?: string, contentEncoding?: "base64", delete?: true}>}
|
|
485
603
|
*/
|
|
486
604
|
export function buildFilePatch(entries, readBlob) {
|
|
605
|
+
/** @type {Array<{ path: string, content?: string, contentEncoding?: "base64", delete?: true }>} */
|
|
487
606
|
const patch = [];
|
|
488
607
|
for (const e of entries) {
|
|
489
|
-
if (e.status === "R") patch.push({ path: e.from, delete: true });
|
|
608
|
+
if (e.status === "R") patch.push({ path: /** @type {string} */ (e.from), delete: true });
|
|
490
609
|
if (e.status === "D") {
|
|
491
610
|
patch.push({ path: e.path, delete: true });
|
|
492
611
|
continue;
|
|
493
612
|
}
|
|
494
613
|
const buf = readBlob(e.path);
|
|
495
614
|
const asUtf8 = buf.toString("utf8");
|
|
496
|
-
const isCleanUtf8 = !asUtf8.includes("
|
|
615
|
+
const isCleanUtf8 = !asUtf8.includes("\x00") && Buffer.from(asUtf8, "utf8").equals(buf);
|
|
497
616
|
patch.push(
|
|
498
617
|
isCleanUtf8
|
|
499
618
|
? { path: e.path, content: asUtf8 }
|
|
@@ -523,41 +642,14 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
523
642
|
|
|
524
643
|
// ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
|
|
525
644
|
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
535
|
-
*/
|
|
536
|
-
export function splitAuthedRemote(remoteUrl) {
|
|
537
|
-
try {
|
|
538
|
-
const u = new URL(String(remoteUrl));
|
|
539
|
-
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
540
|
-
if (!token) return null;
|
|
541
|
-
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
542
|
-
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
543
|
-
} catch {
|
|
544
|
-
return null;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
/**
|
|
549
|
-
* The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
|
|
550
|
-
* invocation (base64 of `user:token`) — so a freshly-minted forge token
|
|
551
|
-
* authenticates one push without ever being written to `.git/config`. Pure —
|
|
552
|
-
* unit-tested.
|
|
553
|
-
* @param {string} username
|
|
554
|
-
* @param {string} token
|
|
555
|
-
* @returns {string}
|
|
556
|
-
*/
|
|
557
|
-
export function basicAuthExtraHeader(username, token) {
|
|
558
|
-
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
559
|
-
return `Authorization: Basic ${b64}`;
|
|
560
|
-
}
|
|
645
|
+
// splitAuthedRemote / basicAuthExtraHeader now live in ../git-credential.mjs (unit
|
|
646
|
+
// u10) — a dependency-free module BOTH this file and clone.mjs/commands/
|
|
647
|
+
// git-credential.mjs need, so they moved out of here to avoid a submit.mjs ↔
|
|
648
|
+
// clone.mjs import cycle. Re-exported so every existing import of these two names
|
|
649
|
+
// FROM submit.mjs (this file's own callers below, plus tests) keeps working
|
|
650
|
+
// unchanged.
|
|
651
|
+
export { splitAuthedRemote, basicAuthExtraHeader } from "../git-credential.mjs";
|
|
652
|
+
import { splitAuthedRemote, basicAuthExtraHeader, ensureTokenlessRemote } from "../git-credential.mjs";
|
|
561
653
|
|
|
562
654
|
/**
|
|
563
655
|
* Recognise a forge auth failure (expired / invalid push token) in a failed git
|
|
@@ -605,7 +697,7 @@ export function tagFromRepoName(repoName, tenant) {
|
|
|
605
697
|
*
|
|
606
698
|
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
607
699
|
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
608
|
-
* @param {{ ref
|
|
700
|
+
* @param {{ ref?: string }} [opts]
|
|
609
701
|
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
610
702
|
*/
|
|
611
703
|
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
@@ -710,6 +802,59 @@ export function chooseChangeId({ tenant, actorKey, branch = null, active = null,
|
|
|
710
802
|
return { changeId, stableId, persist };
|
|
711
803
|
}
|
|
712
804
|
|
|
805
|
+
/**
|
|
806
|
+
* The forge state of ONE candidate (`"open"`/`"merged"`/`"closed"`/…), read by
|
|
807
|
+
* changeId via `candidate_status`, or null when it can't be POSITIVELY determined —
|
|
808
|
+
* the candidate isn't found, carries no state, or the read throws. null ("couldn't
|
|
809
|
+
* tell") is the deliberately SAFE answer: the caller (resolveActivePointer) then
|
|
810
|
+
* behaves exactly as if the pointer were still live, so a purely-diagnostic check
|
|
811
|
+
* that can't run never blocks, changes, or crashes a submit (u17 acceptance #3).
|
|
812
|
+
* Tolerates the tool returning a bare candidate, a `{candidates:[…]}` list, or a
|
|
813
|
+
* plain array. Injectable client for tests.
|
|
814
|
+
* @param {{callTool:Function}} client
|
|
815
|
+
* @param {{ repo: string, changeId: string }} opts
|
|
816
|
+
* @returns {Promise<string|null>}
|
|
817
|
+
*/
|
|
818
|
+
export async function candidateStateFor(client, { repo, changeId }) {
|
|
819
|
+
try {
|
|
820
|
+
const r = await client.callTool("candidate_status", { repo, changeId });
|
|
821
|
+
const c = Array.isArray(r)
|
|
822
|
+
? r.find((x) => x?.changeId === changeId)
|
|
823
|
+
: Array.isArray(r?.candidates)
|
|
824
|
+
? r.candidates.find((x) => x?.changeId === changeId)
|
|
825
|
+
: r;
|
|
826
|
+
return c && typeof c.state === "string" ? c.state : null;
|
|
827
|
+
} catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* u17 — before REUSING a remembered active-candidate pointer, confirm its PR is
|
|
834
|
+
* still open. The live incident this guards against: after a candidate PR merged, a
|
|
835
|
+
* plain `tot preview` reused the remembered pointer, force-pushed onto the now-DEAD
|
|
836
|
+
* candidate branch (stale old-base history), and `candidate_open` opened a NEW PR
|
|
837
|
+
* from it — inheriting a guaranteed conflict from the very first commit. When the
|
|
838
|
+
* pointer's PR has gone terminal (merged/closed) we DROP it here, so `chooseChangeId`
|
|
839
|
+
* falls back to the stable per-branch id exactly as if no pointer existed.
|
|
840
|
+
*
|
|
841
|
+
* PURELY DIAGNOSTIC — never blocks a submit over the check itself: no pointer, no
|
|
842
|
+
* repo, or a check that errors / can't positively confirm terminal all resolve to
|
|
843
|
+
* `{ active }` UNCHANGED (behave exactly as before). Only a POSITIVELY terminal
|
|
844
|
+
* state drops the pointer. When it does, `dropped` carries the old changeId + the
|
|
845
|
+
* terminal state so the caller can tell the operator and forget the on-disk pointer.
|
|
846
|
+
* Injectable client for tests.
|
|
847
|
+
* @param {{callTool:Function}} client
|
|
848
|
+
* @param {{ repo: string|null, active: string|null }} opts
|
|
849
|
+
* @returns {Promise<{ active: string|null, dropped?: { changeId: string, state: string } }>}
|
|
850
|
+
*/
|
|
851
|
+
export async function resolveActivePointer(client, { repo, active }) {
|
|
852
|
+
if (!active || !repo) return { active };
|
|
853
|
+
const state = await candidateStateFor(client, { repo, changeId: active });
|
|
854
|
+
if (isTerminalCandidateState(state)) return { active: null, dropped: { changeId: active, state: /** @type {string} */ (state) } };
|
|
855
|
+
return { active };
|
|
856
|
+
}
|
|
857
|
+
|
|
713
858
|
/**
|
|
714
859
|
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
715
860
|
* SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
|
|
@@ -836,7 +981,7 @@ export function buildJsonResult({ ok, ref = null, commit = null, changeId = null
|
|
|
836
981
|
shipped: status?.shipped ?? null,
|
|
837
982
|
dispatched: status?.dispatched ?? null,
|
|
838
983
|
notDispatched: status?.notDispatched ?? false,
|
|
839
|
-
forwardFailed: status?.forwardFailed ?? false,
|
|
984
|
+
forwardFailed: /** @type {any} */ (status)?.forwardFailed ?? false,
|
|
840
985
|
delivery: status?.delivery ?? null,
|
|
841
986
|
previewPrUrl,
|
|
842
987
|
...(error ? { error } : {}),
|
|
@@ -899,6 +1044,39 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
899
1044
|
const tenant = ctx.tenant;
|
|
900
1045
|
const git = (cargs) => execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
901
1046
|
|
|
1047
|
+
// Self-heal a LEGACY checkout (unit u10): strip any token still embedded in
|
|
1048
|
+
// `origin`'s URL and install the credential helper, so this run (and every one
|
|
1049
|
+
// after) mints fresh creds through `tot` instead of relying on one that quietly
|
|
1050
|
+
// expired. Best-effort — never blocks the actual preview on a migration hiccup.
|
|
1051
|
+
try {
|
|
1052
|
+
ensureTokenlessRemote(git);
|
|
1053
|
+
} catch {
|
|
1054
|
+
/* best-effort — see above */
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Freshness preflight (unit u16) — BEFORE minting anything: is the checkout's
|
|
1058
|
+
// cached view of the base branch already behind the store? A candidate built
|
|
1059
|
+
// on a stale base is an instant, avoidable "not mergeable" the moment the
|
|
1060
|
+
// forge compares it against the real (already-advanced) base. --skip-freshness
|
|
1061
|
+
// opts out (e.g. offline/CI, or a deliberate re-run against a known-good tip).
|
|
1062
|
+
if (!args.skipFreshness) {
|
|
1063
|
+
let staleTip = null;
|
|
1064
|
+
try {
|
|
1065
|
+
staleTip = detectStaleBase(git, FRESHNESS_BASE_BRANCH);
|
|
1066
|
+
} catch {
|
|
1067
|
+
staleTip = null; // never block a submit on the preflight's OWN failure
|
|
1068
|
+
}
|
|
1069
|
+
if (staleTip) {
|
|
1070
|
+
const msg = `your checkout is behind the store — "${FRESHNESS_BASE_BRANCH}" has moved since your last sync`;
|
|
1071
|
+
console.error(
|
|
1072
|
+
fail(msg, `tot sync (fetches the latest ${FRESHNESS_BASE_BRANCH} + merges it into your branch, then re-run \`tot ${verb}\`) — or re-run with --skip-freshness to build anyway`),
|
|
1073
|
+
);
|
|
1074
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1075
|
+
return 1;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
|
|
902
1080
|
// 0. auto-commit the known content trees (unit u2) — on a dirty tree, commit your
|
|
903
1081
|
// content edits BEFORE previewing so the preview reflects your working changes.
|
|
904
1082
|
// Only content/, public/, theme.json, .tot/ (staged by explicit path, never
|
|
@@ -906,7 +1084,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
906
1084
|
// --no-commit opts out (preview whatever's already committed).
|
|
907
1085
|
let auto;
|
|
908
1086
|
try {
|
|
909
|
-
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit });
|
|
1087
|
+
auto = autoCommitKnownTrees(git, { message: args.message, noCommit: args.noCommit, workspace });
|
|
910
1088
|
} catch (e) {
|
|
911
1089
|
emitGitOp("commit", false, { command: verb, errorClass: "git_commit_failed" });
|
|
912
1090
|
const msg = `auto-commit failed: ${String(e?.stderr || e?.message || e)}`;
|
|
@@ -915,6 +1093,20 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
915
1093
|
return 1;
|
|
916
1094
|
}
|
|
917
1095
|
if (auto.committed) emitGitOp("commit", true, { command: verb });
|
|
1096
|
+
if (auto.inProgress) {
|
|
1097
|
+
const abortCmd = abortCommandFor(auto.inProgress);
|
|
1098
|
+
const msg = `a ${auto.inProgress} is still in progress here`;
|
|
1099
|
+
console.error(
|
|
1100
|
+
fail(
|
|
1101
|
+
msg,
|
|
1102
|
+
`finish it (resolve + continue) or back out (\`${abortCmd}\`), then re-run \`tot ${verb}\` — `
|
|
1103
|
+
+ "auto-committing over an unresolved rebase/merge/cherry-pick would fold your half-resolved "
|
|
1104
|
+
+ "tree into a plain content commit and can lose whatever edit was still unresolved",
|
|
1105
|
+
),
|
|
1106
|
+
);
|
|
1107
|
+
emitJson(args, buildJsonResult({ ok: false, error: msg }));
|
|
1108
|
+
return 1;
|
|
1109
|
+
}
|
|
918
1110
|
if (auto.refused) {
|
|
919
1111
|
console.error(
|
|
920
1112
|
fail(
|
|
@@ -937,6 +1129,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
937
1129
|
// 1. validate locally — refuse on errors.
|
|
938
1130
|
if (!args.skipValidate) {
|
|
939
1131
|
const { ok, findings } = validateTenant(workspace, { tenantId: tenant, scope: ctx.config?.scope });
|
|
1132
|
+
// Advisory but LOUD: git conflict markers must never slip past as "validated"
|
|
1133
|
+
// (the half-resolved-rebase incident). Surfaced on the ok path too — warnings
|
|
1134
|
+
// are otherwise swallowed here — but they never block the submit.
|
|
1135
|
+
const conflicts = findings.filter((f) => f.rule === "git-conflict-markers");
|
|
1136
|
+
if (conflicts.length) {
|
|
1137
|
+
console.error(`\n⚠ git conflict markers in submitted content (${conflicts.length} file(s)) — an unfinished merge/rebase?`);
|
|
1138
|
+
for (const f of conflicts) console.error(` ⚠ ${f.file} — ${f.message}`);
|
|
1139
|
+
console.error(" The preview will still build, but it will serve the broken markers. Resolve before shipping.\n");
|
|
1140
|
+
}
|
|
940
1141
|
if (!ok) {
|
|
941
1142
|
const errs = findings.filter((f) => f.level === ERROR);
|
|
942
1143
|
console.error(
|
|
@@ -1008,7 +1209,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1008
1209
|
// needed — so they're available even on the no-session fallback path below.
|
|
1009
1210
|
const branch = currentBranch(gitSafe);
|
|
1010
1211
|
const statePath = defaultCandidateStatePath(env);
|
|
1011
|
-
|
|
1212
|
+
let active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
1012
1213
|
|
|
1013
1214
|
let session;
|
|
1014
1215
|
try {
|
|
@@ -1059,6 +1260,30 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
1059
1260
|
return 0;
|
|
1060
1261
|
}
|
|
1061
1262
|
|
|
1263
|
+
// u17 — before REUSING a remembered active pointer, confirm its PR is still open.
|
|
1264
|
+
// If it merged/closed we DROP it (and forget it on disk) so chooseChangeId falls
|
|
1265
|
+
// back to the stable id, rather than force-pushing onto a now-dead candidate branch
|
|
1266
|
+
// and opening a NEW PR that inherits a guaranteed conflict (the live incident this
|
|
1267
|
+
// guards against). Skipped under --new (chooseChangeId ignores `active` there
|
|
1268
|
+
// anyway). Purely diagnostic: a check that errors leaves the pointer untouched.
|
|
1269
|
+
// Needs the tenant scope bound for candidate_status to resolve — idempotent with
|
|
1270
|
+
// the later client_switch / the fresh-mint checkoutTenant.
|
|
1271
|
+
if (active && repo && !args.new) {
|
|
1272
|
+
try {
|
|
1273
|
+
await client.callTool("client_switch", { tenant });
|
|
1274
|
+
} catch { /* scope bind is best-effort; candidateStateFor tolerates a miss */ }
|
|
1275
|
+
const resolved = await resolveActivePointer(client, { repo, active });
|
|
1276
|
+
if (resolved.dropped) {
|
|
1277
|
+
console.error(
|
|
1278
|
+
`~ remembered candidate ${resolved.dropped.changeId} is ${resolved.dropped.state} — dropping it and submitting fresh (a ${resolved.dropped.state} PR can't be reused).`,
|
|
1279
|
+
);
|
|
1280
|
+
try {
|
|
1281
|
+
clearActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch });
|
|
1282
|
+
} catch { /* best-effort local cleanup — a miss just re-checks next run */ }
|
|
1283
|
+
}
|
|
1284
|
+
active = resolved.active;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1062
1287
|
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
1063
1288
|
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
1064
1289
|
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
@@ -1372,7 +1597,7 @@ export function shareablePrUrl(base, tenant, prNumber) {
|
|
|
1372
1597
|
* @returns {string}
|
|
1373
1598
|
*/
|
|
1374
1599
|
export function describeReadbackError(e) {
|
|
1375
|
-
const msg = String(e?.message || e || "");
|
|
1600
|
+
const msg = String(/** @type {any} */ (e)?.message || e || "");
|
|
1376
1601
|
const jsonStart = msg.indexOf("{");
|
|
1377
1602
|
if (jsonStart >= 0 && msg.includes("self_repair")) {
|
|
1378
1603
|
try {
|
|
@@ -1428,7 +1653,7 @@ export function formatShareableUrlBlock(s, tenant) {
|
|
|
1428
1653
|
* back / re-submit" lie for the dead-end case: re-submitting cannot help, so we say
|
|
1429
1654
|
* what actually happened and what to do, and never recommend another submit. Pure —
|
|
1430
1655
|
* unit-tested. `verb` brands the copy with whatever the developer typed.
|
|
1431
|
-
* @param {{ commit?: string|null, ref?: string|null }} ctx
|
|
1656
|
+
* @param {{ commit?: string|null, ref?: string|null, noChanges?: boolean }} ctx
|
|
1432
1657
|
* @param {string} tenant @param {string} [verb]
|
|
1433
1658
|
* @returns {string[]}
|
|
1434
1659
|
*/
|
|
@@ -1485,6 +1710,10 @@ export function formatForwardFailedBlock({ commit = null } = {}, tenant) {
|
|
|
1485
1710
|
* --json — automation doesn't want a browser popping up). `commit`/`ref`/`verb`
|
|
1486
1711
|
* feed the honest never-dispatched block.
|
|
1487
1712
|
*/
|
|
1713
|
+
/**
|
|
1714
|
+
* @param {any} s @param {string} tenant
|
|
1715
|
+
* @param {{ open?: boolean, quiet?: boolean, commit?: string|null, ref?: string|null, verb?: string, noChanges?: boolean }} [opts]
|
|
1716
|
+
*/
|
|
1488
1717
|
function reportStatus(s, tenant, { open = true, quiet = false, commit = null, ref = null, verb = "preview", noChanges = false } = {}) {
|
|
1489
1718
|
if (!s || s.status === "unknown") {
|
|
1490
1719
|
if (!quiet) {
|
package/src/commands/sync.mjs
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
import { execFileSync } from "node:child_process";
|
|
33
33
|
import { fail } from "../errors.mjs";
|
|
34
|
+
import { ensureTokenlessRemote } from "../git-credential.mjs";
|
|
34
35
|
|
|
35
36
|
/** The protected branch `tot sync` fetches + merges from by default. */
|
|
36
37
|
export const DEFAULT_SYNC_BRANCH = "preview";
|
|
@@ -159,6 +160,16 @@ export async function run(argv, ctx) {
|
|
|
159
160
|
}
|
|
160
161
|
};
|
|
161
162
|
|
|
163
|
+
// Self-heal a LEGACY checkout (unit u10, absorbs u7): `tot login` refreshes
|
|
164
|
+
// this CLI's own session, never the token baked into a checkout's remote at
|
|
165
|
+
// clone time — the exact reason a stale checkout's `git fetch origin` (below)
|
|
166
|
+
// used to 401 even right after signing back in. Best-effort, never blocks sync.
|
|
167
|
+
try {
|
|
168
|
+
ensureTokenlessRemote(git);
|
|
169
|
+
} catch {
|
|
170
|
+
/* best-effort — see above */
|
|
171
|
+
}
|
|
172
|
+
|
|
162
173
|
// Refuse a dirty tree up front — a merge on top of uncommitted edits is how
|
|
163
174
|
// local work gets silently entangled with the merge, and we never sweep
|
|
164
175
|
// anything in with `git add -A`. Commit or stash first, then re-run.
|
|
@@ -71,12 +71,13 @@ export function run(argv, ctx) {
|
|
|
71
71
|
console.error(fail(target.error, "tot clone <tenant>, or pass --workspace <dir>"));
|
|
72
72
|
return 2;
|
|
73
73
|
}
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
const dir = /** @type {string} */ (target.dir);
|
|
75
|
+
if (!existsSync(dir)) {
|
|
76
|
+
console.error(fail(`no tenant directory at ${dir}`, "confirm the path, or `tot clone <tenant>`"));
|
|
76
77
|
return 2;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
const { ok, findings } = validateTenant(
|
|
80
|
+
const { ok, findings } = validateTenant(dir, {
|
|
80
81
|
tenantId: target.tenantId ?? undefined,
|
|
81
82
|
scope: target.scope ?? undefined,
|
|
82
83
|
// A checkout / bare-tenant target is served by the ToT storefront platform, so
|
|
@@ -92,7 +93,12 @@ export function run(argv, ctx) {
|
|
|
92
93
|
|
|
93
94
|
const errors = findings.filter((f) => f.level === ERROR);
|
|
94
95
|
const warns = findings.filter((f) => f.level === WARN);
|
|
95
|
-
|
|
96
|
+
// Never present the checkout PATH as if it were the tenant name — when the
|
|
97
|
+
// tenant couldn't be resolved (e.g. an invalid/missing .tot/config.json),
|
|
98
|
+
// the findings below say why; the header should say so too, not disguise
|
|
99
|
+
// a directory as a domain.
|
|
100
|
+
const label = target.tenantId ? target.tenantId : `(tenant unresolved) ${target.dir}`;
|
|
101
|
+
console.log(`\ntot validate — ${label}\n`);
|
|
96
102
|
for (const f of findings) {
|
|
97
103
|
const tag = f.level === ERROR ? "✗" : "⚠";
|
|
98
104
|
console.log(` ${tag} [${f.rule}] ${f.file}\n ${f.message}${f.fix ? `\n → ${f.fix}` : ""}`);
|
package/src/dev-heartbeat.mjs
CHANGED
|
@@ -35,7 +35,8 @@ const OS_INFO = { os: os.platform(), osVersion: os.release(), arch: os.arch() };
|
|
|
35
35
|
* an activity URL and a bearer token. Never throws — a failed/offline hosted
|
|
36
36
|
* worker just means the cockpit doesn't light up this beat.
|
|
37
37
|
* @param {{ activityUrl?: string, token?: string, url?: string,
|
|
38
|
-
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null
|
|
38
|
+
* cliVersion?: string, runnerVersion?: string|null, editor?: string|null,
|
|
39
|
+
* cwd?: string|null }} args
|
|
39
40
|
*/
|
|
40
41
|
export function postHeartbeat({ activityUrl, token, url, cliVersion, runnerVersion, editor, cwd } = {}) {
|
|
41
42
|
if (!activityUrl || !token) return undefined;
|
package/src/errors.mjs
CHANGED
|
@@ -32,13 +32,17 @@ function versionFooter() {
|
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* A failure worth surfacing with a concrete next step.
|
|
35
|
+
* @typedef {object} CliErrorOpts
|
|
36
|
+
* @property {string} [next] - the exact command (or one-line instruction) to run next.
|
|
37
|
+
* @property {number} [exitCode] - process exit code to use (default 1).
|
|
38
|
+
* @property {unknown} [cause]
|
|
39
|
+
*
|
|
35
40
|
* @param {string} what - what went wrong, in plain words.
|
|
36
|
-
* @param {
|
|
37
|
-
* next - the exact command (or one-line instruction) to run next.
|
|
38
|
-
* exitCode - process exit code to use (default 1).
|
|
41
|
+
* @param {CliErrorOpts} [opts]
|
|
39
42
|
*/
|
|
40
43
|
export class CliError extends Error {
|
|
41
|
-
constructor(what,
|
|
44
|
+
constructor(what, opts = {}) {
|
|
45
|
+
const { next, exitCode = 1, cause } = opts;
|
|
42
46
|
super(what);
|
|
43
47
|
this.name = "CliError";
|
|
44
48
|
this.what = what;
|