@tokenoftrust/cli 1.4.0 → 1.5.0
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 +5 -0
- package/bin/tot.mjs +148 -57
- package/package.json +6 -1
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +4 -4
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +3 -3
- package/src/commands/accept.mjs +498 -59
- package/src/commands/app/dev.mjs +8 -4
- package/src/commands/app/index.mjs +3 -3
- package/src/commands/app/scaffold.mjs +1 -1
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +264 -0
- package/src/commands/clone.mjs +307 -25
- package/src/commands/dev.mjs +440 -156
- package/src/commands/doctor.mjs +4 -4
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +9 -5
- package/src/commands/grants.mjs +7 -5
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/ideas.mjs +2 -2
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +5 -6
- package/src/commands/pr.mjs +62 -25
- package/src/commands/preview-build.mjs +6 -6
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview-retry-evidence.mjs +156 -0
- package/src/commands/preview.mjs +19 -3
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +18 -16
- package/src/commands/ship.mjs +51 -14
- package/src/commands/start.mjs +101 -59
- package/src/commands/submit.mjs +1183 -169
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +10 -4
- package/src/commands/whoami.mjs +1 -1
- package/src/dev-heartbeat.mjs +3 -2
- package/src/dev-logs.mjs +2 -2
- package/src/errors.mjs +11 -4
- package/src/git-credential.mjs +257 -0
- package/src/last-tenant.mjs +1 -1
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +18 -14
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +83 -15
- package/src/sample.mjs +4 -4
- package/src/validate.mjs +187 -15
- package/src/vendor/private-apps-devkit.mjs +3 -3
- package/src/viewer-session.mjs +118 -0
- package/template/private-app/README.md +12 -6
- package/src/commands/retire.mjs +0 -203
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot revert --preview <PR|integration-sha>` — OPERATOR verb: REMOVE
|
|
3
|
+
* already-integrated content from the protected `preview` AGGREGATE by creating a
|
|
4
|
+
* NEW auditable revert commit, then rebuilding the aggregate. It is the explicit,
|
|
5
|
+
* safe answer to "several candidates are accepted, but one must be removed"
|
|
6
|
+
* (branch-lifecycle contract): NEVER a force-reset, NEVER a branch delete — a revert
|
|
7
|
+
* is a new commit on `preview`, and the shared preview moves only when the rebuilt
|
|
8
|
+
* aggregate is green again. Full history is preserved.
|
|
9
|
+
*
|
|
10
|
+
* DISTINCT from `tot ship`: revert is NOT go-live. It touches NO `main` and NO live
|
|
11
|
+
* channel; it only rewinds the SHARED preview aggregate. The verb is `--preview`-only
|
|
12
|
+
* by construction — there is no `--main` revert. To undo something already LIVE, use
|
|
13
|
+
* `tot rollback` (a live re-point), not this.
|
|
14
|
+
*
|
|
15
|
+
* TARGET — `--preview <target>` names EITHER a PR number (`42` or `#42`) OR an exact
|
|
16
|
+
* `preview` integration SHA. A numeric target is sent as `prNumber`; anything else as
|
|
17
|
+
* `integrationSha`. The server resolves either to the exact integration commit to
|
|
18
|
+
* undo (the resolver, inside the tenant-serialized queue lock), and REFUSES a
|
|
19
|
+
* target that was never integrated or has already been reverted.
|
|
20
|
+
*
|
|
21
|
+
* TRANSPORT — `POST /api/changes/revert` (the ONE call site of
|
|
22
|
+
* `TenantIntegrationQueue.enqueueRevert`). Like `tot accept` / `tot ship`, the CLI
|
|
23
|
+
* reaches it with the OPERATOR-SECRET Bearer transport (`resolveOperatorSecret` +
|
|
24
|
+
* `X-Tot-Owner` + `x-tot-capability`). The response is the honest terminal aggregate
|
|
25
|
+
* state — `queueState` / `runState` / `pointerMoved` / `aggregateSha` /
|
|
26
|
+
* `revertedSha` / `statusMessage` — rendered VERBATIM, never a bare "reverted".
|
|
27
|
+
*
|
|
28
|
+
* HUMAN GATE — rewinding the shared preview is a decision a human makes, so this
|
|
29
|
+
* ALWAYS states the EXACT plan (a NEW revert commit, NO force-reset, NO touch to
|
|
30
|
+
* main/live) and requires an explicit confirm. `--yes` is an explicit affirmative; a
|
|
31
|
+
* non-TTY without `--yes` is refused (mirrors `tot accept` / `tot ship`).
|
|
32
|
+
*
|
|
33
|
+
* Dependency-free (global fetch + the shared plan module).
|
|
34
|
+
*/
|
|
35
|
+
import { fail } from "../errors.mjs";
|
|
36
|
+
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
37
|
+
// Reuse `tot ship`'s operator-secret precedence verbatim so accept + ship + revert
|
|
38
|
+
// speak ONE operator-auth contract.
|
|
39
|
+
import { resolveOperatorSecret } from "./ship.mjs";
|
|
40
|
+
|
|
41
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
42
|
+
|
|
43
|
+
const USAGE = `tot revert — remove already-integrated content from the preview aggregate (auditable)
|
|
44
|
+
|
|
45
|
+
tot revert --preview <PR|integration-sha> --tenant <t>
|
|
46
|
+
|
|
47
|
+
Creates a NEW auditable revert commit on the tenant's protected \`preview\`
|
|
48
|
+
aggregate that undoes one prior integration, then rebuilds the aggregate and
|
|
49
|
+
runs combined evidence. The shared preview advances only when it is GREEN
|
|
50
|
+
again. A revert is NEVER a force-reset and NEVER deletes commits — full history
|
|
51
|
+
is preserved. It does NOT touch main and does NOT go live.
|
|
52
|
+
|
|
53
|
+
<PR|integration-sha> is EITHER a PR number (e.g. 42 or #42) OR an exact preview
|
|
54
|
+
integration SHA. A target that was never integrated, or has already been
|
|
55
|
+
reverted, is refused before any change is made.
|
|
56
|
+
|
|
57
|
+
Rewinding the shared preview is a human decision: this ALWAYS prints the exact
|
|
58
|
+
plan and asks for an explicit confirm. A non-TTY without --yes is refused.
|
|
59
|
+
|
|
60
|
+
Options:
|
|
61
|
+
--preview <PR|sha> REQUIRED. The PR number or integration SHA to revert.
|
|
62
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
63
|
+
current checkout's tenant when run inside one.
|
|
64
|
+
--message <msg> Optional revert-commit message.
|
|
65
|
+
--url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
66
|
+
--secret <s> operator secret (prefer the env vars below)
|
|
67
|
+
--yes, -y Skip the interactive confirm (still an explicit human
|
|
68
|
+
affirmative — there is no default-yes).
|
|
69
|
+
--help, -h Show this help.
|
|
70
|
+
|
|
71
|
+
To undo something already LIVE, use \`tot rollback\` — not \`tot revert\`.
|
|
72
|
+
|
|
73
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
74
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).`;
|
|
75
|
+
|
|
76
|
+
/** Parse `tot revert` argv. Pure — unit-testable. */
|
|
77
|
+
export function parseRevertArgs(argv) {
|
|
78
|
+
const a = {
|
|
79
|
+
preview: null,
|
|
80
|
+
tenant: null,
|
|
81
|
+
message: null,
|
|
82
|
+
url: null,
|
|
83
|
+
secret: null,
|
|
84
|
+
yes: false,
|
|
85
|
+
help: false,
|
|
86
|
+
};
|
|
87
|
+
for (let i = 0; i < argv.length; i++) {
|
|
88
|
+
const t = argv[i];
|
|
89
|
+
if (t === "--preview") a.preview = argv[++i];
|
|
90
|
+
else if (t === "--tenant") a.tenant = argv[++i];
|
|
91
|
+
else if (t === "--message" || t === "-m") a.message = argv[++i];
|
|
92
|
+
else if (t === "--url") a.url = argv[++i];
|
|
93
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
94
|
+
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
95
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
96
|
+
}
|
|
97
|
+
return a;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Classify a `--preview` target into `{ prNumber }` or `{ integrationSha }`. A bare
|
|
102
|
+
* integer (optionally `#`-prefixed) is a PR number; anything else (a hex-ish SHA) is
|
|
103
|
+
* an integration SHA. Pure — unit-tested.
|
|
104
|
+
* @param {string|null} raw
|
|
105
|
+
* @returns {{ prNumber:number|null, integrationSha:string|null }}
|
|
106
|
+
*/
|
|
107
|
+
export function classifyRevertTarget(raw) {
|
|
108
|
+
const t = (raw || "").trim().replace(/^#/, "");
|
|
109
|
+
if (!t) return { prNumber: null, integrationSha: null };
|
|
110
|
+
if (/^\d+$/.test(t)) return { prNumber: Number(t), integrationSha: null };
|
|
111
|
+
return { prNumber: null, integrationSha: t };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Normalise a `POST /api/changes/revert` body to the honest terminal aggregate
|
|
116
|
+
* fields this verb renders. Reads defensively so a plausible field rename degrades
|
|
117
|
+
* rather than crashes. Pure — unit-tested.
|
|
118
|
+
* @param {any} data
|
|
119
|
+
*/
|
|
120
|
+
export function normalizeRevertResponse(data) {
|
|
121
|
+
const o = data && typeof data === "object" ? data : {};
|
|
122
|
+
return {
|
|
123
|
+
ok: o.ok === true,
|
|
124
|
+
queueState: typeof o.queueState === "string" ? o.queueState : null,
|
|
125
|
+
runState: typeof o.runState === "string" ? o.runState : null,
|
|
126
|
+
pointerMoved: o.pointerMoved === true,
|
|
127
|
+
aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
|
|
128
|
+
revertedSha: typeof o.revertedSha === "string" ? o.revertedSha : null,
|
|
129
|
+
statusMessage: typeof o.statusMessage === "string" ? o.statusMessage : null,
|
|
130
|
+
reason: typeof o.reason === "string" ? o.reason : null,
|
|
131
|
+
changeId: typeof o.changeId === "string" ? o.changeId : null,
|
|
132
|
+
error: typeof o.error === "string" ? o.error : null,
|
|
133
|
+
raw: data,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Render the terminal aggregate status in house style — the honest queue/run state,
|
|
139
|
+
* NEVER a bare "reverted". Pure given its inputs; returns the process exit code.
|
|
140
|
+
* @param {ReturnType<typeof normalizeRevertResponse>} result
|
|
141
|
+
* @param {{ tenant:string, label:string }} ctx
|
|
142
|
+
* @returns {number}
|
|
143
|
+
*/
|
|
144
|
+
export function reportReverted(result, { tenant, label }) {
|
|
145
|
+
const state = `${result.queueState ?? "?"}/${result.runState ?? "—"}`;
|
|
146
|
+
if (result.ok) {
|
|
147
|
+
console.log(`\n ✓ reverted ${label} out of ${tenant}'s preview aggregate — it is GREEN.`);
|
|
148
|
+
console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
|
|
149
|
+
if (result.revertedSha) console.log(` reverted integration commit: ${result.revertedSha}`);
|
|
150
|
+
if (result.pointerMoved) console.log(" the shared preview pointer moved to the reverted aggregate.");
|
|
151
|
+
console.log(" → next: review the aggregate preview, then `tot ship` to publish it live.");
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
// Honest non-green: the revert did NOT land in the shippable aggregate.
|
|
155
|
+
console.log(`\n ✗ ${label} was NOT reverted out of ${tenant}'s preview aggregate.`);
|
|
156
|
+
console.log(` aggregate: ${state}${result.reason ? ` (${result.reason})` : ""}`);
|
|
157
|
+
if (result.statusMessage) console.log(` why: ${result.statusMessage}`);
|
|
158
|
+
if (result.reason === "target_not_found" || result.reason === "already_reverted") {
|
|
159
|
+
console.log(` → check the target: \`tot ship\` shows the aggregate's included PRs.`);
|
|
160
|
+
} else {
|
|
161
|
+
console.log(" → the aggregate is unchanged (last green preserved); fix forward or retry.");
|
|
162
|
+
}
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The revert flow after args are parsed: state the exact plan, confirm, then POST
|
|
168
|
+
* `/api/changes/revert` (operator-secret transport) and render the honest terminal
|
|
169
|
+
* aggregate status. `fetch`/`confirmPlan` injected so it is unit-tested with no
|
|
170
|
+
* network/TTY.
|
|
171
|
+
*
|
|
172
|
+
* @param {{ tenant:string, prNumber:number|null, integrationSha:string|null,
|
|
173
|
+
* message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean }} params
|
|
174
|
+
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm }} [deps]
|
|
175
|
+
* @returns {Promise<number>} process exit code
|
|
176
|
+
*/
|
|
177
|
+
export async function runRevert(
|
|
178
|
+
{ tenant, prNumber, integrationSha, message = null, secret, storefrontUrl = null, yes = false },
|
|
179
|
+
deps = {},
|
|
180
|
+
) {
|
|
181
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
182
|
+
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
183
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
184
|
+
const label = prNumber != null ? `PR #${prNumber}` : `integration ${integrationSha}`;
|
|
185
|
+
|
|
186
|
+
// 1. State the EXACT plan (shared affordance) — a NEW revert commit on
|
|
187
|
+
// preview, NO force-reset, NO touch to main/live — and gate on a confirm.
|
|
188
|
+
const planLines = planForAction({
|
|
189
|
+
action: "revert",
|
|
190
|
+
tenant,
|
|
191
|
+
pr: prNumber,
|
|
192
|
+
integrationSha,
|
|
193
|
+
});
|
|
194
|
+
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
195
|
+
yes,
|
|
196
|
+
question: `Revert ${label} out of ${tenant}'s preview aggregate (a new revert commit)?`,
|
|
197
|
+
});
|
|
198
|
+
if (!confirmed) {
|
|
199
|
+
if (reason === "non-tty") {
|
|
200
|
+
console.error(
|
|
201
|
+
fail(
|
|
202
|
+
"refusing to revert without confirmation on a non-TTY.",
|
|
203
|
+
"re-run with --yes (an explicit human affirmative), or from an interactive terminal.",
|
|
204
|
+
),
|
|
205
|
+
);
|
|
206
|
+
return 2;
|
|
207
|
+
}
|
|
208
|
+
console.log("Aborted — nothing was reverted.");
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 2. Operator-secret transport — the CLI holds no storefront cookie.
|
|
213
|
+
if (!secret) {
|
|
214
|
+
console.error(
|
|
215
|
+
fail(
|
|
216
|
+
"reverting the preview aggregate is an OPERATOR action — it needs an operator secret",
|
|
217
|
+
"set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
|
|
218
|
+
),
|
|
219
|
+
);
|
|
220
|
+
return 2;
|
|
221
|
+
}
|
|
222
|
+
const authHeaders = {
|
|
223
|
+
"content-type": "application/json",
|
|
224
|
+
authorization: `Bearer ${secret}`,
|
|
225
|
+
"x-tot-owner": tenant,
|
|
226
|
+
"x-tot-capability": "ship-on-behalf",
|
|
227
|
+
};
|
|
228
|
+
const requestBody = {
|
|
229
|
+
repo: tenant,
|
|
230
|
+
...(prNumber != null ? { prNumber } : {}),
|
|
231
|
+
...(integrationSha ? { integrationSha } : {}),
|
|
232
|
+
...(message ? { message } : {}),
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// 3. POST the honest revert path (queue enqueueRevert) and render terminal state.
|
|
236
|
+
let res;
|
|
237
|
+
try {
|
|
238
|
+
res = await fetchImpl(`${base}/api/changes/revert`, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: authHeaders,
|
|
241
|
+
body: JSON.stringify(requestBody),
|
|
242
|
+
});
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error(
|
|
245
|
+
fail(`couldn't reach the revert endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
246
|
+
);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let data = {};
|
|
251
|
+
try {
|
|
252
|
+
data = await res.json();
|
|
253
|
+
} catch {
|
|
254
|
+
/* non-JSON / empty body */
|
|
255
|
+
}
|
|
256
|
+
const result = normalizeRevertResponse(data);
|
|
257
|
+
|
|
258
|
+
// A pre-flight error (auth, unknown tenant, bad body) is an HTTP 4xx with
|
|
259
|
+
// `{ error }` and no queue verdict — surface it distinctly from a red run.
|
|
260
|
+
if (!res.ok && result.queueState == null) {
|
|
261
|
+
const msg = result.error || `HTTP ${res.status}`;
|
|
262
|
+
console.error(
|
|
263
|
+
fail(
|
|
264
|
+
`the revert request was refused: ${msg}`,
|
|
265
|
+
res.status === 401 || res.status === 403
|
|
266
|
+
? "check the operator secret and that it's authorised for this tenant"
|
|
267
|
+
: "check --tenant / --url / --preview, then re-run",
|
|
268
|
+
),
|
|
269
|
+
);
|
|
270
|
+
return 1;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return reportReverted(result, { tenant, label });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* @param {string[]} argv
|
|
278
|
+
* @param {any} ctx
|
|
279
|
+
*/
|
|
280
|
+
export async function run(argv, ctx) {
|
|
281
|
+
const env = process.env;
|
|
282
|
+
const args = parseRevertArgs(argv);
|
|
283
|
+
if (args.help) {
|
|
284
|
+
console.log(USAGE);
|
|
285
|
+
return 0;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
289
|
+
if (!tenant) {
|
|
290
|
+
console.error(
|
|
291
|
+
fail(
|
|
292
|
+
"no target tenant.",
|
|
293
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
294
|
+
),
|
|
295
|
+
);
|
|
296
|
+
return 2;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const { prNumber, integrationSha } = classifyRevertTarget(args.preview);
|
|
300
|
+
if (prNumber == null && !integrationSha) {
|
|
301
|
+
console.error(
|
|
302
|
+
fail(
|
|
303
|
+
"no revert target.",
|
|
304
|
+
"pass --preview <PR|integration-sha> (a PR number like 42, or an exact preview integration SHA).",
|
|
305
|
+
),
|
|
306
|
+
);
|
|
307
|
+
return 2;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const storefrontUrl =
|
|
311
|
+
args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
312
|
+
|
|
313
|
+
return await runRevert({
|
|
314
|
+
tenant,
|
|
315
|
+
prNumber,
|
|
316
|
+
integrationSha,
|
|
317
|
+
message: (args.message || "").trim() || null,
|
|
318
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
319
|
+
storefrontUrl,
|
|
320
|
+
yes: args.yes,
|
|
321
|
+
});
|
|
322
|
+
}
|
|
@@ -7,17 +7,17 @@
|
|
|
7
7
|
* tot rollback <versionId> re-point the live channel back to that version
|
|
8
8
|
*
|
|
9
9
|
* This does NOT reimplement pointer moves. Every selection and every move goes
|
|
10
|
-
* through
|
|
10
|
+
* through the MCP-side live-pointer seam (`promotion_status` /
|
|
11
11
|
* `promotion_rollback`, tot-mcp `src/modules/mcp/change/promotion-pointer-tools.ts`
|
|
12
12
|
* + `promotion-pointer-store.ts`) — the SAME primitive `tot ship`'s eventual
|
|
13
|
-
* orchestrator composes
|
|
14
|
-
* There is no
|
|
13
|
+
* orchestrator composes.
|
|
14
|
+
* There is no ship-orchestrator service in-tree yet, so this calls that seam
|
|
15
15
|
* DIRECTLY; when the orchestrator lands it should absorb the physical
|
|
16
16
|
* publish+verify this command currently leaves to it (see the seam's own
|
|
17
17
|
* "NOT a live claim" caveat, echoed in `reportRolledBack` below) — a follow-on can
|
|
18
18
|
* route through it instead without changing this file's UX.
|
|
19
19
|
*
|
|
20
|
-
* Fail-closed
|
|
20
|
+
* Fail-closed: rollback selects
|
|
21
21
|
* an immutable prior digest/versionId recorded by a PRIOR promote; a missing or
|
|
22
22
|
* ineligible target is refused with a clear next step, never a silent no-op dressed
|
|
23
23
|
* up as success. The eligibility check and the pointer move both come from the
|
|
@@ -41,7 +41,7 @@ import { startProgress } from "../progress.mjs";
|
|
|
41
41
|
|
|
42
42
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
43
43
|
|
|
44
|
-
// The only publish channel the seam is exercised against today (see the
|
|
44
|
+
// The only publish channel the seam is exercised against today (see the tests
|
|
45
45
|
// and `promotion_set`'s own default). A future multi-target store can override with
|
|
46
46
|
// `--target`; nothing here assumes there's only ever one.
|
|
47
47
|
const DEFAULT_TARGET = "production";
|
|
@@ -63,7 +63,9 @@ const USAGE = `tot rollback [<versionId>] — instant re-point to a prior live v
|
|
|
63
63
|
|
|
64
64
|
/** Parse `tot rollback` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
65
65
|
export function parseRollbackArgs(argv) {
|
|
66
|
+
/** @type {{ versionId: string|null, target: string|null, mcp: string|null, identity: string|null, help: boolean }} */
|
|
66
67
|
const a = { versionId: null, target: null, mcp: null, identity: null, help: false };
|
|
68
|
+
/** @type {string[]} */
|
|
67
69
|
const positional = [];
|
|
68
70
|
for (let i = 0; i < argv.length; i++) {
|
|
69
71
|
const t = argv[i];
|
|
@@ -138,11 +140,11 @@ export function normalizeRollbackResult(r) {
|
|
|
138
140
|
};
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
// ─── Revision links (pure
|
|
143
|
+
// ─── Revision links (pure) ───────────────────────────────────────────────
|
|
142
144
|
|
|
143
145
|
/**
|
|
144
146
|
* The immutable `/preview/<tenant>/rev/<sha>/` deep-link for a promoted
|
|
145
|
-
* `versionId` —
|
|
147
|
+
* `versionId` — this route, and per `gitea-only-tenant-content-authority` the
|
|
146
148
|
* `versionId` recorded by a promote IS the tenant Gitea sha, so no extra lookup
|
|
147
149
|
* is needed to build it. `null` when there's no `base` (storefront origin) or no
|
|
148
150
|
* `versionId` to link, so a caller can render the history line either way.
|
|
@@ -177,10 +179,10 @@ export function rollbackCandidates(history, currentVersionId) {
|
|
|
177
179
|
}
|
|
178
180
|
|
|
179
181
|
/** Render the `tot rollback` (no args) history listing — the rollback-target
|
|
180
|
-
* picker
|
|
181
|
-
*
|
|
182
|
-
* @param {{ tenant:string, target:string, current:
|
|
183
|
-
* candidates:
|
|
182
|
+
* picker, each entry annotated with its immutable `/rev/<sha>` link
|
|
183
|
+
* when a storefront origin is available. Pure — unit-tested.
|
|
184
|
+
* @param {{ tenant:string, target:string, current:any,
|
|
185
|
+
* candidates:any[], revisionBase?:string|null }} input
|
|
184
186
|
* @returns {string[]}
|
|
185
187
|
*/
|
|
186
188
|
export function renderHistory({ tenant, target, current, candidates, revisionBase }) {
|
|
@@ -209,7 +211,7 @@ export function renderHistory({ tenant, target, current, candidates, revisionBas
|
|
|
209
211
|
*/
|
|
210
212
|
export function renderRollbackPreview({ tenant, target, toVersionId, preview }) {
|
|
211
213
|
const lines = ["", ` This rollback will change the LIVE site for ${tenant}:`];
|
|
212
|
-
const current = preview.pointer?.current;
|
|
214
|
+
const current = /** @type {any} */ (preview.pointer?.current);
|
|
213
215
|
if (current) lines.push(` ${target}: ${current.versionId} → ${toVersionId}`);
|
|
214
216
|
else lines.push(` ${target}: → ${toVersionId}`);
|
|
215
217
|
return lines;
|
|
@@ -225,7 +227,7 @@ export function renderRollbackPreview({ tenant, target, toVersionId, preview })
|
|
|
225
227
|
* @param {{callTool:Function}} client an MCP client (real or mock)
|
|
226
228
|
* @param {{ tenant:string, target:string, toVersionId:string|null,
|
|
227
229
|
* revisionBase?:string|null }} params `revisionBase` is the storefront origin
|
|
228
|
-
* the listing's `/rev/<sha>` links resolve against
|
|
230
|
+
* the listing's `/rev/<sha>` links resolve against; omit to list
|
|
229
231
|
* without links.
|
|
230
232
|
* @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
|
|
231
233
|
* progress?:boolean }} [deps]
|
|
@@ -258,7 +260,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
|
|
|
258
260
|
);
|
|
259
261
|
return 1;
|
|
260
262
|
}
|
|
261
|
-
const candidates = rollbackCandidates(status.history, status.current?.versionId ?? null);
|
|
263
|
+
const candidates = rollbackCandidates(status.history, /** @type {any} */ (status).current?.versionId ?? null);
|
|
262
264
|
for (const line of renderHistory({ tenant, target, current: status.current, candidates, revisionBase })) {
|
|
263
265
|
console.log(line);
|
|
264
266
|
}
|
|
@@ -375,8 +377,8 @@ export async function run(argv, ctx) {
|
|
|
375
377
|
const tenant = ctx.tenant;
|
|
376
378
|
const target = args.target?.trim() || DEFAULT_TARGET;
|
|
377
379
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
378
|
-
// The storefront origin the listing's /rev/<sha> links resolve against
|
|
379
|
-
//
|
|
380
|
+
// The storefront origin the listing's /rev/<sha> links resolve against
|
|
381
|
+
// — owner == appDomain on this platform, mirroring go-live.mjs's default.
|
|
380
382
|
const revisionBase = env.TOT_STOREFRONT_URL || (tenant ? `https://${tenant}` : null);
|
|
381
383
|
const client = createMcpClient(baseUrl);
|
|
382
384
|
try {
|
package/src/commands/ship.mjs
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `tot ship` — publish the tenant's CURRENT GREEN AGGREGATE to live. ONE
|
|
3
|
-
* meaning
|
|
4
|
-
*
|
|
5
|
-
* operator-deploy-only / ship-any-built-PR branches):
|
|
3
|
+
* meaning — SUPERSEDES the retired context-dependent ship and its
|
|
4
|
+
* developer-accept-then-deploy / operator-deploy-only / ship-any-built-PR branches:
|
|
6
5
|
*
|
|
7
6
|
* tot dev run your store locally with save→reload
|
|
8
7
|
* tot preview push it to a reviewable preview (validate → reconcile → compliance)
|
|
@@ -11,17 +10,17 @@
|
|
|
11
10
|
* `tot ship` NEVER takes a PR/candidate target and NEVER infers context from
|
|
12
11
|
* your checkout or who's running it. There is nothing to detect: it ships the
|
|
13
12
|
* tenant's shared `preview` aggregate — the batch of PRs that integrated
|
|
14
|
-
* cleanly and went green
|
|
13
|
+
* cleanly and went green — full stop. The candidate-level merge
|
|
15
14
|
* is the separate `tot accept` verb (`accept.mjs`); ship never merges.
|
|
16
15
|
*
|
|
17
16
|
* THE FLOW:
|
|
18
17
|
*
|
|
19
|
-
* 1. GET the read-only PLAN from `/api/changes/ship` —
|
|
18
|
+
* 1. GET the read-only PLAN from `/api/changes/ship` — the
|
|
20
19
|
* `AggregateShipOrchestrator.plan()` exposed over HTTP: the pinned
|
|
21
20
|
* aggregate sha, its content-addressed artifact digest, every included
|
|
22
21
|
* PR, the rollback target (what a rollback would restore), and the
|
|
23
22
|
* go-live paywall verdict. Zero side effects.
|
|
24
|
-
* 2. Print the EXACT plan via the shared
|
|
23
|
+
* 2. Print the EXACT plan via the shared affordance (`../plan.mjs`) and
|
|
25
24
|
* require ONE explicit confirm, defaulting to NO. `--yes` confirms
|
|
26
25
|
* non-interactively; a non-TTY WITHOUT `--yes` REFUSES — nothing ships
|
|
27
26
|
* without an explicit yes.
|
|
@@ -47,6 +46,12 @@ import { fail } from "../errors.mjs";
|
|
|
47
46
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
48
47
|
import { startProgress } from "../progress.mjs";
|
|
49
48
|
import { openBrowser } from "../open.mjs";
|
|
49
|
+
import { emitActivity } from "../activity.mjs";
|
|
50
|
+
// The doctor PUSH: on a refused/failed ship (most tellingly "not green"),
|
|
51
|
+
// auto-append the COMPACT summary over the SAME operator transport, so the operator
|
|
52
|
+
// sees WHICH changes are keeping the aggregate from going green without a separate
|
|
53
|
+
// `tot preview doctor` run.
|
|
54
|
+
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
50
55
|
|
|
51
56
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
52
57
|
|
|
@@ -97,7 +102,7 @@ export function parseShipArgs(argv) {
|
|
|
97
102
|
return a;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
|
-
// ─── Operator-secret transport (shared with `tot pr list --tenant`
|
|
105
|
+
// ─── Operator-secret transport (shared with `tot pr list --tenant`) ────
|
|
101
106
|
//
|
|
102
107
|
// `resolveOperatorSecret` / `normalizeChangesQueue` are the SAME helpers the
|
|
103
108
|
// retired operator-by-PR ship path minted; `tot pr list --tenant` (`pr.mjs`)
|
|
@@ -149,9 +154,10 @@ export function normalizeChangesQueue(data) {
|
|
|
149
154
|
|
|
150
155
|
// ─── Shared with `tot accept` (`accept.mjs`) ─────────────────────────────────────
|
|
151
156
|
//
|
|
152
|
-
// `tot accept`
|
|
153
|
-
//
|
|
154
|
-
// normalisers straight from here rather
|
|
157
|
+
// `tot accept` queues a PR's integration into the protected `preview` aggregate
|
|
158
|
+
// (NOT a merge to main — see accept.mjs's header) — a DISTINCT operator verb from
|
|
159
|
+
// ship (no deploy). It imports these two normalisers straight from here rather
|
|
160
|
+
// than re-implementing them.
|
|
155
161
|
|
|
156
162
|
/**
|
|
157
163
|
* Normalise a `change_accept` / `change_status` result to { shipped, state,
|
|
@@ -202,7 +208,7 @@ export function liveUrlFor(tenant) {
|
|
|
202
208
|
// ─── Response normalisation (defensive — one endpoint, but shapes may vary) ─────
|
|
203
209
|
|
|
204
210
|
/**
|
|
205
|
-
* Normalise a `GET /api/changes/ship` body —
|
|
211
|
+
* Normalise a `GET /api/changes/ship` body — the `AggregateShipPlan` (a
|
|
206
212
|
* shippable aggregate) or `AggregateShipRefusal` (nothing shippable), read
|
|
207
213
|
* defensively since it crossed the wire as JSON. Pure — unit-tested.
|
|
208
214
|
* @param {any} data
|
|
@@ -243,7 +249,7 @@ export function normalizeShipPlan(data) {
|
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
/**
|
|
246
|
-
* Normalise a `POST /api/changes/ship` body —
|
|
252
|
+
* Normalise a `POST /api/changes/ship` body — the `AggregateShipResult`.
|
|
247
253
|
* `state` is the ONLY honest terminal-state authority: render "shipped live"
|
|
248
254
|
* for `"shipped"` and nothing else. Pure — unit-tested.
|
|
249
255
|
* @param {any} data
|
|
@@ -325,6 +331,14 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
325
331
|
"x-tot-capability": "ship-on-behalf",
|
|
326
332
|
};
|
|
327
333
|
|
|
334
|
+
// Auto-surface the merge-doctor on a refused/failed ship. Best-effort:
|
|
335
|
+
// fetches the hosted doctor over the SAME operator transport and prints the COMPACT
|
|
336
|
+
// summary; never throws and never touches the exit code. A clean/unreachable doctor
|
|
337
|
+
// prints nothing — so this only ever ADDS the "here's what's not green" context.
|
|
338
|
+
const surfaceDoctor = async () => {
|
|
339
|
+
for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
|
|
340
|
+
};
|
|
341
|
+
|
|
328
342
|
// 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
|
|
329
343
|
// rollback target + paywall verdict. Zero side effects.
|
|
330
344
|
let planRes;
|
|
@@ -354,10 +368,13 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
354
368
|
const plan = normalizeShipPlan(planData);
|
|
355
369
|
if (!plan.ok) {
|
|
356
370
|
console.error(fail(plan.message, refusalNextStep(plan.reason)));
|
|
371
|
+
// A not-green / no-passed-run refusal is exactly what the doctor diagnoses —
|
|
372
|
+
// name the changes keeping the aggregate red right here.
|
|
373
|
+
await surfaceDoctor();
|
|
357
374
|
return 1;
|
|
358
375
|
}
|
|
359
376
|
|
|
360
|
-
// 2. Print the EXACT plan via the shared
|
|
377
|
+
// 2. Print the EXACT plan via the shared affordance, then require ONE
|
|
361
378
|
// explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
|
|
362
379
|
const liveUrl = liveUrlFor(tenant);
|
|
363
380
|
const planLines = planForAction({
|
|
@@ -413,7 +430,27 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
413
430
|
const shipData = await readJsonSafe(shipRes);
|
|
414
431
|
progress?.stop();
|
|
415
432
|
|
|
416
|
-
|
|
433
|
+
const shipResult = normalizeShipResult(shipData);
|
|
434
|
+
// Emit the ship publish lifecycle event — `tot ship` does no LOCAL git op
|
|
435
|
+
// (it's HTTP-orchestrated), so this marks the outcome of the publish step
|
|
436
|
+
// itself, distinct from the outer command's invoked/result pair. Fire-and-
|
|
437
|
+
// forget best-effort: a silent no-op without a hosted-bridge credential, never
|
|
438
|
+
// awaited, never throws, never alters the ship. `errorClass` stays low-
|
|
439
|
+
// cardinality (the orchestrator's own terminal state/reason, never free text).
|
|
440
|
+
const shipped = shipResult.state === "shipped";
|
|
441
|
+
void emitActivity({
|
|
442
|
+
action: "cli.command.result",
|
|
443
|
+
outcome: {
|
|
444
|
+
status: shipped ? "succeeded" : "failed",
|
|
445
|
+
...(shipped ? {} : { errorClass: `ship_${shipResult.state}` }),
|
|
446
|
+
},
|
|
447
|
+
scope: { tenantId: tenant },
|
|
448
|
+
payload: { args: { command: "ship", subcommand: "ship.publish" } },
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const code = reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
452
|
+
if (code !== 0) await surfaceDoctor();
|
|
453
|
+
return code;
|
|
417
454
|
}
|
|
418
455
|
|
|
419
456
|
/**
|