@tokenoftrust/cli 1.4.0-rc.14 → 1.4.0-rc.16
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 +66 -8
- package/package.json +1 -1
- package/src/commands/accept.mjs +225 -159
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +19 -5
- package/src/commands/revert.mjs +322 -0
- package/src/commands/ship.mjs +268 -1147
- package/src/commands/submit.mjs +457 -94
- package/src/commands/sync.mjs +192 -0
- package/src/plan.mjs +133 -31
package/src/commands/ship.mjs
CHANGED
|
@@ -1,298 +1,158 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `tot ship` —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* `tot ship` — publish the tenant's CURRENT GREEN AGGREGATE to live. ONE
|
|
3
|
+
* meaning (unit b10 — SUPERSEDES the retired context-dependent ship, decision
|
|
4
|
+
* `ship-context-dependent-semantics`, and its developer-accept-then-deploy /
|
|
5
|
+
* operator-deploy-only / ship-any-built-PR branches):
|
|
6
6
|
*
|
|
7
7
|
* tot dev run your store locally with save→reload
|
|
8
8
|
* tot preview push it to a reviewable preview (validate → reconcile → compliance)
|
|
9
|
-
* tot ship
|
|
10
|
-
*
|
|
11
|
-
* SHIP IS CONTEXT-DEPENDENT (decision ship-context-dependent-semantics). It NEVER
|
|
12
|
-
* silently guesses — it detects the context (`detectShipContext`), prints the EXACT
|
|
13
|
-
* plan for THAT context via the SHARED affordance (`../plan.mjs`, unit U10 — the same
|
|
14
|
-
* `planForAction` + `printPlanAndConfirm` every mutating operator verb and the `/admin`
|
|
15
|
-
* confirm dialog use), and confirms before acting:
|
|
16
|
-
*
|
|
17
|
-
* • DEVELOPER — run in your OWN store checkout on a branch: ship = ACCEPT (merge this
|
|
18
|
-
* branch/PR into main) AND THEN DEPLOY (main → preview + live). This is `runShip`.
|
|
19
|
-
* • OPERATOR — target a `--pr N --tenant t` that ISN'T your checkout: ship = DEPLOY
|
|
20
|
-
* only; the merge is the separate `tot accept` verb. This is `runShipOperator`.
|
|
21
|
-
*
|
|
22
|
-
* The DEVELOPER contract, deliberately strict because this changes the LIVE site:
|
|
9
|
+
* tot ship publish the current green aggregate live ← you are here
|
|
23
10
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* 2. ALWAYS render a diff-vs-live (what this ship changes on the live site), then
|
|
30
|
-
* print the EXACT plan and require ONE explicit confirm, defaulting to NO. `--yes`
|
|
31
|
-
* confirms non-interactively (the operator's explicit go); a NON-TTY WITHOUT
|
|
32
|
-
* `--yes` REFUSES rather than auto-confirm — nothing ships without an explicit yes.
|
|
33
|
-
* Merge-to-main / go-live / deploy stay HUMAN GATES.
|
|
34
|
-
* 3. On confirm, `change_accept` (the human ship gate) transitions the change to
|
|
35
|
-
* shipped; we then poll `change_status` until it reports shipped and print the
|
|
36
|
-
* live URL.
|
|
11
|
+
* `tot ship` NEVER takes a PR/candidate target and NEVER infers context from
|
|
12
|
+
* your checkout or who's running it. There is nothing to detect: it ships the
|
|
13
|
+
* tenant's shared `preview` aggregate — the batch of PRs that integrated
|
|
14
|
+
* cleanly and went green (b07's queue) — full stop. The candidate-level merge
|
|
15
|
+
* is the separate `tot accept` verb (`accept.mjs`); ship never merges.
|
|
37
16
|
*
|
|
38
|
-
*
|
|
39
|
-
* RECORD the approval request (`change_ready` queues it for review) and print WHO
|
|
40
|
-
* can approve it, so the change moves forward instead of dead-ending.
|
|
17
|
+
* THE FLOW:
|
|
41
18
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
19
|
+
* 1. GET the read-only PLAN from `/api/changes/ship` — b09's
|
|
20
|
+
* `AggregateShipOrchestrator.plan()` exposed over HTTP: the pinned
|
|
21
|
+
* aggregate sha, its content-addressed artifact digest, every included
|
|
22
|
+
* PR, the rollback target (what a rollback would restore), and the
|
|
23
|
+
* go-live paywall verdict. Zero side effects.
|
|
24
|
+
* 2. Print the EXACT plan via the shared U10 affordance (`../plan.mjs`) and
|
|
25
|
+
* require ONE explicit confirm, defaulting to NO. `--yes` confirms
|
|
26
|
+
* non-interactively; a non-TTY WITHOUT `--yes` REFUSES — nothing ships
|
|
27
|
+
* without an explicit yes.
|
|
28
|
+
* 3. On confirm, POST `/api/changes/ship` with `confirmGoLive: true` (the
|
|
29
|
+
* human go-live gate) and the reviewed `expectedAggregateSha` +
|
|
30
|
+
* `expectedArtifactDigest` — so a STALE plan (the aggregate moved, or the
|
|
31
|
+
* artifact changed, between GET and POST) is refused rather than shipping
|
|
32
|
+
* something nobody reviewed.
|
|
33
|
+
* 4. Report the orchestrator's HONEST terminal state verbatim:
|
|
34
|
+
* `shipped` (the live channel is VERIFIED serving the pinned artifact —
|
|
35
|
+
* the only state ever rendered as "shipped live"), `refused` (a gate
|
|
36
|
+
* refused before any side effect — not green, paywalled, etc.),
|
|
37
|
+
* `promote_failed` / `record_failed` (a partial failure, recoverable by
|
|
38
|
+
* re-running `tot ship`, never a false live claim).
|
|
56
39
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
40
|
+
* TRANSPORT: the CLI holds no storefront cookie (its OAuth login is with the
|
|
41
|
+
* MCP, a different trust boundary), so `/api/changes/ship` is reached with a
|
|
42
|
+
* Bearer OPERATOR SECRET + `X-Tot-Owner` — the SAME transport the retired
|
|
43
|
+
* `tot ship <N> --tenant` operator-by-PR path used. No MCP client, no git: ship
|
|
44
|
+
* is now pure HTTP (dependency-free — global `fetch` only).
|
|
59
45
|
*/
|
|
60
|
-
import { execFileSync } from "node:child_process";
|
|
61
|
-
import { createMcpClient } from "../mcp.mjs";
|
|
62
|
-
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
63
46
|
import { fail } from "../errors.mjs";
|
|
64
47
|
import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
65
48
|
import { startProgress } from "../progress.mjs";
|
|
66
49
|
import { openBrowser } from "../open.mjs";
|
|
67
|
-
import {
|
|
68
|
-
repoNameFromRemote,
|
|
69
|
-
parseNameStatus,
|
|
70
|
-
deriveChangeId,
|
|
71
|
-
actorKeyFor,
|
|
72
|
-
currentBranch,
|
|
73
|
-
buildChangeSummary,
|
|
74
|
-
} from "./submit.mjs";
|
|
75
|
-
import {
|
|
76
|
-
defaultCandidateStatePath,
|
|
77
|
-
readActiveChangeId,
|
|
78
|
-
isTerminalCandidateState,
|
|
79
|
-
} from "../candidate-state.mjs";
|
|
80
50
|
|
|
81
|
-
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
82
51
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
83
52
|
|
|
84
|
-
const USAGE = `tot ship —
|
|
85
|
-
|
|
86
|
-
tot ship ship the preview you last pushed with \`tot preview\`
|
|
87
|
-
tot ship <N> DEVELOPER: ship PR #N from your OWN checkout
|
|
88
|
-
tot ship pr/<N> (same — also accepts \`pr#N\`, \`#N\`)
|
|
89
|
-
tot ship --pr <N> --tenant <t> OPERATOR: ship ANY built PR (not just your checkout)
|
|
90
|
-
tot ship --change-id <chg_…> target an explicit change record (operator targeting)
|
|
91
|
-
tot ship --secret <s> operator secret (prefer the env vars below)
|
|
92
|
-
tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
93
|
-
tot ship --yes confirm non-interactively (skip the [y/N] prompt)
|
|
94
|
-
tot ship --identity <id> sign in as a specific identity for this ship
|
|
95
|
-
tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
96
|
-
|
|
97
|
-
\`tot ship\` is CONTEXT-DEPENDENT (decision ship-context-dependent-semantics):
|
|
53
|
+
const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live (ONE meaning)
|
|
98
54
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
candidate from the SHARED queue (GET /api/changes) and ships it via the atomic
|
|
106
|
-
accept (POST /api/changes/accept). This needs an OPERATOR SECRET (env below).
|
|
107
|
-
If PR N has no built candidate, build it first with \`tot preview build\`.
|
|
55
|
+
tot ship ship the checkout tenant's current green aggregate
|
|
56
|
+
tot ship --tenant <t> ship a named tenant's current green aggregate (no checkout needed)
|
|
57
|
+
tot ship --yes confirm non-interactively (skip the [y/N] prompt)
|
|
58
|
+
tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
59
|
+
tot ship --secret <s> operator secret (prefer the env vars below)
|
|
60
|
+
tot ship --no-open don't open the live URL in your browser
|
|
108
61
|
|
|
109
62
|
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
110
63
|
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
|
|
111
64
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
65
|
+
\`tot ship\` has ONE meaning: it publishes the tenant's shared preview
|
|
66
|
+
aggregate — the batch of PRs that integrated cleanly and went GREEN — to
|
|
67
|
+
live. It NEVER takes a PR/candidate target and never infers anything from
|
|
68
|
+
your checkout or who you are. It shows you the EXACT plan first (the pinned
|
|
69
|
+
sha, the artifact digest, every included PR, the rollback target, and the
|
|
70
|
+
go-live paywall verdict) and waits for one explicit confirm (\`--yes\` to
|
|
71
|
+
skip the prompt; a non-TTY without \`--yes\` refuses). Ship then reports
|
|
72
|
+
VERIFIED live truth — never a false "shipped" — surfacing a refusal or a
|
|
73
|
+
recoverable partial failure honestly if it isn't.
|
|
116
74
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
* PR number as a string, or null when the token isn't a PR reference. Pure. Used so
|
|
120
|
-
* `tot ship 4` / `tot ship pr/4` name a PR positionally, the same value the `--pr`
|
|
121
|
-
* flag carries (but developer-context — see `detectShipContext`).
|
|
122
|
-
* @param {string} token
|
|
123
|
-
* @returns {string|null}
|
|
124
|
-
*/
|
|
125
|
-
export function parsePrToken(token) {
|
|
126
|
-
if (typeof token !== "string") return null;
|
|
127
|
-
const m = token.trim().match(/^(?:pr)?[/#-]?(\d+)$/i);
|
|
128
|
-
return m ? m[1] : null;
|
|
129
|
-
}
|
|
75
|
+
The candidate-level merge is the separate \`tot accept\` / \`tot merge\` verb —
|
|
76
|
+
\`tot ship\` never merges.`;
|
|
130
77
|
|
|
131
78
|
/** Parse `tot ship` argv. Pure. */
|
|
132
79
|
export function parseShipArgs(argv) {
|
|
133
80
|
const a = {
|
|
134
|
-
mcp: null,
|
|
135
|
-
identity: null,
|
|
136
|
-
noOpen: false,
|
|
137
|
-
yes: false,
|
|
138
|
-
pr: null,
|
|
139
|
-
// True when `pr` came from a POSITIONAL token (`tot ship 4`) rather than the
|
|
140
|
-
// `--pr` flag. A positional PR in your OWN checkout is DEVELOPER intent (ship my
|
|
141
|
-
// own PR — accept+deploy); the `--pr` flag stays OPERATOR targeting (deploy-only).
|
|
142
|
-
prPositional: false,
|
|
143
81
|
tenant: null,
|
|
144
|
-
|
|
145
|
-
|
|
82
|
+
yes: false,
|
|
83
|
+
noOpen: false,
|
|
146
84
|
url: null,
|
|
147
85
|
secret: null,
|
|
148
86
|
help: false,
|
|
149
87
|
};
|
|
150
88
|
for (let i = 0; i < argv.length; i++) {
|
|
151
89
|
const t = argv[i];
|
|
152
|
-
if (t === "--
|
|
153
|
-
else if (t === "--identity") a.identity = argv[++i];
|
|
154
|
-
else if (t === "--no-open") a.noOpen = true;
|
|
90
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
155
91
|
else if (t === "--yes" || t === "-y") a.yes = true;
|
|
156
|
-
else if (t === "--
|
|
157
|
-
else if (t === "--tenant") a.tenant = argv[++i];
|
|
158
|
-
else if (t === "--change-id") a.changeId = argv[++i];
|
|
159
|
-
else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
|
|
92
|
+
else if (t === "--no-open") a.noOpen = true;
|
|
160
93
|
else if (t === "--url") a.url = argv[++i];
|
|
161
94
|
else if (t === "--secret") a.secret = argv[++i];
|
|
162
95
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
163
|
-
else if (!t.startsWith("-") && a.pr == null) {
|
|
164
|
-
// A POSITIONAL PR reference (`tot ship 4` / `tot ship pr/4` / `tot ship #4`).
|
|
165
|
-
// Only the first one wins; an explicit `--pr` flag (set above) takes precedence.
|
|
166
|
-
const n = parsePrToken(t);
|
|
167
|
-
if (n != null) {
|
|
168
|
-
a.pr = n;
|
|
169
|
-
a.prPositional = true;
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
96
|
}
|
|
173
97
|
return a;
|
|
174
98
|
}
|
|
175
99
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
* - "operator": you're targeting a specific `--pr`/`--tenant` that ISN'T your active
|
|
183
|
-
* checkout → ship = DEPLOY only (the merge is the separate `tot accept` verb).
|
|
184
|
-
*
|
|
185
|
-
* Ship NEVER silently guesses which it is — this classifies from the EXPLICIT signals
|
|
186
|
-
* (an explicit `--pr`, a `--tenant` that differs from the checkout, or a `--tenant`
|
|
187
|
-
* named from outside any checkout), and the caller then prints the concrete plan for
|
|
188
|
-
* THAT context and confirms.
|
|
189
|
-
*
|
|
190
|
-
* A POSITIONAL PR (`tot ship 4`) is the exception the `--pr` FLAG is not: it names the
|
|
191
|
-
* developer's OWN PR to ship from their OWN checkout, so — absent any cross-target
|
|
192
|
-
* signal (a differing `--tenant`, or being named from outside a checkout) — it stays
|
|
193
|
-
* DEVELOPER (accept+deploy). The `--pr` flag remains OPERATOR targeting (deploy-only),
|
|
194
|
-
* per its documented "a PR that isn't your checkout" contract.
|
|
195
|
-
*
|
|
196
|
-
* @param {{ pr?: number|string|null, prPositional?: boolean, tenant?: string|null,
|
|
197
|
-
* ctxTenant?: string|null, ctxMode?: string|null }} p
|
|
198
|
-
* @returns {"developer"|"operator"}
|
|
199
|
-
*/
|
|
200
|
-
export function detectShipContext({ pr = null, prPositional = false, tenant = null, ctxTenant = null, ctxMode = null } = {}) {
|
|
201
|
-
const hasPr = pr != null && `${pr}`.trim() !== "";
|
|
202
|
-
const t = (tenant || "").trim();
|
|
203
|
-
const ct = (ctxTenant || "").trim();
|
|
204
|
-
const tenantDiffers = Boolean(t && ct && t !== ct);
|
|
205
|
-
const targetingFromOutside = Boolean(t && ctxMode !== "checkout");
|
|
206
|
-
// A positional `tot ship 4` inside your OWN checkout, with no cross-target flags, is
|
|
207
|
-
// the developer shipping their own PR — accept+deploy, NOT operator deploy-only.
|
|
208
|
-
const positionalOwnPr = hasPr && prPositional && ctxMode === "checkout" && !tenantDiffers && !targetingFromOutside;
|
|
209
|
-
if (positionalOwnPr) return "developer";
|
|
210
|
-
if (hasPr || tenantDiffers || targetingFromOutside) return "operator";
|
|
211
|
-
return "developer";
|
|
212
|
-
}
|
|
100
|
+
// ─── Operator-secret transport (shared with `tot pr list --tenant` / u16/u17) ────
|
|
101
|
+
//
|
|
102
|
+
// `resolveOperatorSecret` / `normalizeChangesQueue` are the SAME helpers the
|
|
103
|
+
// retired operator-by-PR ship path minted; `tot pr list --tenant` (`pr.mjs`)
|
|
104
|
+
// still imports them for its own (unrelated) `GET /api/changes` candidate-queue
|
|
105
|
+
// listing, so they stay here rather than move — one wire, one place it's typed.
|
|
213
106
|
|
|
214
|
-
|
|
107
|
+
const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
|
|
215
108
|
|
|
216
109
|
/**
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
* @param {
|
|
221
|
-
* @returns {
|
|
222
|
-
* prNumber:number|null, url:string|null, mergeable:unknown}|null}
|
|
110
|
+
* Resolve the operator secret from an explicit `--secret` or the env (first
|
|
111
|
+
* found). Pure given its env argument.
|
|
112
|
+
* @param {string|null|undefined} explicit
|
|
113
|
+
* @param {NodeJS.ProcessEnv} env
|
|
114
|
+
* @returns {string}
|
|
223
115
|
*/
|
|
224
|
-
export function
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
: r && Array.isArray(r.environments)
|
|
232
|
-
? r.environments[0]
|
|
233
|
-
: r;
|
|
234
|
-
if (!c || typeof c !== "object" || typeof c.changeId !== "string") return null;
|
|
235
|
-
return {
|
|
236
|
-
changeId: c.changeId,
|
|
237
|
-
state: typeof c.state === "string" ? c.state : null,
|
|
238
|
-
headSha: c.headSha ?? null,
|
|
239
|
-
baseSha: c.baseSha ?? null,
|
|
240
|
-
prNumber: typeof c.prNumber === "number" ? c.prNumber : null,
|
|
241
|
-
url: c.url ?? null,
|
|
242
|
-
mergeable: c.mergeable,
|
|
243
|
-
};
|
|
116
|
+
export function resolveOperatorSecret(explicit, env = {}) {
|
|
117
|
+
if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
|
|
118
|
+
for (const k of OPERATOR_SECRET_ENV) {
|
|
119
|
+
const v = env[k];
|
|
120
|
+
if (v && `${v}`.trim()) return `${v}`.trim();
|
|
121
|
+
}
|
|
122
|
+
return "";
|
|
244
123
|
}
|
|
245
124
|
|
|
246
125
|
/**
|
|
247
|
-
* Normalise a `
|
|
248
|
-
*
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
* TRI-STATE-ish: they're only `true` when the server clearly says so, so an
|
|
253
|
-
* unrecognised shape fails CLOSED (we don't ship on ambiguity). Pure — unit-tested.
|
|
254
|
-
* @param {any} r
|
|
255
|
-
* @returns {{reconciled:boolean, authorized:boolean, approvers:string[],
|
|
256
|
-
* previewUrl:string|null, detail:string|null, raw:any}}
|
|
126
|
+
* Normalise a `GET /api/changes` body to the OPEN candidate list. Kept for
|
|
127
|
+
* `tot pr list --tenant` (`pr.mjs`) — NOT used by ship itself anymore (ship
|
|
128
|
+
* reads the aggregate plan from `/api/changes/ship`, a distinct endpoint).
|
|
129
|
+
* @param {any} data
|
|
130
|
+
* @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
|
|
257
131
|
*/
|
|
258
|
-
export function
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
132
|
+
export function normalizeChangesQueue(data) {
|
|
133
|
+
const list = Array.isArray(data)
|
|
134
|
+
? data
|
|
135
|
+
: data && Array.isArray(data.changes)
|
|
136
|
+
? data.changes
|
|
137
|
+
: [];
|
|
138
|
+
// Keep an entry if it has a changeId (a BUILT candidate) OR a numeric prNumber (a
|
|
139
|
+
// not-built forge PR merged in by GET /api/changes, u9b) — so `tot pr list --tenant`
|
|
140
|
+
// shows orphaned/un-built PRs too, not only built candidates. (Re-applied after a
|
|
141
|
+
// branch-scale ship rework reverted it to changeId-only.)
|
|
142
|
+
return list.filter(
|
|
143
|
+
(c) =>
|
|
144
|
+
c &&
|
|
145
|
+
typeof c === "object" &&
|
|
146
|
+
(typeof c.changeId === "string" || typeof c.prNumber === "number"),
|
|
271
147
|
);
|
|
272
|
-
return {
|
|
273
|
-
reconciled: reconciled === true,
|
|
274
|
-
authorized: authorized === true,
|
|
275
|
-
approvers,
|
|
276
|
-
previewUrl: o.previewUrl ?? ev.previewUrl ?? null,
|
|
277
|
-
detail: typeof o.detail === "string" ? o.detail : (typeof o.reason === "string" ? o.reason : null),
|
|
278
|
-
raw: r,
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** First non-undefined boolean in a list, else undefined. Pure. */
|
|
283
|
-
function firstBool(candidates) {
|
|
284
|
-
for (const v of candidates) if (typeof v === "boolean") return v;
|
|
285
|
-
return undefined;
|
|
286
148
|
}
|
|
287
149
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
.map((s) => s.trim());
|
|
295
|
-
}
|
|
150
|
+
// ─── Shared with `tot accept` (`accept.mjs`) ─────────────────────────────────────
|
|
151
|
+
//
|
|
152
|
+
// `tot accept` queues a PR's integration into the protected `preview` aggregate
|
|
153
|
+
// (NOT a merge to main — see accept.mjs's header) — a DISTINCT operator verb from
|
|
154
|
+
// ship (no deploy). It imports these two normalisers straight from here rather
|
|
155
|
+
// than re-implementing them.
|
|
296
156
|
|
|
297
157
|
/**
|
|
298
158
|
* Normalise a `change_accept` / `change_status` result to { shipped, state,
|
|
@@ -314,219 +174,9 @@ export function normalizeChangeResult(r) {
|
|
|
314
174
|
};
|
|
315
175
|
}
|
|
316
176
|
|
|
317
|
-
// ─── The candidate-resolution seam (u4 extends THIS) ─────────────────────────────
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Resolve the ACTIVE candidate's changeId for this checkout — the SAME resolution
|
|
321
|
-
* `tot preview` uses so ship promotes exactly what preview last pushed: the
|
|
322
|
-
* remembered active pointer (a prior `--new` / roll), else the STABLE
|
|
323
|
-
* per-developer-per-tenant default (`deriveChangeId`).
|
|
324
|
-
*
|
|
325
|
-
* ┌─ SEAM (u4) ────────────────────────────────────────────────────────────────┐
|
|
326
|
-
* │ This is the ONE place ship keys the candidate on (tenant, actor, branch). │
|
|
327
|
-
* │ u4 (branch-bound candidates) keys HERE — folding in the current git branch — │
|
|
328
|
-
* │ so `tot preview` and `tot ship` resolve the SAME branch-bound candidate, │
|
|
329
|
-
* │ WITHOUT touching the gate, diff, confirm, or accept flow below. On the │
|
|
330
|
-
* │ default branch `branch` is null and this is byte-identical to pre-u4. │
|
|
331
|
-
* └──────────────────────────────────────────────────────────────────────────────┘
|
|
332
|
-
* Pure given its inputs (state is read through candidate-state.mjs). Exported so a
|
|
333
|
-
* follow-on unit can wrap/replace it.
|
|
334
|
-
* @param {{ statePath:string, mcpUrl:string, repo:string, tenant:string, actorKey:string,
|
|
335
|
-
* branch?:string|null }} keys
|
|
336
|
-
* @returns {string}
|
|
337
|
-
*/
|
|
338
|
-
export function resolveActiveChangeId({ statePath, mcpUrl, repo, tenant, actorKey, branch = null }) {
|
|
339
|
-
const stable = deriveChangeId(tenant, actorKey, branch);
|
|
340
|
-
const active = readActiveChangeId(statePath, { mcpUrl, repo, branch });
|
|
341
|
-
return active || stable;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* Normalise a `change_open` result to its minted change-record id. `change_open`
|
|
346
|
-
* returns the record id flat in `data` (id, state); read it defensively. Pure.
|
|
347
|
-
* @param {any} r
|
|
348
|
-
* @returns {{ id: string|null, state: string|null }}
|
|
349
|
-
*/
|
|
350
|
-
export function normalizeOpenedChange(r) {
|
|
351
|
-
const o = r && typeof r === "object" ? r : {};
|
|
352
|
-
return {
|
|
353
|
-
id: typeof o.id === "string" && o.id ? o.id : null,
|
|
354
|
-
state: typeof o.state === "string" ? o.state : null,
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Resolve the change-RECORD id (`chg_<uuid>`) that the ship gate + `change_accept`
|
|
360
|
-
* key on — a DISTINCT namespace from the candidate handle (see the module header).
|
|
361
|
-
* There is NO candidate→record lookup on the wire (`change_status` lists only
|
|
362
|
-
* id/title/state, never the head sha), so we CAPTURE the id at `change_open`, the
|
|
363
|
-
* option u7's decision (ship-path-wire-resolutions) records as supported:
|
|
364
|
-
*
|
|
365
|
-
* 1. read the reviewed commit — the local HEAD, the commit `tot preview` pushed and
|
|
366
|
-
* the reconcile evidence is keyed on;
|
|
367
|
-
* 2. `change_open` it with `commit:` set, so u7's promote-by-digest can content-
|
|
368
|
-
* address the exact reviewed tree (absent it, `change_accept` fails closed
|
|
369
|
-
* `no_reviewed_commit`);
|
|
370
|
-
* 3. `change_ready` it, attaching the AUTHORITATIVE reconcile evidence keyed by
|
|
371
|
-
* (tenant, commit). This REQUIRES that commit to have reconciled — if it hasn't,
|
|
372
|
-
* `change_ready` throws (no authoritative evidence) and we propagate it so the
|
|
373
|
-
* caller refuses with a "run `tot preview` first" next step (never ship a commit
|
|
374
|
-
* whose evidence isn't in).
|
|
375
|
-
*
|
|
376
|
-
* Returns the resolved `chg_` id. Throws if the record can't be opened/readied — the
|
|
377
|
-
* caller fails CLOSED (never falls back to shipping the candidate handle or no record).
|
|
378
|
-
* The `change_*` calls go through the same client; `git` is injected.
|
|
379
|
-
* @param {{callTool:Function}} client
|
|
380
|
-
* @param {{ tenant:string, git:(args:string[])=>string,
|
|
381
|
-
* changeSummary?:{ title?:string, body?:string[] } }} params
|
|
382
|
-
* @returns {Promise<string>}
|
|
383
|
-
*/
|
|
384
|
-
export async function resolveChangeRecordId(client, { tenant, git, changeSummary }) {
|
|
385
|
-
const commit = git(["rev-parse", "HEAD"]).trim();
|
|
386
|
-
if (!commit) {
|
|
387
|
-
throw new Error("couldn't read the reviewed commit (git HEAD) for this checkout");
|
|
388
|
-
}
|
|
389
|
-
const title = (changeSummary?.title || `Ship ${tenant} store change`).slice(0, 200);
|
|
390
|
-
const body = changeSummary?.body?.length ? changeSummary.body.join("\n").slice(0, 2000) : undefined;
|
|
391
|
-
const opened = normalizeOpenedChange(
|
|
392
|
-
await client.callTool("change_open", {
|
|
393
|
-
title,
|
|
394
|
-
...(body ? { summary: body } : {}),
|
|
395
|
-
commit,
|
|
396
|
-
tenant,
|
|
397
|
-
dryRun: false,
|
|
398
|
-
}),
|
|
399
|
-
);
|
|
400
|
-
if (!opened.id) {
|
|
401
|
-
throw new Error("change_open did not return a change-record id");
|
|
402
|
-
}
|
|
403
|
-
// Ready it against the authoritative reconcile evidence for THIS exact commit —
|
|
404
|
-
// throws (no_authoritative_evidence) if the commit hasn't reconciled yet.
|
|
405
|
-
await client.callTool("change_ready", { id: opened.id, tenant, dryRun: false });
|
|
406
|
-
return opened.id;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/** A neutral review used only when there's no open candidate to resolve a record for
|
|
410
|
-
* — shipReadiness reports the candidate blocker first and never reads these. */
|
|
411
|
-
export const NO_REVIEW = Object.freeze({
|
|
412
|
-
reconciled: false, authorized: false, approvers: [], previewUrl: null, detail: null, raw: null,
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
// ─── The ship gate (pure) ────────────────────────────────────────────────────────
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Decide, from the resolved candidate + the review verdict, whether the ship may
|
|
419
|
-
* proceed — the correctness-critical core, kept PURE so every branch is unit-tested
|
|
420
|
-
* without a client/TTY/git. Order matters: OPEN, then RECONCILED, then AUTHORISED,
|
|
421
|
-
* so the developer always sees the most fundamental blocker first.
|
|
422
|
-
*
|
|
423
|
-
* @param {ReturnType<typeof normalizeCandidate>} candidate
|
|
424
|
-
* @param {ReturnType<typeof normalizeReview>} review
|
|
425
|
-
* @returns {{ kind: "no-candidate"|"not-open"|"not-reconciled"|"unauthorized"|"ready",
|
|
426
|
-
* what?: string, next?: string }}
|
|
427
|
-
*/
|
|
428
|
-
export function shipReadiness(candidate, review) {
|
|
429
|
-
if (!candidate) {
|
|
430
|
-
return {
|
|
431
|
-
kind: "no-candidate",
|
|
432
|
-
what: "no active candidate to ship for this checkout",
|
|
433
|
-
next: "push one first with `tot preview`, then `tot ship`",
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
if (candidate.state !== "open") {
|
|
437
|
-
// Terminal (merged/closed) vs simply-not-open both mean "nothing OPEN to ship".
|
|
438
|
-
const terminal = isTerminalCandidateState(candidate.state);
|
|
439
|
-
return {
|
|
440
|
-
kind: "not-open",
|
|
441
|
-
what: terminal
|
|
442
|
-
? `this candidate is already ${candidate.state} — there's nothing open to ship`
|
|
443
|
-
: `this candidate isn't open (state: ${candidate.state ?? "unknown"})`,
|
|
444
|
-
next: "`tot preview` to push a fresh candidate, then `tot ship`",
|
|
445
|
-
};
|
|
446
|
-
}
|
|
447
|
-
if (!review.reconciled) {
|
|
448
|
-
return {
|
|
449
|
-
kind: "not-reconciled",
|
|
450
|
-
what: `this preview hasn't reconciled cleanly yet${review.detail ? ` — ${review.detail}` : ""}`,
|
|
451
|
-
next: "run `tot preview` and wait for the green reconcile/compliance result, then `tot ship`",
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
if (!review.authorized) {
|
|
455
|
-
return { kind: "unauthorized" };
|
|
456
|
-
}
|
|
457
|
-
return { kind: "ready" };
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// ─── Diff-vs-live (pure render + thin git compute) ───────────────────────────────
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Render the "what goes live" block from parsed name-status entries + a shortstat
|
|
464
|
-
* line. Always prints SOMETHING actionable: the changed paths when we have them,
|
|
465
|
-
* else a pointer to the candidate PR so the reviewer can read the full diff. Pure —
|
|
466
|
-
* unit-tested.
|
|
467
|
-
* @param {{ entries:{status:string,path:string,from?:string}[], statLine?:string,
|
|
468
|
-
* prUrl?:string|null, ok:boolean }} input
|
|
469
|
-
* @returns {string[]}
|
|
470
|
-
*/
|
|
471
|
-
export function renderDiffVsLive({ entries, statLine, prUrl, ok }) {
|
|
472
|
-
const lines = ["", " This ship will change the LIVE site:"];
|
|
473
|
-
if (ok && entries.length) {
|
|
474
|
-
for (const e of entries.slice(0, 40)) {
|
|
475
|
-
const label = e.status === "R" && e.from ? `${e.from} → ${e.path}` : e.path;
|
|
476
|
-
lines.push(` ${statusGlyph(e.status)} ${label}`);
|
|
477
|
-
}
|
|
478
|
-
if (entries.length > 40) lines.push(` … +${entries.length - 40} more file(s)`);
|
|
479
|
-
if (statLine) lines.push(` (${statLine})`);
|
|
480
|
-
} else {
|
|
481
|
-
lines.push(" (couldn't render a local file diff for this candidate)");
|
|
482
|
-
if (prUrl) lines.push(` review the full diff in the candidate PR: ${prUrl}`);
|
|
483
|
-
}
|
|
484
|
-
return lines;
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
/** A/M/D/R → a stable one-char glyph for the diff list. Pure. */
|
|
488
|
-
function statusGlyph(status) {
|
|
489
|
-
return { A: "+", M: "~", D: "-", R: "»", C: "»" }[status] || "·";
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
/**
|
|
493
|
-
* Compute the diff-vs-live (base→head) via git in the checkout — best-effort. Tries
|
|
494
|
-
* the local objects first; if the shas aren't present it fetches once and retries;
|
|
495
|
-
* if it still can't, returns `ok:false` so the caller falls back to the PR pointer.
|
|
496
|
-
* `git` is injected (a `(args:string[])=>string` runner) so it's testable.
|
|
497
|
-
* @param {(args:string[]) => string} git
|
|
498
|
-
* @param {string|null} baseSha
|
|
499
|
-
* @param {string|null} headSha
|
|
500
|
-
* @returns {{ entries:{status:string,path:string,from?:string}[], statLine:string, ok:boolean }}
|
|
501
|
-
*/
|
|
502
|
-
export function computeDiffVsLive(git, baseSha, headSha) {
|
|
503
|
-
if (!baseSha || !headSha) return { entries: [], statLine: "", ok: false };
|
|
504
|
-
const range = `${baseSha}..${headSha}`;
|
|
505
|
-
const tryDiff = () => ({
|
|
506
|
-
entries: parseNameStatus(git(["diff", "--name-status", range])),
|
|
507
|
-
statLine: git(["diff", "--shortstat", range]).trim(),
|
|
508
|
-
});
|
|
509
|
-
try {
|
|
510
|
-
return { ...tryDiff(), ok: true };
|
|
511
|
-
} catch {
|
|
512
|
-
// The shas may not be local yet (base moved, head only on the remote) — one
|
|
513
|
-
// fetch, then retry. Still failing ⇒ fall back to the PR pointer.
|
|
514
|
-
try {
|
|
515
|
-
git(["fetch", "--quiet", "origin"]);
|
|
516
|
-
return { ...tryDiff(), ok: true };
|
|
517
|
-
} catch {
|
|
518
|
-
return { entries: [], statLine: "", ok: false };
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
// ─── Ship-decision poll ──────────────────────────────────────────────────────────
|
|
524
|
-
|
|
525
177
|
/**
|
|
526
178
|
* Poll `change_status` until the change reports shipped (or the attempts budget
|
|
527
|
-
* runs out).
|
|
528
|
-
* the first read — it exists to CONFIRM the shipped state and pick up the live URL,
|
|
529
|
-
* and to tolerate a promote that lands a beat later. Injectable delay/attempts.
|
|
179
|
+
* runs out). Injectable delay/attempts.
|
|
530
180
|
* @param {{callTool:Function}} client
|
|
531
181
|
* @param {{ id:string, tenant?:string }} target
|
|
532
182
|
* @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
|
|
@@ -542,213 +192,7 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
|
|
|
542
192
|
return last;
|
|
543
193
|
}
|
|
544
194
|
|
|
545
|
-
// ───
|
|
546
|
-
|
|
547
|
-
/**
|
|
548
|
-
* The ship flow after a session is established — resolve the candidate, gate,
|
|
549
|
-
* diff-vs-live, confirm, accept, poll, report. Split out from `run` so it's driven
|
|
550
|
-
* in tests with a mock client + injected git/confirm/interactive, no network/TTY.
|
|
551
|
-
*
|
|
552
|
-
* @param {{callTool:Function}} client an MCP client (real or mock)
|
|
553
|
-
* @param {{ tenant:string, changeId:string, repo:string,
|
|
554
|
-
* git:(args:string[])=>string, noOpen?:boolean, yes?:boolean, storefrontUrl?:string|null,
|
|
555
|
-
* changeSummary?:{ title?:string, body?:string[] }, expectedPr?:number|string|null }} params
|
|
556
|
-
* @param {{ confirmPlan?:typeof printPlanAndConfirm,
|
|
557
|
-
* poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
558
|
-
* resolveRecord?:typeof resolveChangeRecordId,
|
|
559
|
-
* shipByPrFallback?:()=>Promise<number> }} [deps]
|
|
560
|
-
* shipByPrFallback (u16) — when a POSITIONAL `tot ship <N>` finds NO candidate for
|
|
561
|
-
* this checkout, invoke this instead of the "no active candidate" dead-end: it ships
|
|
562
|
-
* the built PR from the shared queue (the operator-by-PR path).
|
|
563
|
-
* @returns {Promise<number>} process exit code
|
|
564
|
-
*/
|
|
565
|
-
export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes = false, storefrontUrl = null, changeSummary, expectedPr = null }, deps = {}) {
|
|
566
|
-
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
567
|
-
const poll = deps.poll || pollChangeShipped;
|
|
568
|
-
const resolveRecord = deps.resolveRecord || resolveChangeRecordId;
|
|
569
|
-
|
|
570
|
-
// 1. Resolve the active candidate (the forge slice — open-gate + diff-vs-live).
|
|
571
|
-
const candidate = normalizeCandidate(await client.callTool("candidate_status", { repo, changeId }));
|
|
572
|
-
|
|
573
|
-
// 1a. If the developer named a PR positionally (`tot ship 4`), that number is a
|
|
574
|
-
// SAFETY assertion of WHICH PR ships — refuse (before opening any change record)
|
|
575
|
-
// if the checkout's resolved branch-bound candidate is a DIFFERENT PR, so
|
|
576
|
-
// `tot ship 4` can never silently ship PR #7. Only guards when both are known.
|
|
577
|
-
const want = expectedPr != null && `${expectedPr}`.trim() ? Number(expectedPr) : null;
|
|
578
|
-
if (want != null && candidate && candidate.prNumber != null && Number(candidate.prNumber) !== want) {
|
|
579
|
-
console.error(
|
|
580
|
-
fail(
|
|
581
|
-
`you asked to ship PR #${want}, but this checkout's active candidate is PR #${candidate.prNumber}`,
|
|
582
|
-
`switch to the branch whose PR is #${want} (or run \`tot ship\` with no number to ship this checkout's candidate)`,
|
|
583
|
-
),
|
|
584
|
-
);
|
|
585
|
-
return 1;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// 1b. Resolve the change-RECORD id (`chg_<uuid>`) that the gate + change_accept key
|
|
589
|
-
// on — a DISTINCT namespace from the candidate handle (see the header). Only
|
|
590
|
-
// meaningful for an OPEN candidate; for a missing/closed one we skip it and let
|
|
591
|
-
// shipReadiness report that blocker first (never open a record we can't ship).
|
|
592
|
-
let id = null;
|
|
593
|
-
if (candidate && candidate.state === "open") {
|
|
594
|
-
try {
|
|
595
|
-
id = await resolveRecord(client, { tenant, git, changeSummary });
|
|
596
|
-
} catch (e) {
|
|
597
|
-
console.error(
|
|
598
|
-
fail(
|
|
599
|
-
`couldn't prepare a shipable change record: ${String(e?.message || e)}`,
|
|
600
|
-
"the commit you're shipping may not have reconciled — run `tot preview` and wait for the green result, then `tot ship`",
|
|
601
|
-
),
|
|
602
|
-
);
|
|
603
|
-
return 1;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
// 2. Read the ship-readiness verdict against the RESOLVED record (or the neutral
|
|
608
|
-
// review when there's no open candidate — shipReadiness reports the candidate).
|
|
609
|
-
const review = id
|
|
610
|
-
? normalizeReview(await client.callTool("change_request_review", { id, tenant }))
|
|
611
|
-
: NO_REVIEW;
|
|
612
|
-
const gate = shipReadiness(candidate, review);
|
|
613
|
-
|
|
614
|
-
// u16: `tot ship <N>` in a checkout that has NO candidate matching this PR (the PR
|
|
615
|
-
// isn't yours) — fall through to the OPERATOR-by-PR queue path (resolve PR→changeId
|
|
616
|
-
// from GET /api/changes, ship via POST /api/changes/accept) instead of dead-ending on
|
|
617
|
-
// "no active candidate". Only fires for a POSITIONAL PR (expectedPr) with the fallback
|
|
618
|
-
// wired (run() supplies it). The U15 different-PR guard (step 1a) still REFUSES — this
|
|
619
|
-
// is the distinct "nothing to ship from THIS checkout" case, not a mismatch.
|
|
620
|
-
if (gate.kind === "no-candidate" && expectedPr != null && deps.shipByPrFallback) {
|
|
621
|
-
return await deps.shipByPrFallback();
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
if (gate.kind === "unauthorized") {
|
|
625
|
-
return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
|
|
626
|
-
}
|
|
627
|
-
if (gate.kind !== "ready") {
|
|
628
|
-
console.error(fail(gate.what, gate.next));
|
|
629
|
-
return 1;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// 2. ALWAYS render the diff-vs-live, then print the EXACT plan and gate on ONE
|
|
633
|
-
// explicit confirm via the SHARED affordance (plan.mjs). DEVELOPER context =
|
|
634
|
-
// accept-then-deploy (merge this PR into main, THEN deploy main → preview + live).
|
|
635
|
-
const diff = computeDiffVsLive(git, candidate.baseSha, candidate.headSha);
|
|
636
|
-
for (const line of renderDiffVsLive({ ...diff, prUrl: candidate.url })) console.log(line);
|
|
637
|
-
|
|
638
|
-
const previewUrl = review.previewUrl || candidate.url || previewUrlFor(storefrontUrl, tenant, candidate.prNumber);
|
|
639
|
-
const planLines = planForAction({
|
|
640
|
-
action: "ship",
|
|
641
|
-
context: "developer",
|
|
642
|
-
tenant,
|
|
643
|
-
pr: candidate.prNumber,
|
|
644
|
-
changeId: id,
|
|
645
|
-
headSha: candidate.headSha,
|
|
646
|
-
targets: { preview: previewUrl, live: liveUrlFor(tenant) },
|
|
647
|
-
});
|
|
648
|
-
// `--yes` confirms non-interactively; a non-TTY WITHOUT `--yes` refuses (never
|
|
649
|
-
// auto-confirm a live change). Default answer is NO on an interactive prompt.
|
|
650
|
-
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
651
|
-
yes,
|
|
652
|
-
question: `Ship this live to ${tenant}?`,
|
|
653
|
-
});
|
|
654
|
-
if (!confirmed) {
|
|
655
|
-
if (reason === "non-tty") {
|
|
656
|
-
console.error(
|
|
657
|
-
fail(
|
|
658
|
-
"`tot ship` needs an interactive terminal to confirm the live change",
|
|
659
|
-
"run it from a terminal, or pass --yes to confirm non-interactively",
|
|
660
|
-
),
|
|
661
|
-
);
|
|
662
|
-
return 2;
|
|
663
|
-
}
|
|
664
|
-
console.log(" Ship cancelled — nothing changed.");
|
|
665
|
-
return 0;
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
// 3. Accept (the ship gate), then poll change_status until shipped.
|
|
669
|
-
let accept;
|
|
670
|
-
try {
|
|
671
|
-
accept = normalizeChangeResult(
|
|
672
|
-
await client.callTool("change_accept", {
|
|
673
|
-
id,
|
|
674
|
-
tenant,
|
|
675
|
-
dryRun: false,
|
|
676
|
-
// Stable per (candidate, head): a re-run after a blip returns the original
|
|
677
|
-
// ship instead of double-accepting.
|
|
678
|
-
idempotencyKey: `ship-${id}-${candidate.headSha ?? "head"}`,
|
|
679
|
-
}),
|
|
680
|
-
);
|
|
681
|
-
} catch (e) {
|
|
682
|
-
console.error(
|
|
683
|
-
fail(
|
|
684
|
-
`the ship gate refused to accept this change: ${String(e?.message || e)}`,
|
|
685
|
-
"check the reconcile/compliance result with `tot preview`, then re-run `tot ship`",
|
|
686
|
-
),
|
|
687
|
-
);
|
|
688
|
-
return 1;
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const progress = deps.progress === false ? null : startProgress("shipping…");
|
|
692
|
-
let status;
|
|
693
|
-
try {
|
|
694
|
-
status = await poll(client, { id, tenant });
|
|
695
|
-
} finally {
|
|
696
|
-
progress?.stop();
|
|
697
|
-
}
|
|
698
|
-
return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
/**
|
|
702
|
-
* The unauthorised path — don't just error out. The change record was already opened
|
|
703
|
-
* and moved to ready_for_review while resolving the ship (`resolveChangeRecordId`), so
|
|
704
|
-
* the review request IS recorded; we just tell the developer that and print WHO can
|
|
705
|
-
* approve. (There is no separate `change_ready` to run — re-readying a ready record is
|
|
706
|
-
* an illegal transition; the record already sits in the reviewers' queue.)
|
|
707
|
-
* @returns {Promise<number>} exit code (non-zero — the ship didn't happen)
|
|
708
|
-
*/
|
|
709
|
-
async function handleUnauthorized(client, { id, tenant, approvers }) {
|
|
710
|
-
void client;
|
|
711
|
-
void id;
|
|
712
|
-
console.error(
|
|
713
|
-
fail(
|
|
714
|
-
"you're not authorised to ship this change live yourself",
|
|
715
|
-
"the change is recorded and ready for review — an authorised approver can ship it",
|
|
716
|
-
),
|
|
717
|
-
);
|
|
718
|
-
if (approvers.length) {
|
|
719
|
-
console.error(` Who can approve: ${approvers.join(", ")}`);
|
|
720
|
-
} else {
|
|
721
|
-
console.error(` Who can approve: a teammate with ship/promote authority for ${tenant}.`);
|
|
722
|
-
}
|
|
723
|
-
return 1;
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
/** Report the shipped result and open the live URL (unless suppressed). */
|
|
727
|
-
function reportShipped(accept, status, { tenant, noOpen, openUrl }) {
|
|
728
|
-
const url = status?.previewUrl || accept?.previewUrl || null;
|
|
729
|
-
if (status?.shipped || accept?.shipped) {
|
|
730
|
-
console.log(`\n ✓ shipped ${tenant} live.`);
|
|
731
|
-
if (url) {
|
|
732
|
-
console.log(` Live: ${url}`);
|
|
733
|
-
if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
|
|
734
|
-
}
|
|
735
|
-
return 0;
|
|
736
|
-
}
|
|
737
|
-
// Accept committed but the shipped state hasn't been observed yet (a promote may
|
|
738
|
-
// still be landing) — report honestly rather than claim a live URL.
|
|
739
|
-
console.log(`\n ~ ship accepted for ${tenant}; it's going live now.`);
|
|
740
|
-
if (url) console.log(` Track it here: ${url}`);
|
|
741
|
-
return 0;
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ─── URL derivation (pure) ─────────────────────────────────────────────────────────
|
|
745
|
-
|
|
746
|
-
/** The hosted preview URL for a tenant's PR, or null when the PR/base is unknown. Pure. */
|
|
747
|
-
export function previewUrlFor(base, tenant, pr) {
|
|
748
|
-
const origin = (base || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
749
|
-
if (pr == null || `${pr}`.trim() === "" || !tenant) return null;
|
|
750
|
-
return `${origin}/preview/${encodeURIComponent(tenant)}/pr/${pr}`;
|
|
751
|
-
}
|
|
195
|
+
// ─── URL derivation (pure) ─────────────────────────────────────────────────────
|
|
752
196
|
|
|
753
197
|
/** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
|
|
754
198
|
export function liveUrlFor(tenant) {
|
|
@@ -756,159 +200,120 @@ export function liveUrlFor(tenant) {
|
|
|
756
200
|
return t ? `https://${t}` : null;
|
|
757
201
|
}
|
|
758
202
|
|
|
759
|
-
// ───
|
|
760
|
-
//
|
|
761
|
-
// The real user report: `tot ship 4` for a PR the operator did NOT author fails
|
|
762
|
-
// `✗ no active candidate to ship for this checkout` — U15's developer path resolves
|
|
763
|
-
// the candidate from the LOCAL git checkout, so it can't ship a PR that isn't yours,
|
|
764
|
-
// and U15's operator path needed an explicit `--change-id` (it couldn't resolve
|
|
765
|
-
// PR→changeId — the `// u9:` gap).
|
|
766
|
-
//
|
|
767
|
-
// The insight this unit builds on (NO forge PR-read needed): a BUILT PR already has a
|
|
768
|
-
// `ReviewEnvironment` in the candidate index, and `GET /api/changes` (the operator
|
|
769
|
-
// accept queue) returns the tenant's OPEN candidates — each with `changeId` +
|
|
770
|
-
// `prNumber` + `headSha`. So PR N → changeId is resolvable from the QUEUE. The ship is
|
|
771
|
-
// then the SAME atomic merge→main→reconcile→promote-live the `/admin` Accept button
|
|
772
|
-
// drives — `POST /api/changes/accept` with `{ repo:<appDomain>, changeId,
|
|
773
|
-
// expectedHeadSha }` — NOT the MCP `change_accept` (that's chg_-record-id-keyed +
|
|
774
|
-
// checkout-bound). The go-live HUMAN GATE stays the confirm.
|
|
775
|
-
//
|
|
776
|
-
// AUTH (investigated): `resolveOwnerSession` (apps/storefront/src/lib/grants/session.ts)
|
|
777
|
-
// accepts a SIGNED-IN storefront `tot_session` cookie (path 1) OR the Bearer operator
|
|
778
|
-
// secret + `X-Tot-Owner` (path 2). The CLI holds NO storefront cookie — its OAuth login
|
|
779
|
-
// is with the MCP, a different trust boundary — so the signed-in/OAuth path can't apply
|
|
780
|
-
// here. The Bearer operator-secret path is the CLI's ONLY route, exactly mirroring
|
|
781
|
-
// `tot preview build`'s transport (Bearer + `X-Tot-Owner` + `x-tot-capability`).
|
|
782
|
-
|
|
783
|
-
const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
|
|
203
|
+
// ─── Response normalisation (defensive — one endpoint, but shapes may vary) ─────
|
|
784
204
|
|
|
785
205
|
/**
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
* @param {
|
|
790
|
-
* @returns {string
|
|
206
|
+
* Normalise a `GET /api/changes/ship` body — b09's `AggregateShipPlan` (a
|
|
207
|
+
* shippable aggregate) or `AggregateShipRefusal` (nothing shippable), read
|
|
208
|
+
* defensively since it crossed the wire as JSON. Pure — unit-tested.
|
|
209
|
+
* @param {any} data
|
|
210
|
+
* @returns {{ok:true, tenantId:string|null, pinnedSha:string|null, artifactDigest:string|null,
|
|
211
|
+
* includedPrs:Array<{prNumber?:number|null,changeId?:string|null,headSha?:string|null}>,
|
|
212
|
+
* integrationRunId:string|null, receiptId:string|null,
|
|
213
|
+
* rollbackTarget:{receiptId:string,aggregateSha:string,artifactDigest?:string}|null,
|
|
214
|
+
* paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean} |
|
|
215
|
+
* {ok:false, reason:string, message:string}}
|
|
791
216
|
*/
|
|
792
|
-
export function
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
217
|
+
export function normalizeShipPlan(data) {
|
|
218
|
+
const o = data && typeof data === "object" ? data : {};
|
|
219
|
+
if (o.ok === true) {
|
|
220
|
+
return {
|
|
221
|
+
ok: true,
|
|
222
|
+
tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
|
|
223
|
+
pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
|
|
224
|
+
artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
|
|
225
|
+
includedPrs: Array.isArray(o.includedPrs) ? o.includedPrs : [],
|
|
226
|
+
integrationRunId: typeof o.integrationRunId === "string" ? o.integrationRunId : null,
|
|
227
|
+
receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
|
|
228
|
+
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
229
|
+
paywall:
|
|
230
|
+
o.paywall && typeof o.paywall === "object"
|
|
231
|
+
? {
|
|
232
|
+
allowed: o.paywall.allowed === true,
|
|
233
|
+
message: typeof o.paywall.message === "string" ? o.paywall.message : null,
|
|
234
|
+
}
|
|
235
|
+
: { allowed: true, message: null },
|
|
236
|
+
alreadyShipped: o.alreadyShipped === true,
|
|
237
|
+
};
|
|
797
238
|
}
|
|
798
|
-
return
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
reason: typeof o.reason === "string" ? o.reason : "unknown",
|
|
242
|
+
message: typeof o.message === "string" ? o.message : "the ship plan was refused for an unknown reason",
|
|
243
|
+
};
|
|
799
244
|
}
|
|
800
245
|
|
|
801
246
|
/**
|
|
802
|
-
* Normalise a `
|
|
803
|
-
*
|
|
804
|
-
* `
|
|
805
|
-
* wrapper) and keep only records carrying a `changeId`. Pure — unit-tested.
|
|
247
|
+
* Normalise a `POST /api/changes/ship` body — b09's `AggregateShipResult`.
|
|
248
|
+
* `state` is the ONLY honest terminal-state authority: render "shipped live"
|
|
249
|
+
* for `"shipped"` and nothing else. Pure — unit-tested.
|
|
806
250
|
* @param {any} data
|
|
807
|
-
* @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
|
|
808
251
|
*/
|
|
809
|
-
export function
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
:
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
252
|
+
export function normalizeShipResult(data) {
|
|
253
|
+
const o = data && typeof data === "object" ? data : {};
|
|
254
|
+
return {
|
|
255
|
+
ok: o.ok === true,
|
|
256
|
+
state: typeof o.state === "string" ? o.state : "unknown",
|
|
257
|
+
reason: typeof o.reason === "string" ? o.reason : null,
|
|
258
|
+
message: typeof o.message === "string" ? o.message : "",
|
|
259
|
+
pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
|
|
260
|
+
artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
|
|
261
|
+
receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
|
|
262
|
+
rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
|
|
263
|
+
};
|
|
816
264
|
}
|
|
817
265
|
|
|
818
|
-
/**
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
previewUrl: c.previewUrl ?? null,
|
|
836
|
-
};
|
|
837
|
-
}
|
|
266
|
+
/** A clear next step per plan/ship refusal reason. Pure. */
|
|
267
|
+
export function refusalNextStep(reason) {
|
|
268
|
+
switch (reason) {
|
|
269
|
+
case "not_green":
|
|
270
|
+
case "no_passed_run":
|
|
271
|
+
return "land PRs into the shared preview queue and wait for it to go green, then re-run `tot ship`";
|
|
272
|
+
case "sha_mismatch":
|
|
273
|
+
case "digest_mismatch":
|
|
274
|
+
return "the aggregate moved since you reviewed it — re-run `tot ship` to review the current plan";
|
|
275
|
+
case "artifact_missing":
|
|
276
|
+
return "re-integrate to re-materialize the artifact (b09 never rebuilds at ship), then re-run `tot ship`";
|
|
277
|
+
case "paywall":
|
|
278
|
+
return "upgrade the storefront subscription to enable go-live";
|
|
279
|
+
case "golive_unconfirmed":
|
|
280
|
+
return "re-run `tot ship` and confirm the plan";
|
|
281
|
+
default:
|
|
282
|
+
return "re-run `tot ship`";
|
|
838
283
|
}
|
|
839
|
-
return null;
|
|
840
284
|
}
|
|
841
285
|
|
|
842
|
-
/**
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
export function normalizeAcceptResponse(data) {
|
|
850
|
-
const o = data && typeof data === "object" ? data : {};
|
|
851
|
-
const state = typeof o.state === "string" ? o.state : null;
|
|
852
|
-
const shipped = state === "shipped" || o.live === true;
|
|
853
|
-
return {
|
|
854
|
-
shipped,
|
|
855
|
-
pending: !shipped && (state === "publish_pending" || o.merged === true),
|
|
856
|
-
state,
|
|
857
|
-
sha: typeof o.sha === "string" ? o.sha : null,
|
|
858
|
-
message: typeof o.message === "string" ? o.message : null,
|
|
859
|
-
error: typeof o.error === "string" ? o.error : null,
|
|
860
|
-
raw: data,
|
|
861
|
-
};
|
|
286
|
+
/** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
|
|
287
|
+
async function readJsonSafe(res) {
|
|
288
|
+
try {
|
|
289
|
+
return await res.json();
|
|
290
|
+
} catch {
|
|
291
|
+
return {};
|
|
292
|
+
}
|
|
862
293
|
}
|
|
863
294
|
|
|
295
|
+
// ─── Orchestration ───────────────────────────────────────────────────────────────
|
|
296
|
+
|
|
864
297
|
/**
|
|
865
|
-
* The
|
|
866
|
-
*
|
|
298
|
+
* The ship flow: GET the plan, print it + confirm, POST to ship, report the
|
|
299
|
+
* honest terminal state. `fetch`/`confirmPlan`/`openUrl`/`progress` are
|
|
300
|
+
* injected so it's unit-tested with no live network/TTY.
|
|
867
301
|
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
* queue the PR has no built candidate → instruct `tot preview build` first (we
|
|
871
|
-
* NEVER invent a changeId).
|
|
872
|
-
* 2. PLAN + CONFIRM — the go-live HUMAN GATE. `POST /api/changes/accept` is the
|
|
873
|
-
* atomic merge→main + reconcile + promote-live, so the plan states BOTH (the
|
|
874
|
-
* developer-context wording, mirroring the `/admin` Accept plan). `--yes` confirms
|
|
875
|
-
* non-interactively; a non-TTY without `--yes` REFUSES — nothing merges/goes live
|
|
876
|
-
* without an explicit yes.
|
|
877
|
-
* 3. SHIP via `POST /api/changes/accept` `{ repo:<tenant appDomain>, changeId,
|
|
878
|
-
* expectedHeadSha }` — the SAME endpoint the `/admin` Accept button uses.
|
|
879
|
-
*
|
|
880
|
-
* Transport mirrors `tot preview build` exactly (Bearer operator secret + `X-Tot-Owner`
|
|
881
|
-
* + `x-tot-capability: ship-on-behalf`). `fetch`/confirm/openUrl/progress are injected
|
|
882
|
-
* so it's unit-tested with no network/TTY.
|
|
883
|
-
*
|
|
884
|
-
* @param {{ tenant:string, pr:number|string|null, secret:string, storefrontUrl?:string|null,
|
|
885
|
-
* noOpen?:boolean, yes?:boolean }} params
|
|
302
|
+
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
303
|
+
* yes?:boolean, noOpen?:boolean }} params
|
|
886
304
|
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
887
305
|
* openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
|
|
888
306
|
* @returns {Promise<number>} process exit code
|
|
889
307
|
*/
|
|
890
|
-
export async function
|
|
891
|
-
{ tenant, pr, secret, storefrontUrl = null, noOpen = false, yes = false },
|
|
892
|
-
deps = {},
|
|
893
|
-
) {
|
|
308
|
+
export async function runShip({ tenant, secret, storefrontUrl = null, yes = false, noOpen = false }, deps = {}) {
|
|
894
309
|
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
895
310
|
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
896
311
|
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
897
312
|
|
|
898
|
-
const want = pr != null && `${pr}`.trim() && Number.isFinite(Number(pr)) ? Number(pr) : null;
|
|
899
|
-
if (want == null) {
|
|
900
|
-
console.error(
|
|
901
|
-
fail(
|
|
902
|
-
"operator ship needs a PR number to resolve the built candidate",
|
|
903
|
-
"pass a PR — `tot ship <N>` or `tot ship --pr <N> --tenant <appDomain>`",
|
|
904
|
-
),
|
|
905
|
-
);
|
|
906
|
-
return 2;
|
|
907
|
-
}
|
|
908
313
|
if (!secret) {
|
|
909
314
|
console.error(
|
|
910
315
|
fail(
|
|
911
|
-
"shipping
|
|
316
|
+
"shipping is an OPERATOR action — it needs an operator secret",
|
|
912
317
|
"set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
|
|
913
318
|
),
|
|
914
319
|
);
|
|
@@ -921,66 +326,61 @@ export async function runShipByPr(
|
|
|
921
326
|
"x-tot-capability": "ship-on-behalf",
|
|
922
327
|
};
|
|
923
328
|
|
|
924
|
-
// 1.
|
|
925
|
-
|
|
329
|
+
// 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
|
|
330
|
+
// rollback target + paywall verdict. Zero side effects.
|
|
331
|
+
let planRes;
|
|
926
332
|
try {
|
|
927
|
-
|
|
333
|
+
planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
|
|
928
334
|
} catch (e) {
|
|
929
335
|
console.error(
|
|
930
|
-
fail(`couldn't reach the
|
|
336
|
+
fail(`couldn't reach the ship plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
931
337
|
);
|
|
932
338
|
return 1;
|
|
933
339
|
}
|
|
934
|
-
const
|
|
935
|
-
if (!
|
|
936
|
-
|
|
340
|
+
const planData = await readJsonSafe(planRes);
|
|
341
|
+
if (!planRes.ok && planData?.ok !== false) {
|
|
342
|
+
// A transport-level refusal the endpoint didn't explain in its own shape
|
|
343
|
+
// (auth/tenant-resolution/network) — surface it honestly.
|
|
344
|
+
const msg = planData?.error || `HTTP ${planRes.status}`;
|
|
937
345
|
console.error(
|
|
938
346
|
fail(
|
|
939
|
-
`the
|
|
940
|
-
|
|
347
|
+
`the ship plan was refused: ${msg}`,
|
|
348
|
+
planRes.status === 401 || planRes.status === 403
|
|
941
349
|
? "check the operator secret and that it's authorised for this tenant"
|
|
942
350
|
: "check --tenant / --url, then re-run",
|
|
943
351
|
),
|
|
944
352
|
);
|
|
945
353
|
return 1;
|
|
946
354
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const previewUrl = previewUrlFor(base, tenant, want);
|
|
951
|
-
console.error(
|
|
952
|
-
fail(
|
|
953
|
-
`PR #${want} has no built candidate in ${tenant}'s queue — there's nothing to ship`,
|
|
954
|
-
`build it first: \`tot preview build --tenant ${tenant} --pr ${want}\`, then \`tot ship ${want} --tenant ${tenant}\``,
|
|
955
|
-
),
|
|
956
|
-
);
|
|
957
|
-
if (previewUrl) console.error(` Once built it appears at: ${previewUrl}`);
|
|
355
|
+
const plan = normalizeShipPlan(planData);
|
|
356
|
+
if (!plan.ok) {
|
|
357
|
+
console.error(fail(plan.message, refusalNextStep(plan.reason)));
|
|
958
358
|
return 1;
|
|
959
359
|
}
|
|
960
360
|
|
|
961
|
-
// 2.
|
|
962
|
-
//
|
|
963
|
-
|
|
964
|
-
const previewUrl = candidate.previewUrl || previewUrlFor(base, tenant, want);
|
|
361
|
+
// 2. Print the EXACT plan via the shared U10 affordance, then require ONE
|
|
362
|
+
// explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
|
|
363
|
+
const liveUrl = liveUrlFor(tenant);
|
|
965
364
|
const planLines = planForAction({
|
|
966
365
|
action: "ship",
|
|
967
|
-
context: "developer",
|
|
968
366
|
tenant,
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
367
|
+
pinnedSha: plan.pinnedSha,
|
|
368
|
+
artifactDigest: plan.artifactDigest,
|
|
369
|
+
includedPrs: plan.includedPrs,
|
|
370
|
+
rollbackTarget: plan.rollbackTarget,
|
|
371
|
+
paywall: plan.paywall,
|
|
372
|
+
targets: { live: liveUrl },
|
|
973
373
|
});
|
|
974
374
|
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
975
375
|
yes,
|
|
976
|
-
question: `Ship
|
|
376
|
+
question: `Ship ${tenant}'s current green aggregate live?`,
|
|
977
377
|
});
|
|
978
378
|
if (!confirmed) {
|
|
979
379
|
if (reason === "non-tty") {
|
|
980
380
|
console.error(
|
|
981
381
|
fail(
|
|
982
382
|
"`tot ship` needs an interactive terminal to confirm this live change",
|
|
983
|
-
"
|
|
383
|
+
"run it from a terminal, or pass --yes to confirm non-interactively",
|
|
984
384
|
),
|
|
985
385
|
);
|
|
986
386
|
return 2;
|
|
@@ -989,17 +389,19 @@ export async function runShipByPr(
|
|
|
989
389
|
return 0;
|
|
990
390
|
}
|
|
991
391
|
|
|
992
|
-
// 3.
|
|
392
|
+
// 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
|
|
393
|
+
// STALE plan (the aggregate moved / the artifact changed since GET) is
|
|
394
|
+
// refused rather than shipping something nobody reviewed.
|
|
993
395
|
const progress = deps.progress === false ? null : startProgress("shipping…");
|
|
994
|
-
let
|
|
396
|
+
let shipRes;
|
|
995
397
|
try {
|
|
996
|
-
|
|
398
|
+
shipRes = await fetchImpl(`${base}/api/changes/ship`, {
|
|
997
399
|
method: "POST",
|
|
998
400
|
headers: { "content-type": "application/json", ...authHeaders },
|
|
999
401
|
body: JSON.stringify({
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
...(
|
|
402
|
+
confirmGoLive: true,
|
|
403
|
+
...(plan.pinnedSha ? { expectedAggregateSha: plan.pinnedSha } : {}),
|
|
404
|
+
...(plan.artifactDigest ? { expectedArtifactDigest: plan.artifactDigest } : {}),
|
|
1003
405
|
}),
|
|
1004
406
|
});
|
|
1005
407
|
} catch (e) {
|
|
@@ -1009,234 +411,51 @@ export async function runShipByPr(
|
|
|
1009
411
|
);
|
|
1010
412
|
return 1;
|
|
1011
413
|
}
|
|
1012
|
-
const
|
|
414
|
+
const shipData = await readJsonSafe(shipRes);
|
|
1013
415
|
progress?.stop();
|
|
1014
416
|
|
|
1015
|
-
|
|
1016
|
-
if (!acceptRes.ok && !result.shipped && !result.pending) {
|
|
1017
|
-
const why = result.error || result.message || `HTTP ${acceptRes.status}`;
|
|
1018
|
-
console.error(
|
|
1019
|
-
fail(
|
|
1020
|
-
`the ship gate refused this change (HTTP ${acceptRes.status}): ${why}`,
|
|
1021
|
-
"check the PR reconciled cleanly (rebuild with `tot preview build`), then re-run",
|
|
1022
|
-
),
|
|
1023
|
-
);
|
|
1024
|
-
return 1;
|
|
1025
|
-
}
|
|
1026
|
-
return reportShippedByPr(result, { tenant, pr: want, previewUrl, noOpen, openUrl: deps.openUrl });
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
/** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
|
|
1030
|
-
async function readJsonSafe(res) {
|
|
1031
|
-
try {
|
|
1032
|
-
return await res.json();
|
|
1033
|
-
} catch {
|
|
1034
|
-
return {};
|
|
1035
|
-
}
|
|
417
|
+
return reportShipResult(normalizeShipResult(shipData), { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
1036
418
|
}
|
|
1037
419
|
|
|
1038
|
-
/**
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
420
|
+
/**
|
|
421
|
+
* Report the orchestrator's HONEST terminal state. `shipped` is the ONLY state
|
|
422
|
+
* rendered as "shipped live" — `refused`/`promote_failed`/`record_failed` are
|
|
423
|
+
* surfaced with their own message + a clear next step, never dressed up as a
|
|
424
|
+
* success and never silently swallowed.
|
|
425
|
+
* @returns {number} process exit code
|
|
426
|
+
*/
|
|
427
|
+
function reportShipResult(result, { tenant, liveUrl, noOpen, openUrl }) {
|
|
428
|
+
if (result.state === "shipped") {
|
|
429
|
+
console.log(`\n ✓ shipped ${tenant} live.`);
|
|
430
|
+
if (result.pinnedSha) console.log(` pinned: ${result.pinnedSha}`);
|
|
431
|
+
if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
|
|
1043
432
|
if (liveUrl) {
|
|
1044
433
|
console.log(` Live: ${liveUrl}`);
|
|
1045
434
|
if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
|
|
1046
435
|
}
|
|
436
|
+
if (result.message) console.log(` ${result.message}`);
|
|
1047
437
|
return 0;
|
|
1048
438
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
if (previewUrl) console.log(` Track it here: ${previewUrl}`);
|
|
1052
|
-
return 0;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
// ─── Operator ship (DEPLOY-only) ─────────────────────────────────────────────────────
|
|
1056
|
-
|
|
1057
|
-
/**
|
|
1058
|
-
* The OPERATOR ship flow (decision ship-context-dependent-semantics): the caller is
|
|
1059
|
-
* targeting a `--pr N --tenant t` that ISN'T their checkout, so ship means DEPLOY
|
|
1060
|
-
* (main → preview + live) — NOT merge. The merge is the separate `tot accept` verb.
|
|
1061
|
-
*
|
|
1062
|
-
* We STATE the exact deploy plan (the shared `planForAction`/`printPlanAndConfirm`
|
|
1063
|
-
* affordance) and confirm before acting (`--yes` to confirm non-interactively; a
|
|
1064
|
-
* non-TTY without `--yes` refuses).
|
|
1065
|
-
*
|
|
1066
|
-
* ┌─ CROSS-TENANT/CROSS-PR TARGETING (the app-bound-identity constraint) ────────────┐
|
|
1067
|
-
* │ Resolving a TARGETED PR's change-record id / head sha from the forge needs a │
|
|
1068
|
-
* │ `candidate_status` read the CLI's user identity may NOT be allowed (it's │
|
|
1069
|
-
* │ app-bound). So we take the change-record id (and head sha) via EXPLICIT flags │
|
|
1070
|
-
* │ (`--change-id` / `--head-sha`) rather than blocking — mirroring U3's descriptor │
|
|
1071
|
-
* │ flags. See the `u9` marker below for the auto-resolution follow-on. │
|
|
1072
|
-
* └───────────────────────────────────────────────────────────────────────────────────┘
|
|
1073
|
-
*
|
|
1074
|
-
* @param {{callTool:Function}} client an MCP client (real or mock)
|
|
1075
|
-
* @param {{ tenant:string, pr?:number|string|null, changeId?:string|null,
|
|
1076
|
-
* headSha?:string|null, storefrontUrl?:string|null, noOpen?:boolean, yes?:boolean }} params
|
|
1077
|
-
* @param {{ confirmPlan?:typeof printPlanAndConfirm, poll?:typeof pollChangeShipped,
|
|
1078
|
-
* openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
|
|
1079
|
-
* @returns {Promise<number>} process exit code
|
|
1080
|
-
*/
|
|
1081
|
-
export async function runShipOperator(
|
|
1082
|
-
client,
|
|
1083
|
-
{ tenant, pr = null, changeId = null, headSha = null, storefrontUrl = null, noOpen = false, yes = false },
|
|
1084
|
-
deps = {},
|
|
1085
|
-
) {
|
|
1086
|
-
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
1087
|
-
const poll = deps.poll || pollChangeShipped;
|
|
1088
|
-
|
|
1089
|
-
// u9: auto-resolve targeted PR descriptor (changeId/headSha) via forge PR-read —
|
|
1090
|
-
// the CLI's app-bound user identity may not be allowed the candidate_status read for
|
|
1091
|
-
// a cross-tenant/cross-PR target, so today the operator supplies these via flags.
|
|
1092
|
-
const previewUrl = previewUrlFor(storefrontUrl, tenant, pr);
|
|
1093
|
-
const planLines = planForAction({
|
|
1094
|
-
action: "ship",
|
|
1095
|
-
context: "operator",
|
|
1096
|
-
tenant,
|
|
1097
|
-
pr,
|
|
1098
|
-
changeId,
|
|
1099
|
-
headSha,
|
|
1100
|
-
targets: { preview: previewUrl, live: liveUrlFor(tenant) },
|
|
1101
|
-
});
|
|
1102
|
-
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
1103
|
-
yes,
|
|
1104
|
-
question: `Deploy ${tenant} live now (main → preview + live)?`,
|
|
1105
|
-
});
|
|
1106
|
-
if (!confirmed) {
|
|
1107
|
-
if (reason === "non-tty") {
|
|
1108
|
-
console.error(
|
|
1109
|
-
fail(
|
|
1110
|
-
"`tot ship` needs an interactive terminal to confirm this deploy",
|
|
1111
|
-
"re-run from a terminal, or pass --yes to confirm non-interactively",
|
|
1112
|
-
),
|
|
1113
|
-
);
|
|
1114
|
-
return 2;
|
|
1115
|
-
}
|
|
1116
|
-
console.log(" Ship cancelled — nothing deployed.");
|
|
1117
|
-
return 0;
|
|
1118
|
-
}
|
|
1119
|
-
|
|
1120
|
-
// We can't auto-resolve the targeted PR's change record from the forge (see above),
|
|
1121
|
-
// so the operator must name it explicitly to promote it.
|
|
1122
|
-
if (!changeId) {
|
|
1123
|
-
console.error(
|
|
1124
|
-
fail(
|
|
1125
|
-
"operator ship needs the target change-record id to deploy",
|
|
1126
|
-
"pass --change-id <chg_…> (u9 will auto-resolve it from --pr via a forge PR-read)",
|
|
1127
|
-
),
|
|
1128
|
-
);
|
|
1129
|
-
return 2;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
// TODO(u9): operator ship is DEPLOY-only (the merge is the separate `tot accept`),
|
|
1133
|
-
// but a DISTINCT deploy-only trigger separate from `change_accept` is NOT yet wired.
|
|
1134
|
-
// Until it is, this uses the EXISTING promote path — `change_accept` (u7's
|
|
1135
|
-
// promote-by-digest, keyed on the resolved change-record id) — which is the same
|
|
1136
|
-
// mechanism the developer path calls. Adding a new deploy path is out of scope for u4.
|
|
1137
|
-
let accept;
|
|
1138
|
-
try {
|
|
1139
|
-
accept = normalizeChangeResult(
|
|
1140
|
-
await client.callTool("change_accept", {
|
|
1141
|
-
id: changeId,
|
|
1142
|
-
tenant,
|
|
1143
|
-
dryRun: false,
|
|
1144
|
-
idempotencyKey: `ship-${changeId}-${headSha ?? "head"}`,
|
|
1145
|
-
}),
|
|
1146
|
-
);
|
|
1147
|
-
} catch (e) {
|
|
1148
|
-
console.error(
|
|
1149
|
-
fail(
|
|
1150
|
-
`the deploy gate refused this change: ${String(e?.message || e)}`,
|
|
1151
|
-
"check the change is merged to main and reconciled, then re-run `tot ship`",
|
|
1152
|
-
),
|
|
1153
|
-
);
|
|
439
|
+
if (result.state === "refused") {
|
|
440
|
+
console.error(fail(result.message || `ship refused (${result.reason})`, refusalNextStep(result.reason)));
|
|
1154
441
|
return 1;
|
|
1155
442
|
}
|
|
1156
|
-
|
|
1157
|
-
const progress = deps.progress === false ? null : startProgress("deploying…");
|
|
1158
|
-
let status;
|
|
1159
|
-
try {
|
|
1160
|
-
status = await poll(client, { id: changeId, tenant });
|
|
1161
|
-
} finally {
|
|
1162
|
-
progress?.stop();
|
|
1163
|
-
}
|
|
1164
|
-
return reportShipped(accept, status, { tenant, noOpen, openUrl: deps.openUrl });
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1167
|
-
/**
|
|
1168
|
-
* The `run` wrapper for OPERATOR ship — establish a session, bind the target tenant,
|
|
1169
|
-
* and drive `runShipOperator`. Split from `run` for the same reason the developer half
|
|
1170
|
-
* is split from `runShip`: this half does the I/O (auth + tenant switch), the pure flow
|
|
1171
|
-
* is unit-tested with a mock client.
|
|
1172
|
-
* @param {ReturnType<typeof parseShipArgs>} args
|
|
1173
|
-
* @param {any} ctx
|
|
1174
|
-
* @param {NodeJS.ProcessEnv} env
|
|
1175
|
-
*/
|
|
1176
|
-
async function runOperatorFlow(args, ctx, env) {
|
|
1177
|
-
const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
|
|
1178
|
-
if (!tenant) {
|
|
1179
|
-
console.error(
|
|
1180
|
-
fail(
|
|
1181
|
-
"operator ship needs a target tenant",
|
|
1182
|
-
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com) with --pr <N>",
|
|
1183
|
-
),
|
|
1184
|
-
);
|
|
1185
|
-
return 2;
|
|
1186
|
-
}
|
|
1187
|
-
const pr = args.pr != null && `${args.pr}`.trim() && Number.isFinite(Number(args.pr)) ? Number(args.pr) : args.pr;
|
|
1188
|
-
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
1189
|
-
|
|
1190
|
-
// u16: with NO explicit change-record id, ship ANY built PR via the SHARED candidate
|
|
1191
|
-
// queue — resolve PR→changeId from GET /api/changes and ship via POST
|
|
1192
|
-
// /api/changes/accept (operator secret transport). This is the path that ships a PR
|
|
1193
|
-
// the operator did NOT author. The explicit `--change-id` path below stays the MCP
|
|
1194
|
-
// change_accept targeting (backward-compatible; distinct chg_-record namespace).
|
|
1195
|
-
if (!args.changeId) {
|
|
1196
|
-
return await runShipByPr(
|
|
1197
|
-
{
|
|
1198
|
-
tenant,
|
|
1199
|
-
pr,
|
|
1200
|
-
secret: resolveOperatorSecret(args.secret, env),
|
|
1201
|
-
storefrontUrl,
|
|
1202
|
-
noOpen: args.noOpen,
|
|
1203
|
-
yes: args.yes,
|
|
1204
|
-
},
|
|
1205
|
-
{ openUrl: (u) => openBrowser(u) },
|
|
1206
|
-
);
|
|
1207
|
-
}
|
|
1208
|
-
|
|
1209
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
1210
|
-
const client = createMcpClient(baseUrl);
|
|
1211
|
-
try {
|
|
1212
|
-
await establishSession(client, { env, prefer: args.identity || undefined });
|
|
1213
|
-
await client.callTool("client_switch", { tenant });
|
|
1214
|
-
return await runShipOperator(
|
|
1215
|
-
client,
|
|
1216
|
-
{
|
|
1217
|
-
tenant,
|
|
1218
|
-
pr,
|
|
1219
|
-
changeId: args.changeId,
|
|
1220
|
-
headSha: args.headSha,
|
|
1221
|
-
storefrontUrl,
|
|
1222
|
-
noOpen: args.noOpen,
|
|
1223
|
-
yes: args.yes,
|
|
1224
|
-
},
|
|
1225
|
-
{ openUrl: (u) => openBrowser(u) },
|
|
1226
|
-
);
|
|
1227
|
-
} catch (e) {
|
|
1228
|
-
if (e instanceof AuthUnavailableError) {
|
|
1229
|
-
console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
|
|
1230
|
-
return 1;
|
|
1231
|
-
}
|
|
443
|
+
if (result.state === "promote_failed" || result.state === "record_failed") {
|
|
1232
444
|
console.error(
|
|
1233
445
|
fail(
|
|
1234
|
-
|
|
1235
|
-
"
|
|
446
|
+
result.message || `ship ${result.state.replace("_", " ")}`,
|
|
447
|
+
"re-run `tot ship` — the ship is idempotent and safe to retry",
|
|
1236
448
|
),
|
|
1237
449
|
);
|
|
1238
450
|
return 1;
|
|
1239
451
|
}
|
|
452
|
+
console.error(
|
|
453
|
+
fail(
|
|
454
|
+
`unexpected ship response (state: ${result.state})`,
|
|
455
|
+
"re-run `tot ship`; check the storefront logs if it persists",
|
|
456
|
+
),
|
|
457
|
+
);
|
|
458
|
+
return 1;
|
|
1240
459
|
}
|
|
1241
460
|
|
|
1242
461
|
/**
|
|
@@ -1251,127 +470,29 @@ export async function run(argv, ctx) {
|
|
|
1251
470
|
return 0;
|
|
1252
471
|
}
|
|
1253
472
|
|
|
1254
|
-
//
|
|
1255
|
-
// `--
|
|
1256
|
-
//
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
const context = detectShipContext({
|
|
1260
|
-
pr: args.pr,
|
|
1261
|
-
prPositional: args.prPositional,
|
|
1262
|
-
tenant: args.tenant,
|
|
1263
|
-
ctxTenant,
|
|
1264
|
-
ctxMode: ctx.mode,
|
|
1265
|
-
});
|
|
1266
|
-
if (context === "operator") {
|
|
1267
|
-
return await runOperatorFlow(args, ctx, env);
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
if (ctx.mode !== "checkout") {
|
|
473
|
+
// ONE meaning, no context detection: the target tenant is either named
|
|
474
|
+
// explicitly (`--tenant`, works from anywhere — no checkout needed) or
|
|
475
|
+
// inferred from the current checkout. Never guessed from a PR/branch/actor.
|
|
476
|
+
const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
|
|
477
|
+
if (!tenant) {
|
|
1271
478
|
console.error(
|
|
1272
479
|
fail(
|
|
1273
|
-
"`tot ship`
|
|
1274
|
-
"
|
|
480
|
+
"`tot ship` needs a target tenant",
|
|
481
|
+
"run it from inside a `tot clone`d store, or pass --tenant <appDomain>",
|
|
1275
482
|
),
|
|
1276
483
|
);
|
|
1277
484
|
return 2;
|
|
1278
485
|
}
|
|
1279
486
|
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
execFileSync("git", ["-C", workspace, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
1284
|
-
const gitSafe = (cargs) => {
|
|
1285
|
-
try {
|
|
1286
|
-
return git(cargs);
|
|
1287
|
-
} catch {
|
|
1288
|
-
return "";
|
|
1289
|
-
}
|
|
1290
|
-
};
|
|
1291
|
-
|
|
1292
|
-
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
1293
|
-
if (!repo) {
|
|
1294
|
-
console.error(
|
|
1295
|
-
fail("couldn't derive the forge repo from this checkout's remote", "run this from a `tot clone`d store"),
|
|
1296
|
-
);
|
|
1297
|
-
return 1;
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
1301
|
-
const statePath = defaultCandidateStatePath(env);
|
|
1302
|
-
const client = createMcpClient(baseUrl);
|
|
1303
|
-
try {
|
|
1304
|
-
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
1305
|
-
// Bind the active tenant so the candidate/change tools read the right scope.
|
|
1306
|
-
await client.callTool("client_switch", { tenant });
|
|
1307
|
-
|
|
1308
|
-
// Resolve which candidate this checkout ships (the u4 seam) — branch-bound so
|
|
1309
|
-
// ship promotes exactly the candidate `tot preview` pushed from THIS branch.
|
|
1310
|
-
const changeId = resolveActiveChangeId({
|
|
1311
|
-
statePath,
|
|
1312
|
-
mcpUrl: baseUrl,
|
|
1313
|
-
repo,
|
|
487
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
488
|
+
return await runShip(
|
|
489
|
+
{
|
|
1314
490
|
tenant,
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
|
|
1323
|
-
|
|
1324
|
-
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
1325
|
-
return await runShip(
|
|
1326
|
-
client,
|
|
1327
|
-
{
|
|
1328
|
-
tenant,
|
|
1329
|
-
changeId,
|
|
1330
|
-
repo,
|
|
1331
|
-
git,
|
|
1332
|
-
noOpen: args.noOpen,
|
|
1333
|
-
yes: args.yes,
|
|
1334
|
-
storefrontUrl,
|
|
1335
|
-
changeSummary,
|
|
1336
|
-
// A positional `tot ship 4` asserts WHICH PR — passed as a safety guard so
|
|
1337
|
-
// ship refuses if this checkout's active candidate is a different PR.
|
|
1338
|
-
expectedPr: args.prPositional ? args.pr : null,
|
|
1339
|
-
},
|
|
1340
|
-
{
|
|
1341
|
-
openUrl: (u) => openBrowser(u),
|
|
1342
|
-
// u16: when `tot ship <N>` names a PR that ISN'T this checkout's candidate (no
|
|
1343
|
-
// local candidate resolves), fall through to the operator-by-PR queue path — so
|
|
1344
|
-
// `tot ship 4` ships someone else's built PR too, using this checkout's tenant.
|
|
1345
|
-
...(args.prPositional
|
|
1346
|
-
? {
|
|
1347
|
-
shipByPrFallback: () =>
|
|
1348
|
-
runShipByPr(
|
|
1349
|
-
{
|
|
1350
|
-
tenant,
|
|
1351
|
-
pr: args.pr,
|
|
1352
|
-
secret: resolveOperatorSecret(args.secret, env),
|
|
1353
|
-
storefrontUrl,
|
|
1354
|
-
noOpen: args.noOpen,
|
|
1355
|
-
yes: args.yes,
|
|
1356
|
-
},
|
|
1357
|
-
{ openUrl: (u) => openBrowser(u) },
|
|
1358
|
-
),
|
|
1359
|
-
}
|
|
1360
|
-
: {}),
|
|
1361
|
-
},
|
|
1362
|
-
);
|
|
1363
|
-
} catch (e) {
|
|
1364
|
-
if (e instanceof AuthUnavailableError) {
|
|
1365
|
-
console.error(fail("sign in to ship", e.hint || "run `tot login`, then re-run `tot ship`"));
|
|
1366
|
-
return 1;
|
|
1367
|
-
}
|
|
1368
|
-
console.error(
|
|
1369
|
-
fail(
|
|
1370
|
-
`couldn't reach the ship service: ${String(e?.message || e)}`,
|
|
1371
|
-
"check your connection and that you're signed in, then re-run",
|
|
1372
|
-
),
|
|
1373
|
-
);
|
|
1374
|
-
return 1;
|
|
1375
|
-
}
|
|
491
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
492
|
+
storefrontUrl,
|
|
493
|
+
yes: args.yes,
|
|
494
|
+
noOpen: args.noOpen,
|
|
495
|
+
},
|
|
496
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
497
|
+
);
|
|
1376
498
|
}
|
|
1377
|
-
|