@tokenoftrust/cli 1.4.0-rc.2 → 1.4.0-rc.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -9
- package/bin/tot.mjs +219 -44
- package/package.json +7 -2
- package/src/activity.mjs +379 -0
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +137 -0
- package/src/commands/accept.mjs +736 -0
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +297 -0
- package/src/commands/cleanup.mjs +269 -0
- package/src/commands/clone.mjs +713 -0
- package/src/commands/dev.mjs +441 -93
- package/src/commands/doctor.mjs +4 -3
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +486 -0
- package/src/commands/grants.mjs +14 -7
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/link.mjs +225 -0
- package/src/commands/login.mjs +12 -8
- package/src/commands/pr.mjs +425 -0
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +403 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +91 -29
- package/src/commands/submit.mjs +1360 -131
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +11 -5
- package/src/commands/whoami.mjs +6 -2
- package/src/context.mjs +2 -2
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/oauth.mjs +26 -11
- package/src/obstacle-beacon.cjs +3 -3
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +30 -4
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
- package/src/commands/checkout.mjs +0 -330
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview build --tenant <t> --pr <N>` — OPERATOR build-on-demand (unit U3).
|
|
3
|
+
*
|
|
4
|
+
* Materialize ANY PR's hosted preview (including one that isn't yours, and one that
|
|
5
|
+
* was orphaned — its webhook never processed so it has no ReviewEnvironment). It
|
|
6
|
+
* calls the session-authenticated storefront endpoint `POST /api/preview/build`
|
|
7
|
+
* (unit U1), which runs the EXISTING `reconcileCandidate` against the PR head. The
|
|
8
|
+
* CLI passes the KNOWN descriptor (the head sha it holds), so there is NO forge
|
|
9
|
+
* PR-read dependency: reconcile reads the tenant files at that sha (content-addressed).
|
|
10
|
+
*
|
|
11
|
+
* This is distinct from `tot preview` (the developer's push-your-checkout-to-preview
|
|
12
|
+
* flow, which talks MCP). `tot preview build` is an OPERATOR verb over a specific
|
|
13
|
+
* `--tenant`/`--pr`, gated server-side on owner / ship-on-behalf privilege.
|
|
14
|
+
*
|
|
15
|
+
* AUTH — the storefront endpoint accepts the headless Bearer-operator-secret path
|
|
16
|
+
* (`resolveOwnerSession` fallback). This verb sends `Authorization: Bearer <secret>`
|
|
17
|
+
* (from `PREVIEW_RECONCILE_SECRET` / `GRANTS_ADMIN_SECRET` / `TOT_OPERATOR_SECRET`,
|
|
18
|
+
* or `--secret`), `X-Tot-Owner: <tenant>`, and `X-Tot-Capability: ship-on-behalf` —
|
|
19
|
+
* mirroring the `/admin` AdminPublishTab accept call.
|
|
20
|
+
*
|
|
21
|
+
* SELF-DECLARING — it prints the EXACT plan (which PR → which tenant → the preview
|
|
22
|
+
* URL) and confirms before acting (`--yes` to skip; a non-TTY without `--yes`
|
|
23
|
+
* aborts rather than acting silently). Build-on-demand is INERT — it flips no shared
|
|
24
|
+
* channel and is not go-live — so this is a materialize, not a deploy. The plan
|
|
25
|
+
* itself is built by the SHARED plan module (`../plan.mjs`, unit U10) — the same
|
|
26
|
+
* affordance every other mutating operator verb (accept/ship/retire) and the
|
|
27
|
+
* `/admin` confirm dialog use, per decision `operator-verb-and-hosting-model`.
|
|
28
|
+
*
|
|
29
|
+
* Dependency-free (global fetch + `git` for the optional head-sha default).
|
|
30
|
+
*/
|
|
31
|
+
import { execFileSync } from "node:child_process";
|
|
32
|
+
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
33
|
+
|
|
34
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
35
|
+
|
|
36
|
+
/** Parse `tot preview build` argv. Pure — unit-testable. */
|
|
37
|
+
export function parseBuildArgs(argv) {
|
|
38
|
+
const a = {
|
|
39
|
+
tenant: null,
|
|
40
|
+
pr: null,
|
|
41
|
+
headSha: null,
|
|
42
|
+
baseSha: null,
|
|
43
|
+
changeId: null,
|
|
44
|
+
url: null,
|
|
45
|
+
secret: null,
|
|
46
|
+
yes: false,
|
|
47
|
+
help: false,
|
|
48
|
+
};
|
|
49
|
+
for (let i = 0; i < argv.length; i++) {
|
|
50
|
+
const t = argv[i];
|
|
51
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
52
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
53
|
+
else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
|
|
54
|
+
else if (t === "--base-sha" || t === "--base") a.baseSha = argv[++i];
|
|
55
|
+
else if (t === "--change-id") a.changeId = argv[++i];
|
|
56
|
+
else if (t === "--url") a.url = argv[++i];
|
|
57
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
58
|
+
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
59
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
60
|
+
}
|
|
61
|
+
return a;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function renderUsage() {
|
|
65
|
+
return `tot preview build — operator build-on-demand: materialize a PR's hosted preview
|
|
66
|
+
|
|
67
|
+
Usage:
|
|
68
|
+
tot preview build --tenant <appDomain> --pr <N> [--head-sha <sha>] [options]
|
|
69
|
+
|
|
70
|
+
Materializes (or rebuilds) the candidate preview for PR #N of <tenant> by running
|
|
71
|
+
the server-side reconcile. Recovers an orphaned PR (one with no built preview yet).
|
|
72
|
+
It performs NO merge, NO go-live, and flips NO shared channel.
|
|
73
|
+
|
|
74
|
+
Options:
|
|
75
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
76
|
+
current checkout's tenant when run inside one.
|
|
77
|
+
--pr <N> PR number to build.
|
|
78
|
+
--head-sha <sha> PR head commit to materialize. Defaults to \`git rev-parse
|
|
79
|
+
HEAD\` when run inside a git checkout.
|
|
80
|
+
--base-sha <sha> Optional merge-base (projection metadata only).
|
|
81
|
+
--change-id <id> Optional explicit candidate id (defaults to pr-<N>).
|
|
82
|
+
--url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
|
|
83
|
+
${DEFAULT_STOREFRONT_URL}.
|
|
84
|
+
--secret <s> Operator secret. Prefer the env vars below.
|
|
85
|
+
--yes, -y Skip the confirmation prompt.
|
|
86
|
+
--help, -h Show this help.
|
|
87
|
+
|
|
88
|
+
Auth (operator secret, from env, first found):
|
|
89
|
+
PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Best-effort local HEAD sha (only when invoked inside a git checkout). */
|
|
93
|
+
function gitHeadSha() {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim() || null;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* @param {string[]} argv
|
|
103
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
104
|
+
*/
|
|
105
|
+
export async function run(argv, ctx) {
|
|
106
|
+
const args = parseBuildArgs(argv);
|
|
107
|
+
if (args.help) {
|
|
108
|
+
console.log(renderUsage());
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
|
|
113
|
+
.trim()
|
|
114
|
+
.replace(/\/+$/, "");
|
|
115
|
+
|
|
116
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
117
|
+
if (!tenant) {
|
|
118
|
+
console.error(
|
|
119
|
+
"✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
|
|
120
|
+
"or run inside a store checkout.",
|
|
121
|
+
);
|
|
122
|
+
return 2;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const prRaw = args.pr;
|
|
126
|
+
const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
|
|
127
|
+
if (pr == null && !args.changeId) {
|
|
128
|
+
console.error("✗ no PR to build.\n\n → next: pass --pr <N> (the PR number to materialize).");
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const headSha = (args.headSha || gitHeadSha() || "").trim();
|
|
133
|
+
if (!headSha) {
|
|
134
|
+
console.error(
|
|
135
|
+
"✗ no head sha.\n\n → next: pass --head-sha <sha> (the PR head commit to build), " +
|
|
136
|
+
"or run inside a checkout of that commit.",
|
|
137
|
+
);
|
|
138
|
+
return 2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
|
|
142
|
+
if (!secret) {
|
|
143
|
+
console.error(
|
|
144
|
+
"✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
|
|
145
|
+
"TOT_OPERATOR_SECRET) in the environment.",
|
|
146
|
+
);
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const previewUrl = pr != null ? `${base}/preview/${encodeURIComponent(tenant)}/pr/${pr}` : null;
|
|
151
|
+
|
|
152
|
+
// Self-declaring: state the EXACT plan before acting (the shared plan module).
|
|
153
|
+
const planLines = planForAction({
|
|
154
|
+
action: "build",
|
|
155
|
+
tenant,
|
|
156
|
+
pr,
|
|
157
|
+
changeId: args.changeId,
|
|
158
|
+
headSha,
|
|
159
|
+
endpoint: `${base}/api/preview/build`,
|
|
160
|
+
targets: { preview: previewUrl },
|
|
161
|
+
});
|
|
162
|
+
const { confirmed, reason } = await printPlanAndConfirm(planLines, {
|
|
163
|
+
yes: args.yes,
|
|
164
|
+
question: "Build this preview now?",
|
|
165
|
+
});
|
|
166
|
+
if (!confirmed) {
|
|
167
|
+
if (reason === "non-tty") {
|
|
168
|
+
console.error("✗ refusing to build without confirmation on a non-TTY.\n\n → next: re-run with --yes.");
|
|
169
|
+
return 1;
|
|
170
|
+
}
|
|
171
|
+
console.log("Aborted — nothing was built.");
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const body = {
|
|
176
|
+
...(pr != null ? { pr } : {}),
|
|
177
|
+
...(args.changeId ? { changeId: args.changeId } : {}),
|
|
178
|
+
headSha,
|
|
179
|
+
...(args.baseSha ? { baseSha: args.baseSha } : {}),
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
let res;
|
|
183
|
+
try {
|
|
184
|
+
res = await fetch(`${base}/api/preview/build`, {
|
|
185
|
+
method: "POST",
|
|
186
|
+
headers: {
|
|
187
|
+
"content-type": "application/json",
|
|
188
|
+
authorization: `Bearer ${secret}`,
|
|
189
|
+
"x-tot-owner": tenant,
|
|
190
|
+
"x-tot-capability": "ship-on-behalf",
|
|
191
|
+
},
|
|
192
|
+
body: JSON.stringify(body),
|
|
193
|
+
});
|
|
194
|
+
} catch (e) {
|
|
195
|
+
console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let data = {};
|
|
200
|
+
try {
|
|
201
|
+
data = await res.json();
|
|
202
|
+
} catch {
|
|
203
|
+
/* non-JSON error body */
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolvedUrl = data?.previewUrl || data?.environment?.previewUrl || previewUrl || null;
|
|
207
|
+
|
|
208
|
+
if (res.ok && data?.ok) {
|
|
209
|
+
const status = data?.environment?.status || "ready";
|
|
210
|
+
console.log(`✓ Preview built (status: ${status}).`);
|
|
211
|
+
if (resolvedUrl) console.log(`\n ${resolvedUrl}\n`);
|
|
212
|
+
return 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Blocked (422) or an auth/other failure.
|
|
216
|
+
const errors = Array.isArray(data?.errors) && data.errors.length
|
|
217
|
+
? data.errors
|
|
218
|
+
: [data?.error || `HTTP ${res.status}`];
|
|
219
|
+
console.error(`✗ Build did not succeed (HTTP ${res.status}).`);
|
|
220
|
+
for (const e of errors) console.error(` • ${e}`);
|
|
221
|
+
if (resolvedUrl && res.status === 422) {
|
|
222
|
+
console.error(`\n The PR page will show the reason: ${resolvedUrl}`);
|
|
223
|
+
}
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview` — submit your store for PREVIEW (the deliberate, gated step).
|
|
3
|
+
*
|
|
4
|
+
* This is the first-class verb for the dev → preview → ship loop:
|
|
5
|
+
*
|
|
6
|
+
* tot dev run your store locally with save→reload
|
|
7
|
+
* tot preview push it to a reviewable preview (validate → reconcile → compliance) ← you are here
|
|
8
|
+
* tot ship promote a reconciled preview live ← unit u3
|
|
9
|
+
*
|
|
10
|
+
* The whole preview flow (validate, push the preview ref, open/update the PR-backed
|
|
11
|
+
* candidate, stream the reconcile/compliance/preview result) lives in submit.mjs — this
|
|
12
|
+
* module is a thin wrapper that runs that SAME flow and, on success, teaches the next
|
|
13
|
+
* verb (`tot ship`). Keeping the flow in one place means `tot preview` and its teaching
|
|
14
|
+
* aliases (`tot submit` / `tot deploy`, below) share exactly one implementation — every
|
|
15
|
+
* submit flag works identically through preview, with no chance of drift.
|
|
16
|
+
*
|
|
17
|
+
* TEACHING ALIASES: `tot submit` and `tot deploy` are the same preview flow. They run it
|
|
18
|
+
* unchanged and then print a one-line hint teaching the rename (and `tot ship`), so a
|
|
19
|
+
* developer who reaches for the old verb is nudged onto the new model without losing a
|
|
20
|
+
* step. Pass `{ alias }` (the verb the developer typed) to get that hint.
|
|
21
|
+
*
|
|
22
|
+
* Dependency-free (delegates to submit.mjs, which uses global fetch + `git`).
|
|
23
|
+
*/
|
|
24
|
+
import { run as runPreviewFlow, parseArgs, renderUsage } from "./submit.mjs";
|
|
25
|
+
|
|
26
|
+
/** The ship hint printed after a successful `tot preview` — teaches the next verb.
|
|
27
|
+
* Pure — unit-tested. */
|
|
28
|
+
export const shipHint = () =>
|
|
29
|
+
"\n → next: once this preview reconciles cleanly, `tot ship` promotes it live.";
|
|
30
|
+
|
|
31
|
+
/** The teaching hint printed when the preview flow is reached via an old verb
|
|
32
|
+
* (`tot submit` / `tot deploy`). Names the rename AND the ship verb in one line.
|
|
33
|
+
* Pure — unit-tested. */
|
|
34
|
+
export const aliasHint = (alias) =>
|
|
35
|
+
`\n ℹ \`tot ${alias}\` is now \`tot preview\` — and \`tot ship\` promotes it live.`;
|
|
36
|
+
|
|
37
|
+
/** The hint to print after the flow returns, or null when there's nothing to teach
|
|
38
|
+
* (the flow failed). An alias gets the rename+ship hint; the first-class verb gets
|
|
39
|
+
* the ship hint; a non-zero exit teaches nothing (the push didn't land). Pure. */
|
|
40
|
+
export function postRunHint(code, alias) {
|
|
41
|
+
if (code !== 0) return null;
|
|
42
|
+
return alias ? aliasHint(alias) : shipHint();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string[]} argv
|
|
47
|
+
* @param {any} ctx
|
|
48
|
+
* @param {{ alias?: string|null }} [opts] - `alias` is the old verb the developer typed
|
|
49
|
+
* (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
|
|
50
|
+
* for the first-class `tot preview`.
|
|
51
|
+
*/
|
|
52
|
+
export async function run(argv, ctx, { alias = null } = {}) {
|
|
53
|
+
// `tot preview build …` is the OPERATOR build-on-demand subcommand (unit U3) —
|
|
54
|
+
// a distinct verb from the developer preview flow, so it's dispatched BEFORE the
|
|
55
|
+
// submit-flow arg parse. Only the first-class `tot preview` carries it (not the
|
|
56
|
+
// `submit`/`deploy` teaching aliases, which are the push-your-checkout flow).
|
|
57
|
+
if (!alias && argv[0] === "build") {
|
|
58
|
+
const { run: runBuild } = await import("./preview-build.mjs");
|
|
59
|
+
return runBuild(argv.slice(1), ctx);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const verb = alias || "preview";
|
|
63
|
+
const args = parseArgs(argv);
|
|
64
|
+
|
|
65
|
+
// Handle --help here so we can brand the usage with the typed verb and, for an
|
|
66
|
+
// alias, teach the rename — without also printing the post-success ship hint.
|
|
67
|
+
if (args.help) {
|
|
68
|
+
console.log(renderUsage(verb));
|
|
69
|
+
if (alias) console.log(aliasHint(alias));
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const code = await runPreviewFlow(argv, ctx, { verb });
|
|
74
|
+
|
|
75
|
+
// Teach the next step only on a successful preview (the push landed). An alias
|
|
76
|
+
// gets the rename+ship hint; the first-class verb just gets the ship hint.
|
|
77
|
+
const hint = postRunHint(code, alias);
|
|
78
|
+
if (hint) console.log(hint);
|
|
79
|
+
return code;
|
|
80
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot retire --tenant <t> --pr <N>` — OPERATOR retire/GC (unit U7).
|
|
3
|
+
*
|
|
4
|
+
* Evict a candidate PR's hosted preview — its `ReviewEnvironment` projection +
|
|
5
|
+
* its immutable site version — to RECLAIM SPACE. It calls the
|
|
6
|
+
* session-authenticated storefront endpoint `POST /api/preview/retire` (unit
|
|
7
|
+
* U7), which deletes the candidate record + prunes the tenant index and evicts
|
|
8
|
+
* the candidate version (guarded: it refuses any version a channel points at).
|
|
9
|
+
*
|
|
10
|
+
* DISTINCT FROM `tot accept` / reject: retire touches NO change lifecycle — the
|
|
11
|
+
* change stays open. It is REVERSIBLE-BY-REBUILD: because U1 build-on-demand
|
|
12
|
+
* (`tot preview build`) + the U2 fallback page can rematerialize the PR head on
|
|
13
|
+
* request, a retired PR degrades to "not built yet", NOT a dead 404.
|
|
14
|
+
*
|
|
15
|
+
* IDEMPOTENT — retiring an already-absent candidate is a clean success
|
|
16
|
+
* (nothing to evict). Unlike `tot preview build`, retire needs NO head sha: it
|
|
17
|
+
* targets an EXISTING candidate by `--pr` (→ `pr-<N>`) or an explicit
|
|
18
|
+
* `--change-id`.
|
|
19
|
+
*
|
|
20
|
+
* AUTH — the storefront endpoint accepts the headless Bearer-operator-secret
|
|
21
|
+
* path (`resolveOwnerSession` fallback). This verb sends `Authorization: Bearer
|
|
22
|
+
* <secret>` (from `PREVIEW_RECONCILE_SECRET` / `GRANTS_ADMIN_SECRET` /
|
|
23
|
+
* `TOT_OPERATOR_SECRET`, or `--secret`), `X-Tot-Owner: <tenant>`, and
|
|
24
|
+
* `X-Tot-Capability: ship-on-behalf` — mirroring `tot preview build` and the
|
|
25
|
+
* `/admin` AdminPublishTab retire call.
|
|
26
|
+
*
|
|
27
|
+
* SELF-DECLARING — it prints the EXACT plan (which PR → which tenant → evict +
|
|
28
|
+
* rebuildable) and confirms before acting (`--yes` to skip; a non-TTY without
|
|
29
|
+
* `--yes` aborts rather than acting silently). Retire is INERT re: shared
|
|
30
|
+
* channels — it flips no preview/live channel and touches no live pointer — so
|
|
31
|
+
* this is a teardown, not a deploy. The plan itself is built by the SHARED plan
|
|
32
|
+
* module (`../plan.mjs`, unit U10) — the same affordance every other mutating
|
|
33
|
+
* operator verb (build/accept/ship) and the `/admin` confirm dialog use, per
|
|
34
|
+
* decision `operator-verb-and-hosting-model`.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-free (global fetch + the shared plan module).
|
|
37
|
+
*/
|
|
38
|
+
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
39
|
+
|
|
40
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
41
|
+
|
|
42
|
+
/** Parse `tot retire` argv. Pure — unit-testable. */
|
|
43
|
+
export function parseRetireArgs(argv) {
|
|
44
|
+
const a = {
|
|
45
|
+
tenant: null,
|
|
46
|
+
pr: null,
|
|
47
|
+
changeId: null,
|
|
48
|
+
url: null,
|
|
49
|
+
secret: null,
|
|
50
|
+
yes: false,
|
|
51
|
+
help: false,
|
|
52
|
+
};
|
|
53
|
+
for (let i = 0; i < argv.length; i++) {
|
|
54
|
+
const t = argv[i];
|
|
55
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
56
|
+
else if (t === "--pr") a.pr = argv[++i];
|
|
57
|
+
else if (t === "--change-id") a.changeId = argv[++i];
|
|
58
|
+
else if (t === "--url") a.url = argv[++i];
|
|
59
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
60
|
+
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
61
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
62
|
+
}
|
|
63
|
+
return a;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderUsage() {
|
|
67
|
+
return `tot retire — operator retire/GC: evict a candidate PR's hosted preview
|
|
68
|
+
|
|
69
|
+
Usage:
|
|
70
|
+
tot retire --tenant <appDomain> --pr <N> [options]
|
|
71
|
+
tot retire --tenant <appDomain> --change-id <id> [options]
|
|
72
|
+
|
|
73
|
+
Evicts the candidate preview for PR #N of <tenant> — its review environment +
|
|
74
|
+
immutable version — to reclaim space. REVERSIBLE: rebuild it any time with
|
|
75
|
+
\`tot preview build\` (the change stays open; this is NOT reject). It flips NO
|
|
76
|
+
shared channel and is NOT go-live.
|
|
77
|
+
|
|
78
|
+
Options:
|
|
79
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
80
|
+
current checkout's tenant when run inside one.
|
|
81
|
+
--pr <N> PR number to retire (identifies the candidate pr-<N>).
|
|
82
|
+
--change-id <id> Optional explicit candidate id (defaults to pr-<N>).
|
|
83
|
+
--url <origin> Storefront origin. Defaults to $TOT_STOREFRONT_URL or
|
|
84
|
+
${DEFAULT_STOREFRONT_URL}.
|
|
85
|
+
--secret <s> Operator secret. Prefer the env vars below.
|
|
86
|
+
--yes, -y Skip the confirmation prompt.
|
|
87
|
+
--help, -h Show this help.
|
|
88
|
+
|
|
89
|
+
Auth (operator secret, from env, first found):
|
|
90
|
+
PREVIEW_RECONCILE_SECRET, GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string[]} argv
|
|
95
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
96
|
+
*/
|
|
97
|
+
export async function run(argv, ctx) {
|
|
98
|
+
const args = parseRetireArgs(argv);
|
|
99
|
+
if (args.help) {
|
|
100
|
+
console.log(renderUsage());
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const base = (args.url || process.env.TOT_STOREFRONT_URL || process.env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL)
|
|
105
|
+
.trim()
|
|
106
|
+
.replace(/\/+$/, "");
|
|
107
|
+
|
|
108
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
109
|
+
if (!tenant) {
|
|
110
|
+
console.error(
|
|
111
|
+
"✗ no target tenant.\n\n → next: pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), " +
|
|
112
|
+
"or run inside a store checkout.",
|
|
113
|
+
);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const prRaw = args.pr;
|
|
118
|
+
const pr = prRaw != null && `${prRaw}`.trim() && Number.isFinite(Number(prRaw)) ? Number(prRaw) : null;
|
|
119
|
+
const changeId = (args.changeId || "").trim() || null;
|
|
120
|
+
if (pr == null && !changeId) {
|
|
121
|
+
console.error(
|
|
122
|
+
"✗ no candidate to retire.\n\n → next: pass --pr <N> (the PR number to retire), or --change-id <id>.",
|
|
123
|
+
);
|
|
124
|
+
return 2;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const secret = (args.secret || process.env.PREVIEW_RECONCILE_SECRET || process.env.GRANTS_ADMIN_SECRET || process.env.TOT_OPERATOR_SECRET || "").trim();
|
|
128
|
+
if (!secret) {
|
|
129
|
+
console.error(
|
|
130
|
+
"✗ no operator secret.\n\n → next: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / " +
|
|
131
|
+
"TOT_OPERATOR_SECRET) in the environment.",
|
|
132
|
+
);
|
|
133
|
+
return 2;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Self-declaring: state the EXACT plan before acting (the shared plan module).
|
|
137
|
+
const planLines = planForAction({
|
|
138
|
+
action: "retire",
|
|
139
|
+
tenant,
|
|
140
|
+
pr,
|
|
141
|
+
changeId,
|
|
142
|
+
endpoint: `${base}/api/preview/retire`,
|
|
143
|
+
});
|
|
144
|
+
const { confirmed, reason } = await printPlanAndConfirm(planLines, {
|
|
145
|
+
yes: args.yes,
|
|
146
|
+
question: "Retire this preview now?",
|
|
147
|
+
});
|
|
148
|
+
if (!confirmed) {
|
|
149
|
+
if (reason === "non-tty") {
|
|
150
|
+
console.error("✗ refusing to retire without confirmation on a non-TTY.\n\n → next: re-run with --yes.");
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
console.log("Aborted — nothing was retired.");
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const body = {
|
|
158
|
+
...(pr != null ? { pr } : {}),
|
|
159
|
+
...(changeId ? { changeId } : {}),
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
let res;
|
|
163
|
+
try {
|
|
164
|
+
res = await fetch(`${base}/api/preview/retire`, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
"content-type": "application/json",
|
|
168
|
+
authorization: `Bearer ${secret}`,
|
|
169
|
+
"x-tot-owner": tenant,
|
|
170
|
+
"x-tot-capability": "ship-on-behalf",
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify(body),
|
|
173
|
+
});
|
|
174
|
+
} catch (e) {
|
|
175
|
+
console.error(`✗ could not reach ${base}: ${e?.message || e}\n\n → next: check --url / your network.`);
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let data = {};
|
|
180
|
+
try {
|
|
181
|
+
data = await res.json();
|
|
182
|
+
} catch {
|
|
183
|
+
/* non-JSON error body */
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const label = pr != null ? `PR #${pr}` : changeId;
|
|
187
|
+
|
|
188
|
+
if (res.ok && data?.ok) {
|
|
189
|
+
if (data.evicted) {
|
|
190
|
+
console.log(`✓ Retired ${label} for ${tenant} — the preview was evicted (rebuildable on demand).`);
|
|
191
|
+
} else {
|
|
192
|
+
console.log(`✓ Retire ${label} for ${tenant}: nothing to evict (already retired).`);
|
|
193
|
+
}
|
|
194
|
+
console.log("\n → rebuild any time: `tot preview build --tenant " + tenant + (pr != null ? " --pr " + pr : "") + "`\n");
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// A refusal (e.g. 409 version-pinned) or an auth/other failure.
|
|
199
|
+
const message = data?.error || `HTTP ${res.status}`;
|
|
200
|
+
console.error(`✗ Retire did not succeed (HTTP ${res.status}).`);
|
|
201
|
+
console.error(` • ${message}`);
|
|
202
|
+
return 1;
|
|
203
|
+
}
|