@tokenoftrust/cli 2.4.0 → 2.4.1
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 +4 -4
- package/package.json +1 -1
- package/src/commands/accept.mjs +35 -15
- package/src/commands/revert.mjs +27 -14
- package/src/pipeline-job.mjs +63 -0
package/bin/tot.mjs
CHANGED
|
@@ -219,8 +219,8 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
219
219
|
|
|
220
220
|
// `accept` queue-integrates a PR into the protected `preview` aggregate —
|
|
221
221
|
// NO merge-to-main, NO go-live; going live is `tot ship`, which publishes the
|
|
222
|
-
// whole current GREEN AGGREGATE. Backed by POST /api/changes/integrate
|
|
223
|
-
//
|
|
222
|
+
// whole current GREEN AGGREGATE. Backed by POST /api/changes/integrate (an
|
|
223
|
+
// `integrate` job in the store's pipeline); see accept.mjs's header. `merge` is a
|
|
224
224
|
// first-class alias, not a teaching nudge: both dispatch straight to the same command.
|
|
225
225
|
if (cmd === "accept" || cmd === "merge") {
|
|
226
226
|
const { run } = await import("../src/commands/accept.mjs");
|
|
@@ -245,8 +245,8 @@ async function dispatch(cmd, rest, ctx) {
|
|
|
245
245
|
// `revert --preview <PR|change-id>` undoes an already-integrated PR out of the
|
|
246
246
|
// protected `preview` aggregate by opening a NEW corrective candidate and
|
|
247
247
|
// merging it through the SAME accept pipeline — NO force-reset, NO touch to
|
|
248
|
-
// main/live. Backed by POST /api/changes/revert (
|
|
249
|
-
// the same
|
|
248
|
+
// main/live. Backed by POST /api/changes/revert (a `revert` job in the store's
|
|
249
|
+
// pipeline, the same steps `tot accept` runs). To undo something already LIVE,
|
|
250
250
|
// that's `tot rollback`, not this. See revert.mjs's header.
|
|
251
251
|
if (cmd === "revert") {
|
|
252
252
|
const { run } = await import("../src/commands/revert.mjs");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1",
|
|
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/commands/accept.mjs
CHANGED
|
@@ -18,13 +18,13 @@
|
|
|
18
18
|
* chg-id). An explicit `--change-id` is still accepted (targets a specific record),
|
|
19
19
|
* but is no longer REQUIRED — a PR number alone is enough.
|
|
20
20
|
*
|
|
21
|
-
* TRANSPORT — the honest accept path is `POST /api/changes/integrate`
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
* `
|
|
27
|
-
*
|
|
21
|
+
* TRANSPORT — the honest accept path is `POST /api/changes/integrate`. Like `tot ship --pr`
|
|
22
|
+
* and `tot pr list --tenant`, the CLI reaches it with the OPERATOR-SECRET Bearer transport
|
|
23
|
+
* (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`), since the CLI holds no
|
|
24
|
+
* storefront cookie. The route submits an `integrate` job to the store's pipeline and answers
|
|
25
|
+
* `202 {jobId}`; this verb waits on `GET /api/pipeline/jobs/<jobId>` for the job's outcome —
|
|
26
|
+
* `jobState` / `runState` / `pointerMoved` / `aggregateSha` / `statusMessage` — and renders it
|
|
27
|
+
* VERBATIM, never a bare "merged". The job runs on the platform whether or not this waits.
|
|
28
28
|
*
|
|
29
29
|
* HUMAN GATE — integrating into the shared preview is a decision a human makes, so
|
|
30
30
|
* this ALWAYS states the EXACT plan (shared `planForAction`: which PR,
|
|
@@ -53,6 +53,7 @@ import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
|
53
53
|
// "here's what's blocking you" summary over the SAME transport we just used, so a
|
|
54
54
|
// developer doesn't have to remember to run `tot preview doctor` themselves.
|
|
55
55
|
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
56
|
+
import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
|
|
56
57
|
|
|
57
58
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
58
59
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -150,7 +151,7 @@ export function parseAcceptArgs(argv) {
|
|
|
150
151
|
* fields this verb renders. Reads defensively so a plausible field rename degrades
|
|
151
152
|
* rather than crashes. Pure — unit-tested.
|
|
152
153
|
* @param {any} data
|
|
153
|
-
* @returns {{ ok:boolean,
|
|
154
|
+
* @returns {{ ok:boolean, pending:boolean, jobId:string|null, jobState:string|null, runState:string|null,
|
|
154
155
|
* pointerMoved:boolean, aggregateSha:string|null, statusMessage:string|null,
|
|
155
156
|
* reason:string|null, changeId:string|null, prNumber:number|null, error:string|null, raw:any }}
|
|
156
157
|
*/
|
|
@@ -158,7 +159,9 @@ export function normalizeIntegrateResponse(data) {
|
|
|
158
159
|
const o = data && typeof data === "object" ? data : {};
|
|
159
160
|
return {
|
|
160
161
|
ok: o.ok === true,
|
|
161
|
-
|
|
162
|
+
pending: o.pending === true,
|
|
163
|
+
jobId: typeof o.jobId === "string" ? o.jobId : null,
|
|
164
|
+
jobState: typeof o.jobState === "string" ? o.jobState : null,
|
|
162
165
|
runState: typeof o.runState === "string" ? o.runState : null,
|
|
163
166
|
pointerMoved: o.pointerMoved === true,
|
|
164
167
|
aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
|
|
@@ -388,8 +391,11 @@ async function handleNotMergeable(
|
|
|
388
391
|
return { integrate: true, pr: rr.prNumber ?? pr, changeId };
|
|
389
392
|
}
|
|
390
393
|
|
|
391
|
-
/**
|
|
392
|
-
|
|
394
|
+
/**
|
|
395
|
+
* POST `/api/changes/integrate`, wait on the job it submitted, and normalise the outcome.
|
|
396
|
+
* Returns { res, result } — `res.ok` is the submit's transport verdict.
|
|
397
|
+
*/
|
|
398
|
+
async function postIntegrate({ base, authHeaders, body }, fetchImpl, waitOpts = {}) {
|
|
393
399
|
const res = await fetchImpl(`${base}/api/changes/integrate`, {
|
|
394
400
|
method: "POST",
|
|
395
401
|
headers: { "content-type": "application/json", ...authHeaders },
|
|
@@ -401,6 +407,11 @@ async function postIntegrate({ base, authHeaders, body }, fetchImpl) {
|
|
|
401
407
|
} catch {
|
|
402
408
|
/* non-JSON / empty body */
|
|
403
409
|
}
|
|
410
|
+
if (isJobAccepted(res, data)) {
|
|
411
|
+
console.log(` submitted as job ${data.jobId} — waiting for it to finish (it continues on the platform if you stop waiting)…`);
|
|
412
|
+
const { outcome } = await waitForPipelineJob({ base, authHeaders, jobId: data.jobId }, fetchImpl, waitOpts);
|
|
413
|
+
return { res, result: normalizeIntegrateResponse({ ...outcome, jobId: data.jobId }) };
|
|
414
|
+
}
|
|
404
415
|
return { res, result: normalizeIntegrateResponse(data) };
|
|
405
416
|
}
|
|
406
417
|
|
|
@@ -422,7 +433,13 @@ function integrateBody({ tenant, pr, changeId, headSha }) {
|
|
|
422
433
|
* @returns {number}
|
|
423
434
|
*/
|
|
424
435
|
export function reportIntegrated(result, { tenant, label }) {
|
|
425
|
-
const state = `${result.
|
|
436
|
+
const state = `${result.jobState ?? "?"}/${result.runState ?? "—"}`;
|
|
437
|
+
if (result.pending) {
|
|
438
|
+
console.log(`\n … ${label} is still being added to ${tenant}'s preview aggregate — it finishes on the platform.`);
|
|
439
|
+
if (result.jobId) console.log(` job: ${result.jobId}`);
|
|
440
|
+
console.log(" → next: re-check the queue (`tot pr list --tenant " + tenant + "`) in a few minutes.");
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
426
443
|
if (result.ok) {
|
|
427
444
|
console.log(`\n ✓ queued ${label} into ${tenant}'s preview aggregate — it is GREEN.`);
|
|
428
445
|
console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
|
|
@@ -458,7 +475,8 @@ export function reportIntegrated(result, { tenant, label }) {
|
|
|
458
475
|
* secret:string, storefrontUrl?:string|null, yes?:boolean, refresh?:boolean,
|
|
459
476
|
* strategy?:string, mcpUrl?:string|null, identity?:string|null, env?:NodeJS.ProcessEnv }} params
|
|
460
477
|
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
461
|
-
* resolveViewerTransport?:typeof resolveViewerTransport
|
|
478
|
+
* resolveViewerTransport?:typeof resolveViewerTransport,
|
|
479
|
+
* wait?:Parameters<typeof waitForPipelineJob>[2] }} [deps]
|
|
462
480
|
* @returns {Promise<number>} process exit code
|
|
463
481
|
*/
|
|
464
482
|
export async function runIntegrate(
|
|
@@ -575,6 +593,7 @@ export async function runIntegrate(
|
|
|
575
593
|
({ res, result } = await postIntegrate(
|
|
576
594
|
{ base, authHeaders, body: integrateBody({ tenant, pr: curPr, changeId: curChangeId, headSha: curHead }) },
|
|
577
595
|
fetchImpl,
|
|
596
|
+
deps.wait,
|
|
578
597
|
));
|
|
579
598
|
} catch (e) {
|
|
580
599
|
console.error(
|
|
@@ -585,7 +604,7 @@ export async function runIntegrate(
|
|
|
585
604
|
|
|
586
605
|
// A pre-flight error (auth, unknown tenant, candidate not found) is an HTTP 4xx
|
|
587
606
|
// with `{ error }` and no queue verdict — surface it distinctly from a red run.
|
|
588
|
-
if (!res.ok
|
|
607
|
+
if (!res.ok) {
|
|
589
608
|
return reportIntegrateHttpError(result, res, { tenant, label });
|
|
590
609
|
}
|
|
591
610
|
|
|
@@ -612,6 +631,7 @@ export async function runIntegrate(
|
|
|
612
631
|
}),
|
|
613
632
|
},
|
|
614
633
|
fetchImpl,
|
|
634
|
+
deps.wait,
|
|
615
635
|
));
|
|
616
636
|
} catch (e) {
|
|
617
637
|
console.error(
|
|
@@ -619,7 +639,7 @@ export async function runIntegrate(
|
|
|
619
639
|
);
|
|
620
640
|
return 1;
|
|
621
641
|
}
|
|
622
|
-
if (!res.ok
|
|
642
|
+
if (!res.ok) {
|
|
623
643
|
return reportIntegrateHttpError(result, res, { tenant, label });
|
|
624
644
|
}
|
|
625
645
|
}
|
package/src/commands/revert.mjs
CHANGED
|
@@ -22,15 +22,14 @@
|
|
|
22
22
|
* anything — when there's nothing TO undo (never integrated, no longer part of
|
|
23
23
|
* the current aggregate, or already undone).
|
|
24
24
|
*
|
|
25
|
-
* TRANSPORT — `POST /api/changes/revert`
|
|
26
|
-
*
|
|
27
|
-
* `tot accept`
|
|
28
|
-
* operator-secret Bearer transport
|
|
29
|
-
* `x-tot-capability`) or a signed-in developer's
|
|
30
|
-
* a live `ship-on-behalf` grant. The
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
* VERBATIM, never a bare "reverted".
|
|
25
|
+
* TRANSPORT — `POST /api/changes/revert` submits a `revert` job to the store's pipeline (open
|
|
26
|
+
* the corrective candidate, then merge → build → evidence → advance the preview, exactly as
|
|
27
|
+
* `tot accept`) and answers `202 {jobId}`; this verb waits on `GET /api/pipeline/jobs/<jobId>`.
|
|
28
|
+
* Like `tot accept`, the CLI reaches it with either the operator-secret Bearer transport
|
|
29
|
+
* (`resolveOperatorSecret` + `X-Tot-Owner` + `x-tot-capability`) or a signed-in developer's
|
|
30
|
+
* viewer session when they hold a live `ship-on-behalf` grant. The outcome — `jobState` /
|
|
31
|
+
* `runState` / `pointerMoved` / `aggregateSha` / `changeId` (the corrective candidate's own id) /
|
|
32
|
+
* `statusMessage` — is rendered VERBATIM, never a bare "reverted".
|
|
34
33
|
*
|
|
35
34
|
* HUMAN GATE — correcting the shared preview is a decision a human makes, so
|
|
36
35
|
* this ALWAYS states the EXACT plan (a NEW corrective candidate, merged through
|
|
@@ -48,6 +47,7 @@ import { resolveOperatorSecret } from "./ship.mjs";
|
|
|
48
47
|
// No-operator-secret path: mint a viewer session from the developer's OWN `tot`
|
|
49
48
|
// login. The server still gates the request on their live ship-on-behalf grant.
|
|
50
49
|
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
50
|
+
import { isJobAccepted, waitForPipelineJob } from "../pipeline-job.mjs";
|
|
51
51
|
|
|
52
52
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
53
53
|
|
|
@@ -163,7 +163,9 @@ export function normalizeRevertResponse(data) {
|
|
|
163
163
|
const o = data && typeof data === "object" ? data : {};
|
|
164
164
|
return {
|
|
165
165
|
ok: o.ok === true,
|
|
166
|
-
|
|
166
|
+
pending: o.pending === true,
|
|
167
|
+
jobId: typeof o.jobId === "string" ? o.jobId : null,
|
|
168
|
+
jobState: typeof o.jobState === "string" ? o.jobState : null,
|
|
167
169
|
runState: typeof o.runState === "string" ? o.runState : null,
|
|
168
170
|
pointerMoved: o.pointerMoved === true,
|
|
169
171
|
aggregateSha: typeof o.aggregateSha === "string" ? o.aggregateSha : null,
|
|
@@ -184,7 +186,12 @@ export function normalizeRevertResponse(data) {
|
|
|
184
186
|
* @returns {number}
|
|
185
187
|
*/
|
|
186
188
|
export function reportReverted(result, { tenant, label }) {
|
|
187
|
-
const state = `${result.
|
|
189
|
+
const state = `${result.jobState ?? "?"}/${result.runState ?? "—"}`;
|
|
190
|
+
if (result.pending) {
|
|
191
|
+
console.log(`\n … undoing ${label} in ${tenant}'s preview aggregate is still running — it finishes on the platform.`);
|
|
192
|
+
if (result.jobId) console.log(` job: ${result.jobId}`);
|
|
193
|
+
return 0;
|
|
194
|
+
}
|
|
188
195
|
if (result.ok) {
|
|
189
196
|
console.log(`\n ✓ undid ${label} in ${tenant}'s preview aggregate — it is GREEN.`);
|
|
190
197
|
console.log(` aggregate: ${state}${result.aggregateSha ? ` (${result.aggregateSha})` : ""}`);
|
|
@@ -227,7 +234,8 @@ export function reportReverted(result, { tenant, label }) {
|
|
|
227
234
|
* message:string|null, secret:string, storefrontUrl?:string|null, yes?:boolean,
|
|
228
235
|
* env?:NodeJS.ProcessEnv }} params
|
|
229
236
|
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
230
|
-
* resolveViewerTransport?:typeof resolveViewerTransport
|
|
237
|
+
* resolveViewerTransport?:typeof resolveViewerTransport,
|
|
238
|
+
* wait?:Parameters<typeof waitForPipelineJob>[2] }} [deps]
|
|
231
239
|
* @returns {Promise<number>} process exit code
|
|
232
240
|
*/
|
|
233
241
|
export async function runRevert(
|
|
@@ -314,11 +322,16 @@ export async function runRevert(
|
|
|
314
322
|
} catch {
|
|
315
323
|
/* non-JSON / empty body */
|
|
316
324
|
}
|
|
325
|
+
if (isJobAccepted(res, data)) {
|
|
326
|
+
console.log(` submitted as job ${data.jobId} — waiting for it to finish (it continues on the platform if you stop waiting)…`);
|
|
327
|
+
const { outcome } = await waitForPipelineJob({ base, authHeaders, jobId: data.jobId }, fetchImpl, deps.wait);
|
|
328
|
+
data = { ...outcome, jobId: data.jobId };
|
|
329
|
+
}
|
|
317
330
|
const result = normalizeRevertResponse(data);
|
|
318
331
|
|
|
319
332
|
// A pre-flight error (auth, unknown tenant, bad body) is an HTTP 4xx with
|
|
320
|
-
// `{ error }` and no
|
|
321
|
-
if (!res.ok
|
|
333
|
+
// `{ error }` and no job outcome — surface it distinctly from a red run.
|
|
334
|
+
if (!res.ok) {
|
|
322
335
|
const msg = result.error || `HTTP ${res.status}`;
|
|
323
336
|
console.error(
|
|
324
337
|
fail(
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting on a store pipeline job. Add to release (`tot accept`) and Undo (`tot revert`) run as
|
|
3
|
+
* jobs in the store's pipeline: the route answers `202 {jobId}` at once, and the job's outcome is
|
|
4
|
+
* read from `GET /api/pipeline/jobs/<jobId>` until it is no longer pending. The job runs on the
|
|
5
|
+
* platform whether or not anything is waiting for it — stopping here (Ctrl-C, a timeout) stops
|
|
6
|
+
* only the wait.
|
|
7
|
+
*
|
|
8
|
+
* Dependency-free (global fetch).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const DEFAULT_INTERVAL_MS = 3_000;
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 25 * 60_000;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Poll the job until its outcome is terminal, or `timeoutMs` passes. Resolves with
|
|
16
|
+
* `{ outcome, timedOut }` — `outcome` is the last read (`pending: true` when it timed out).
|
|
17
|
+
* Network faults between polls are retried; a non-2xx read ends the wait with that status.
|
|
18
|
+
*
|
|
19
|
+
* @param {{ base:string, authHeaders:Record<string,string>, jobId:string }} target
|
|
20
|
+
* @param {typeof fetch} fetchImpl
|
|
21
|
+
* @param {{ intervalMs?:number, timeoutMs?:number, sleep?:(ms:number)=>Promise<void>,
|
|
22
|
+
* now?:()=>number, onPoll?:(outcome:any)=>void }} [opts]
|
|
23
|
+
*/
|
|
24
|
+
export async function waitForPipelineJob({ base, authHeaders, jobId }, fetchImpl, opts = {}) {
|
|
25
|
+
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
|
|
26
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
27
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
28
|
+
const now = opts.now ?? (() => Date.now());
|
|
29
|
+
const started = now();
|
|
30
|
+
let last = { ok: false, pending: true, jobId };
|
|
31
|
+
for (;;) {
|
|
32
|
+
let res = null;
|
|
33
|
+
try {
|
|
34
|
+
res = await fetchImpl(`${base}/api/pipeline/jobs/${encodeURIComponent(jobId)}`, {
|
|
35
|
+
method: "GET",
|
|
36
|
+
headers: { accept: "application/json", ...authHeaders },
|
|
37
|
+
});
|
|
38
|
+
} catch {
|
|
39
|
+
res = null;
|
|
40
|
+
}
|
|
41
|
+
if (res) {
|
|
42
|
+
let data = {};
|
|
43
|
+
try {
|
|
44
|
+
data = await res.json();
|
|
45
|
+
} catch {
|
|
46
|
+
data = {};
|
|
47
|
+
}
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
return { outcome: { ok: false, pending: true, jobId, error: data.error || `job read failed (${res.status})` }, timedOut: false };
|
|
50
|
+
}
|
|
51
|
+
last = data && data.outcome && typeof data.outcome === "object" ? data.outcome : last;
|
|
52
|
+
opts.onPoll?.(last);
|
|
53
|
+
if (last.pending !== true) return { outcome: last, timedOut: false };
|
|
54
|
+
}
|
|
55
|
+
if (now() - started >= timeoutMs) return { outcome: last, timedOut: true };
|
|
56
|
+
await sleep(intervalMs);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when a submit answered with a job to wait on (`202 {jobId}`). */
|
|
61
|
+
export function isJobAccepted(res, data) {
|
|
62
|
+
return Boolean(res && res.status === 202 && data && typeof data.jobId === "string");
|
|
63
|
+
}
|