@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.
- package/README.md +7 -4
- package/bin/tot.mjs +169 -8
- package/package.json +1 -1
- package/src/activity.mjs +378 -0
- package/src/candidate-state.mjs +56 -16
- package/src/commands/accept.mjs +725 -0
- package/src/commands/branches.mjs +296 -0
- package/src/commands/cleanup.mjs +268 -0
- package/src/commands/clone.mjs +289 -10
- package/src/commands/dev.mjs +479 -118
- package/src/commands/doctor.mjs +2 -1
- package/src/commands/git-credential.mjs +180 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/hotfix.mjs +428 -0
- package/src/commands/pr.mjs +239 -15
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +80 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/revert.mjs +322 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +517 -0
- package/src/commands/start.mjs +40 -8
- package/src/commands/submit.mjs +1325 -146
- package/src/commands/sync.mjs +203 -0
- package/src/commands/validate.mjs +6 -1
- package/src/git-credential.mjs +184 -0
- package/src/no-gitea-links.test.mjs +55 -0
- package/src/plan.mjs +262 -0
- package/src/sample.mjs +27 -1
- package/src/validate.mjs +52 -0
package/src/commands/doctor.mjs
CHANGED
|
@@ -187,7 +187,8 @@ export async function run(argv, ctx) {
|
|
|
187
187
|
}
|
|
188
188
|
console.log(
|
|
189
189
|
failed === 0
|
|
190
|
-
? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone
|
|
190
|
+
? "\n✔ Ready. Try: tot start (checks out your store and runs it), or tot clone." +
|
|
191
|
+
"\n The loop: tot dev → tot preview → tot ship.\n"
|
|
191
192
|
: args.fix
|
|
192
193
|
? `\n✖ ${failed} check(s) still failing — see the exact next command(s) above, then re-run \`tot doctor\`.\n`
|
|
193
194
|
: `\n✖ ${failed} check(s) failed — try \`tot doctor --fix\`, or fix the above, then re-run \`tot doctor\`.\n`,
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot git-credential` — git's own credential-helper protocol (see `git help
|
|
3
|
+
* gitcredentials`), implemented over the developer's cached `tot login`
|
|
4
|
+
* session (unit u10, workstream tot-merge-conflict-resolution-ux). `tot
|
|
5
|
+
* clone` configures a fresh checkout's `credential.helper` to run this (see
|
|
6
|
+
* ../git-credential.mjs's CREDENTIAL_HELPER), so `git fetch`/`git push`/`git
|
|
7
|
+
* pull` — run DIRECTLY by the developer, not just through `tot preview` —
|
|
8
|
+
* transparently mint a fresh forge token instead of ever needing one
|
|
9
|
+
* persisted in `.git/config`. This is the fix for the "the panel-prescribed
|
|
10
|
+
* `git fetch origin` fails even after `tot login`" dead-end: `tot login`
|
|
11
|
+
* only ever refreshed the CLI's OWN MCP session, never the token baked into
|
|
12
|
+
* a checkout's remote URL at clone time.
|
|
13
|
+
*
|
|
14
|
+
* git invokes this with ONE positional arg (`get`/`store`/`erase`) and the
|
|
15
|
+
* request on stdin (key=value lines, blank-line/EOF terminated). Only `get`
|
|
16
|
+
* does real work — this CLI never persists a forge credential of its own
|
|
17
|
+
* beyond the short-lived cache in ../git-credential.mjs, so `store`/`erase`
|
|
18
|
+
* are no-ops (git calls them after a successful/failed auth respectively; we
|
|
19
|
+
* just drain stdin and exit 0, the correct behavior for a stateless helper).
|
|
20
|
+
*
|
|
21
|
+
* FAILS SILENT, NEVER LOUD: `get` prints NOTHING and exits non-zero on any
|
|
22
|
+
* problem (not signed in, MCP unreachable, cwd isn't a recognizable tenant
|
|
23
|
+
* checkout) — git then falls through to its next configured helper or its
|
|
24
|
+
* own prompt, exactly as if this helper weren't configured. A stack trace or
|
|
25
|
+
* a malformed credential line here would otherwise corrupt EVERY git
|
|
26
|
+
* operation in the checkout. It also NEVER triggers an interactive sign-in —
|
|
27
|
+
* this runs as a non-interactive subprocess of `git`, so a missing session
|
|
28
|
+
* fails through rather than trying to open a browser mid-`git fetch`.
|
|
29
|
+
*
|
|
30
|
+
* Dependency-free (global fetch + `git`, via the same MCP client + auth
|
|
31
|
+
* module every other command uses).
|
|
32
|
+
*/
|
|
33
|
+
import { readFileSync } from "node:fs";
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { createMcpClient } from "../mcp.mjs";
|
|
36
|
+
import { establishSession } from "../auth.mjs";
|
|
37
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
38
|
+
import { detectContext } from "../context.mjs";
|
|
39
|
+
import { repoNameFromRemote, tagFromRepoName } from "./submit.mjs";
|
|
40
|
+
import {
|
|
41
|
+
parseCredentialInput, formatCredentialOutput, splitAuthedRemote,
|
|
42
|
+
credentialCachePath, readCachedCredential, writeCachedCredential,
|
|
43
|
+
} from "../git-credential.mjs";
|
|
44
|
+
|
|
45
|
+
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
46
|
+
|
|
47
|
+
/** Does `remoteUrl`'s host equal `host` (case-insensitive)? An unparseable or
|
|
48
|
+
* missing remote URL never matches — fail closed. Exported for testing. */
|
|
49
|
+
export function hostMatches(remoteUrl, host) {
|
|
50
|
+
if (!remoteUrl || !host) return false;
|
|
51
|
+
try {
|
|
52
|
+
// scp-like remotes (git@host:owner/repo.git) have no scheme — synthesize one so URL can parse the host.
|
|
53
|
+
const normalized = /^[^/]+@[^/:]+:/.test(remoteUrl) ? `ssh://${remoteUrl.replace(":", "/")}` : remoteUrl;
|
|
54
|
+
return new URL(normalized).host.toLowerCase() === host.toLowerCase();
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Read the credential request git writes to stdin — blocking is correct
|
|
61
|
+
* here: git writes the request then closes its end, so this returns as
|
|
62
|
+
* soon as it's fully sent. Never blocks on an interactive terminal (git
|
|
63
|
+
* NEVER attaches a TTY to a credential helper's stdin — only a human
|
|
64
|
+
* poking at this command directly by hand would) and never throws (an
|
|
65
|
+
* unreadable/absent stdin is treated as an empty request). Exported so a
|
|
66
|
+
* test can inject a canned request instead of touching real fd 0. */
|
|
67
|
+
export function readStdin() {
|
|
68
|
+
if (process.stdin.isTTY) return "";
|
|
69
|
+
try {
|
|
70
|
+
return readFileSync(0, "utf8");
|
|
71
|
+
} catch {
|
|
72
|
+
return "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Mint (or reuse a cached) forge credential for the tenant the CURRENT
|
|
78
|
+
* directory checks out — the same `tenant_checkout` MCP call `tot clone`
|
|
79
|
+
* itself uses, so the credential is exactly as push-capable. Returns null on
|
|
80
|
+
* ANY failure (wrong dir, not signed in, MCP unreachable) rather than
|
|
81
|
+
* throwing — `get` treats null as "say nothing, exit non-zero".
|
|
82
|
+
*
|
|
83
|
+
* `createClient`/`establish`/`checkout` are injected (default to the real MCP
|
|
84
|
+
* client + auth + clone.mjs's checkoutTenant) purely so this is testable
|
|
85
|
+
* without a live MCP — same DI shape as chooseChangeId's injected `mint` /
|
|
86
|
+
* resolveDeveloperSession's injected `fetchImpl` elsewhere in this CLI.
|
|
87
|
+
* SCOPED TO THE CHECKOUT'S OWN REMOTE: `expectedHost` (git's requested host,
|
|
88
|
+
* from the credential-helper request) is checked against the host of this
|
|
89
|
+
* checkout's `origin` remote before a credential is ever minted or returned
|
|
90
|
+
* — a `get` for any OTHER host returns null (silent fail), never handing the
|
|
91
|
+
* tenant's forge token to a host this checkout doesn't itself push to. This
|
|
92
|
+
* matters because `credential.helper` is invoked per-URL by git, and a
|
|
93
|
+
* globally-scoped helper (or a checkout with a submodule / unrelated remote)
|
|
94
|
+
* must not become a way to exfiltrate the token to an arbitrary host.
|
|
95
|
+
* @param {{
|
|
96
|
+
* env?: NodeJS.ProcessEnv, cwd?: string, expectedHost?: string,
|
|
97
|
+
* createClient?: typeof createMcpClient,
|
|
98
|
+
* establish?: typeof establishSession,
|
|
99
|
+
* checkout?: typeof checkoutTenant,
|
|
100
|
+
* }} [opts]
|
|
101
|
+
* @returns {Promise<{username:string,password:string}|null>}
|
|
102
|
+
*/
|
|
103
|
+
export async function mintOrCacheCredential({
|
|
104
|
+
env = process.env, cwd = process.cwd(), expectedHost = null,
|
|
105
|
+
createClient = createMcpClient, establish = establishSession, checkout = checkoutTenant,
|
|
106
|
+
} = {}) {
|
|
107
|
+
const ctx = detectContext(cwd);
|
|
108
|
+
if (ctx.mode !== "checkout" || !ctx.tenant) return null;
|
|
109
|
+
|
|
110
|
+
const git = (cargs) =>
|
|
111
|
+
execFileSync("git", ["-C", ctx.workspacePath, ...cargs], { stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
112
|
+
let originUrl = null;
|
|
113
|
+
let repoName = null;
|
|
114
|
+
try {
|
|
115
|
+
originUrl = git(["remote", "get-url", "origin"]).trim();
|
|
116
|
+
repoName = repoNameFromRemote(originUrl);
|
|
117
|
+
} catch {
|
|
118
|
+
/* fall through — tagFromRepoName degrades to "main" on a null repo name */
|
|
119
|
+
}
|
|
120
|
+
if (expectedHost && !hostMatches(originUrl, expectedHost)) return null;
|
|
121
|
+
const tag = tagFromRepoName(repoName, ctx.tenant);
|
|
122
|
+
|
|
123
|
+
const cachePath = credentialCachePath(ctx.tenant, tag, env);
|
|
124
|
+
const cached = readCachedCredential(cachePath);
|
|
125
|
+
if (cached) return { username: cached.username, password: cached.password };
|
|
126
|
+
|
|
127
|
+
const baseUrl = env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
128
|
+
const client = createClient(baseUrl);
|
|
129
|
+
try {
|
|
130
|
+
await establish(client, { env });
|
|
131
|
+
} catch {
|
|
132
|
+
return null; // not signed in (or session unrefreshable) — nothing this helper can do
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
const res = await checkout(client, { tenant: ctx.tenant, tag, cloneDir: null });
|
|
136
|
+
const cred = splitAuthedRemote(res.gitRemote || "");
|
|
137
|
+
if (!cred) return null;
|
|
138
|
+
const fresh = { username: cred.username, password: cred.token };
|
|
139
|
+
writeCachedCredential(cachePath, fresh);
|
|
140
|
+
return fresh;
|
|
141
|
+
} catch {
|
|
142
|
+
return null; // MCP unreachable / tenant_checkout refused — say nothing, exit non-zero
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const USAGE = `tot git-credential — git credential-helper protocol over your \`tot login\` session
|
|
147
|
+
|
|
148
|
+
Configured automatically by \`tot clone\` (credential.helper = !tot git-credential)
|
|
149
|
+
in every checkout it creates — you should never need to run this by hand.
|
|
150
|
+
See \`git help gitcredentials\` for the protocol this implements.`;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {string[]} argv argv[0] is git's operation: get|store|erase
|
|
154
|
+
* @param {any} _ctx unused — this command derives its OWN context from cwd
|
|
155
|
+
* (mintOrCacheCredential), since git invokes it with the credentialed
|
|
156
|
+
* repo's directory as cwd, which may differ from wherever `tot` itself
|
|
157
|
+
* was dispatched from.
|
|
158
|
+
* @param {{ env?: NodeJS.ProcessEnv, cwd?: string, readStdin?: typeof readStdin }
|
|
159
|
+
* & Parameters<typeof mintOrCacheCredential>[0]} [opts]
|
|
160
|
+
*/
|
|
161
|
+
export async function run(argv, _ctx, opts = {}) {
|
|
162
|
+
const { env = process.env, readStdin: read = readStdin } = opts;
|
|
163
|
+
const op = argv[0];
|
|
164
|
+
if (!op || op === "--help" || op === "-h") {
|
|
165
|
+
console.log(USAGE);
|
|
166
|
+
return op ? 0 : 2;
|
|
167
|
+
}
|
|
168
|
+
// git always writes a request to stdin, even for store/erase — drain it either way
|
|
169
|
+
// so the subprocess exits cleanly instead of leaving git's write blocked on a full pipe.
|
|
170
|
+
const request = parseCredentialInput(read());
|
|
171
|
+
|
|
172
|
+
if (op !== "get") return 0; // store/erase: stateless, nothing to persist or drop.
|
|
173
|
+
|
|
174
|
+
const cred = await mintOrCacheCredential({ ...opts, expectedHost: request.host });
|
|
175
|
+
if (!cred) return 1; // silent — let git fall through to its next helper / its own prompt.
|
|
176
|
+
process.stdout.write(formatCredentialOutput({
|
|
177
|
+
protocol: request.protocol, host: request.host, username: cred.username, password: cred.password,
|
|
178
|
+
}));
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tot go-live` — cut the apex domain over to the storefront (unit u9). The CLI
|
|
3
|
+
* counterpart to the admin Publish tab's Domain "Connect" button: it drives the
|
|
4
|
+
* SAME go-live CI/dispatch the button does (the storefront `/api/domain/dispatch`
|
|
5
|
+
* → `www-domain` repository_dispatch → `cutover-dns.mjs`), gated by the SAME
|
|
6
|
+
* server-authoritative readiness gate the admin display reads
|
|
7
|
+
* (`GET /api/domain/readiness`, unit u9). This command CONSUMES those seams — it
|
|
8
|
+
* does NOT reimplement the DNS cutover, the readiness policy, or the revert.
|
|
9
|
+
*
|
|
10
|
+
* tot go-live show apex readiness, then (if ready) connect the apex
|
|
11
|
+
* tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
|
|
12
|
+
* tot go-live --rollback restore the captured prior DNS records (the one-line revert)
|
|
13
|
+
*
|
|
14
|
+
* The contract, deliberately strict — this is the apex DNS cutover of the live
|
|
15
|
+
* site (mirrors `tot ship` / `tot rollback`):
|
|
16
|
+
*
|
|
17
|
+
* 1. GET the readiness verdict and ALWAYS print the itemization (owner
|
|
18
|
+
* capability, dual-run parity, target health, evidence freshness, captured
|
|
19
|
+
* revert). Fail-closed: a CONNECT is refused unless the server reports
|
|
20
|
+
* `ready:true` — the CLI never recomputes readiness, it reads the same gate
|
|
21
|
+
* the cutover is governed by, so they cannot diverge.
|
|
22
|
+
* 2. Owner-only: the server resolves owner capability (u10); a non-owner is
|
|
23
|
+
* DENIED with the itemized reason. The CLI does not assert ownership itself.
|
|
24
|
+
* 3. ALWAYS require ONE explicit [y/N] confirm (default NO). There is NO
|
|
25
|
+
* `--yes`/`--force`; in a NON-TTY (CI, piped) it REFUSES rather than
|
|
26
|
+
* auto-confirm — nothing cuts over without a human at the keyboard.
|
|
27
|
+
* 4. On confirm, POST the dispatch and report the REAL terminal state — a
|
|
28
|
+
* dispatched run is IN FLIGHT until the CI HMAC callback lands; the CLI polls
|
|
29
|
+
* `/api/domain/status` and never claims "cut over" before the callback
|
|
30
|
+
* confirms it. A tested one-line revert (`tot go-live --rollback`) is always
|
|
31
|
+
* surfaced.
|
|
32
|
+
*
|
|
33
|
+
* Dependency-free (global fetch); pure helpers are exported and unit-tested with a
|
|
34
|
+
* mock HTTP client, no network, no TTY, and NO live DNS.
|
|
35
|
+
*/
|
|
36
|
+
import { fail } from "../errors.mjs";
|
|
37
|
+
import { isInteractive, promptYesNo } from "../prompt.mjs";
|
|
38
|
+
import { startProgress } from "../progress.mjs";
|
|
39
|
+
import { openBrowser } from "../open.mjs";
|
|
40
|
+
|
|
41
|
+
const USAGE = `tot go-live — cut the apex domain over to the storefront
|
|
42
|
+
|
|
43
|
+
tot go-live show apex readiness, then (if ready) connect the apex
|
|
44
|
+
tot go-live --rehearsal dispatch a DRY-RUN cutover (never moves DNS state)
|
|
45
|
+
tot go-live --rollback restore the captured prior DNS records (the revert)
|
|
46
|
+
tot go-live --url <base> storefront base URL (default: env TOT_STOREFRONT_URL / https://<owner>)
|
|
47
|
+
tot go-live --owner <domain> owner/appDomain to act on (default: env TOT_STOREFRONT_OWNER / checkout tenant)
|
|
48
|
+
tot go-live --no-open don't open the store in your browser afterward
|
|
49
|
+
|
|
50
|
+
go-live drives the SAME apex cutover the admin Domain button does, gated by the
|
|
51
|
+
same server readiness gate. It ALWAYS shows you the readiness itemization and
|
|
52
|
+
asks for a single y/N confirmation first; a CONNECT is refused unless the server
|
|
53
|
+
reports ready. There is no --yes/--force, and it refuses to run without an
|
|
54
|
+
interactive terminal. Reversible: \`tot go-live --rollback\`.`;
|
|
55
|
+
|
|
56
|
+
/** Parse `tot go-live` argv. Pure. Deliberately NO --yes/--force (see the header). */
|
|
57
|
+
export function parseGoLiveArgs(argv) {
|
|
58
|
+
const a = {
|
|
59
|
+
action: "connect",
|
|
60
|
+
rehearsal: false,
|
|
61
|
+
url: null,
|
|
62
|
+
owner: null,
|
|
63
|
+
identity: null,
|
|
64
|
+
noOpen: false,
|
|
65
|
+
help: false,
|
|
66
|
+
};
|
|
67
|
+
for (let i = 0; i < argv.length; i++) {
|
|
68
|
+
const t = argv[i];
|
|
69
|
+
if (t === "--rehearsal" || t === "--dry-run") a.rehearsal = true;
|
|
70
|
+
else if (t === "--rollback") a.action = "rollback";
|
|
71
|
+
else if (t === "--connect") a.action = "connect";
|
|
72
|
+
else if (t === "--url") a.url = argv[++i];
|
|
73
|
+
else if (t === "--owner") a.owner = argv[++i];
|
|
74
|
+
else if (t === "--identity") a.identity = argv[++i];
|
|
75
|
+
else if (t === "--no-open") a.noOpen = true;
|
|
76
|
+
else if (t === "--help" || t === "-h") a.help = true;
|
|
77
|
+
else if (t === "rollback") a.action = "rollback";
|
|
78
|
+
else if (t === "connect") a.action = "connect";
|
|
79
|
+
}
|
|
80
|
+
return a;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ─── Response normalisation (defensive — one server, but shapes may vary) ─────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Normalise a `GET /api/domain/readiness` response to what the gate needs:
|
|
87
|
+
* whether the acting principal is owner-capable, the readiness verdict + its
|
|
88
|
+
* itemized checks, and the current domain platform. TRI-STATE-safe: `ready`/
|
|
89
|
+
* `ownerCapable` are only true when the server clearly says so, so an unrecognised
|
|
90
|
+
* shape fails CLOSED (we never connect on ambiguity). Pure — unit-tested.
|
|
91
|
+
* @param {any} r
|
|
92
|
+
*/
|
|
93
|
+
export function normalizeReadiness(r) {
|
|
94
|
+
const o = r && typeof r === "object" ? r : {};
|
|
95
|
+
const rd = o.readiness && typeof o.readiness === "object" ? o.readiness : {};
|
|
96
|
+
const checks = Array.isArray(rd.checks)
|
|
97
|
+
? rd.checks
|
|
98
|
+
.filter((c) => c && typeof c === "object")
|
|
99
|
+
.map((c) => ({
|
|
100
|
+
id: typeof c.id === "string" ? c.id : "?",
|
|
101
|
+
label: typeof c.label === "string" ? c.label : c.id || "?",
|
|
102
|
+
ok: c.ok === true,
|
|
103
|
+
detail: typeof c.detail === "string" ? c.detail : "",
|
|
104
|
+
}))
|
|
105
|
+
: [];
|
|
106
|
+
return {
|
|
107
|
+
ownerCapable: o.ownerCapable === true,
|
|
108
|
+
ready: rd.ready === true,
|
|
109
|
+
checks,
|
|
110
|
+
blockedReasons: Array.isArray(rd.blockedReasons)
|
|
111
|
+
? rd.blockedReasons.filter((s) => typeof s === "string")
|
|
112
|
+
: checks.filter((c) => !c.ok).map((c) => c.detail),
|
|
113
|
+
appDomain: typeof o.appDomain === "string" ? o.appDomain : null,
|
|
114
|
+
domainState: rd && o.domain && typeof o.domain === "object" ? o.domain : null,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Normalise a `POST /api/domain/dispatch` response to { dispatched, runId, error }.
|
|
120
|
+
* `dispatched` is true only when the server confirms it fired the CI dispatch. Pure.
|
|
121
|
+
* @param {any} r
|
|
122
|
+
*/
|
|
123
|
+
export function normalizeDispatch(r) {
|
|
124
|
+
const o = r && typeof r === "object" ? r : {};
|
|
125
|
+
return {
|
|
126
|
+
dispatched: o.dispatched === true,
|
|
127
|
+
runId: typeof o.runId === "string" ? o.runId : null,
|
|
128
|
+
error: typeof o.error === "string" ? o.error : null,
|
|
129
|
+
domain: o.domain && typeof o.domain === "object" ? o.domain : null,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalise a `GET /api/domain/status` response's run to a terminal verdict:
|
|
135
|
+
* { status: "dispatched"|"succeeded"|"failed"|null, runUrl, error, state }. The
|
|
136
|
+
* connect is CUT OVER only when the last run reports `succeeded` (non-rehearsal) —
|
|
137
|
+
* never inferred from the dispatch. Pure — unit-tested.
|
|
138
|
+
* @param {any} r
|
|
139
|
+
*/
|
|
140
|
+
export function normalizeRunStatus(r) {
|
|
141
|
+
const o = r && typeof r === "object" ? r : {};
|
|
142
|
+
const domain = o.domain && typeof o.domain === "object" ? o.domain : {};
|
|
143
|
+
const run = domain.lastRun && typeof domain.lastRun === "object" ? domain.lastRun : {};
|
|
144
|
+
return {
|
|
145
|
+
status: typeof run.status === "string" ? run.status : null,
|
|
146
|
+
action: typeof run.action === "string" ? run.action : null,
|
|
147
|
+
rehearsal: run.rehearsal === true,
|
|
148
|
+
runUrl: typeof run.runUrl === "string" ? run.runUrl : null,
|
|
149
|
+
error: typeof run.error === "string" ? run.error : null,
|
|
150
|
+
platformState: typeof domain.state === "string" ? domain.state : null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ─── The go-live gate (pure) ──────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Decide, from the normalised readiness, whether a CONNECT may proceed — PURE so
|
|
158
|
+
* every branch is unit-tested without HTTP/TTY. Order: owner first (the most
|
|
159
|
+
* fundamental denial), then the full readiness verdict. A rollback does NOT gate
|
|
160
|
+
* on dual-run parity (it restores prior records); the server's `beginDomainRun`
|
|
161
|
+
* enforces "no captured prior records ⇒ refused", so rollback only needs owner +
|
|
162
|
+
* not-in-flight here and lets the server be the authority.
|
|
163
|
+
*
|
|
164
|
+
* @param {ReturnType<typeof normalizeReadiness>} readiness
|
|
165
|
+
* @param {"connect"|"rollback"} action
|
|
166
|
+
* @returns {{ kind: "not-owner"|"not-ready"|"ready", blockers: string[] }}
|
|
167
|
+
*/
|
|
168
|
+
export function goLiveReadinessGate(readiness, action) {
|
|
169
|
+
if (!readiness.ownerCapable) {
|
|
170
|
+
return {
|
|
171
|
+
kind: "not-owner",
|
|
172
|
+
blockers: [
|
|
173
|
+
"apex cutover is owner-only — sign in as the store owner (a ship-on-behalf developer cannot cut over the apex)",
|
|
174
|
+
],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (action === "rollback") {
|
|
178
|
+
// Rollback readiness = owner (above) + not mid-run. The server enforces the
|
|
179
|
+
// captured-records precondition; don't block the revert on connect-only signals.
|
|
180
|
+
const inFlight = readiness.checks.find((c) => c.id === "no-run-in-flight");
|
|
181
|
+
if (inFlight && !inFlight.ok) {
|
|
182
|
+
return { kind: "not-ready", blockers: [inFlight.detail] };
|
|
183
|
+
}
|
|
184
|
+
return { kind: "ready", blockers: [] };
|
|
185
|
+
}
|
|
186
|
+
if (!readiness.ready) {
|
|
187
|
+
return { kind: "not-ready", blockers: readiness.blockedReasons };
|
|
188
|
+
}
|
|
189
|
+
return { kind: "ready", blockers: [] };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Rendering (pure) ─────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
/** Render the readiness itemization — one ✓/✗ line per check + a headline. Pure. */
|
|
195
|
+
export function renderReadiness({ appDomain, readiness, action }) {
|
|
196
|
+
const lines = ["", ` Apex ${action === "rollback" ? "rollback" : "cutover"} readiness for ${appDomain ?? "this store"}:`];
|
|
197
|
+
for (const c of readiness.checks) {
|
|
198
|
+
lines.push(` ${c.ok ? "✓" : "✗"} ${c.label}${c.detail ? ` — ${c.detail}` : ""}`);
|
|
199
|
+
}
|
|
200
|
+
if (action === "connect") {
|
|
201
|
+
lines.push(
|
|
202
|
+
"",
|
|
203
|
+
readiness.ready
|
|
204
|
+
? " All preconditions green — this cutover is permitted."
|
|
205
|
+
: " Not ready — the failing preconditions above block the cutover.",
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return lines;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Status poll ───────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Poll `GET /api/domain/status` until the in-flight run reaches a terminal state
|
|
215
|
+
* (succeeded|failed) or the attempts budget runs out. The dispatch is async — CI
|
|
216
|
+
* runs then POSTs the HMAC callback — so this CONFIRMS the real outcome instead of
|
|
217
|
+
* assuming it from the dispatch. Injectable delay/attempts. Returns the last
|
|
218
|
+
* normalised run status (may still be "dispatched" if CI is slow — reported honestly).
|
|
219
|
+
* @param {{ get:(path:string)=>Promise<any> }} http
|
|
220
|
+
*/
|
|
221
|
+
export async function pollRunTerminal(http, { attempts = 8, delayMs = 2000, sleep } = {}) {
|
|
222
|
+
const wait = sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
223
|
+
let last = null;
|
|
224
|
+
for (let i = 0; i < attempts; i++) {
|
|
225
|
+
last = normalizeRunStatus(await http.get("/api/domain/status"));
|
|
226
|
+
if (last.status === "succeeded" || last.status === "failed") return last;
|
|
227
|
+
if (i < attempts - 1) await wait(delayMs);
|
|
228
|
+
}
|
|
229
|
+
return last;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Orchestration ───────────────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The go-live flow over an HTTP client — read readiness, render, gate, confirm,
|
|
236
|
+
* dispatch, poll, report. Split out from `run` so it's driven in tests with a mock
|
|
237
|
+
* `{ get, post }` HTTP client + injected confirm/interactive, no network / TTY /
|
|
238
|
+
* live DNS.
|
|
239
|
+
*
|
|
240
|
+
* @param {{ get:(path:string)=>Promise<any>, post:(path:string,body:any)=>Promise<any> }} http
|
|
241
|
+
* @param {{ appDomain:string, action:"connect"|"rollback", rehearsal:boolean, noOpen?:boolean }} params
|
|
242
|
+
* @param {{ interactive?:()=>boolean, confirm?:(q:string,d:boolean)=>Promise<boolean>,
|
|
243
|
+
* poll?:typeof pollRunTerminal, openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
|
|
244
|
+
* @returns {Promise<number>} process exit code
|
|
245
|
+
*/
|
|
246
|
+
export async function runGoLive(http, { appDomain, action, rehearsal, noOpen }, deps = {}) {
|
|
247
|
+
const interactive = deps.interactive || isInteractive;
|
|
248
|
+
const confirm = deps.confirm || promptYesNo;
|
|
249
|
+
const poll = deps.poll || pollRunTerminal;
|
|
250
|
+
|
|
251
|
+
// 1. Read the server-authoritative readiness verdict + ALWAYS itemize it.
|
|
252
|
+
let readiness;
|
|
253
|
+
try {
|
|
254
|
+
readiness = normalizeReadiness(await http.get("/api/domain/readiness"));
|
|
255
|
+
} catch (e) {
|
|
256
|
+
console.error(
|
|
257
|
+
fail(
|
|
258
|
+
`couldn't read apex readiness: ${String(e?.message || e)}`,
|
|
259
|
+
"check your connection and that the operator token/owner are set, then re-run",
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
return 1;
|
|
263
|
+
}
|
|
264
|
+
const domain = appDomain || readiness.appDomain;
|
|
265
|
+
for (const line of renderReadiness({ appDomain: domain, readiness, action })) console.log(line);
|
|
266
|
+
|
|
267
|
+
// 2. Gate — owner-only + (for connect) fail-closed on the readiness verdict.
|
|
268
|
+
const gate = goLiveReadinessGate(readiness, action);
|
|
269
|
+
if (gate.kind === "not-owner") {
|
|
270
|
+
console.error(fail(gate.blockers[0], "the admin Domain tab connects the apex from the owner's signed-in session"));
|
|
271
|
+
return 1;
|
|
272
|
+
}
|
|
273
|
+
if (gate.kind !== "ready") {
|
|
274
|
+
console.error(
|
|
275
|
+
fail(
|
|
276
|
+
`apex ${action} is not ready`,
|
|
277
|
+
"clear the failing preconditions above (e.g. run the dual-run, capture prior records), then re-run",
|
|
278
|
+
),
|
|
279
|
+
);
|
|
280
|
+
return 1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3. ALWAYS require one explicit y/N confirm; refuse in a NON-TTY.
|
|
284
|
+
if (!interactive()) {
|
|
285
|
+
console.error(
|
|
286
|
+
fail(
|
|
287
|
+
`\`tot go-live${action === "rollback" ? " --rollback" : ""}\` needs an interactive terminal to confirm the live change`,
|
|
288
|
+
"run it from a terminal (there is intentionally no --yes/--force)",
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
return 2;
|
|
292
|
+
}
|
|
293
|
+
const verb = action === "rollback" ? "roll the apex DNS back" : rehearsal ? "REHEARSE the apex cutover" : "cut the apex over";
|
|
294
|
+
const proceed = await confirm(`\n ${cap(verb)} for ${domain}?`, false);
|
|
295
|
+
if (!proceed) {
|
|
296
|
+
console.log(" Cancelled — nothing changed.");
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 4. Dispatch the SAME go-live CI action the admin button fires. The connect
|
|
301
|
+
// requires the typed domain confirmation server-side (confirmDomain).
|
|
302
|
+
let dispatch;
|
|
303
|
+
try {
|
|
304
|
+
dispatch = normalizeDispatch(
|
|
305
|
+
await http.post("/api/domain/dispatch", {
|
|
306
|
+
action,
|
|
307
|
+
rehearsal,
|
|
308
|
+
...(action === "connect" ? { confirmDomain: domain } : {}),
|
|
309
|
+
}),
|
|
310
|
+
);
|
|
311
|
+
} catch (e) {
|
|
312
|
+
console.error(
|
|
313
|
+
fail(
|
|
314
|
+
`the go-live dispatch was refused: ${String(e?.message || e)}`,
|
|
315
|
+
"the server gates the cutover (owner session + readiness); resolve the reason it reported, then re-run",
|
|
316
|
+
),
|
|
317
|
+
);
|
|
318
|
+
return 1;
|
|
319
|
+
}
|
|
320
|
+
if (!dispatch.dispatched) {
|
|
321
|
+
console.error(
|
|
322
|
+
fail(
|
|
323
|
+
dispatch.error || "the go-live dispatch did not fire",
|
|
324
|
+
"the server refused the cutover — recheck `tot go-live` readiness and that you're the signed-in owner",
|
|
325
|
+
),
|
|
326
|
+
);
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// 5. Poll for the REAL terminal state — never claim "cut over" before the CI
|
|
331
|
+
// callback confirms it.
|
|
332
|
+
const progress = deps.progress === false ? null : startProgress(action === "rollback" ? "rolling back…" : "cutting over…");
|
|
333
|
+
let status;
|
|
334
|
+
try {
|
|
335
|
+
status = await poll(http);
|
|
336
|
+
} finally {
|
|
337
|
+
progress?.stop();
|
|
338
|
+
}
|
|
339
|
+
return reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl: deps.openUrl });
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Report the go-live outcome honestly + always surface the one-line revert. */
|
|
343
|
+
function reportGoLive({ status, dispatch, action, rehearsal, domain, noOpen, openUrl }) {
|
|
344
|
+
const runUrl = status?.runUrl || null;
|
|
345
|
+
const revertHint = " Revert (one line): tot go-live --rollback";
|
|
346
|
+
|
|
347
|
+
if (status?.status === "succeeded") {
|
|
348
|
+
if (rehearsal) {
|
|
349
|
+
console.log(`\n ✓ rehearsal succeeded for ${domain} — DNS state unchanged (dry run).`);
|
|
350
|
+
} else if (action === "rollback") {
|
|
351
|
+
console.log(`\n ✓ rolled the apex DNS back for ${domain} to the captured prior records.`);
|
|
352
|
+
} else {
|
|
353
|
+
console.log(`\n ✓ apex cut over for ${domain} — the storefront is now live on the apex.`);
|
|
354
|
+
const url = `https://${domain}`;
|
|
355
|
+
console.log(` Live: ${url}`);
|
|
356
|
+
if (!noOpen && openUrl && openUrl(url)) console.log(" (opened in your browser)");
|
|
357
|
+
}
|
|
358
|
+
if (action !== "rollback") console.log(revertHint);
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
if (status?.status === "failed") {
|
|
363
|
+
console.error(
|
|
364
|
+
fail(
|
|
365
|
+
`the apex ${action} run failed${status.error ? `: ${status.error}` : ""}`,
|
|
366
|
+
action === "rollback"
|
|
367
|
+
? "check the CI run, then retry"
|
|
368
|
+
: "the DNS was not changed if the run failed before cutover; check the CI run, then retry — or `tot go-live --rollback`",
|
|
369
|
+
),
|
|
370
|
+
);
|
|
371
|
+
if (runUrl) console.error(` CI run: ${runUrl}`);
|
|
372
|
+
return 1;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Dispatched but not yet terminal — CI is still running. Report honestly.
|
|
376
|
+
console.log(`\n ~ ${action} dispatched for ${domain}${dispatch.runId ? ` (run ${dispatch.runId})` : ""}; it's running now.`);
|
|
377
|
+
if (runUrl) console.log(` Track it: ${runUrl}`);
|
|
378
|
+
console.log(" Re-run `tot go-live` (or watch the admin Domain tab) for the terminal state.");
|
|
379
|
+
if (action !== "rollback") console.log(revertHint);
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function cap(s) {
|
|
384
|
+
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Build an HTTP client over the storefront apex-domain endpoints. Auth mirrors the
|
|
389
|
+
* headless operator trust boundary the storefront's `resolveOwnerSession` Path 2
|
|
390
|
+
* accepts: a Bearer operator secret + `X-Tot-Owner`, plus the `x-tot-capability`
|
|
391
|
+
* ship floor. Throws on a non-2xx with the server's error message (so the caller
|
|
392
|
+
* surfaces the real refusal). `fetchImpl` is injectable for tests.
|
|
393
|
+
* @param {string} base
|
|
394
|
+
* @param {{ token:string|undefined, owner:string, capability?:string, fetchImpl?:typeof fetch }} auth
|
|
395
|
+
*/
|
|
396
|
+
export function createStorefrontHttp(base, { token, owner, capability = "ship-on-behalf", fetchImpl } = {}) {
|
|
397
|
+
const root = base.replace(/\/+$/, "");
|
|
398
|
+
const doFetch = fetchImpl || fetch;
|
|
399
|
+
const headers = () => ({
|
|
400
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
401
|
+
...(owner ? { "x-tot-owner": owner } : {}),
|
|
402
|
+
"x-tot-capability": capability,
|
|
403
|
+
});
|
|
404
|
+
const parse = async (res) => {
|
|
405
|
+
const text = await res.text();
|
|
406
|
+
let body = null;
|
|
407
|
+
try {
|
|
408
|
+
body = text ? JSON.parse(text) : null;
|
|
409
|
+
} catch {
|
|
410
|
+
body = null;
|
|
411
|
+
}
|
|
412
|
+
if (!res.ok) {
|
|
413
|
+
const msg = (body && typeof body === "object" && typeof body.error === "string" && body.error) ||
|
|
414
|
+
`HTTP ${res.status}`;
|
|
415
|
+
throw new Error(msg);
|
|
416
|
+
}
|
|
417
|
+
return body;
|
|
418
|
+
};
|
|
419
|
+
return {
|
|
420
|
+
async get(path) {
|
|
421
|
+
return parse(await doFetch(`${root}${path}`, { method: "GET", headers: headers() }));
|
|
422
|
+
},
|
|
423
|
+
async post(path, body) {
|
|
424
|
+
return parse(
|
|
425
|
+
await doFetch(`${root}${path}`, {
|
|
426
|
+
method: "POST",
|
|
427
|
+
headers: { ...headers(), "content-type": "application/json" },
|
|
428
|
+
body: JSON.stringify(body ?? {}),
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
},
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* @param {string[]} argv
|
|
437
|
+
* @param {any} ctx
|
|
438
|
+
*/
|
|
439
|
+
export async function run(argv, ctx) {
|
|
440
|
+
const env = process.env;
|
|
441
|
+
const args = parseGoLiveArgs(argv);
|
|
442
|
+
if (args.help) {
|
|
443
|
+
console.log(USAGE);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
if (ctx.mode !== "checkout") {
|
|
447
|
+
console.error(
|
|
448
|
+
fail(
|
|
449
|
+
"`tot go-live` runs from inside a tenant checkout",
|
|
450
|
+
"tot clone <tenant> <dir> (then `cd` in, and `tot go-live`)",
|
|
451
|
+
),
|
|
452
|
+
);
|
|
453
|
+
return 2;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// The owner/appDomain to act on, and the storefront base URL. Owner defaults to
|
|
457
|
+
// the checkout's tenant (owner == appDomain in this platform); the base URL
|
|
458
|
+
// defaults to https://<owner> unless overridden.
|
|
459
|
+
const owner = args.owner || env.TOT_STOREFRONT_OWNER || ctx.tenant;
|
|
460
|
+
if (!owner) {
|
|
461
|
+
console.error(
|
|
462
|
+
fail(
|
|
463
|
+
"couldn't resolve the store owner/appDomain for this checkout",
|
|
464
|
+
"pass --owner <appDomain> or set TOT_STOREFRONT_OWNER",
|
|
465
|
+
),
|
|
466
|
+
);
|
|
467
|
+
return 1;
|
|
468
|
+
}
|
|
469
|
+
const base = args.url || env.TOT_STOREFRONT_URL || `https://${owner}`;
|
|
470
|
+
const token = env.TOT_STOREFRONT_OPERATOR_TOKEN || env.PREVIEW_RECONCILE_SECRET;
|
|
471
|
+
|
|
472
|
+
// The storefront apex-domain endpoints authenticate via the operator token +
|
|
473
|
+
// X-Tot-Owner (resolveOwnerSession Path 2), NOT the MCP OAuth session — so there
|
|
474
|
+
// is no MCP sign-in step here. Owner INTENT is enforced by the explicit confirm
|
|
475
|
+
// below and, authoritatively, by the server's owner-only readiness/dispatch gate.
|
|
476
|
+
const http = createStorefrontHttp(base, { token, owner });
|
|
477
|
+
return await runGoLive(
|
|
478
|
+
http,
|
|
479
|
+
{ appDomain: owner, action: args.action, rehearsal: args.rehearsal, noOpen: args.noOpen },
|
|
480
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
481
|
+
);
|
|
482
|
+
}
|