@tokenoftrust/cli 1.4.0-rc.20 → 1.4.0-rc.22
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 +52 -54
- package/package.json +6 -1
- package/src/activity.mjs +5 -4
- package/src/app-scaffold.mjs +2 -2
- package/src/auth.mjs +13 -5
- package/src/candidate-state.mjs +2 -2
- package/src/commands/accept.mjs +489 -50
- package/src/commands/app/dev.mjs +7 -3
- package/src/commands/app/index.mjs +2 -2
- package/src/commands/branches.mjs +1 -0
- package/src/commands/cleanup.mjs +2 -1
- package/src/commands/clone.mjs +51 -20
- package/src/commands/dev.mjs +30 -12
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +6 -2
- package/src/commands/grants.mjs +6 -4
- package/src/commands/link.mjs +2 -2
- package/src/commands/login.mjs +3 -4
- package/src/commands/pr.mjs +6 -5
- package/src/commands/preview-doctor.mjs +225 -0
- package/src/commands/preview.mjs +9 -1
- package/src/commands/rollback.mjs +6 -4
- package/src/commands/ship.mjs +19 -1
- package/src/commands/start.mjs +59 -11
- package/src/commands/submit.mjs +526 -68
- package/src/commands/sync.mjs +11 -0
- package/src/commands/validate.mjs +10 -4
- package/src/dev-heartbeat.mjs +2 -1
- package/src/errors.mjs +8 -4
- package/src/git-credential.mjs +185 -0
- package/src/mcp.mjs +6 -1
- package/src/merge-doctor-report.mjs +208 -0
- package/src/oauth.mjs +12 -8
- package/src/obstacle-beacon.cjs +2 -2
- package/src/obstacle.mjs +1 -1
- package/src/plan.mjs +3 -3
- package/src/sample.mjs +3 -3
- package/src/validate.mjs +56 -0
- package/src/viewer-session.mjs +118 -0
package/src/commands/pr.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* tot pr close <N|id> close (reject) a candidate without merging
|
|
8
8
|
*
|
|
9
9
|
* A candidate PR is the reviewable unit `tot submit` creates. By default a
|
|
10
|
-
* re-submit UPDATES your open candidate; `tot submit --
|
|
10
|
+
* re-submit UPDATES your open candidate; `tot submit --fork-candidate` forks another. This
|
|
11
11
|
* command fills the gap the raw submit loop left — a first-party way to list your
|
|
12
12
|
* open candidates and to close one (candidate close was otherwise gated to
|
|
13
13
|
* version-control apps only).
|
|
@@ -71,7 +71,7 @@ const USAGE = `tot pr — see and manage candidate PRs
|
|
|
71
71
|
|
|
72
72
|
DEVELOPER (default) — run inside your OWN store checkout: lists/manages the
|
|
73
73
|
candidates \`tot submit\` opens. A re-submit updates your open one by default;
|
|
74
|
-
\`tot submit --
|
|
74
|
+
\`tot submit --fork-candidate\` forks another.
|
|
75
75
|
|
|
76
76
|
OPERATOR — \`tot pr list --tenant <appDomain>\` lists ANY tenant's open candidate
|
|
77
77
|
queue WITHOUT a checkout (the read-only companion to \`tot ship --pr <N> --tenant\`).
|
|
@@ -225,8 +225,9 @@ export async function runPrListOperator({ tenant, secret, storefrontUrl = null }
|
|
|
225
225
|
if (!secret) {
|
|
226
226
|
console.error(
|
|
227
227
|
fail(
|
|
228
|
-
"
|
|
229
|
-
|
|
228
|
+
"no operator secret — view this tenant's queue from the storefront admin instead.",
|
|
229
|
+
`open https://${tenant}/admin → Publish tab to see the candidate queue and act on it. ` +
|
|
230
|
+
"Operators/CI: set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret.",
|
|
230
231
|
),
|
|
231
232
|
);
|
|
232
233
|
return 2;
|
|
@@ -287,7 +288,7 @@ export async function run(argv, ctx) {
|
|
|
287
288
|
console.log(USAGE);
|
|
288
289
|
return 0;
|
|
289
290
|
}
|
|
290
|
-
if (!SUBCOMMANDS.includes(args.sub)) {
|
|
291
|
+
if (!SUBCOMMANDS.includes(/** @type {any} */ (args.sub))) {
|
|
291
292
|
console.error(fail(`unknown subcommand: \`tot pr ${args.sub}\``, "tot pr list | view <N> | close <N>"));
|
|
292
293
|
return 2;
|
|
293
294
|
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot preview doctor --tenant <t>` — the merge self-serve DIAGNOSIS in the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Calls the hosted merge-doctor seam `GET /<tenant>/api/preview/merge-doctor` (unit
|
|
5
|
+
* A2) — which composes the SAME two server-side reads the admin Publish tab builds
|
|
6
|
+
* (`GET /api/changes` + `GET /api/changes/branches`) through the PURE `analyzeMerge`
|
|
7
|
+
* taxonomy — and prints its `{ scopeKnown, verdict, findings, counts }` as the compact,
|
|
8
|
+
* ordered (blocker → warn → info) report, each finding carrying who-owns-it and its
|
|
9
|
+
* remedy/action. It composes NO per-PR forge calls of its own: one GET, one answer.
|
|
10
|
+
*
|
|
11
|
+
* READ-ONLY — it mutates nothing, so there is NO plan/confirm gate (unlike `tot
|
|
12
|
+
* accept` / `tot preview build`). It just answers "what's blocking my merges, and
|
|
13
|
+
* what do I do about it?" in seconds.
|
|
14
|
+
*
|
|
15
|
+
* AUTH — the endpoint is session-gated exactly like `GET /api/changes`, so this
|
|
16
|
+
* reaches it by EITHER of the two transports `tot accept` already speaks:
|
|
17
|
+
* - OPERATOR SECRET (operators / headless CI): Bearer + `X-Tot-Owner` on the
|
|
18
|
+
* generic storefront origin (from PREVIEW_RECONCILE_SECRET / GRANTS_ADMIN_SECRET
|
|
19
|
+
* / TOT_OPERATOR_SECRET, or `--secret`).
|
|
20
|
+
* - VIEWER SESSION (an invited developer with no secret): mint a `tot_session`
|
|
21
|
+
* from their own `tot login` on the tenant's own host (`resolveViewerTransport`).
|
|
22
|
+
* A caller who cannot see the accept queue cannot get a diagnosis of it — an auth
|
|
23
|
+
* refusal (401/403) is relayed as a clean house-style failure, not a wrapped verdict.
|
|
24
|
+
*
|
|
25
|
+
* EXIT CODE — mirrors the `scripts/tenant/gitea-merge-doctor.mjs` driver: a report
|
|
26
|
+
* with any blocker exits 1 (so a CI gate fails on a blocked merge), otherwise 0.
|
|
27
|
+
*
|
|
28
|
+
* The rendering helpers are the SHARED port in `../merge-doctor-report.mjs` (a
|
|
29
|
+
* faithful PORT of the analyzer's own `formatReport`/`attentionBanner`/
|
|
30
|
+
* `supportContext` — mergeDoctor.ts §render / the gitea-merge-doctor.mjs mirror);
|
|
31
|
+
* this package is the dependency-free published `@tokenoftrust/cli`, so it cannot
|
|
32
|
+
* import the app/scripts source, and the port is kept in sync with that taxonomy by
|
|
33
|
+
* hand. They live in their own module so `tot accept` / `tot ship` can reuse the same
|
|
34
|
+
* fetch + compact render (C5) without a cycle back through this command's transports.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-free (global fetch + the shared viewer/operator transports).
|
|
37
|
+
*/
|
|
38
|
+
import { fail } from "../errors.mjs";
|
|
39
|
+
import { resolveOperatorSecret } from "./ship.mjs";
|
|
40
|
+
import { resolveViewerTransport } from "../viewer-session.mjs";
|
|
41
|
+
import {
|
|
42
|
+
DOCTOR_PATH,
|
|
43
|
+
normalizeAnalysis,
|
|
44
|
+
attentionBanner,
|
|
45
|
+
supportContext,
|
|
46
|
+
formatReport,
|
|
47
|
+
} from "../merge-doctor-report.mjs";
|
|
48
|
+
|
|
49
|
+
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
50
|
+
|
|
51
|
+
const USAGE = `tot preview doctor — diagnose what's blocking this tenant's merges
|
|
52
|
+
|
|
53
|
+
tot preview doctor diagnose the current checkout's tenant
|
|
54
|
+
tot preview doctor --tenant <t> diagnose <t> (works without a checkout)
|
|
55
|
+
tot preview doctor --json emit the raw { scopeKnown, verdict, findings, counts }
|
|
56
|
+
|
|
57
|
+
Calls the hosted merge-doctor (GET /api/preview/merge-doctor), which composes the
|
|
58
|
+
SAME reads the admin Publish tab builds and runs them through the shared analyzer,
|
|
59
|
+
and prints the ordered report: blockers first, then warnings, then info — each
|
|
60
|
+
finding names who resolves it (you / on-us / housekeeping) and the exact remedy.
|
|
61
|
+
Read-only: it mutates nothing and needs no confirmation.
|
|
62
|
+
|
|
63
|
+
Exit code: 1 if the report has any blocker (so it can gate CI), else 0.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--tenant <appDomain> Target tenant (e.g. tokenoftrust.com). Defaults to the
|
|
67
|
+
current checkout's tenant when run inside one.
|
|
68
|
+
--url <origin> Storefront origin for the operator-secret path
|
|
69
|
+
(default: env TOT_STOREFRONT_URL or ${DEFAULT_STOREFRONT_URL}).
|
|
70
|
+
--secret <s> Operator secret (prefer the env vars below).
|
|
71
|
+
--json Print the raw analysis JSON instead of the report.
|
|
72
|
+
--help, -h Show this help.
|
|
73
|
+
|
|
74
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
75
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret). Without one, an
|
|
76
|
+
invited developer's own \`tot login\` session is used.`;
|
|
77
|
+
|
|
78
|
+
/** Parse `tot preview doctor` argv. Pure — unit-testable. */
|
|
79
|
+
export function parsePreviewDoctorArgs(argv) {
|
|
80
|
+
const a = { tenant: null, url: null, secret: null, json: false, help: false };
|
|
81
|
+
for (let i = 0; i < argv.length; i++) {
|
|
82
|
+
const t = argv[i];
|
|
83
|
+
if (t === "--tenant") a.tenant = argv[++i];
|
|
84
|
+
else if (t === "--url") a.url = argv[++i];
|
|
85
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
86
|
+
else if (t === "--json") a.json = true;
|
|
87
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
88
|
+
}
|
|
89
|
+
return a;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The render/normalize helpers (normalizeAnalysis / attentionBanner /
|
|
93
|
+
// supportContext / formatReport) are the shared port in ../merge-doctor-report.mjs,
|
|
94
|
+
// imported at the top of this file.
|
|
95
|
+
|
|
96
|
+
// ── run ────────────────────────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fetch + render the merge-doctor report for one tenant. Resolves the SAME two
|
|
100
|
+
* transports `tot accept` uses (operator secret → generic origin + X-Tot-Owner;
|
|
101
|
+
* else viewer session → tenant host + cookie), GETs the hosted seam, and prints the
|
|
102
|
+
* ordered report (or raw JSON). `fetch`/`resolveViewerTransport` are injected so it
|
|
103
|
+
* is unit-tested with no network. Returns the process exit code (blocker → 1).
|
|
104
|
+
*
|
|
105
|
+
* @param {{ tenant:string, secret:string, storefrontUrl?:string|null,
|
|
106
|
+
* json?:boolean, env?:NodeJS.ProcessEnv }} params
|
|
107
|
+
* @param {{ fetch?:typeof fetch, resolveViewerTransport?:typeof resolveViewerTransport }} [deps]
|
|
108
|
+
* @returns {Promise<number>}
|
|
109
|
+
*/
|
|
110
|
+
export async function runDoctor(
|
|
111
|
+
{ tenant, secret, storefrontUrl = null, json = false, env = process.env },
|
|
112
|
+
deps = {},
|
|
113
|
+
) {
|
|
114
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
115
|
+
const resolveViewer = deps.resolveViewerTransport || resolveViewerTransport;
|
|
116
|
+
|
|
117
|
+
// Resolve the transport — operator secret preferred, else the developer's own
|
|
118
|
+
// viewer session on the tenant host.
|
|
119
|
+
let base;
|
|
120
|
+
let authHeaders;
|
|
121
|
+
if (secret) {
|
|
122
|
+
base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
123
|
+
authHeaders = {
|
|
124
|
+
authorization: `Bearer ${secret}`,
|
|
125
|
+
"x-tot-owner": tenant,
|
|
126
|
+
"x-tot-capability": "ship-on-behalf",
|
|
127
|
+
};
|
|
128
|
+
} else {
|
|
129
|
+
const viewer = await resolveViewer({ tenant, env, fetchImpl });
|
|
130
|
+
if (!viewer.ok) {
|
|
131
|
+
console.error(fail(viewer.message, viewer.hint));
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
base = viewer.base;
|
|
135
|
+
authHeaders = viewer.authHeaders;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
let res;
|
|
139
|
+
try {
|
|
140
|
+
res = await fetchImpl(`${base}${DOCTOR_PATH}`, { method: "GET", headers: authHeaders });
|
|
141
|
+
} catch (e) {
|
|
142
|
+
console.error(
|
|
143
|
+
fail(
|
|
144
|
+
`couldn't reach the merge-doctor at ${base}: ${String(e?.message || e)}`,
|
|
145
|
+
"check --url / your network, then re-run",
|
|
146
|
+
),
|
|
147
|
+
);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let data = {};
|
|
152
|
+
try {
|
|
153
|
+
data = await res.json();
|
|
154
|
+
} catch {
|
|
155
|
+
/* non-JSON / empty body */
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Session gating: the endpoint relays the queue read's auth refusal verbatim, so a
|
|
159
|
+
// caller who cannot see the accept queue cannot diagnose it.
|
|
160
|
+
if (res.status === 401 || res.status === 403) {
|
|
161
|
+
console.error(
|
|
162
|
+
fail(
|
|
163
|
+
`not authorized to diagnose ${tenant}'s merges: ${data?.error || `HTTP ${res.status}`}`,
|
|
164
|
+
"you need a live ship-on-behalf grant on this tenant (ask the store owner), or an operator secret authorised for it — then re-run",
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
return 2;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!res.ok) {
|
|
171
|
+
console.error(
|
|
172
|
+
fail(
|
|
173
|
+
`the merge-doctor did not answer for ${tenant}: ${data?.error || `HTTP ${res.status}`}`,
|
|
174
|
+
"check --tenant / --url, then re-run",
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
return 1;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const analysis = normalizeAnalysis(data);
|
|
181
|
+
|
|
182
|
+
if (json) {
|
|
183
|
+
console.log(JSON.stringify(analysis, null, 2));
|
|
184
|
+
} else {
|
|
185
|
+
console.log(formatReport(analysis));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mirror the scripts driver: any blocker fails the run so a CI gate catches it.
|
|
189
|
+
return analysis.counts.blocker > 0 ? 1 : 0;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* @param {string[]} argv
|
|
194
|
+
* @param {any} ctx — detected CLI context (ctx.tenant when in a checkout)
|
|
195
|
+
*/
|
|
196
|
+
export async function run(argv, ctx) {
|
|
197
|
+
const env = process.env;
|
|
198
|
+
const args = parsePreviewDoctorArgs(argv);
|
|
199
|
+
if (args.help) {
|
|
200
|
+
console.log(USAGE);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tenant = (args.tenant || ctx?.tenant || "").trim();
|
|
205
|
+
if (!tenant) {
|
|
206
|
+
console.error(
|
|
207
|
+
fail(
|
|
208
|
+
"no target tenant.",
|
|
209
|
+
"pass --tenant <appDomain> (e.g. --tenant tokenoftrust.com), or run inside a store checkout.",
|
|
210
|
+
),
|
|
211
|
+
);
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const storefrontUrl =
|
|
216
|
+
args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
217
|
+
|
|
218
|
+
return await runDoctor({
|
|
219
|
+
tenant,
|
|
220
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
221
|
+
storefrontUrl,
|
|
222
|
+
json: args.json,
|
|
223
|
+
env,
|
|
224
|
+
});
|
|
225
|
+
}
|
package/src/commands/preview.mjs
CHANGED
|
@@ -45,7 +45,7 @@ export function postRunHint(code, alias) {
|
|
|
45
45
|
/**
|
|
46
46
|
* @param {string[]} argv
|
|
47
47
|
* @param {any} ctx
|
|
48
|
-
* @param {{ alias?: string|null }} [opts]
|
|
48
|
+
* @param {{ alias?: string|null }} [opts] - `alias` is the old verb the developer typed
|
|
49
49
|
* (`"submit"` / `"deploy"`) when this flow is reached as a teaching alias; null/omitted
|
|
50
50
|
* for the first-class `tot preview`.
|
|
51
51
|
*/
|
|
@@ -59,6 +59,14 @@ export async function run(argv, ctx, { alias = null } = {}) {
|
|
|
59
59
|
return runBuild(argv.slice(1), ctx);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// `tot preview doctor …` is the read-only merge DIAGNOSIS subcommand (unit C1) —
|
|
63
|
+
// a distinct verb from the developer preview flow, dispatched before the submit-
|
|
64
|
+
// flow arg parse (like `build`). Only the first-class `tot preview` carries it.
|
|
65
|
+
if (!alias && argv[0] === "doctor") {
|
|
66
|
+
const { run: runDoctor } = await import("./preview-doctor.mjs");
|
|
67
|
+
return runDoctor(argv.slice(1), ctx);
|
|
68
|
+
}
|
|
69
|
+
|
|
62
70
|
const verb = alias || "preview";
|
|
63
71
|
const args = parseArgs(argv);
|
|
64
72
|
|
|
@@ -63,7 +63,9 @@ const USAGE = `tot rollback [<versionId>] — instant re-point to a prior live v
|
|
|
63
63
|
|
|
64
64
|
/** Parse `tot rollback` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
65
65
|
export function parseRollbackArgs(argv) {
|
|
66
|
+
/** @type {{ versionId: string|null, target: string|null, mcp: string|null, identity: string|null, help: boolean }} */
|
|
66
67
|
const a = { versionId: null, target: null, mcp: null, identity: null, help: false };
|
|
68
|
+
/** @type {string[]} */
|
|
67
69
|
const positional = [];
|
|
68
70
|
for (let i = 0; i < argv.length; i++) {
|
|
69
71
|
const t = argv[i];
|
|
@@ -179,8 +181,8 @@ export function rollbackCandidates(history, currentVersionId) {
|
|
|
179
181
|
/** Render the `tot rollback` (no args) history listing — the rollback-target
|
|
180
182
|
* picker (unit u2), each entry annotated with its immutable `/rev/<sha>` link
|
|
181
183
|
* (unit u5) when a storefront origin is available. Pure — unit-tested.
|
|
182
|
-
* @param {{ tenant:string, target:string, current:
|
|
183
|
-
* candidates:
|
|
184
|
+
* @param {{ tenant:string, target:string, current:any,
|
|
185
|
+
* candidates:any[], revisionBase?:string|null }} input
|
|
184
186
|
* @returns {string[]}
|
|
185
187
|
*/
|
|
186
188
|
export function renderHistory({ tenant, target, current, candidates, revisionBase }) {
|
|
@@ -209,7 +211,7 @@ export function renderHistory({ tenant, target, current, candidates, revisionBas
|
|
|
209
211
|
*/
|
|
210
212
|
export function renderRollbackPreview({ tenant, target, toVersionId, preview }) {
|
|
211
213
|
const lines = ["", ` This rollback will change the LIVE site for ${tenant}:`];
|
|
212
|
-
const current = preview.pointer?.current;
|
|
214
|
+
const current = /** @type {any} */ (preview.pointer?.current);
|
|
213
215
|
if (current) lines.push(` ${target}: ${current.versionId} → ${toVersionId}`);
|
|
214
216
|
else lines.push(` ${target}: → ${toVersionId}`);
|
|
215
217
|
return lines;
|
|
@@ -258,7 +260,7 @@ export async function runRollback(client, { tenant, target, toVersionId, revisio
|
|
|
258
260
|
);
|
|
259
261
|
return 1;
|
|
260
262
|
}
|
|
261
|
-
const candidates = rollbackCandidates(status.history, status.current?.versionId ?? null);
|
|
263
|
+
const candidates = rollbackCandidates(status.history, /** @type {any} */ (status).current?.versionId ?? null);
|
|
262
264
|
for (const line of renderHistory({ tenant, target, current: status.current, candidates, revisionBase })) {
|
|
263
265
|
console.log(line);
|
|
264
266
|
}
|
package/src/commands/ship.mjs
CHANGED
|
@@ -48,6 +48,11 @@ import { planForAction, printPlanAndConfirm } from "../plan.mjs";
|
|
|
48
48
|
import { startProgress } from "../progress.mjs";
|
|
49
49
|
import { openBrowser } from "../open.mjs";
|
|
50
50
|
import { emitActivity } from "../activity.mjs";
|
|
51
|
+
// C5 — the doctor PUSH: on a refused/failed ship (most tellingly "not green"),
|
|
52
|
+
// auto-append the COMPACT summary over the SAME operator transport, so the operator
|
|
53
|
+
// sees WHICH changes are keeping the aggregate from going green without a separate
|
|
54
|
+
// `tot preview doctor` run.
|
|
55
|
+
import { autoSurfaceDoctor } from "../merge-doctor-report.mjs";
|
|
51
56
|
|
|
52
57
|
const DEFAULT_STOREFRONT_URL = "https://storefront.tokenoftrust.store";
|
|
53
58
|
|
|
@@ -327,6 +332,14 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
327
332
|
"x-tot-capability": "ship-on-behalf",
|
|
328
333
|
};
|
|
329
334
|
|
|
335
|
+
// C5 — auto-surface the merge-doctor on a refused/failed ship. Best-effort:
|
|
336
|
+
// fetches the hosted doctor over the SAME operator transport and prints the COMPACT
|
|
337
|
+
// summary; never throws and never touches the exit code. A clean/unreachable doctor
|
|
338
|
+
// prints nothing — so this only ever ADDS the "here's what's not green" context.
|
|
339
|
+
const surfaceDoctor = async () => {
|
|
340
|
+
for (const line of await autoSurfaceDoctor({ base, authHeaders, fetchImpl })) console.error(line);
|
|
341
|
+
};
|
|
342
|
+
|
|
330
343
|
// 1. GET the read-only plan: the pinned sha + artifact digest + included PRs +
|
|
331
344
|
// rollback target + paywall verdict. Zero side effects.
|
|
332
345
|
let planRes;
|
|
@@ -356,6 +369,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
356
369
|
const plan = normalizeShipPlan(planData);
|
|
357
370
|
if (!plan.ok) {
|
|
358
371
|
console.error(fail(plan.message, refusalNextStep(plan.reason)));
|
|
372
|
+
// A not-green / no-passed-run refusal is exactly what the doctor diagnoses —
|
|
373
|
+
// name the changes keeping the aggregate red right here.
|
|
374
|
+
await surfaceDoctor();
|
|
359
375
|
return 1;
|
|
360
376
|
}
|
|
361
377
|
|
|
@@ -433,7 +449,9 @@ export async function runShip({ tenant, secret, storefrontUrl = null, yes = fals
|
|
|
433
449
|
payload: { args: { command: "ship", subcommand: "ship.publish" } },
|
|
434
450
|
});
|
|
435
451
|
|
|
436
|
-
|
|
452
|
+
const code = reportShipResult(shipResult, { tenant, liveUrl, noOpen, openUrl: deps.openUrl });
|
|
453
|
+
if (code !== 0) await surfaceDoctor();
|
|
454
|
+
return code;
|
|
437
455
|
}
|
|
438
456
|
|
|
439
457
|
/**
|
package/src/commands/start.mjs
CHANGED
|
@@ -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";
|
|
@@ -262,11 +262,11 @@ export async function run(argv, ctx) {
|
|
|
262
262
|
const dir = resolve(process.cwd(), tenant);
|
|
263
263
|
// Resolve a free port so the URL we open/poll/print matches what the runner
|
|
264
264
|
// binds (Vite strictPort is off → a busy port would drift). No-op if free.
|
|
265
|
-
const devArgs = {
|
|
265
|
+
const devArgs = /** @type {any} */ ({
|
|
266
266
|
image: null, port: String(await firstFreePort(Number(args.port || 4321))), mcp: args.mcp,
|
|
267
267
|
noLogin: false, noOpen: args.noOpen, docker: args.docker,
|
|
268
|
-
};
|
|
269
|
-
const runtime = { useDocker: args.docker, runnerDir: null };
|
|
268
|
+
});
|
|
269
|
+
const runtime = /** @type {{ useDocker: boolean, runnerDir: any }} */ ({ useDocker: args.docker, runnerDir: null });
|
|
270
270
|
await Promise.all([
|
|
271
271
|
ensureCheckout(client, tenant, dir, env),
|
|
272
272
|
prefetchRuntime(client, devArgs, env, runtime, ctx),
|
|
@@ -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");
|
|
@@ -581,6 +581,10 @@ function noStoresError({ session, baseUrl, listErr, list = null }) {
|
|
|
581
581
|
* the remembered last tenant (A4) — then remember whatever was decided so the
|
|
582
582
|
* next bare `tot start` doesn't have to ask again.
|
|
583
583
|
*/
|
|
584
|
+
/**
|
|
585
|
+
* @param {any} stores @param {any} args @param {any} env @param {string} baseUrl
|
|
586
|
+
* @param {{ session?: any, listErr?: string|null, list?: any }} [opts]
|
|
587
|
+
*/
|
|
584
588
|
async function resolveTenant(stores, args, env, baseUrl, { session = null, listErr = null, list = null } = {}) {
|
|
585
589
|
const lastTenantPath = defaultLastTenantPath(env);
|
|
586
590
|
const pick = pickTenant(stores, {
|
|
@@ -603,9 +607,10 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
|
|
|
603
607
|
} else {
|
|
604
608
|
// Many stores, nothing remembered — choose. Non-interactive (no TTY /
|
|
605
609
|
// --yes without a name) can't guess.
|
|
610
|
+
const many = /** @type {any} */ (pick);
|
|
606
611
|
if (!isInteractive() || args.yes) {
|
|
607
612
|
throw new CliError("you can build on several stores — pick one", {
|
|
608
|
-
next: `tot start --tenant <tenant> (one of: ${
|
|
613
|
+
next: `tot start --tenant <tenant> (one of: ${many.stores.map((s) => s.id).join(", ")})`,
|
|
609
614
|
});
|
|
610
615
|
}
|
|
611
616
|
// Loud + unmissable: a >1-store dev is often looking at their browser
|
|
@@ -614,11 +619,11 @@ async function resolveTenant(stores, args, env, baseUrl, { session = null, listE
|
|
|
614
619
|
console.log("");
|
|
615
620
|
console.log(" ⚑ ACTION NEEDED IN YOUR TERMINAL — you can build on several stores.");
|
|
616
621
|
console.log(" Pick one here to light up your cockpit:\n");
|
|
617
|
-
|
|
622
|
+
many.stores.forEach((s, i) => console.log(` ${i + 1}. ${s.id}${s.name ? ` — ${s.name}` : ""}`));
|
|
618
623
|
console.log("");
|
|
619
|
-
const idx = await promptChoice(
|
|
624
|
+
const idx = await promptChoice(many.stores.length);
|
|
620
625
|
console.log("");
|
|
621
|
-
tenant =
|
|
626
|
+
tenant = many.stores[idx].id;
|
|
622
627
|
}
|
|
623
628
|
|
|
624
629
|
writeLastTenant(lastTenantPath, { mcpUrl: baseUrl, tenant });
|
|
@@ -685,6 +690,7 @@ export function formatElapsed(ms) {
|
|
|
685
690
|
* Developer Cockpit URL, the one next step is framed as "edit a line → see it in
|
|
686
691
|
* your cockpit"; otherwise it's the same edit-to-see-it-reload action.
|
|
687
692
|
*/
|
|
693
|
+
/** @param {string} dir @param {string|null} [cockpitUrl] */
|
|
688
694
|
function printCheckoutLanding(dir, cockpitUrl = null) {
|
|
689
695
|
console.log("");
|
|
690
696
|
console.log(` 📁 Your store code is at: ${dir}`);
|
|
@@ -693,10 +699,38 @@ function printCheckoutLanding(dir, cockpitUrl = null) {
|
|
|
693
699
|
console.log(` content/home.html — you'll see it reflected in ${where}.`);
|
|
694
700
|
}
|
|
695
701
|
|
|
702
|
+
/**
|
|
703
|
+
* OS/editor-aware suggestions for opening the checkout, shown once at the
|
|
704
|
+
* "you're live" moment. $VISUAL/$EDITOR (same signal the cockpit's "open this
|
|
705
|
+
* file" hint uses — see dev-heartbeat.mjs#resolveEditor) wins when set;
|
|
706
|
+
* otherwise a short per-platform shortlist of common editor launch commands.
|
|
707
|
+
* Deliberately NOT probed against PATH — a spawnSync per candidate would add
|
|
708
|
+
* real latency to the crafted "aha" ending for the common case (nothing set),
|
|
709
|
+
* so this is a labeled suggestion list, not a detection result.
|
|
710
|
+
*/
|
|
711
|
+
function editorOpenLines(dir, { platform = process.platform, env = process.env } = {}) {
|
|
712
|
+
const configured = resolveEditor(env);
|
|
713
|
+
if (configured) return [`${configured} ${dir}`];
|
|
714
|
+
if (platform === "darwin") {
|
|
715
|
+
return [`code ${dir} (VS Code, if installed)`, `cursor ${dir} (Cursor, if installed)`, `open ${dir} (Finder)`];
|
|
716
|
+
}
|
|
717
|
+
if (platform === "win32") {
|
|
718
|
+
return [`code ${dir} (VS Code, if installed)`, `explorer ${dir} (File Explorer)`];
|
|
719
|
+
}
|
|
720
|
+
return [`code ${dir} (VS Code, if installed)`, `cursor ${dir} (Cursor, if installed)`, `xdg-open ${dir} (file manager)`];
|
|
721
|
+
}
|
|
722
|
+
|
|
696
723
|
/** The crafted "you're live" ending — a PROMINENT milestone banner (u2) that,
|
|
697
724
|
* when we hold a Developer Cockpit URL, explicitly sends the developer BACK to
|
|
698
|
-
* their cockpit as the next place to look.
|
|
699
|
-
|
|
725
|
+
* their cockpit as the next place to look. Then: an OS-suited "open this in
|
|
726
|
+
* an editor" suggestion, and the four-pane layout tip (this terminal, cockpit,
|
|
727
|
+
* local preview, editor) so a build problem, a reload, and the code are never
|
|
728
|
+
* more than a glance apart. */
|
|
729
|
+
/**
|
|
730
|
+
* @param {string} tenant @param {string} url @param {string|null} elapsed
|
|
731
|
+
* @param {string|null} [cockpitUrl] @param {string|null} [dir]
|
|
732
|
+
*/
|
|
733
|
+
function printLiveEnding(tenant, url, elapsed, cockpitUrl = null, dir = null) {
|
|
700
734
|
const lines = [
|
|
701
735
|
`✨ You're live.${elapsed ? ` (${elapsed})` : ""}`,
|
|
702
736
|
` ${url}`,
|
|
@@ -710,6 +744,20 @@ function printLiveEnding(tenant, url, elapsed, cockpitUrl = null) {
|
|
|
710
744
|
console.log(milestoneBanner(lines));
|
|
711
745
|
console.log(" " + versionStamp("native"));
|
|
712
746
|
console.log("");
|
|
747
|
+
if (dir) {
|
|
748
|
+
console.log(" Open your project in an editor (an AI coding agent like Claude works too —");
|
|
749
|
+
console.log(" same idea, different hands on the keyboard):");
|
|
750
|
+
for (const line of editorOpenLines(dir)) console.log(` ${line}`);
|
|
751
|
+
console.log("");
|
|
752
|
+
}
|
|
753
|
+
console.log(" Keep THIS terminal open — it's your live build/status feed, the fastest way to");
|
|
754
|
+
console.log(" see a problem the moment it happens. Need the command line for something else?");
|
|
755
|
+
console.log(" Open a NEW terminal window rather than closing this one.");
|
|
756
|
+
console.log("");
|
|
757
|
+
console.log(" Best view: arrange this terminal, your Developer Cockpit, the local preview,");
|
|
758
|
+
console.log(" and your editor so you can see all four at once — site, cockpit, build status,");
|
|
759
|
+
console.log(" and code, together.");
|
|
760
|
+
console.log("");
|
|
713
761
|
}
|
|
714
762
|
|
|
715
763
|
/**
|