@tokenoftrust/cli 1.4.0-rc.9 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,517 @@
1
+ /**
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
+ *
7
+ * tot dev run your store locally with save→reload
8
+ * tot preview push it to a reviewable preview (validate → reconcile → compliance)
9
+ * tot ship publish the current green aggregate live ← you are here
10
+ *
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.
16
+ *
17
+ * THE FLOW:
18
+ *
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).
39
+ *
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).
45
+ */
46
+ import { fail } from "../errors.mjs";
47
+ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
48
+ import { startProgress } from "../progress.mjs";
49
+ import { openBrowser } from "../open.mjs";
50
+ import { emitActivity } from "../activity.mjs";
51
+
52
+ const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
53
+
54
+ const USAGE = `tot ship — publish the tenant's CURRENT GREEN AGGREGATE to live (ONE meaning)
55
+
56
+ tot ship ship the checkout tenant's current green aggregate
57
+ tot ship --tenant <t> ship a named tenant's current green aggregate (no checkout needed)
58
+ tot ship --yes confirm non-interactively (skip the [y/N] prompt)
59
+ tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
60
+ tot ship --secret <s> operator secret (prefer the env vars below)
61
+ tot ship --no-open don't open the live URL in your browser
62
+
63
+ Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
64
+ GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
65
+
66
+ \`tot ship\` has ONE meaning: it publishes the tenant's shared preview
67
+ aggregate — the batch of PRs that integrated cleanly and went GREEN — to
68
+ live. It NEVER takes a PR/candidate target and never infers anything from
69
+ your checkout or who you are. It shows you the EXACT plan first (the pinned
70
+ sha, the artifact digest, every included PR, the rollback target, and the
71
+ go-live paywall verdict) and waits for one explicit confirm (\`--yes\` to
72
+ skip the prompt; a non-TTY without \`--yes\` refuses). Ship then reports
73
+ VERIFIED live truth — never a false "shipped" — surfacing a refusal or a
74
+ recoverable partial failure honestly if it isn't.
75
+
76
+ The candidate-level merge is the separate \`tot accept\` / \`tot merge\` verb —
77
+ \`tot ship\` never merges.`;
78
+
79
+ /** Parse `tot ship` argv. Pure. */
80
+ export function parseShipArgs(argv) {
81
+ const a = {
82
+ tenant: null,
83
+ yes: false,
84
+ noOpen: false,
85
+ url: null,
86
+ secret: null,
87
+ help: false,
88
+ };
89
+ for (let i = 0; i < argv.length; i++) {
90
+ const t = argv[i];
91
+ if (t === "--tenant") a.tenant = argv[++i];
92
+ else if (t === "--yes" || t === "-y") a.yes = true;
93
+ else if (t === "--no-open") a.noOpen = true;
94
+ else if (t === "--url") a.url = argv[++i];
95
+ else if (t === "--secret") a.secret = argv[++i];
96
+ else if (t === "--help" || t === "-h") a.help = true;
97
+ }
98
+ return a;
99
+ }
100
+
101
+ // ─── Operator-secret transport (shared with `tot pr list --tenant` / u16/u17) ────
102
+ //
103
+ // `resolveOperatorSecret` / `normalizeChangesQueue` are the SAME helpers the
104
+ // retired operator-by-PR ship path minted; `tot pr list --tenant` (`pr.mjs`)
105
+ // still imports them for its own (unrelated) `GET /api/changes` candidate-queue
106
+ // listing, so they stay here rather than move — one wire, one place it's typed.
107
+
108
+ const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
109
+
110
+ /**
111
+ * Resolve the operator secret from an explicit `--secret` or the env (first
112
+ * found). Pure given its env argument.
113
+ * @param {string|null|undefined} explicit
114
+ * @param {NodeJS.ProcessEnv} env
115
+ * @returns {string}
116
+ */
117
+ export function resolveOperatorSecret(explicit, env = {}) {
118
+ if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
119
+ for (const k of OPERATOR_SECRET_ENV) {
120
+ const v = env[k];
121
+ if (v && `${v}`.trim()) return `${v}`.trim();
122
+ }
123
+ return "";
124
+ }
125
+
126
+ /**
127
+ * Normalise a `GET /api/changes` body to the OPEN candidate list. Kept for
128
+ * `tot pr list --tenant` (`pr.mjs`) — NOT used by ship itself anymore (ship
129
+ * reads the aggregate plan from `/api/changes/ship`, a distinct endpoint).
130
+ * @param {any} data
131
+ * @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
132
+ */
133
+ export function normalizeChangesQueue(data) {
134
+ const list = Array.isArray(data)
135
+ ? data
136
+ : data && Array.isArray(data.changes)
137
+ ? data.changes
138
+ : [];
139
+ // Keep an entry if it has a changeId (a BUILT candidate) OR a numeric prNumber (a
140
+ // not-built forge PR merged in by GET /api/changes, u9b) — so `tot pr list --tenant`
141
+ // shows orphaned/un-built PRs too, not only built candidates. (Re-applied after a
142
+ // branch-scale ship rework reverted it to changeId-only.)
143
+ return list.filter(
144
+ (c) =>
145
+ c &&
146
+ typeof c === "object" &&
147
+ (typeof c.changeId === "string" || typeof c.prNumber === "number"),
148
+ );
149
+ }
150
+
151
+ // ─── Shared with `tot accept` (`accept.mjs`) ─────────────────────────────────────
152
+ //
153
+ // `tot accept` queues a PR's integration into the protected `preview` aggregate
154
+ // (NOT a merge to main — see accept.mjs's header) — a DISTINCT operator verb from
155
+ // ship (no deploy). It imports these two normalisers straight from here rather
156
+ // than re-implementing them.
157
+
158
+ /**
159
+ * Normalise a `change_accept` / `change_status` result to { shipped, state,
160
+ * previewUrl, shippedAt }. `shipped` is true once the record reports the shipped
161
+ * state (or carries a shipped stamp). Pure — unit-tested.
162
+ * @param {any} r
163
+ */
164
+ export function normalizeChangeResult(r) {
165
+ const o = (r && typeof r === "object" ? r : {});
166
+ const state = typeof o.state === "string" ? o.state : null;
167
+ const shippedStamp = o.shipped && typeof o.shipped === "object" ? o.shipped : null;
168
+ const shipped = state === "shipped" || Boolean(o.shipped === true || shippedStamp);
169
+ return {
170
+ shipped,
171
+ state,
172
+ previewUrl: o.previewUrl ?? shippedStamp?.previewUrl ?? o.liveUrl ?? null,
173
+ shippedAt: shippedStamp?.shippedAt ?? o.shippedAt ?? null,
174
+ raw: r,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Poll `change_status` until the change reports shipped (or the attempts budget
180
+ * runs out). Injectable delay/attempts.
181
+ * @param {{callTool:Function}} client
182
+ * @param {{ id:string, tenant?:string }} target
183
+ * @param {{ attempts?:number, delayMs?:number, sleep?:(ms:number)=>Promise<void> }} [opts]
184
+ */
185
+ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6, delayMs = 1500, sleep } = {}) {
186
+ const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
187
+ let last = null;
188
+ for (let i = 0; i < attempts; i++) {
189
+ last = normalizeChangeResult(await client.callTool("change_status", { id, ...(tenant ? { tenant } : {}) }));
190
+ if (last.shipped) return last;
191
+ if (i < attempts - 1) await wait(delayMs);
192
+ }
193
+ return last;
194
+ }
195
+
196
+ // ─── URL derivation (pure) ─────────────────────────────────────────────────────
197
+
198
+ /** The live site URL for a tenant (the tenant IS its apex domain, e.g. tokenoftrust.com). Pure. */
199
+ export function liveUrlFor(tenant) {
200
+ const t = (tenant || "").trim();
201
+ return t ? `https://${t}` : null;
202
+ }
203
+
204
+ // ─── Response normalisation (defensive — one endpoint, but shapes may vary) ─────
205
+
206
+ /**
207
+ * Normalise a `GET /api/changes/ship` body — b09's `AggregateShipPlan` (a
208
+ * shippable aggregate) or `AggregateShipRefusal` (nothing shippable), read
209
+ * defensively since it crossed the wire as JSON. Pure — unit-tested.
210
+ * @param {any} data
211
+ * @returns {{ok:true, tenantId:string|null, pinnedSha:string|null, artifactDigest:string|null,
212
+ * includedPrs:Array<{prNumber?:number|null,changeId?:string|null,headSha?:string|null}>,
213
+ * integrationRunId:string|null, receiptId:string|null,
214
+ * rollbackTarget:{receiptId:string,aggregateSha:string,artifactDigest?:string}|null,
215
+ * paywall:{allowed:boolean,message:string|null}, alreadyShipped:boolean} |
216
+ * {ok:false, reason:string, message:string}}
217
+ */
218
+ export function normalizeShipPlan(data) {
219
+ const o = data && typeof data === "object" ? data : {};
220
+ if (o.ok === true) {
221
+ return {
222
+ ok: true,
223
+ tenantId: typeof o.tenantId === "string" ? o.tenantId : null,
224
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
225
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
226
+ includedPrs: Array.isArray(o.includedPrs) ? o.includedPrs : [],
227
+ integrationRunId: typeof o.integrationRunId === "string" ? o.integrationRunId : null,
228
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
229
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
230
+ paywall:
231
+ o.paywall && typeof o.paywall === "object"
232
+ ? {
233
+ allowed: o.paywall.allowed === true,
234
+ message: typeof o.paywall.message === "string" ? o.paywall.message : null,
235
+ }
236
+ : { allowed: true, message: null },
237
+ alreadyShipped: o.alreadyShipped === true,
238
+ };
239
+ }
240
+ return {
241
+ ok: false,
242
+ reason: typeof o.reason === "string" ? o.reason : "unknown",
243
+ message: typeof o.message === "string" ? o.message : "the ship plan was refused for an unknown reason",
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Normalise a `POST /api/changes/ship` body — b09's `AggregateShipResult`.
249
+ * `state` is the ONLY honest terminal-state authority: render "shipped live"
250
+ * for `"shipped"` and nothing else. Pure — unit-tested.
251
+ * @param {any} data
252
+ */
253
+ export function normalizeShipResult(data) {
254
+ const o = data && typeof data === "object" ? data : {};
255
+ return {
256
+ ok: o.ok === true,
257
+ state: typeof o.state === "string" ? o.state : "unknown",
258
+ reason: typeof o.reason === "string" ? o.reason : null,
259
+ message: typeof o.message === "string" ? o.message : "",
260
+ pinnedSha: typeof o.pinnedSha === "string" ? o.pinnedSha : null,
261
+ artifactDigest: typeof o.artifactDigest === "string" ? o.artifactDigest : null,
262
+ receiptId: typeof o.receiptId === "string" ? o.receiptId : null,
263
+ rollbackTarget: o.rollbackTarget && typeof o.rollbackTarget === "object" ? o.rollbackTarget : null,
264
+ };
265
+ }
266
+
267
+ /** A clear next step per plan/ship refusal reason. Pure. */
268
+ export function refusalNextStep(reason) {
269
+ switch (reason) {
270
+ case "not_green":
271
+ case "no_passed_run":
272
+ return "land PRs into the shared preview queue and wait for it to go green, then re-run `tot ship`";
273
+ case "sha_mismatch":
274
+ case "digest_mismatch":
275
+ return "the aggregate moved since you reviewed it — re-run `tot ship` to review the current plan";
276
+ case "artifact_missing":
277
+ return "re-integrate to re-materialize the artifact (b09 never rebuilds at ship), then re-run `tot ship`";
278
+ case "paywall":
279
+ return "upgrade the storefront subscription to enable go-live";
280
+ case "golive_unconfirmed":
281
+ return "re-run `tot ship` and confirm the plan";
282
+ default:
283
+ return "re-run `tot ship`";
284
+ }
285
+ }
286
+
287
+ /** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
288
+ async function readJsonSafe(res) {
289
+ try {
290
+ return await res.json();
291
+ } catch {
292
+ return {};
293
+ }
294
+ }
295
+
296
+ // ─── Orchestration ───────────────────────────────────────────────────────────────
297
+
298
+ /**
299
+ * The ship flow: GET the plan, print it + confirm, POST to ship, report the
300
+ * honest terminal state. `fetch`/`confirmPlan`/`openUrl`/`progress` are
301
+ * injected so it's unit-tested with no live network/TTY.
302
+ *
303
+ * @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
304
+ * yes?:boolean, noOpen?:boolean }} params
305
+ * @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
306
+ * openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
307
+ * @returns {Promise<number>} process exit code
308
+ */
309
+ export async function runShip({ tenant, secret, storefrontUrl = null, yes = false, noOpen = false }, deps = {}) {
310
+ const fetchImpl = deps.fetch || globalThis.fetch;
311
+ const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
312
+ const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
313
+
314
+ if (!secret) {
315
+ console.error(
316
+ fail(
317
+ "shipping is an OPERATOR action — it needs an operator secret",
318
+ "set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
319
+ ),
320
+ );
321
+ return 2;
322
+ }
323
+
324
+ const authHeaders = {
325
+ authorization: `Bearer ${secret}`,
326
+ "x-tot-owner": tenant,
327
+ "x-tot-capability": "ship-on-behalf",
328
+ };
329
+
330
+ // 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
331
+ // rollback target + paywall verdict. Zero side effects.
332
+ let planRes;
333
+ try {
334
+ planRes = await fetchImpl(`${base}/api/changes/ship`, { method: "GET", headers: authHeaders });
335
+ } catch (e) {
336
+ console.error(
337
+ fail(`couldn't reach the ship plan at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
338
+ );
339
+ return 1;
340
+ }
341
+ const planData = await readJsonSafe(planRes);
342
+ if (!planRes.ok && planData?.ok !== false) {
343
+ // A transport-level refusal the endpoint didn't explain in its own shape
344
+ // (auth/tenant-resolution/network) — surface it honestly.
345
+ const msg = planData?.error || `HTTP ${planRes.status}`;
346
+ console.error(
347
+ fail(
348
+ `the ship plan was refused: ${msg}`,
349
+ planRes.status === 401 || planRes.status === 403
350
+ ? "check the operator secret and that it's authorised for this tenant"
351
+ : "check --tenant / --url, then re-run",
352
+ ),
353
+ );
354
+ return 1;
355
+ }
356
+ const plan = normalizeShipPlan(planData);
357
+ if (!plan.ok) {
358
+ console.error(fail(plan.message, refusalNextStep(plan.reason)));
359
+ return 1;
360
+ }
361
+
362
+ // 2. Print the EXACT plan via the shared U10 affordance, then require ONE
363
+ // explicit confirm (`--yes` non-interactive; a non-TTY without it refuses).
364
+ const liveUrl = liveUrlFor(tenant);
365
+ const planLines = planForAction({
366
+ action: "ship",
367
+ tenant,
368
+ pinnedSha: plan.pinnedSha,
369
+ artifactDigest: plan.artifactDigest,
370
+ includedPrs: plan.includedPrs,
371
+ rollbackTarget: plan.rollbackTarget,
372
+ paywall: plan.paywall,
373
+ targets: { live: liveUrl },
374
+ });
375
+ const { confirmed, reason } = await confirmPlan(planLines, {
376
+ yes,
377
+ question: `Ship ${tenant}'s current green aggregate live?`,
378
+ });
379
+ if (!confirmed) {
380
+ if (reason === "non-tty") {
381
+ console.error(
382
+ fail(
383
+ "`tot ship` needs an interactive terminal to confirm this live change",
384
+ "run it from a terminal, or pass --yes to confirm non-interactively",
385
+ ),
386
+ );
387
+ return 2;
388
+ }
389
+ console.log(" Ship cancelled — nothing changed.");
390
+ return 0;
391
+ }
392
+
393
+ // 3. SHIP — the human go-live gate (confirmGoLive) plus the reviewed pin, so a
394
+ // STALE plan (the aggregate moved / the artifact changed since GET) is
395
+ // refused rather than shipping something nobody reviewed.
396
+ const progress = deps.progress === false ? null : startProgress("shipping…");
397
+ let shipRes;
398
+ try {
399
+ shipRes = await fetchImpl(`${base}/api/changes/ship`, {
400
+ method: "POST",
401
+ headers: { "content-type": "application/json", ...authHeaders },
402
+ body: JSON.stringify({
403
+ confirmGoLive: true,
404
+ ...(plan.pinnedSha ? { expectedAggregateSha: plan.pinnedSha } : {}),
405
+ ...(plan.artifactDigest ? { expectedArtifactDigest: plan.artifactDigest } : {}),
406
+ }),
407
+ });
408
+ } catch (e) {
409
+ progress?.stop();
410
+ console.error(
411
+ fail(`couldn't reach the ship endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
412
+ );
413
+ return 1;
414
+ }
415
+ const shipData = await readJsonSafe(shipRes);
416
+ progress?.stop();
417
+
418
+ const shipResult = normalizeShipResult(shipData);
419
+ // D3: emit the ship publish lifecycle event — `tot ship` does no LOCAL git op
420
+ // (it's HTTP-orchestrated), so this marks the outcome of the publish step
421
+ // itself, distinct from the outer command's invoked/result pair. Fire-and-
422
+ // forget best-effort: a silent no-op without a hosted-bridge credential, never
423
+ // awaited, never throws, never alters the ship. `errorClass` stays low-
424
+ // cardinality (the orchestrator's own terminal state/reason, never free text).
425
+ const shipped = shipResult.state === "shipped";
426
+ void emitActivity({
427
+ action: "cli.command.result",
428
+ outcome: {
429
+ status: shipped ? "succeeded" : "failed",
430
+ ...(shipped ? {} : { errorClass: `ship_${shipResult.state}` }),
431
+ },
432
+ scope: { tenantId: tenant },
433
+ payload: { args: { command: "ship", subcommand: "ship.publish" } },
434
+ });
435
+
436
+ return reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
437
+ }
438
+
439
+ /**
440
+ * Report the orchestrator's HONEST terminal state. `shipped` is the ONLY state
441
+ * rendered as "shipped live" — `refused`/`promote_failed`/`record_failed` are
442
+ * surfaced with their own message + a clear next step, never dressed up as a
443
+ * success and never silently swallowed.
444
+ * @returns {number} process exit code
445
+ */
446
+ function reportShipResult(result, { tenant, liveUrl, noOpen, openUrl }) {
447
+ if (result.state === "shipped") {
448
+ console.log(`\n ✓ shipped ${tenant} live.`);
449
+ if (result.pinnedSha) console.log(` pinned: ${result.pinnedSha}`);
450
+ if (result.receiptId) console.log(` receipt: ${result.receiptId}`);
451
+ if (liveUrl) {
452
+ console.log(` Live: ${liveUrl}`);
453
+ if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
454
+ }
455
+ if (result.message) console.log(` ${result.message}`);
456
+ return 0;
457
+ }
458
+ if (result.state === "refused") {
459
+ console.error(fail(result.message || `ship refused (${result.reason})`, refusalNextStep(result.reason)));
460
+ return 1;
461
+ }
462
+ if (result.state === "promote_failed" || result.state === "record_failed") {
463
+ console.error(
464
+ fail(
465
+ result.message || `ship ${result.state.replace("_", " ")}`,
466
+ "re-run `tot ship` — the ship is idempotent and safe to retry",
467
+ ),
468
+ );
469
+ return 1;
470
+ }
471
+ console.error(
472
+ fail(
473
+ `unexpected ship response (state: ${result.state})`,
474
+ "re-run `tot ship`; check the storefront logs if it persists",
475
+ ),
476
+ );
477
+ return 1;
478
+ }
479
+
480
+ /**
481
+ * @param {string[]} argv
482
+ * @param {any} ctx
483
+ */
484
+ export async function run(argv, ctx) {
485
+ const env = process.env;
486
+ const args = parseShipArgs(argv);
487
+ if (args.help) {
488
+ console.log(USAGE);
489
+ return 0;
490
+ }
491
+
492
+ // ONE meaning, no context detection: the target tenant is either named
493
+ // explicitly (`--tenant`, works from anywhere — no checkout needed) or
494
+ // inferred from the current checkout. Never guessed from a PR/branch/actor.
495
+ const tenant = (args.tenant || (ctx.mode === "checkout" ? ctx.tenant : null) || "").trim();
496
+ if (!tenant) {
497
+ console.error(
498
+ fail(
499
+ "`tot ship` needs a target tenant",
500
+ "run it from inside a `tot clone`d store, or pass --tenant <appDomain>",
501
+ ),
502
+ );
503
+ return 2;
504
+ }
505
+
506
+ const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
507
+ return await runShip(
508
+ {
509
+ tenant,
510
+ secret: resolveOperatorSecret(args.secret, env),
511
+ storefrontUrl,
512
+ yes: args.yes,
513
+ noOpen: args.noOpen,
514
+ },
515
+ { openUrl: (u) => openBrowser(u) },
516
+ );
517
+ }
@@ -67,7 +67,7 @@ import {
67
67
  activityBridgeEnv, NativeArtifactUnavailableError,
68
68
  } from "./dev.mjs";
69
69
  import { scaffoldSample, isSampleCheckout, sampleConfig, SAMPLE_DIR_NAME } from "../sample.mjs";
70
- import { startHeartbeatFromEnv } from "../dev-heartbeat.mjs";
70
+ import { startHeartbeatFromEnv, resolveEditor } from "../dev-heartbeat.mjs";
71
71
  import { streamDevLogs } from "../dev-logs.mjs";
72
72
  import { milestoneBanner, cockpitUrlFrom, DEVELOPER_COCKPIT } from "../banner.mjs";
73
73
  import { IDEAS } from "./ideas.mjs";
@@ -318,7 +318,7 @@ export async function run(argv, ctx) {
318
318
  // (A3) so the "instant" claim is measured. The "Connect Claude" step is
319
319
  // intentionally removed for now — a blocking prompt here meant Ctrl-C'ing it
320
320
  // tore down the dev server; revisit AI-connect as a non-blocking step later.
321
- printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt), cockpitUrl);
321
+ printLiveEnding(tenant, url, formatElapsed(Date.now() - startedAt), cockpitUrl, dir);
322
322
 
323
323
  // 7. hand the terminal to the running dev server until Ctrl-C.
324
324
  console.log("\n Watching your store — edit content/home.html + save. Ctrl-C to stop.\n");
@@ -693,11 +693,34 @@ function printCheckoutLanding(dir, cockpitUrl = null) {
693
693
  console.log(` content/home.html — you'll see it reflected in ${where}.`);
694
694
  }
695
695
 
696
+ /**
697
+ * OS/editor-aware suggestions for opening the checkout, shown once at the
698
+ * "you're live" moment. $VISUAL/$EDITOR (same signal the cockpit's "open this
699
+ * file" hint uses — see dev-heartbeat.mjs#resolveEditor) wins when set;
700
+ * otherwise a short per-platform shortlist of common editor launch commands.
701
+ * Deliberately NOT probed against PATH — a spawnSync per candidate would add
702
+ * real latency to the crafted "aha" ending for the common case (nothing set),
703
+ * so this is a labeled suggestion list, not a detection result.
704
+ */
705
+ function editorOpenLines(dir, { platform = process.platform, env = process.env } = {}) {
706
+ const configured = resolveEditor(env);
707
+ if (configured) return [`${configured} ${dir}`];
708
+ if (platform === "darwin") {
709
+ return [`code ${dir} (VS Code, if installed)`, `cursor ${dir} (Cursor, if installed)`, `open ${dir} (Finder)`];
710
+ }
711
+ if (platform === "win32") {
712
+ return [`code ${dir} (VS Code, if installed)`, `explorer ${dir} (File Explorer)`];
713
+ }
714
+ return [`code ${dir} (VS Code, if installed)`, `cursor ${dir} (Cursor, if installed)`, `xdg-open ${dir} (file manager)`];
715
+ }
716
+
696
717
  /** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
697
718
  * when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
698
- * their cockpit as the next place to look; then the AI-wow (G), seeded with
699
- * IDEAS[0] (G2/G3 one prompt list shared with `tot ideas`, no drift). */
700
- function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
719
+ * their cockpit as the next place to look. Then: an OS-suited "open this in
720
+ * an editor" suggestion, and the four-pane layout tip (this terminal, cockpit,
721
+ * local preview, editor) so a build problem, a reload, and the code are never
722
+ * more than a glance apart. */
723
+ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null, dir = null) {
701
724
  const lines = [
702
725
  `✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
703
726
  ` ${url}`,
@@ -711,10 +734,19 @@ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
711
734
  console.log(milestoneBanner(lines));
712
735
  console.log(" " + versionStamp("native"));
713
736
  console.log("");
714
- console.log(" Now try, in Claude:");
715
- console.log(` "${IDEAS[0]}"`);
737
+ if (dir) {
738
+ console.log(" Open your project in an editor (an AI coding agent like Claude works too —");
739
+ console.log(" same idea, different hands on the keyboard):");
740
+ for (const line of editorOpenLines(dir)) console.log(` ${line}`);
741
+ console.log("");
742
+ }
743
+ console.log(" Keep THIS terminal open — it's your live build/status feed, the fastest way to");
744
+ console.log(" see a problem the moment it happens. Need the command line for something else?");
745
+ console.log(" Open a NEW terminal window rather than closing this one.");
716
746
  console.log("");
717
- console.log(" More ideas: tot ideas");
747
+ console.log(" Best view: arrange this terminal, your Developer Cockpit, the local preview,");
748
+ console.log(" and your editor so you can see all four at once — site, cockpit, build status,");
749
+ console.log(" and code, together.");
718
750
  console.log("");
719
751
  }
720
752