@tokenoftrust/cli 1.4.0-rc.8 → 1.4.0-rc.9
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 +7 -0
- package/package.json +1 -1
- package/src/candidate-state.mjs +97 -0
- package/src/commands/pr.mjs +200 -0
- package/src/commands/submit.mjs +51 -9
package/bin/tot.mjs
CHANGED
|
@@ -14,6 +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 submit submit your store for preview ← built (validate + push preview ref; MCP preview_status read-back)
|
|
17
|
+
* tot pr list / view / close your candidate PRs ← built (candidate_status/candidate_close; gh-pr-shaped)
|
|
17
18
|
* tot doctor check this machine is ready
|
|
18
19
|
* tot ideas copy-paste AI prompts that reliably wow
|
|
19
20
|
* tot feedback send a note to ToT + your recent CLI activity ← built (activity-log.mjs → feedback_submit MCP tool)
|
|
@@ -60,6 +61,7 @@ tot — Token of Trust developer CLI
|
|
|
60
61
|
tot validate lint your store before you submit
|
|
61
62
|
tot dev run your store locally with save→reload
|
|
62
63
|
tot submit submit your store for preview
|
|
64
|
+
tot pr list / view / close your candidate PRs
|
|
63
65
|
tot doctor check this machine is ready
|
|
64
66
|
tot ideas copy-paste AI prompts that reliably wow
|
|
65
67
|
tot feedback "<msg>" send feedback to Token of Trust (attaches recent activity)
|
|
@@ -151,6 +153,11 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
151
153
|
return run(rest, ctx);
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
if (cmd === "pr") {
|
|
157
|
+
const { run } = await import("../src/commands/pr.mjs");
|
|
158
|
+
return run(rest, ctx);
|
|
159
|
+
}
|
|
160
|
+
|
|
154
161
|
if (cmd === "app") {
|
|
155
162
|
const { run } = await import("../src/commands/app/index.mjs");
|
|
156
163
|
return run(rest, ctx);
|
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.9",
|
|
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",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "active candidate" pointer — which open candidate PR a plain `tot submit`
|
|
3
|
+
* updates, per forge repo.
|
|
4
|
+
*
|
|
5
|
+
* `tot submit` is idempotent on a STABLE changeId (`deriveChangeId`) so a re-submit
|
|
6
|
+
* updates the same PR by default — the common case needs NO state and writes
|
|
7
|
+
* nothing here (backward-compatible with the stateless original). This file only
|
|
8
|
+
* records a DIVERGENCE from that stable default:
|
|
9
|
+
*
|
|
10
|
+
* - `tot submit --new` forks a fresh candidate and remembers it here, so the
|
|
11
|
+
* NEXT plain `tot submit` keeps updating the NEW PR (like pushing more commits
|
|
12
|
+
* to a `gh pr` branch), not the old one; and
|
|
13
|
+
* - a terminal-roll (the active candidate was merged/closed) records the fresh
|
|
14
|
+
* candidate it rolled to, so you're never wedged submitting to a dead PR.
|
|
15
|
+
*
|
|
16
|
+
* ONE file, `~/.tot/candidates.json`, a map keyed by `<mcpUrl>::<repo>` — a
|
|
17
|
+
* different MCP or repo is a different candidate namespace. Same atomic-write
|
|
18
|
+
* discipline as last-tenant.mjs (0600 in a 0700 dir, write-tmp-then-rename).
|
|
19
|
+
* Dependency-free (node:fs/os/path). `TOT_HOME` overrides home (tests).
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync,
|
|
23
|
+
} from "node:fs";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join, dirname } from "node:path";
|
|
26
|
+
import { randomBytes } from "node:crypto";
|
|
27
|
+
|
|
28
|
+
/** Absolute path to the active-candidate map for this machine. */
|
|
29
|
+
export function defaultCandidateStatePath(env = process.env) {
|
|
30
|
+
const home = env.TOT_HOME || homedir();
|
|
31
|
+
return join(home, ".tot", "candidates.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Namespace key for one (MCP, repo) candidate pointer. */
|
|
35
|
+
function stateKey(mcpUrl, repo) {
|
|
36
|
+
return `${mcpUrl}::${repo}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readMap(filePath) {
|
|
40
|
+
try {
|
|
41
|
+
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
|
42
|
+
return parsed && typeof parsed === "object" ? parsed : {};
|
|
43
|
+
} catch {
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeMap(filePath, map) {
|
|
49
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
50
|
+
const tmp = `${filePath}.tmp`;
|
|
51
|
+
writeFileSync(tmp, `${JSON.stringify(map, null, 2)}\n`, { mode: 0o600 });
|
|
52
|
+
renameSync(tmp, filePath);
|
|
53
|
+
chmodSync(filePath, 0o600);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The remembered active changeId for `(mcpUrl, repo)`, or null when there isn't
|
|
58
|
+
* one (absent/unreadable/malformed) — a miss means "use the stable default".
|
|
59
|
+
* Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export function readActiveChangeId(filePath, { mcpUrl, repo }) {
|
|
62
|
+
const rec = readMap(filePath)[stateKey(mcpUrl, repo)];
|
|
63
|
+
return rec && typeof rec.changeId === "string" && rec.changeId ? rec.changeId : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Remember `changeId` as the active candidate for `(mcpUrl, repo)`, atomically. */
|
|
67
|
+
export function writeActiveChangeId(filePath, { mcpUrl, repo, changeId }) {
|
|
68
|
+
const map = readMap(filePath);
|
|
69
|
+
map[stateKey(mcpUrl, repo)] = { changeId, updatedAt: Date.now() };
|
|
70
|
+
writeMap(filePath, map);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Forget the active candidate for `(mcpUrl, repo)` (e.g. after closing it). */
|
|
74
|
+
export function clearActiveChangeId(filePath, { mcpUrl, repo }) {
|
|
75
|
+
const map = readMap(filePath);
|
|
76
|
+
const key = stateKey(mcpUrl, repo);
|
|
77
|
+
if (key in map) {
|
|
78
|
+
delete map[key];
|
|
79
|
+
writeMap(filePath, map);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A fresh candidate handle forked from a stable base — `<baseId>-<suffix>`, still
|
|
85
|
+
* matching candidate_open's `[a-z0-9._-]` handle grammar. The suffix defaults to
|
|
86
|
+
* 6 random hex chars (so two `--new` runs never collide); tests inject a fixed
|
|
87
|
+
* suffix. Pure given `suffix`.
|
|
88
|
+
*/
|
|
89
|
+
export function mintFreshChangeId(baseId, suffix = randomBytes(3).toString("hex")) {
|
|
90
|
+
const safe = String(suffix).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 12) || "new";
|
|
91
|
+
return `${baseId}-${safe}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Whether a forge candidate state means "no open PR to update" (rolled past). */
|
|
95
|
+
export function isTerminalCandidateState(state) {
|
|
96
|
+
return state === "merged" || state === "closed";
|
|
97
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot pr` — see and manage the candidate PRs `tot submit` opens, mirroring
|
|
3
|
+
* `gh pr`.
|
|
4
|
+
*
|
|
5
|
+
* tot pr [list] list your OPEN candidate PRs for this store
|
|
6
|
+
* tot pr view <N|id> show one candidate (by PR number or changeId)
|
|
7
|
+
* tot pr close <N|id> close (reject) a candidate without merging
|
|
8
|
+
*
|
|
9
|
+
* A candidate PR is the reviewable unit `tot submit` creates. By default a
|
|
10
|
+
* re-submit UPDATES your open candidate; `tot submit --new` forks another. This
|
|
11
|
+
* command fills the gap the raw submit loop left — a first-party way to list your
|
|
12
|
+
* open candidates and to close one (candidate close was otherwise gated to
|
|
13
|
+
* version-control apps only).
|
|
14
|
+
*
|
|
15
|
+
* Runs from inside a tenant checkout (it derives the forge repo from the
|
|
16
|
+
* checkout's origin remote, exactly as `tot submit` does) and reads over the MCP
|
|
17
|
+
* `candidate_status` / `candidate_close` tools. Dependency-free (global fetch +
|
|
18
|
+
* `git`).
|
|
19
|
+
*/
|
|
20
|
+
import { execFileSync } from "node:child_process";
|
|
21
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
22
|
+
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
23
|
+
import { fail } from "../errors.mjs";
|
|
24
|
+
import { repoNameFromRemote } from "./submit.mjs";
|
|
25
|
+
import {
|
|
26
|
+
defaultCandidateStatePath,
|
|
27
|
+
readActiveChangeId,
|
|
28
|
+
clearActiveChangeId,
|
|
29
|
+
} from "../candidate-state.mjs";
|
|
30
|
+
|
|
31
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
32
|
+
const SUBCOMMANDS = ["list", "view", "close"];
|
|
33
|
+
|
|
34
|
+
const USAGE = `tot pr — see and manage your candidate PRs
|
|
35
|
+
|
|
36
|
+
tot pr [list] list your open candidate PRs for this store
|
|
37
|
+
tot pr view <N|id> show one candidate PR (by PR number or changeId)
|
|
38
|
+
tot pr close <N|id> close (reject) a candidate PR without merging
|
|
39
|
+
tot pr close <N|id> --reason "<why>" record why it was closed (audit note)
|
|
40
|
+
tot pr --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
41
|
+
|
|
42
|
+
A candidate PR is what \`tot submit\` opens for review. A re-submit updates your
|
|
43
|
+
open one by default; \`tot submit --new\` forks another. Use these to see and
|
|
44
|
+
manage them.`;
|
|
45
|
+
|
|
46
|
+
/** Parse `tot pr` argv into { sub, target, reason, mcp, identity, help }. Pure. */
|
|
47
|
+
export function parsePrArgs(argv) {
|
|
48
|
+
const a = { sub: null, target: null, reason: null, mcp: null, identity: null, help: false };
|
|
49
|
+
const positional = [];
|
|
50
|
+
for (let i = 0; i < argv.length; i++) {
|
|
51
|
+
const t = argv[i];
|
|
52
|
+
if (t === "--mcp") a.mcp = argv[++i];
|
|
53
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
54
|
+
else if (t === "--reason") a.reason = argv[++i];
|
|
55
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
56
|
+
else positional.push(t);
|
|
57
|
+
}
|
|
58
|
+
a.sub = positional[0] || "list";
|
|
59
|
+
a.target = positional[1] ?? null;
|
|
60
|
+
return a;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Normalize the `candidate_status` list response to a flat candidate array. Pure. */
|
|
64
|
+
export function extractCandidates(result) {
|
|
65
|
+
if (Array.isArray(result)) return result;
|
|
66
|
+
if (result && Array.isArray(result.candidates)) return result.candidates;
|
|
67
|
+
if (result && Array.isArray(result.environments)) return result.environments;
|
|
68
|
+
if (result && typeof result.changeId === "string") return [result];
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Find a candidate by PR number (all-digits target) or exact changeId. Pure. */
|
|
73
|
+
export function matchCandidate(candidates, target) {
|
|
74
|
+
if (target == null) return null;
|
|
75
|
+
if (/^\d+$/.test(target)) {
|
|
76
|
+
const n = Number(target);
|
|
77
|
+
return candidates.find((c) => c.prNumber === n) ?? null;
|
|
78
|
+
}
|
|
79
|
+
return candidates.find((c) => c.changeId === target) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** One-line candidate summary for `tot pr list`. */
|
|
83
|
+
function line(c) {
|
|
84
|
+
const pr = typeof c.prNumber === "number" ? `#${c.prNumber}` : "#—";
|
|
85
|
+
const url = c.url ? ` ${c.url}` : "";
|
|
86
|
+
return ` PR ${pr} ${c.changeId} [${c.state ?? "?"}]${url}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @param {string[]} argv @param {any} ctx */
|
|
90
|
+
export async function run(argv, ctx) {
|
|
91
|
+
const env = process.env;
|
|
92
|
+
const args = parsePrArgs(argv);
|
|
93
|
+
if (args.help) {
|
|
94
|
+
console.log(USAGE);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
if (!SUBCOMMANDS.includes(args.sub)) {
|
|
98
|
+
console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
|
|
99
|
+
return 2;
|
|
100
|
+
}
|
|
101
|
+
if (ctx.mode !== "checkout") {
|
|
102
|
+
console.error(
|
|
103
|
+
fail("`tot pr` runs from inside a tenant checkout", "tot clone <tenant> <dir> (then `cd` in and re-run)"),
|
|
104
|
+
);
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
if ((args.sub === "view" || args.sub === "close") && !args.target) {
|
|
108
|
+
console.error(fail(`\`tot pr ${args.sub}\` needs a PR number or changeId`, `tot pr ${args.sub} <N>`));
|
|
109
|
+
return 2;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const workspace = ctx.workspacePath;
|
|
113
|
+
const tenant = ctx.tenant;
|
|
114
|
+
const gitSafe = (cargs) => {
|
|
115
|
+
try {
|
|
116
|
+
return execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
117
|
+
} catch {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
122
|
+
if (!repo) {
|
|
123
|
+
console.error(
|
|
124
|
+
fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
|
|
125
|
+
);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
130
|
+
const statePath = defaultCandidateStatePath(env);
|
|
131
|
+
const scope = { mcpUrl: baseUrl, repo };
|
|
132
|
+
const client = createMcpClient(baseUrl);
|
|
133
|
+
try {
|
|
134
|
+
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
135
|
+
void session;
|
|
136
|
+
// Bind the active tenant so candidate_status/close read the right scope.
|
|
137
|
+
await client.callTool("client_switch", { tenant });
|
|
138
|
+
|
|
139
|
+
const candidates = extractCandidates(await client.callTool("candidate_status", { repo }));
|
|
140
|
+
|
|
141
|
+
if (args.sub === "list") {
|
|
142
|
+
if (!candidates.length) {
|
|
143
|
+
console.log(`No open candidate PRs for ${repo}. Run \`tot submit\` to open one.`);
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
const active = readActiveChangeId(statePath, scope);
|
|
147
|
+
console.log(`Open candidate PRs for ${repo}:`);
|
|
148
|
+
for (const c of candidates) {
|
|
149
|
+
console.log(line(c) + (active && c.changeId === active ? " ← active" : ""));
|
|
150
|
+
}
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const match = matchCandidate(candidates, args.target);
|
|
155
|
+
if (!match) {
|
|
156
|
+
console.error(fail(`no open candidate matches "${args.target}"`, "tot pr list (to see your open candidates)"));
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (args.sub === "view") {
|
|
161
|
+
const active = readActiveChangeId(statePath, scope);
|
|
162
|
+
console.log(`PR ${typeof match.prNumber === "number" ? `#${match.prNumber}` : "#—"} — ${match.changeId}${match.changeId === active ? " (active)" : ""}`);
|
|
163
|
+
console.log(` state: ${match.state ?? "?"}`);
|
|
164
|
+
if (match.branch) console.log(` branch: ${match.branch}`);
|
|
165
|
+
if (match.headSha) console.log(` head: ${match.headSha}`);
|
|
166
|
+
if (match.baseSha) console.log(` base: ${match.baseSha}`);
|
|
167
|
+
console.log(` mergeable (forge): ${match.mergeable ?? "?"}`);
|
|
168
|
+
if (match.url) console.log(` ${match.url}`);
|
|
169
|
+
return 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// close
|
|
173
|
+
await client.callTool("candidate_close", {
|
|
174
|
+
repo,
|
|
175
|
+
changeId: match.changeId,
|
|
176
|
+
...(args.reason ? { reason: args.reason } : {}),
|
|
177
|
+
});
|
|
178
|
+
console.log(`✓ closed candidate ${match.changeId}${typeof match.prNumber === "number" ? ` (PR #${match.prNumber})` : ""}.`);
|
|
179
|
+
// If we just closed the remembered active candidate, forget it so the next
|
|
180
|
+
// plain `tot submit` starts a fresh one rather than resurrecting this handle.
|
|
181
|
+
if (readActiveChangeId(statePath, scope) === match.changeId) {
|
|
182
|
+
try {
|
|
183
|
+
clearActiveChangeId(statePath, scope);
|
|
184
|
+
} catch { /* best-effort */ }
|
|
185
|
+
}
|
|
186
|
+
return 0;
|
|
187
|
+
} catch (e) {
|
|
188
|
+
if (e instanceof AuthUnavailableError) {
|
|
189
|
+
console.error(fail("sign in to manage candidate PRs", e.hint || "run `tot login`, then re-run"));
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
console.error(
|
|
193
|
+
fail(
|
|
194
|
+
`couldn't reach the candidate service: ${String(e?.message || e)}`,
|
|
195
|
+
"check your connection and that you're signed in, then re-run",
|
|
196
|
+
),
|
|
197
|
+
);
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
package/src/commands/submit.mjs
CHANGED
|
@@ -46,12 +46,19 @@ import { validateTenant, ERROR } from "../validate.mjs";
|
|
|
46
46
|
import { openBrowser } from "../open.mjs";
|
|
47
47
|
import { startProgress } from "../progress.mjs";
|
|
48
48
|
import { fail } from "../errors.mjs";
|
|
49
|
+
import {
|
|
50
|
+
defaultCandidateStatePath,
|
|
51
|
+
readActiveChangeId,
|
|
52
|
+
writeActiveChangeId,
|
|
53
|
+
mintFreshChangeId,
|
|
54
|
+
isTerminalCandidateState,
|
|
55
|
+
} from "../candidate-state.mjs";
|
|
49
56
|
|
|
50
57
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
51
58
|
const DEFAULT_REF = "preview";
|
|
52
59
|
|
|
53
60
|
export function parseArgs(argv) {
|
|
54
|
-
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, help: false };
|
|
61
|
+
const a = { mcp: null, identity: null, ref: DEFAULT_REF, skipValidate: false, noWait: false, watch: false, noOpen: false, message: null, summary: null, new: false, help: false };
|
|
55
62
|
for (let i = 0; i < argv.length; i++) {
|
|
56
63
|
const t = argv[i];
|
|
57
64
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -63,6 +70,7 @@ export function parseArgs(argv) {
|
|
|
63
70
|
else if (t === "--no-wait") a.noWait = true;
|
|
64
71
|
else if (t === "--watch") a.watch = true;
|
|
65
72
|
else if (t === "--no-open") a.noOpen = true;
|
|
73
|
+
else if (t === "--new") a.new = true;
|
|
66
74
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
67
75
|
}
|
|
68
76
|
return a;
|
|
@@ -71,6 +79,7 @@ export function parseArgs(argv) {
|
|
|
71
79
|
const USAGE = `tot submit — submit your store for preview
|
|
72
80
|
|
|
73
81
|
tot submit validate → push the preview ref → stream the result
|
|
82
|
+
tot submit --new open a NEW candidate PR instead of updating your open one
|
|
74
83
|
tot submit --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
75
84
|
tot submit --skip-validate push without the local lint (not recommended)
|
|
76
85
|
tot submit --ref <name> push ref (default: ${DEFAULT_REF})
|
|
@@ -80,6 +89,12 @@ const USAGE = `tot submit — submit your store for preview
|
|
|
80
89
|
tot submit --no-open don't open the preview URL in the browser on success
|
|
81
90
|
tot submit --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
82
91
|
|
|
92
|
+
By default a re-submit UPDATES your open candidate PR (like pushing more commits
|
|
93
|
+
to a GitHub PR), rather than opening a new one each time. Use --new to fork a
|
|
94
|
+
fresh candidate PR; the next plain \`tot submit\` then updates THAT one. Manage your
|
|
95
|
+
open candidates with \`tot pr\` (list / view / close). If your candidate was already
|
|
96
|
+
merged or closed, a re-submit automatically opens a fresh one.
|
|
97
|
+
|
|
83
98
|
If you omit -m, a summary is generated from git (commit subject + the diff vs
|
|
84
99
|
what's live in preview) so the change record the approver reviews is never blank.`;
|
|
85
100
|
|
|
@@ -289,7 +304,7 @@ export async function run(argv, ctx) {
|
|
|
289
304
|
console.error(
|
|
290
305
|
fail(
|
|
291
306
|
"`tot submit` runs from inside a tenant checkout",
|
|
292
|
-
"tot
|
|
307
|
+
"tot clone <tenant> <dir> (then `cd` in, commit your work, and re-run)",
|
|
293
308
|
),
|
|
294
309
|
);
|
|
295
310
|
return 2;
|
|
@@ -381,13 +396,40 @@ export async function run(argv, ctx) {
|
|
|
381
396
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
382
397
|
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
383
398
|
// reported and swallowed, never blocking the preview push that already landed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
399
|
+
//
|
|
400
|
+
// Which candidate this submit lands on (gh-pr-like):
|
|
401
|
+
// --new → fork a FRESH candidate and remember it as active;
|
|
402
|
+
// otherwise → the remembered active candidate (from a prior --new /
|
|
403
|
+
// roll), else the STABLE per-dev-per-tenant default.
|
|
404
|
+
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
405
|
+
// so a re-submit is never wedged on a dead PR.
|
|
406
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
407
|
+
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
408
|
+
const statePath = defaultCandidateStatePath(env);
|
|
409
|
+
const stableId = deriveChangeId(tenant, actorKeyFor(session));
|
|
410
|
+
const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo }) : null;
|
|
411
|
+
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
412
|
+
// Persist when we diverge from the stable default (a --new fork, or a
|
|
413
|
+
// previously-remembered active pointer) so the next plain submit follows it.
|
|
414
|
+
let persist = args.new || (!!active && active !== stableId);
|
|
415
|
+
|
|
416
|
+
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
417
|
+
|
|
418
|
+
if (candidate && isTerminalCandidateState(candidate.state)) {
|
|
419
|
+
const rolled = mintFreshChangeId(stableId);
|
|
420
|
+
console.log(` ~ candidate ${changeId} is ${candidate.state} — opening a fresh candidate PR instead.`);
|
|
421
|
+
changeId = rolled;
|
|
422
|
+
persist = true;
|
|
423
|
+
candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Remember the active candidate only on a real, non-terminal open (best-effort;
|
|
427
|
+
// never let a state-write failure break the submit).
|
|
428
|
+
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
429
|
+
try {
|
|
430
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
|
|
431
|
+
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
432
|
+
}
|
|
391
433
|
|
|
392
434
|
let status;
|
|
393
435
|
if (args.noWait) {
|