@tokenoftrust/cli 1.4.0-rc.10 → 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 +7 -4
- package/bin/tot.mjs +2 -2
- package/package.json +1 -1
- package/src/candidate-state.mjs +56 -16
- package/src/commands/pr.mjs +21 -7
- package/src/commands/ship.mjs +640 -26
- package/src/commands/submit.mjs +224 -23
package/README.md
CHANGED
|
@@ -9,7 +9,8 @@ tot clone # list the stores you can build on
|
|
|
9
9
|
tot clone <tenant> my-store # mirrors `git clone`; dir defaults to <tenant>
|
|
10
10
|
cd my-store
|
|
11
11
|
tot dev # run it locally with save→reload — no Docker needed
|
|
12
|
-
tot
|
|
12
|
+
tot preview # bundle your edits into a compliance-reviewed preview
|
|
13
|
+
tot ship # promote a reconciled preview live (diff + one confirm)
|
|
13
14
|
```
|
|
14
15
|
|
|
15
16
|
**Prerequisites: Node.js and an invite. Nothing else.** `tot dev` downloads the
|
|
@@ -23,9 +24,11 @@ be fetched.)
|
|
|
23
24
|
| Command | Status | What it does |
|
|
24
25
|
| --- | --- | --- |
|
|
25
26
|
| `tot clone [<tenant>] [<dir>]` | **built** | Clone a store you're entitled to build on (mirrors `git clone`), with an authenticated remote configured. Dir defaults to `<tenant>`. No arg → list your stores. |
|
|
26
|
-
| `tot validate` | next | Lint your store before you
|
|
27
|
+
| `tot validate` | next | Lint your store before you preview. |
|
|
27
28
|
| `tot dev` | **built** | Run your store locally with save→reload — NATIVELY (no Docker; falls back to it with `--docker` or automatically if the native artifact isn't available). |
|
|
28
|
-
| `tot
|
|
29
|
+
| `tot preview` | **built** | Bundle your edits into a compliance-reviewed preview (validates, auto-commits the known content trees, pushes the preview ref, opens/updates a candidate PR, reports the reconcile/compliance verdict + preview URL). `tot submit` / `tot deploy` still work as teaching aliases for this same flow. |
|
|
30
|
+
| `tot ship` | **built** | Promote a reconciled preview live: always shows a diff-vs-live and asks for one `[y/N]` confirm (no `--yes`, refuses outside a terminal); records an approval request if you're not authorised to ship yourself. |
|
|
31
|
+
| `tot pr [list\|view\|close]` | **built** | See and manage the candidate PRs `tot preview` opens (`gh pr`-shaped). |
|
|
29
32
|
| `tot doctor` | built | Check this machine is ready and show which context `tot` detected. |
|
|
30
33
|
|
|
31
34
|
## Context-aware
|
|
@@ -42,7 +45,7 @@ The same `tot` does the right thing wherever you run it (walks up like `git`):
|
|
|
42
45
|
|
|
43
46
|
`tot` talks to the Token of Trust MCP (default `https://mcp.tokenoftrust.com`, override with `--mcp` or `MCP_BASE_URL`). It is **single-plane**: the only identity is **you**, signed in against the MCP over OAuth.
|
|
44
47
|
|
|
45
|
-
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot
|
|
48
|
+
- Run `tot login` once — it opens your browser (or falls back to a device code on a headless box), you sign in as yourself, and the session is cached at `~/.tot/credentials.json` and refreshed silently. Every later command (`tot clone`, `tot start`, `tot preview`, `tot ship`, …) runs as you, with no re-auth. Entitlement is derived server-side from your ToT memberships.
|
|
46
49
|
- Not signed in? On a terminal, `tot start` / `tot clone` **offer to sign you in right there** and continue in-flow — no "run `tot login`, then re-run".
|
|
47
50
|
- The old operator env-triple (`TOT_API_KEY` / `TOT_SECRET_KEY` / `TOT_APP_DOMAIN`) **no longer signs the CLI in** — tot-mcp went OAuth-first on 2026-07-23. If those vars are set, `tot` prints a one-line advisory and uses your `tot login` session anyway; it never reads them for auth.
|
|
48
51
|
|
package/bin/tot.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* tot validate lint your store before you submit ← built
|
|
15
15
|
* tot dev run your store locally with save→reload ← built (monorepo: host astro; standalone: runs the published runner image)
|
|
16
16
|
* tot preview push your store to a reviewable preview ← built (validate + push preview ref; MCP preview_status read-back). `submit`/`deploy` are teaching aliases.
|
|
17
|
-
* tot ship promote a reconciled preview live ←
|
|
17
|
+
* tot ship promote a reconciled preview live ← built (diff-vs-live + y/N confirm → change_accept; refuses non-TTY / unreconciled)
|
|
18
18
|
* tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
|
|
19
19
|
* tot doctor check this machine is ready
|
|
20
20
|
* tot ideas copy-paste AI prompts that reliably wow
|
|
@@ -62,7 +62,7 @@ tot — Token of Trust developer CLI
|
|
|
62
62
|
tot validate lint your store before you submit
|
|
63
63
|
tot dev run your store locally with save→reload
|
|
64
64
|
tot preview push your store to a reviewable preview
|
|
65
|
-
tot ship promote a reconciled preview live (
|
|
65
|
+
tot ship promote a reconciled preview live (diff → confirm → ship)
|
|
66
66
|
tot pr list / view / close your candidate PRs
|
|
67
67
|
tot doctor check this machine is ready
|
|
68
68
|
tot ideas copy-paste AI prompts that reliably wow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
3
|
+
"version": "1.4.0-rc.11",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/candidate-state.mjs
CHANGED
|
@@ -13,8 +13,14 @@
|
|
|
13
13
|
* - a terminal-roll (the active candidate was merged/closed) records the fresh
|
|
14
14
|
* candidate it rolled to, so you're never wedged submitting to a dead PR.
|
|
15
15
|
*
|
|
16
|
-
* ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>`
|
|
17
|
-
*
|
|
16
|
+
* ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` on the
|
|
17
|
+
* DEFAULT branch and `<mcpUrl>::<repo>::<branch>` on any other (u4 — branch-bound
|
|
18
|
+
* candidates): a different MCP, repo, OR non-default git branch is a different
|
|
19
|
+
* candidate namespace, so a feature branch gets its OWN candidate PR instead of
|
|
20
|
+
* fighting main's over the same handle. The default branch deliberately keeps the
|
|
21
|
+
* OLD branch-less key so existing devs' state is byte-identical (zero migration),
|
|
22
|
+
* and a branch-scoped read that misses FALLS BACK to that old key so state written
|
|
23
|
+
* before the rekey (or by the default branch) is never orphaned. Same atomic-write
|
|
18
24
|
* discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
|
|
19
25
|
* Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
|
|
20
26
|
*/
|
|
@@ -31,11 +37,38 @@ export function defaultCandidateStatePath(env = process.env) {
|
|
|
31
37
|
return join(home, ".tot", "candidates.json");
|
|
32
38
|
}
|
|
33
39
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Branch names that are treated as the repo's DEFAULT — their candidates keep the
|
|
42
|
+
* OLD branch-less key (zero migration). A null/empty/detached ("HEAD") branch is
|
|
43
|
+
* treated as default too, so an environment where the branch can't be resolved
|
|
44
|
+
* degrades to exactly today's behavior rather than minting a spurious namespace.
|
|
45
|
+
*/
|
|
46
|
+
export const DEFAULT_BRANCHES = new Set(["main", "master"]);
|
|
47
|
+
|
|
48
|
+
/** Whether `branch` should use the OLD branch-less candidate key. Pure. */
|
|
49
|
+
export function isDefaultBranch(branch) {
|
|
50
|
+
return !branch || branch === "HEAD" || DEFAULT_BRANCHES.has(branch);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The legacy (branch-less) namespace key — today's exact `<mcpUrl>::<repo>`. */
|
|
54
|
+
function legacyStateKey(mcpUrl, repo) {
|
|
36
55
|
return `${mcpUrl}::${repo}`;
|
|
37
56
|
}
|
|
38
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Namespace key for one (MCP, repo, branch) candidate pointer. The DEFAULT branch
|
|
60
|
+
* keeps the legacy `<mcpUrl>::<repo>` key byte-for-byte (zero migration); any other
|
|
61
|
+
* branch gets its own `<mcpUrl>::<repo>::<branch>` namespace. Pure.
|
|
62
|
+
*/
|
|
63
|
+
function stateKey(mcpUrl, repo, branch) {
|
|
64
|
+
return isDefaultBranch(branch) ? legacyStateKey(mcpUrl, repo) : `${mcpUrl}::${repo}::${branch}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Extract a usable changeId from a stored record, or null. Pure. */
|
|
68
|
+
function recordChangeId(rec) {
|
|
69
|
+
return rec && typeof rec.changeId === "string" && rec.changeId ? rec.changeId : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
39
72
|
function readMap(filePath) {
|
|
40
73
|
try {
|
|
41
74
|
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
@@ -54,26 +87,33 @@ function writeMap(filePath, map) {
|
|
|
54
87
|
}
|
|
55
88
|
|
|
56
89
|
/**
|
|
57
|
-
* The remembered active changeId for `(mcpUrl, repo)`, or null when there
|
|
58
|
-
* one (absent/unreadable/malformed) — a miss means "use the stable default".
|
|
59
|
-
*
|
|
90
|
+
* The remembered active changeId for `(mcpUrl, repo, branch)`, or null when there
|
|
91
|
+
* isn't one (absent/unreadable/malformed) — a miss means "use the stable default".
|
|
92
|
+
* On a non-default branch whose branch-scoped key misses, FALLS BACK to the legacy
|
|
93
|
+
* branch-less key so state written before the rekey (or by the default branch)
|
|
94
|
+
* isn't orphaned. Never throws.
|
|
60
95
|
*/
|
|
61
|
-
export function readActiveChangeId(filePath, { mcpUrl, repo }) {
|
|
62
|
-
const
|
|
63
|
-
|
|
96
|
+
export function readActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
97
|
+
const map = readMap(filePath);
|
|
98
|
+
const primary = recordChangeId(map[stateKey(mcpUrl, repo, branch)]);
|
|
99
|
+
if (primary) return primary;
|
|
100
|
+
// Legacy fallback: a branch-scoped miss reads the old branch-less key (a no-op
|
|
101
|
+
// when we're already on the default branch, which IS the legacy key).
|
|
102
|
+
if (!isDefaultBranch(branch)) return recordChangeId(map[legacyStateKey(mcpUrl, repo)]);
|
|
103
|
+
return null;
|
|
64
104
|
}
|
|
65
105
|
|
|
66
|
-
/** Remember `changeId` as the active candidate for `(mcpUrl, repo)`, atomically. */
|
|
67
|
-
export function writeActiveChangeId(filePath, { mcpUrl, repo, changeId }) {
|
|
106
|
+
/** Remember `changeId` as the active candidate for `(mcpUrl, repo, branch)`, atomically. */
|
|
107
|
+
export function writeActiveChangeId(filePath, { mcpUrl, repo, branch, changeId }) {
|
|
68
108
|
const map = readMap(filePath);
|
|
69
|
-
map[stateKey(mcpUrl, repo)] = { changeId, updatedAt: Date.now() };
|
|
109
|
+
map[stateKey(mcpUrl, repo, branch)] = { changeId, updatedAt: Date.now() };
|
|
70
110
|
writeMap(filePath, map);
|
|
71
111
|
}
|
|
72
112
|
|
|
73
|
-
/** Forget the active candidate for `(mcpUrl, repo)` (e.g. after closing it). */
|
|
74
|
-
export function clearActiveChangeId(filePath, { mcpUrl, repo }) {
|
|
113
|
+
/** Forget the active candidate for `(mcpUrl, repo, branch)` (e.g. after closing it). */
|
|
114
|
+
export function clearActiveChangeId(filePath, { mcpUrl, repo, branch }) {
|
|
75
115
|
const map = readMap(filePath);
|
|
76
|
-
const key = stateKey(mcpUrl, repo);
|
|
116
|
+
const key = stateKey(mcpUrl, repo, branch);
|
|
77
117
|
if (key in map) {
|
|
78
118
|
delete map[key];
|
|
79
119
|
writeMap(filePath, map);
|
package/src/commands/pr.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import { execFileSync } from "node:child_process";
|
|
|
21
21
|
import { createMcpClient } from "../mcp.mjs";
|
|
22
22
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
23
23
|
import { fail } from "../errors.mjs";
|
|
24
|
-
import { repoNameFromRemote } from "./submit.mjs";
|
|
24
|
+
import { repoNameFromRemote, currentBranch } from "./submit.mjs";
|
|
25
25
|
import {
|
|
26
26
|
defaultCandidateStatePath,
|
|
27
27
|
readActiveChangeId,
|
|
@@ -79,11 +79,23 @@ export function matchCandidate(candidates, target) {
|
|
|
79
79
|
return candidates.find((c) => c.changeId === target) ?? null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* One-line candidate summary for `tot pr list` — surfaces branch ↔ PR# ↔ preview
|
|
84
|
+
* URL so a dev sees, at a glance, which git branch each candidate belongs to (u4 —
|
|
85
|
+
* branch-bound candidates) and where its preview lives. Prefers the candidate's
|
|
86
|
+
* `previewUrl`, falling back to the PR `url`. `active` marks the one THIS checkout's
|
|
87
|
+
* branch resolves to. Pure — unit-tested.
|
|
88
|
+
* @param {{prNumber?:number|null, branch?:string|null, changeId:string, state?:string|null,
|
|
89
|
+
* previewUrl?:string|null, url?:string|null}} c
|
|
90
|
+
* @param {{ active?: boolean }} [opts]
|
|
91
|
+
*/
|
|
92
|
+
export function formatCandidateLine(c, { active = false } = {}) {
|
|
84
93
|
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
85
|
-
const
|
|
86
|
-
|
|
94
|
+
const branch = c.branch ? c.branch : "(no branch)";
|
|
95
|
+
const previewUrl = c.previewUrl || c.url || null;
|
|
96
|
+
const urlPart = previewUrl ? ` ${previewUrl}` : "";
|
|
97
|
+
const activePart = active ? " ← active" : "";
|
|
98
|
+
return ` PR ${pr} ${branch} ${c.changeId} [${c.state ?? "?"}]${urlPart}${activePart}`;
|
|
87
99
|
}
|
|
88
100
|
|
|
89
101
|
/** @param {string[]} argv @param {any} ctx */
|
|
@@ -128,7 +140,9 @@ export async function run(argv, ctx) {
|
|
|
128
140
|
|
|
129
141
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
130
142
|
const statePath = defaultCandidateStatePath(env);
|
|
131
|
-
|
|
143
|
+
// Branch-bound (u4): the active-pointer namespace is scoped to the current git
|
|
144
|
+
// branch, so the "← active" marker reflects THIS branch's candidate.
|
|
145
|
+
const scope = { mcpUrl: baseUrl, repo, branch: currentBranch(gitSafe) };
|
|
132
146
|
const client = createMcpClient(baseUrl);
|
|
133
147
|
try {
|
|
134
148
|
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
@@ -146,7 +160,7 @@ export async function run(argv, ctx) {
|
|
|
146
160
|
const active = readActiveChangeId(statePath, scope);
|
|
147
161
|
console.log(`Open candidate PRs for ${repo}:`);
|
|
148
162
|
for (const c of candidates) {
|
|
149
|
-
console.log(
|
|
163
|
+
console.log(formatCandidateLine(c, { active: !!active && c.changeId === active }));
|
|
150
164
|
}
|
|
151
165
|
return 0;
|
|
152
166
|
}
|
package/src/commands/ship.mjs
CHANGED
|
@@ -1,53 +1,667 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tot ship` — promote a reconciled PREVIEW live (the ship gate).
|
|
2
|
+
* `tot ship` — promote a reconciled PREVIEW live (the deliberate ship gate).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* The second half of the dev → preview → ship loop:
|
|
5
5
|
*
|
|
6
6
|
* tot dev run your store locally with save→reload
|
|
7
7
|
* tot preview push it to a reviewable preview (validate → reconcile → compliance)
|
|
8
8
|
* tot ship promote a reconciled preview live ← you are here
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* show the diff about to go live, confirm, then `change_accept` / `candidate_accept`
|
|
13
|
-
* via the MCP, and report the shipped result) lands in a later unit.
|
|
10
|
+
* The contract, deliberately strict because this is the step that changes the LIVE
|
|
11
|
+
* site:
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* 1. Resolve the ACTIVE candidate for this checkout (the SAME handle `tot preview`
|
|
14
|
+
* last pushed to — see `resolveActiveChangeId`, the seam u4 rekeys to be
|
|
15
|
+
* branch-bound) and REQUIRE it to be OPEN and RECONCILED. A merged/closed
|
|
16
|
+
* candidate, or one whose evidence isn't green yet, is refused with a clear
|
|
17
|
+
* next step — never shipped.
|
|
18
|
+
* 2. ALWAYS render a diff-vs-live (what this ship changes on the live site) and
|
|
19
|
+
* require ONE explicit [y/N] confirm, defaulting to NO. There is deliberately
|
|
20
|
+
* NO `--yes` / `--force` bypass in v1: shipping live is a decision a human
|
|
21
|
+
* makes at the keyboard. In a NON-TTY (CI, piped) we REFUSE rather than
|
|
22
|
+
* auto-confirm — nothing ships without someone saying yes.
|
|
23
|
+
* 3. On confirm, `change_accept` (the human ship gate) transitions the change to
|
|
24
|
+
* shipped; we then poll `change_status` until it reports shipped and print the
|
|
25
|
+
* live URL.
|
|
19
26
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
27
|
+
* UNAUTHORISED actors don't hit a wall: if you can't approve the ship yourself we
|
|
28
|
+
* RECORD the approval request (`change_ready` queues it for review) and print WHO
|
|
29
|
+
* can approve it, so the change moves forward instead of dead-ending.
|
|
23
30
|
*
|
|
24
|
-
*
|
|
31
|
+
* WIRE (verified server-side by u7, decision ship-path-wire-resolutions):
|
|
32
|
+
* - A CANDIDATE and a change RECORD are DISTINCT namespaces. The candidate handle
|
|
33
|
+
* (`changeId`, the `candidate/<changeId>` Gitea PR) is NOT the change-record id.
|
|
34
|
+
* `change_request_review` / `change_ready` / `change_accept` resolve the record
|
|
35
|
+
* via `getChange(tenant, id)`, where `id` is the `chg_<uuid>` minted by
|
|
36
|
+
* `change_open` — a different id entirely.
|
|
37
|
+
* - So ship RESOLVES a real change-record id before the gate: `resolveChangeRecordId`
|
|
38
|
+
* opens a record for the exact reviewed commit (`change_open commit:<HEAD>`, so
|
|
39
|
+
* u7's promote-by-digest can content-address the reviewed tree — without `commit:`
|
|
40
|
+
* the accept fails closed `no_reviewed_commit`) and readies it against the
|
|
41
|
+
* authoritative reconcile evidence keyed by (tenant, commit). If that commit hasn't
|
|
42
|
+
* reconciled (no evidence), `change_ready` fails and we refuse with a clear next step.
|
|
43
|
+
* - `change_request_review` then reads the readied record (evidence green? authorised
|
|
44
|
+
* to promote?); `change_accept` is the commit. All key on the resolved `chg_` id.
|
|
45
|
+
*
|
|
46
|
+
* Dependency-free (global fetch via the MCP client + `git`); pure helpers are
|
|
47
|
+
* exported and unit-tested with a mock client, no network, no TTY.
|
|
25
48
|
*/
|
|
49
|
+
import { execFileSync } from "node:child_process";
|
|
50
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
51
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
26
52
|
import { fail } from "../errors.mjs";
|
|
53
|
+
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
54
|
+
import { startProgress } from "../progress.mjs";
|
|
55
|
+
import { openBrowser } from "../open.mjs";
|
|
56
|
+
import {
|
|
57
|
+
repoNameFromRemote,
|
|
58
|
+
parseNameStatus,
|
|
59
|
+
deriveChangeId,
|
|
60
|
+
actorKeyFor,
|
|
61
|
+
currentBranch,
|
|
62
|
+
buildChangeSummary,
|
|
63
|
+
} from "./submit.mjs";
|
|
64
|
+
import {
|
|
65
|
+
defaultCandidateStatePath,
|
|
66
|
+
readActiveChangeId,
|
|
67
|
+
isTerminalCandidateState,
|
|
68
|
+
} from "../candidate-state.mjs";
|
|
69
|
+
|
|
70
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
27
71
|
|
|
28
72
|
const USAGE = `tot ship — promote a reconciled preview live
|
|
29
73
|
|
|
30
|
-
tot ship
|
|
74
|
+
tot ship promote the preview you last pushed with \`tot preview\`
|
|
75
|
+
tot ship --identity <id> sign in as a specific identity for this ship
|
|
76
|
+
tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
77
|
+
|
|
78
|
+
Ship makes your preview LIVE once it has reconciled cleanly — the deliberate step
|
|
79
|
+
AFTER \`tot preview\`. It ALWAYS shows you the diff about to go live and asks for a
|
|
80
|
+
single y/N confirmation first; there is no --yes/--force, and it refuses to run
|
|
81
|
+
without an interactive terminal. If you can't approve the ship yourself, it records
|
|
82
|
+
the request and tells you who can.`;
|
|
83
|
+
|
|
84
|
+
/** Parse `tot ship` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
85
|
+
export function parseShipArgs(argv) {
|
|
86
|
+
const a = { mcp: null, identity: null, noOpen: false, help: false };
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const t = argv[i];
|
|
89
|
+
if (t === "--mcp") a.mcp = argv[++i];
|
|
90
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
91
|
+
else if (t === "--no-open") a.noOpen = true;
|
|
92
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
93
|
+
}
|
|
94
|
+
return a;
|
|
95
|
+
}
|
|
31
96
|
|
|
32
|
-
|
|
33
|
-
step AFTER \`tot preview\` — preview makes it reviewable; ship makes it live.`;
|
|
97
|
+
// ─── Response normalisation (defensive — one MCP, but shapes may vary) ───────────
|
|
34
98
|
|
|
35
99
|
/**
|
|
36
|
-
*
|
|
37
|
-
*
|
|
100
|
+
* Normalise a `candidate_status` result (a single ReviewEnvironment forge slice)
|
|
101
|
+
* to the fields ship gates on. Returns null when there's no candidate to act on.
|
|
102
|
+
* Pure — unit-tested.
|
|
103
|
+
* @param {any} r
|
|
104
|
+
* @returns {{changeId?:string, state:string|null, headSha:string|null, baseSha:string|null,
|
|
105
|
+
* prNumber:number|null, url:string|null, mergeable:unknown}|null}
|
|
38
106
|
*/
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
107
|
+
export function normalizeCandidate(r) {
|
|
108
|
+
// `tot pr` already learned candidate_status can answer as an object, a
|
|
109
|
+
// `{candidates:[…]}` list, or a single record — reuse that tolerance here.
|
|
110
|
+
const c = Array.isArray(r)
|
|
111
|
+
? r[0]
|
|
112
|
+
: r && Array.isArray(r.candidates)
|
|
113
|
+
? r.candidates[0]
|
|
114
|
+
: r && Array.isArray(r.environments)
|
|
115
|
+
? r.environments[0]
|
|
116
|
+
: r;
|
|
117
|
+
if (!c || typeof c !== "object" || typeof c.changeId !== "string") return null;
|
|
118
|
+
return {
|
|
119
|
+
changeId: c.changeId,
|
|
120
|
+
state: typeof c.state === "string" ? c.state : null,
|
|
121
|
+
headSha: c.headSha ?? null,
|
|
122
|
+
baseSha: c.baseSha ?? null,
|
|
123
|
+
prNumber: typeof c.prNumber === "number" ? c.prNumber : null,
|
|
124
|
+
url: c.url ?? null,
|
|
125
|
+
mergeable: c.mergeable,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Normalise a `change_request_review` result to the precondition signals ship
|
|
131
|
+
* needs: is the evidence green (reconciled + promotable), is THIS session allowed
|
|
132
|
+
* to promote, and — for the unauthorised path — who can. Field names are read
|
|
133
|
+
* defensively (the CLI is the first caller of this tool) so a plausible rename
|
|
134
|
+
* degrades gracefully rather than crashing the gate. `reconciled`/`authorized` are
|
|
135
|
+
* TRI-STATE-ish: they're only `true` when the server clearly says so, so an
|
|
136
|
+
* unrecognised shape fails CLOSED (we don't ship on ambiguity). Pure — unit-tested.
|
|
137
|
+
* @param {any} r
|
|
138
|
+
* @returns {{reconciled:boolean, authorized:boolean, approvers:string[],
|
|
139
|
+
* previewUrl:string|null, detail:string|null, raw:any}}
|
|
140
|
+
*/
|
|
141
|
+
export function normalizeReview(r) {
|
|
142
|
+
const o = (r && typeof r === "object" ? r : {});
|
|
143
|
+
const ev = o.evidence && typeof o.evidence === "object" ? o.evidence : {};
|
|
144
|
+
// Evidence green ⇒ reconciled + promotable. Accept the several plausible flags.
|
|
145
|
+
const reconciled = firstBool([
|
|
146
|
+
o.evidenceGreen, o.green, o.promotable, ev.green, ev.ok, ev.promotable,
|
|
147
|
+
o.verdict === "green" || o.verdict === "pass" ? true : undefined,
|
|
148
|
+
]);
|
|
149
|
+
const authorized = firstBool([
|
|
150
|
+
o.authorized, o.authorised, o.canPromote, o.canApprove, o.authorisedToPromote,
|
|
151
|
+
]);
|
|
152
|
+
const approvers = normalizeApprovers(
|
|
153
|
+
o.approvers ?? o.eligibleApprovers ?? o.whoCanApprove ?? o.promoters ?? [],
|
|
154
|
+
);
|
|
155
|
+
return {
|
|
156
|
+
reconciled: reconciled === true,
|
|
157
|
+
authorized: authorized === true,
|
|
158
|
+
approvers,
|
|
159
|
+
previewUrl: o.previewUrl ?? ev.previewUrl ?? null,
|
|
160
|
+
detail: typeof o.detail === "string" ? o.detail : (typeof o.reason === "string" ? o.reason : null),
|
|
161
|
+
raw: r,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** First non-undefined boolean in a list, else undefined. Pure. */
|
|
166
|
+
function firstBool(candidates) {
|
|
167
|
+
for (const v of candidates) if (typeof v === "boolean") return v;
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Coerce an approvers payload (strings, or objects with email/name) to a label list. Pure. */
|
|
172
|
+
export function normalizeApprovers(list) {
|
|
173
|
+
if (!Array.isArray(list)) return [];
|
|
174
|
+
return list
|
|
175
|
+
.map((a) => (typeof a === "string" ? a : a && (a.email || a.name || a.label || a.id)))
|
|
176
|
+
.filter((s) => typeof s === "string" && s.trim())
|
|
177
|
+
.map((s) => s.trim());
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Normalise a `change_accept` / `change_status` result to { shipped, state,
|
|
182
|
+
* previewUrl, shippedAt }. `shipped` is true once the record reports the shipped
|
|
183
|
+
* state (or carries a shipped stamp). Pure — unit-tested.
|
|
184
|
+
* @param {any} r
|
|
185
|
+
*/
|
|
186
|
+
export function normalizeChangeResult(r) {
|
|
187
|
+
const o = (r && typeof r === "object" ? r : {});
|
|
188
|
+
const state = typeof o.state === "string" ? o.state : null;
|
|
189
|
+
const shippedStamp = o.shipped && typeof o.shipped === "object" ? o.shipped : null;
|
|
190
|
+
const shipped = state === "shipped" || Boolean(o.shipped === true || shippedStamp);
|
|
191
|
+
return {
|
|
192
|
+
shipped,
|
|
193
|
+
state,
|
|
194
|
+
previewUrl: o.previewUrl ?? shippedStamp?.previewUrl ?? o.liveUrl ?? null,
|
|
195
|
+
shippedAt: shippedStamp?.shippedAt ?? o.shippedAt ?? null,
|
|
196
|
+
raw: r,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ─── The candidate-resolution seam (u4 extends THIS) ─────────────────────────────
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the ACTIVE candidate's changeId for this checkout — the SAME resolution
|
|
204
|
+
* `tot preview` uses so ship promotes exactly what preview last pushed: the
|
|
205
|
+
* remembered active pointer (a prior `--new` / roll), else the STABLE
|
|
206
|
+
* per-developer-per-tenant default (`deriveChangeId`).
|
|
207
|
+
*
|
|
208
|
+
* ┌─ SEAM (u4) ────────────────────────────────────────────────────────────────┐
|
|
209
|
+
* │ This is the ONE place ship keys the candidate on (tenant, actor, branch). │
|
|
210
|
+
* │ u4 (branch-bound candidates) keys HERE — folding in the current git branch — │
|
|
211
|
+
* │ so `tot preview` and `tot ship` resolve the SAME branch-bound candidate, │
|
|
212
|
+
* │ WITHOUT touching the gate, diff, confirm, or accept flow below. On the │
|
|
213
|
+
* │ default branch `branch` is null and this is byte-identical to pre-u4. │
|
|
214
|
+
* └──────────────────────────────────────────────────────────────────────────────┘
|
|
215
|
+
* Pure given its inputs (state is read through candidate-state.mjs). Exported so a
|
|
216
|
+
* follow-on unit can wrap/replace it.
|
|
217
|
+
* @param {{ statePath:string, mcpUrl:string, repo:string, tenant:string, actorKey:string,
|
|
218
|
+
* branch?:string|null }} keys
|
|
219
|
+
* @returns {string}
|
|
220
|
+
*/
|
|
221
|
+
export function resolveActiveChangeId({ statePath, mcpUrl, repo, tenant, actorKey, branch = null }) {
|
|
222
|
+
const stable = deriveChangeId(tenant, actorKey, branch);
|
|
223
|
+
const active = readActiveChangeId(statePath, { mcpUrl, repo, branch });
|
|
224
|
+
return active || stable;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Normalise a `change_open` result to its minted change-record id. `change_open`
|
|
229
|
+
* returns the record id flat in `data` (id, state); read it defensively. Pure.
|
|
230
|
+
* @param {any} r
|
|
231
|
+
* @returns {{ id: string|null, state: string|null }}
|
|
232
|
+
*/
|
|
233
|
+
export function normalizeOpenedChange(r) {
|
|
234
|
+
const o = r && typeof r === "object" ? r : {};
|
|
235
|
+
return {
|
|
236
|
+
id: typeof o.id === "string" && o.id ? o.id : null,
|
|
237
|
+
state: typeof o.state === "string" ? o.state : null,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Resolve the change-RECORD id (`chg_<uuid>`) that the ship gate + `change_accept`
|
|
243
|
+
* key on — a DISTINCT namespace from the candidate handle (see the module header).
|
|
244
|
+
* There is NO candidate→record lookup on the wire (`change_status` lists only
|
|
245
|
+
* id/title/state, never the head sha), so we CAPTURE the id at `change_open`, the
|
|
246
|
+
* option u7's decision (ship-path-wire-resolutions) records as supported:
|
|
247
|
+
*
|
|
248
|
+
* 1. read the reviewed commit — the local HEAD, the commit `tot preview` pushed and
|
|
249
|
+
* the reconcile evidence is keyed on;
|
|
250
|
+
* 2. `change_open` it with `commit:` set, so u7's promote-by-digest can content-
|
|
251
|
+
* address the exact reviewed tree (absent it, `change_accept` fails closed
|
|
252
|
+
* `no_reviewed_commit`);
|
|
253
|
+
* 3. `change_ready` it, attaching the AUTHORITATIVE reconcile evidence keyed by
|
|
254
|
+
* (tenant, commit). This REQUIRES that commit to have reconciled — if it hasn't,
|
|
255
|
+
* `change_ready` throws (no authoritative evidence) and we propagate it so the
|
|
256
|
+
* caller refuses with a "run `tot preview` first" next step (never ship a commit
|
|
257
|
+
* whose evidence isn't in).
|
|
258
|
+
*
|
|
259
|
+
* Returns the resolved `chg_` id. Throws if the record can't be opened/readied — the
|
|
260
|
+
* caller fails CLOSED (never falls back to shipping the candidate handle or no record).
|
|
261
|
+
* The `change_*` calls go through the same client; `git` is injected.
|
|
262
|
+
* @param {{callTool:Function}} client
|
|
263
|
+
* @param {{ tenant:string, git:(args:string[])=>string,
|
|
264
|
+
* changeSummary?:{ title?:string, body?:string[] } }} params
|
|
265
|
+
* @returns {Promise<string>}
|
|
266
|
+
*/
|
|
267
|
+
export async function resolveChangeRecordId(client, { tenant, git, changeSummary }) {
|
|
268
|
+
const commit = git(["rev-parse", "HEAD"]).trim();
|
|
269
|
+
if (!commit) {
|
|
270
|
+
throw new Error("couldn't read the reviewed commit (git HEAD) for this checkout");
|
|
271
|
+
}
|
|
272
|
+
const title = (changeSummary?.title || `Ship ${tenant} store change`).slice(0, 200);
|
|
273
|
+
const body = changeSummary?.body?.length ? changeSummary.body.join("\n").slice(0, 2000) : undefined;
|
|
274
|
+
const opened = normalizeOpenedChange(
|
|
275
|
+
await client.callTool("change_open", {
|
|
276
|
+
title,
|
|
277
|
+
...(body ? { summary: body } : {}),
|
|
278
|
+
commit,
|
|
279
|
+
tenant,
|
|
280
|
+
dryRun: false,
|
|
281
|
+
}),
|
|
282
|
+
);
|
|
283
|
+
if (!opened.id) {
|
|
284
|
+
throw new Error("change_open did not return a change-record id");
|
|
285
|
+
}
|
|
286
|
+
// Ready it against the authoritative reconcile evidence for THIS exact commit —
|
|
287
|
+
// throws (no_authoritative_evidence) if the commit hasn't reconciled yet.
|
|
288
|
+
await client.callTool("change_ready", { id: opened.id, tenant, dryRun: false });
|
|
289
|
+
return opened.id;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** A neutral review used only when there's no open candidate to resolve a record for
|
|
293
|
+
* — shipReadiness reports the candidate blocker first and never reads these. */
|
|
294
|
+
export const NO_REVIEW = Object.freeze({
|
|
295
|
+
reconciled: false, authorized: false, approvers: [], previewUrl: null, detail: null, raw: null,
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ─── The ship gate (pure) ────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Decide, from the resolved candidate + the review verdict, whether the ship may
|
|
302
|
+
* proceed — the correctness-critical core, kept PURE so every branch is unit-tested
|
|
303
|
+
* without a client/TTY/git. Order matters: OPEN, then RECONCILED, then AUTHORISED,
|
|
304
|
+
* so the developer always sees the most fundamental blocker first.
|
|
305
|
+
*
|
|
306
|
+
* @param {ReturnType<typeof normalizeCandidate>} candidate
|
|
307
|
+
* @param {ReturnType<typeof normalizeReview>} review
|
|
308
|
+
* @returns {{ kind: "no-candidate"|"not-open"|"not-reconciled"|"unauthorized"|"ready",
|
|
309
|
+
* what?: string, next?: string }}
|
|
310
|
+
*/
|
|
311
|
+
export function shipReadiness(candidate, review) {
|
|
312
|
+
if (!candidate) {
|
|
313
|
+
return {
|
|
314
|
+
kind: "no-candidate",
|
|
315
|
+
what: "no active candidate to ship for this checkout",
|
|
316
|
+
next: "push one first with `tot preview`, then `tot ship`",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (candidate.state !== "open") {
|
|
320
|
+
// Terminal (merged/closed) vs simply-not-open both mean "nothing OPEN to ship".
|
|
321
|
+
const terminal = isTerminalCandidateState(candidate.state);
|
|
322
|
+
return {
|
|
323
|
+
kind: "not-open",
|
|
324
|
+
what: terminal
|
|
325
|
+
? `this candidate is already ${candidate.state} — there's nothing open to ship`
|
|
326
|
+
: `this candidate isn't open (state: ${candidate.state ?? "unknown"})`,
|
|
327
|
+
next: "`tot preview` to push a fresh candidate, then `tot ship`",
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (!review.reconciled) {
|
|
331
|
+
return {
|
|
332
|
+
kind: "not-reconciled",
|
|
333
|
+
what: `this preview hasn't reconciled cleanly yet${review.detail ? ` — ${review.detail}` : ""}`,
|
|
334
|
+
next: "run `tot preview` and wait for the green reconcile/compliance result, then `tot ship`",
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
if (!review.authorized) {
|
|
338
|
+
return { kind: "unauthorized" };
|
|
339
|
+
}
|
|
340
|
+
return { kind: "ready" };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ─── Diff-vs-live (pure render + thin git compute) ───────────────────────────────
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Render the "what goes live" block from parsed name-status entries + a shortstat
|
|
347
|
+
* line. Always prints SOMETHING actionable: the changed paths when we have them,
|
|
348
|
+
* else a pointer to the candidate PR so the reviewer can read the full diff. Pure —
|
|
349
|
+
* unit-tested.
|
|
350
|
+
* @param {{ entries:{status:string,path:string,from?:string}[], statLine?:string,
|
|
351
|
+
* prUrl?:string|null, ok:boolean }} input
|
|
352
|
+
* @returns {string[]}
|
|
353
|
+
*/
|
|
354
|
+
export function renderDiffVsLive({ entries, statLine, prUrl, ok }) {
|
|
355
|
+
const lines = ["", " This ship will change the LIVE site:"];
|
|
356
|
+
if (ok && entries.length) {
|
|
357
|
+
for (const e of entries.slice(0, 40)) {
|
|
358
|
+
const label = e.status === "R" && e.from ? `${e.from} → ${e.path}` : e.path;
|
|
359
|
+
lines.push(` ${statusGlyph(e.status)} ${label}`);
|
|
360
|
+
}
|
|
361
|
+
if (entries.length > 40) lines.push(` … +${entries.length - 40} more file(s)`);
|
|
362
|
+
if (statLine) lines.push(` (${statLine})`);
|
|
363
|
+
} else {
|
|
364
|
+
lines.push(" (couldn't render a local file diff for this candidate)");
|
|
365
|
+
if (prUrl) lines.push(` review the full diff in the candidate PR: ${prUrl}`);
|
|
366
|
+
}
|
|
367
|
+
return lines;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A/M/D/R → a stable one-char glyph for the diff list. Pure. */
|
|
371
|
+
function statusGlyph(status) {
|
|
372
|
+
return { A: "+", M: "~", D: "-", R: "»", C: "»" }[status] || "·";
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Compute the diff-vs-live (base→head) via git in the checkout — best-effort. Tries
|
|
377
|
+
* the local objects first; if the shas aren't present it fetches once and retries;
|
|
378
|
+
* if it still can't, returns `ok:false` so the caller falls back to the PR pointer.
|
|
379
|
+
* `git` is injected (a `(args:string[])=>string` runner) so it's testable.
|
|
380
|
+
* @param {(args:string[]) => string} git
|
|
381
|
+
* @param {string|null} baseSha
|
|
382
|
+
* @param {string|null} headSha
|
|
383
|
+
* @returns {{ entries:{status:string,path:string,from?:string}[], statLine:string, ok:boolean }}
|
|
384
|
+
*/
|
|
385
|
+
export function computeDiffVsLive(git, baseSha, headSha) {
|
|
386
|
+
if (!baseSha || !headSha) return { entries: [], statLine: "", ok: false };
|
|
387
|
+
const range = `${baseSha}..${headSha}`;
|
|
388
|
+
const tryDiff = () => ({
|
|
389
|
+
entries: parseNameStatus(git(["diff", "--name-status", range])),
|
|
390
|
+
statLine: git(["diff", "--shortstat", range]).trim(),
|
|
391
|
+
});
|
|
392
|
+
try {
|
|
393
|
+
return { ...tryDiff(), ok: true };
|
|
394
|
+
} catch {
|
|
395
|
+
// The shas may not be local yet (base moved, head only on the remote) — one
|
|
396
|
+
// fetch, then retry. Still failing ⇒ fall back to the PR pointer.
|
|
397
|
+
try {
|
|
398
|
+
git(["fetch", "--quiet", "origin"]);
|
|
399
|
+
return { ...tryDiff(), ok: true };
|
|
400
|
+
} catch {
|
|
401
|
+
return { entries: [], statLine: "", ok: false };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ─── Ship-decision poll ──────────────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Poll `change_status` until the change reports shipped (or the attempts budget
|
|
410
|
+
* runs out). `change_accept` transitions synchronously, so this usually resolves on
|
|
411
|
+
* the first read — it exists to CONFIRM the shipped state and pick up the live URL,
|
|
412
|
+
* and to tolerate a promote that lands a beat later. Injectable delay/attempts.
|
|
413
|
+
* @param {{callTool:Function}} client
|
|
414
|
+
* @param {{ id:string, tenant?:string }} target
|
|
415
|
+
* @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
|
|
416
|
+
*/
|
|
417
|
+
export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
|
|
418
|
+
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
419
|
+
let last = null;
|
|
420
|
+
for (let i = 0; i < attempts; i++) {
|
|
421
|
+
last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
|
|
422
|
+
if (last.shipped) return last;
|
|
423
|
+
if (i < attempts - 1) await wait(delayMs);
|
|
424
|
+
}
|
|
425
|
+
return last;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// ─── Orchestration ───────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The ship flow after a session is established — resolve the candidate, gate,
|
|
432
|
+
* diff-vs-live, confirm, accept, poll, report. Split out from `run` so it's driven
|
|
433
|
+
* in tests with a mock client + injected git/confirm/interactive, no network/TTY.
|
|
434
|
+
*
|
|
435
|
+
* @param {{callTool:Function}} client an MCP client (real or mock)
|
|
436
|
+
* @param {{ tenant:string, changeId:string, repo:string,
|
|
437
|
+
* git:(args:string[])=>string, noOpen?:boolean,
|
|
438
|
+
* changeSummary?:{ title?:string, body?:string[] } }} params
|
|
439
|
+
* @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
|
|
440
|
+
* poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
441
|
+
* resolveRecord?:typeof resolveChangeRecordId }} [deps]
|
|
442
|
+
* @returns {Promise<number>} process exit code
|
|
443
|
+
*/
|
|
444
|
+
export async function runShip(client, { tenant, changeId, repo, git, noOpen, changeSummary }, deps = {}) {
|
|
445
|
+
const interactive = deps.interactive || isInteractive;
|
|
446
|
+
const confirm = deps.confirm || promptYesNo;
|
|
447
|
+
const poll = deps.poll || pollChangeShipped;
|
|
448
|
+
const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
|
|
449
|
+
|
|
450
|
+
// 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
|
|
451
|
+
const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
|
|
452
|
+
|
|
453
|
+
// 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
|
|
454
|
+
// on — a DISTINCT namespace from the candidate handle (see the header). Only
|
|
455
|
+
// meaningful for an OPEN candidate; for a missing/closed one we skip it and let
|
|
456
|
+
// shipReadiness report that blocker first (never open a record we can't ship).
|
|
457
|
+
let id = null;
|
|
458
|
+
if (candidate && candidate.state === "open") {
|
|
459
|
+
try {
|
|
460
|
+
id = await resolveRecord(client, { tenant, git, changeSummary });
|
|
461
|
+
} catch (e) {
|
|
462
|
+
console.error(
|
|
463
|
+
fail(
|
|
464
|
+
`couldn't prepare a shipable change record: ${String(e?.message || e)}`,
|
|
465
|
+
"the commit you're shipping may not have reconciled — run `tot preview` and wait for the green result, then `tot ship`",
|
|
466
|
+
),
|
|
467
|
+
);
|
|
468
|
+
return 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 2. Read the ship-readiness verdict against the RESOLVED record (or the neutral
|
|
473
|
+
// review when there's no open candidate — shipReadiness reports the candidate).
|
|
474
|
+
const review = id
|
|
475
|
+
? normalizeReview(await client.callTool("change_request_review", { id, tenant }))
|
|
476
|
+
: NO_REVIEW;
|
|
477
|
+
const gate = shipReadiness(candidate, review);
|
|
478
|
+
|
|
479
|
+
if (gate.kind === "unauthorized") {
|
|
480
|
+
return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
|
|
481
|
+
}
|
|
482
|
+
if (gate.kind !== "ready") {
|
|
483
|
+
console.error(fail(gate.what, gate.next));
|
|
484
|
+
return 1;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// 2. ALWAYS render the diff-vs-live, then require ONE explicit y/N confirm.
|
|
488
|
+
const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
|
|
489
|
+
for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
|
|
490
|
+
|
|
491
|
+
// NON-TTY: refuse rather than auto-confirm — nothing ships without a human yes.
|
|
492
|
+
if (!interactive()) {
|
|
493
|
+
console.error(
|
|
494
|
+
fail(
|
|
495
|
+
"`tot ship` needs an interactive terminal to confirm the live change",
|
|
496
|
+
"run it from a terminal (there is intentionally no --yes/--force)",
|
|
497
|
+
),
|
|
498
|
+
);
|
|
499
|
+
return 2;
|
|
500
|
+
}
|
|
501
|
+
const proceed = await confirm(`\n Ship this live to ${tenant}?`, false);
|
|
502
|
+
if (!proceed) {
|
|
503
|
+
console.log(" Ship cancelled — nothing changed.");
|
|
42
504
|
return 0;
|
|
43
505
|
}
|
|
44
506
|
|
|
507
|
+
// 3. Accept (the ship gate), then poll change_status until shipped.
|
|
508
|
+
let accept;
|
|
509
|
+
try {
|
|
510
|
+
accept = normalizeChangeResult(
|
|
511
|
+
await client.callTool("change_accept", {
|
|
512
|
+
id,
|
|
513
|
+
tenant,
|
|
514
|
+
dryRun: false,
|
|
515
|
+
// Stable per (candidate, head): a re-run after a blip returns the original
|
|
516
|
+
// ship instead of double-accepting.
|
|
517
|
+
idempotencyKey: `ship-${id}-${candidate.headSha ?? "head"}`,
|
|
518
|
+
}),
|
|
519
|
+
);
|
|
520
|
+
} catch (e) {
|
|
521
|
+
console.error(
|
|
522
|
+
fail(
|
|
523
|
+
`the ship gate refused to accept this change: ${String(e?.message || e)}`,
|
|
524
|
+
"check the reconcile/compliance result with `tot preview`, then re-run `tot ship`",
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
return 1;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const progress = deps.progress === false ? null : startProgress("shipping…");
|
|
531
|
+
let status;
|
|
532
|
+
try {
|
|
533
|
+
status = await poll(client, { id, tenant });
|
|
534
|
+
} finally {
|
|
535
|
+
progress?.stop();
|
|
536
|
+
}
|
|
537
|
+
return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The unauthorised path — don't just error out. The change record was already opened
|
|
542
|
+
* and moved to ready_for_review while resolving the ship (`resolveChangeRecordId`), so
|
|
543
|
+
* the review request IS recorded; we just tell the developer that and print WHO can
|
|
544
|
+
* approve. (There is no separate `change_ready` to run — re-readying a ready record is
|
|
545
|
+
* an illegal transition; the record already sits in the reviewers' queue.)
|
|
546
|
+
* @returns {Promise<number>} exit code (non-zero — the ship didn't happen)
|
|
547
|
+
*/
|
|
548
|
+
async function handleUnauthorized(client, { id, tenant, approvers }) {
|
|
549
|
+
void client;
|
|
550
|
+
void id;
|
|
45
551
|
console.error(
|
|
46
552
|
fail(
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"(change_accept / `tot pr`). Self-serve `tot ship` is coming soon.",
|
|
553
|
+
"you're not authorised to ship this change live yourself",
|
|
554
|
+
"the change is recorded and ready for review — an authorised approver can ship it",
|
|
50
555
|
),
|
|
51
556
|
);
|
|
52
|
-
|
|
557
|
+
if (approvers.length) {
|
|
558
|
+
console.error(` Who can approve: ${approvers.join(", ")}`);
|
|
559
|
+
} else {
|
|
560
|
+
console.error(` Who can approve: a teammate with ship/promote authority for ${tenant}.`);
|
|
561
|
+
}
|
|
562
|
+
return 1;
|
|
53
563
|
}
|
|
564
|
+
|
|
565
|
+
/** Report the shipped result and open the live URL (unless suppressed). */
|
|
566
|
+
function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
|
|
567
|
+
const url = status?.previewUrl || accept?.previewUrl || null;
|
|
568
|
+
if (status?.shipped || accept?.shipped) {
|
|
569
|
+
console.log(`\n ✓ shipped ${tenant} live.`);
|
|
570
|
+
if (url) {
|
|
571
|
+
console.log(` Live: ${url}`);
|
|
572
|
+
if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
|
|
573
|
+
}
|
|
574
|
+
return 0;
|
|
575
|
+
}
|
|
576
|
+
// Accept committed but the shipped state hasn't been observed yet (a promote may
|
|
577
|
+
// still be landing) — report honestly rather than claim a live URL.
|
|
578
|
+
console.log(`\n ~ ship accepted for ${tenant}; it's going live now.`);
|
|
579
|
+
if (url) console.log(` Track it here: ${url}`);
|
|
580
|
+
return 0;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* @param {string[]} argv
|
|
585
|
+
* @param {any} ctx
|
|
586
|
+
*/
|
|
587
|
+
export async function run(argv, ctx) {
|
|
588
|
+
const env = process.env;
|
|
589
|
+
const args = parseShipArgs(argv);
|
|
590
|
+
if (args.help) {
|
|
591
|
+
console.log(USAGE);
|
|
592
|
+
return 0;
|
|
593
|
+
}
|
|
594
|
+
if (ctx.mode !== "checkout") {
|
|
595
|
+
console.error(
|
|
596
|
+
fail(
|
|
597
|
+
"`tot ship` runs from inside a tenant checkout",
|
|
598
|
+
"tot clone <tenant> <dir> (then `cd` in, `tot preview`, and `tot ship`)",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
return 2;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const workspace = ctx.workspacePath;
|
|
605
|
+
const tenant = ctx.tenant;
|
|
606
|
+
const git = (cargs) =>
|
|
607
|
+
execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
608
|
+
const gitSafe = (cargs) => {
|
|
609
|
+
try {
|
|
610
|
+
return git(cargs);
|
|
611
|
+
} catch {
|
|
612
|
+
return "";
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
617
|
+
if (!repo) {
|
|
618
|
+
console.error(
|
|
619
|
+
fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
|
|
620
|
+
);
|
|
621
|
+
return 1;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
625
|
+
const statePath = defaultCandidateStatePath(env);
|
|
626
|
+
const client = createMcpClient(baseUrl);
|
|
627
|
+
try {
|
|
628
|
+
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
629
|
+
// Bind the active tenant so the candidate/change tools read the right scope.
|
|
630
|
+
await client.callTool("client_switch", { tenant });
|
|
631
|
+
|
|
632
|
+
// Resolve which candidate this checkout ships (the u4 seam) — branch-bound so
|
|
633
|
+
// ship promotes exactly the candidate `tot preview` pushed from THIS branch.
|
|
634
|
+
const changeId = resolveActiveChangeId({
|
|
635
|
+
statePath,
|
|
636
|
+
mcpUrl: baseUrl,
|
|
637
|
+
repo,
|
|
638
|
+
tenant,
|
|
639
|
+
actorKey: actorKeyFor(session),
|
|
640
|
+
branch: currentBranch(gitSafe),
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// The title/body the change RECORD is opened with — from the HEAD commit subject
|
|
644
|
+
// (the reviewed commit's own message), so the approver's record isn't blank.
|
|
645
|
+
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
646
|
+
const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
|
|
647
|
+
|
|
648
|
+
return await runShip(
|
|
649
|
+
client,
|
|
650
|
+
{ tenant, changeId, repo, git, noOpen: args.noOpen, changeSummary },
|
|
651
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
652
|
+
);
|
|
653
|
+
} catch (e) {
|
|
654
|
+
if (e instanceof AuthUnavailableError) {
|
|
655
|
+
console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
|
|
656
|
+
return 1;
|
|
657
|
+
}
|
|
658
|
+
console.error(
|
|
659
|
+
fail(
|
|
660
|
+
`couldn't reach the ship service: ${String(e?.message || e)}`,
|
|
661
|
+
"check your connection and that you're signed in, then re-run",
|
|
662
|
+
),
|
|
663
|
+
);
|
|
664
|
+
return 1;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
package/src/commands/submit.mjs
CHANGED
|
@@ -42,6 +42,7 @@ 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";
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
writeActiveChangeId,
|
|
53
54
|
mintFreshChangeId,
|
|
54
55
|
isTerminalCandidateState,
|
|
56
|
+
isDefaultBranch,
|
|
55
57
|
} from "../candidate-state.mjs";
|
|
56
58
|
|
|
57
59
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -345,22 +347,165 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
345
347
|
}
|
|
346
348
|
}
|
|
347
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
|
+
|
|
348
465
|
/**
|
|
349
|
-
* A STABLE per-developer-per-tenant candidate handle — so repeat
|
|
350
|
-
* 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
|
|
351
468
|
* (`candidate_open` is idempotent on `changeId`). No local state file needed:
|
|
352
|
-
* it's a deterministic hash of the tenant + the acting identity
|
|
353
|
-
* fresh every run. Two different developers
|
|
354
|
-
* 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.
|
|
355
480
|
* @param {string} tenant
|
|
356
481
|
* @param {string} actorKey
|
|
482
|
+
* @param {string|null} [branch] current git branch; default/null ⇒ today's id
|
|
357
483
|
* @returns {string}
|
|
358
484
|
*/
|
|
359
|
-
export function deriveChangeId(tenant, actorKey) {
|
|
360
|
-
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);
|
|
361
488
|
return `local-${hash}`;
|
|
362
489
|
}
|
|
363
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
|
+
|
|
364
509
|
/** The stable identity key behind `deriveChangeId` — the signed-in developer's
|
|
365
510
|
* email, falling back to the token, then a generic label. Single-plane: the only
|
|
366
511
|
* identity `tot` carries is the developer's own OAuth session. */
|
|
@@ -538,10 +683,68 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
538
683
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
539
684
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
540
685
|
|
|
541
|
-
|
|
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;
|
|
542
694
|
try {
|
|
543
|
-
|
|
544
|
-
|
|
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)`);
|
|
745
|
+
try {
|
|
746
|
+
const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
|
|
747
|
+
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
545
748
|
} catch (e) {
|
|
546
749
|
console.error(
|
|
547
750
|
fail(
|
|
@@ -555,16 +758,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
555
758
|
printChangeSummary(changeSummary);
|
|
556
759
|
|
|
557
760
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
558
|
-
// compliance + preview URL from the MCP
|
|
559
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
560
|
-
const client = createMcpClient(baseUrl);
|
|
761
|
+
// compliance + preview URL from the MCP — reusing the session established above.
|
|
561
762
|
let progress = null;
|
|
562
763
|
try {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
566
|
-
// Set the active tenant so preview_status/candidate_open read the right scope
|
|
567
|
-
// (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).
|
|
568
766
|
await client.callTool("client_switch", { tenant });
|
|
569
767
|
|
|
570
768
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
@@ -576,12 +774,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
576
774
|
// otherwise → the remembered active candidate (from a prior --new /
|
|
577
775
|
// roll), else the STABLE per-dev-per-tenant default.
|
|
578
776
|
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
579
|
-
// so a re-submit is never wedged on a dead PR.
|
|
580
|
-
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
777
|
+
// so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
|
|
581
778
|
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
582
779
|
const statePath = defaultCandidateStatePath(env);
|
|
583
|
-
|
|
584
|
-
|
|
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;
|
|
585
786
|
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
586
787
|
// Persist when we diverge from the stable default (a --new fork, or a
|
|
587
788
|
// previously-remembered active pointer) so the next plain submit follows it.
|
|
@@ -601,7 +802,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
601
802
|
// never let a state-write failure break the submit).
|
|
602
803
|
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
603
804
|
try {
|
|
604
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
|
|
805
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
605
806
|
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
606
807
|
}
|
|
607
808
|
|