@tokenoftrust/cli 1.4.0-rc.10 → 1.4.0-rc.12
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 +35 -2
- package/package.json +1 -1
- package/src/candidate-state.mjs +56 -16
- package/src/commands/accept.mjs +247 -0
- package/src/commands/go-live.mjs +482 -0
- package/src/commands/pr.mjs +21 -7
- package/src/commands/preview-build.mjs +225 -0
- package/src/commands/preview.mjs +9 -0
- package/src/commands/retire.mjs +203 -0
- package/src/commands/rollback.mjs +401 -0
- package/src/commands/ship.mjs +990 -28
- package/src/commands/submit.mjs +259 -28
- package/src/plan.mjs +160 -0
- package/src/sample.mjs +27 -1
package/src/commands/submit.mjs
CHANGED
|
@@ -42,6 +42,7 @@ import { createHash } from "node:crypto";
|
|
|
42
42
|
import { setTimeout as delay } from "node:timers/promises";
|
|
43
43
|
import { createMcpClient } from "../mcp.mjs";
|
|
44
44
|
import { establishSession, AuthUnavailableError } from "../auth.mjs";
|
|
45
|
+
import { checkoutTenant } from "./clone.mjs";
|
|
45
46
|
import { validateTenant, ERROR } from "../validate.mjs";
|
|
46
47
|
import { openBrowser } from "../open.mjs";
|
|
47
48
|
import { startProgress } from "../progress.mjs";
|
|
@@ -52,6 +53,7 @@ import {
|
|
|
52
53
|
writeActiveChangeId,
|
|
53
54
|
mintFreshChangeId,
|
|
54
55
|
isTerminalCandidateState,
|
|
56
|
+
isDefaultBranch,
|
|
55
57
|
} from "../candidate-state.mjs";
|
|
56
58
|
|
|
57
59
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
@@ -345,22 +347,165 @@ export function repoNameFromRemote(remoteUrl) {
|
|
|
345
347
|
}
|
|
346
348
|
}
|
|
347
349
|
|
|
350
|
+
// ─── fresh-forge-credential push (decision B — the invited-dev 401 dead-end) ─────
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Split an authenticated forge remote URL (basic-auth `user:token@host`, as the
|
|
354
|
+
* MCP mints it via `tenant_checkout`) into its tokenless public URL + the embedded
|
|
355
|
+
* credential, so the token can be handed to git EPHEMERALLY for one push instead of
|
|
356
|
+
* being persisted in `.git/config`. Returns null when the URL won't parse or carries
|
|
357
|
+
* no token — the caller then falls back to the checkout's existing remote. Pure —
|
|
358
|
+
* unit-tested.
|
|
359
|
+
* @param {string} remoteUrl
|
|
360
|
+
* @returns {{ publicUrl: string, username: string, token: string }|null}
|
|
361
|
+
*/
|
|
362
|
+
export function splitAuthedRemote(remoteUrl) {
|
|
363
|
+
try {
|
|
364
|
+
const u = new URL(String(remoteUrl));
|
|
365
|
+
const token = u.password ? decodeURIComponent(u.password) : "";
|
|
366
|
+
if (!token) return null;
|
|
367
|
+
const username = u.username ? decodeURIComponent(u.username) : "";
|
|
368
|
+
return { publicUrl: `${u.protocol}//${u.host}${u.pathname}`, username, token };
|
|
369
|
+
} catch {
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* The `http.extraheader` value that hands a basic-auth credential to a SINGLE git
|
|
376
|
+
* invocation (base64 of `user:token`) — so a freshly-minted forge token
|
|
377
|
+
* authenticates one push without ever being written to `.git/config`. Pure —
|
|
378
|
+
* unit-tested.
|
|
379
|
+
* @param {string} username
|
|
380
|
+
* @param {string} token
|
|
381
|
+
* @returns {string}
|
|
382
|
+
*/
|
|
383
|
+
export function basicAuthExtraHeader(username, token) {
|
|
384
|
+
const b64 = Buffer.from(`${username}:${token}`, "utf8").toString("base64");
|
|
385
|
+
return `Authorization: Basic ${b64}`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Recognise a forge auth failure (expired / invalid push token) in a failed git
|
|
390
|
+
* push's stderr, so `tot preview` can re-mint a fresh credential and retry once
|
|
391
|
+
* rather than dead-ending on a stale token (the belt-and-suspenders half of
|
|
392
|
+
* decision B). Pure — unit-tested.
|
|
393
|
+
* @param {string} text
|
|
394
|
+
* @returns {boolean}
|
|
395
|
+
*/
|
|
396
|
+
export function isForgeAuthError(text) {
|
|
397
|
+
return /\b40[13]\b|failed to authenticate|authentication failed|invalid credentials|access denied/i.test(
|
|
398
|
+
String(text || ""),
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
348
402
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
403
|
+
* Derive the checkout's forge tag from its repo name (`"<tenant>-<tag>"`), so a
|
|
404
|
+
* re-mint targets the SAME repo the checkout points at. Defaults to "main" when the
|
|
405
|
+
* repo is bare (`"<tenant>"`, post-8425) or the `<tenant>-` prefix doesn't match, so
|
|
406
|
+
* the mint degrades to the clone default rather than a wrong tag. Pure —
|
|
407
|
+
* unit-tested.
|
|
408
|
+
* @param {string|null} repoName
|
|
409
|
+
* @param {string} tenant
|
|
410
|
+
* @returns {string}
|
|
411
|
+
*/
|
|
412
|
+
export function tagFromRepoName(repoName, tenant) {
|
|
413
|
+
const r = String(repoName || "");
|
|
414
|
+
const prefix = `${tenant}-`;
|
|
415
|
+
return r.startsWith(prefix) && r.length > prefix.length ? r.slice(prefix.length) : "main";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Push the preview ref (decision B): mint a FRESH, short-lived forge credential
|
|
420
|
+
* right before the push and hand it to git EPHEMERALLY (via `http.extraheader` on
|
|
421
|
+
* a per-invocation `-c` — never written to `.git/config`), re-minting once on an
|
|
422
|
+
* auth failure. The push credential the MCP baked into `.git/config` at clone time
|
|
423
|
+
* expires within hours; reusing that stale embedded token is the invited-dev
|
|
424
|
+
* "`tot preview` → Gitea 401 dead-end". We push over the named `origin` remote with
|
|
425
|
+
* its URL overridden to the tokenless public URL for this one invocation, so the
|
|
426
|
+
* remote-tracking ref still updates while no long-lived secret lands on disk.
|
|
427
|
+
*
|
|
428
|
+
* When the mint is unavailable (older MCP, transient failure — `mintRemote` returns
|
|
429
|
+
* null) or the minted URL carries no parseable token, it falls back to pushing over
|
|
430
|
+
* the checkout's EXISTING remote (pre-B behavior) — no regression.
|
|
431
|
+
*
|
|
432
|
+
* @param {(cargs:string[])=>string} git throwing git runner (execFileSync-backed)
|
|
433
|
+
* @param {() => Promise<string|null>} mintRemote mints a fresh authed gitRemote (null when unavailable)
|
|
434
|
+
* @param {{ ref: string }} opts
|
|
435
|
+
* @returns {Promise<{ out: string }>} resolves on a successful push; throws (git's error) otherwise
|
|
436
|
+
*/
|
|
437
|
+
export async function pushPreviewRef(git, mintRemote, { ref } = {}) {
|
|
438
|
+
const attempt = (remote) => {
|
|
439
|
+
const cred = splitAuthedRemote(remote);
|
|
440
|
+
if (!cred) {
|
|
441
|
+
// No fresh credential to hand over — push over the checkout's existing remote.
|
|
442
|
+
return git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
443
|
+
}
|
|
444
|
+
// Ephemeral auth: override the remote URL to the tokenless public URL and supply
|
|
445
|
+
// the credential as a one-shot Authorization header, with any OS credential
|
|
446
|
+
// helper disabled — none of this touches `.git/config`.
|
|
447
|
+
return git([
|
|
448
|
+
"-c", `remote.origin.url=${cred.publicUrl}`,
|
|
449
|
+
"-c", `http.extraheader=${basicAuthExtraHeader(cred.username, cred.token)}`,
|
|
450
|
+
"-c", "credential.helper=",
|
|
451
|
+
"push", "-f", "origin", `HEAD:refs/heads/${ref}`,
|
|
452
|
+
]);
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
const remote = await mintRemote();
|
|
456
|
+
try {
|
|
457
|
+
return { out: attempt(remote) };
|
|
458
|
+
} catch (e) {
|
|
459
|
+
if (!isForgeAuthError(e?.stderr || e?.message || e)) throw e;
|
|
460
|
+
// Belt-and-suspenders: an auth failure re-mints a fresh credential and retries once.
|
|
461
|
+
return { out: attempt(await mintRemote()) };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* A STABLE per-developer-per-tenant(-per-branch) candidate handle — so repeat
|
|
467
|
+
* `tot submit` runs update the SAME PR instead of opening a new one each time
|
|
351
468
|
* (`candidate_open` is idempotent on `changeId`). No local state file needed:
|
|
352
|
-
* it's a deterministic hash of the tenant + the acting identity
|
|
353
|
-
* fresh every run. Two different developers
|
|
354
|
-
* two different (non-colliding) candidates
|
|
469
|
+
* it's a deterministic hash of the tenant + the acting identity (+ the git branch
|
|
470
|
+
* on a non-default branch), recomputed fresh every run. Two different developers
|
|
471
|
+
* submitting to the same tenant get two different (non-colliding) candidates; so
|
|
472
|
+
* do the SAME developer on two different feature branches (u4 — branch-bound
|
|
473
|
+
* candidates), so `git checkout` acts as the PR switcher.
|
|
474
|
+
*
|
|
475
|
+
* ZERO MIGRATION: on the DEFAULT branch (main/master, or an unresolvable branch)
|
|
476
|
+
* the hash material is `tenant|actorKey` — byte-identical to the pre-u4 id — so an
|
|
477
|
+
* existing dev's current candidate keeps working untouched. A non-default branch
|
|
478
|
+
* folds the branch into the material (`tenant|actorKey|branch`) for its own id.
|
|
479
|
+
* Pure — unit-tested.
|
|
355
480
|
* @param {string} tenant
|
|
356
481
|
* @param {string} actorKey
|
|
482
|
+
* @param {string|null} [branch] current git branch; default/null ⇒ today's id
|
|
357
483
|
* @returns {string}
|
|
358
484
|
*/
|
|
359
|
-
export function deriveChangeId(tenant, actorKey) {
|
|
360
|
-
const
|
|
485
|
+
export function deriveChangeId(tenant, actorKey, branch = null) {
|
|
486
|
+
const material = isDefaultBranch(branch) ? `${tenant}|${actorKey}` : `${tenant}|${actorKey}|${branch}`;
|
|
487
|
+
const hash = createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
361
488
|
return `local-${hash}`;
|
|
362
489
|
}
|
|
363
490
|
|
|
491
|
+
/**
|
|
492
|
+
* The current git branch in `workspace`, or null when it can't be resolved (a
|
|
493
|
+
* detached HEAD reports "HEAD", and any git failure is swallowed) — null is read
|
|
494
|
+
* by `isDefaultBranch` as the default branch, so an unresolvable branch keeps
|
|
495
|
+
* today's (branch-less) candidate rather than minting a spurious namespace. `git`
|
|
496
|
+
* is injected (a `(args:string[])=>string` runner) so it's testable. Best-effort.
|
|
497
|
+
* @param {(args:string[]) => string} git
|
|
498
|
+
* @returns {string|null}
|
|
499
|
+
*/
|
|
500
|
+
export function currentBranch(git) {
|
|
501
|
+
try {
|
|
502
|
+
const b = git(["rev-parse", "--abbrev-ref", "HEAD"]).trim();
|
|
503
|
+
return b && b !== "HEAD" ? b : null;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
364
509
|
/** The stable identity key behind `deriveChangeId` — the signed-in developer's
|
|
365
510
|
* email, falling back to the token, then a generic label. Single-plane: the only
|
|
366
511
|
* identity `tot` carries is the developer's own OAuth session. */
|
|
@@ -538,10 +683,68 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
538
683
|
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
539
684
|
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
540
685
|
|
|
541
|
-
|
|
686
|
+
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
687
|
+
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
688
|
+
// ONCE, up front, and reuse it for the whole flow.
|
|
689
|
+
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
690
|
+
const client = createMcpClient(baseUrl);
|
|
691
|
+
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
692
|
+
|
|
693
|
+
let session;
|
|
694
|
+
try {
|
|
695
|
+
session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
696
|
+
} catch (e) {
|
|
697
|
+
// Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
|
|
698
|
+
// back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
|
|
699
|
+
// no worse than before) and skip the read-back that needs a session. The push
|
|
700
|
+
// still lands if that embedded token is live.
|
|
701
|
+
console.error(`~ pushing ${short} → ${args.ref} (origin)`);
|
|
702
|
+
try {
|
|
703
|
+
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${args.ref}`]);
|
|
704
|
+
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
705
|
+
} catch (pushErr) {
|
|
706
|
+
console.error(
|
|
707
|
+
fail(
|
|
708
|
+
`push failed: ${redactUrl(String(pushErr.stderr || pushErr.message || pushErr))}`,
|
|
709
|
+
"check your commit and that the checkout's remote is reachable, then re-run",
|
|
710
|
+
),
|
|
711
|
+
);
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
console.log(`\n+ submitted ${short} to ${args.ref}.`);
|
|
715
|
+
printChangeSummary(changeSummary);
|
|
716
|
+
if (e instanceof AuthUnavailableError) {
|
|
717
|
+
console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
|
|
718
|
+
} else {
|
|
719
|
+
console.log(` (couldn't reach Token of Trust for the result read-back: ${String(e?.message || e)})`);
|
|
720
|
+
}
|
|
721
|
+
console.log(` Your push is in; the preview updates once reconcile completes.`);
|
|
722
|
+
return 0;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
726
|
+
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
727
|
+
// hours, so we re-mint right before the push and hand it to git ephemerally
|
|
728
|
+
// (never persisted to `.git/config`), re-minting once on an auth failure.
|
|
729
|
+
// `checkoutTenant(cloneDir:null)` mints without re-cloning AND client_switch()es,
|
|
730
|
+
// binding the tenant scope the candidate/preview read-back below reads.
|
|
731
|
+
const tag = tagFromRepoName(repo, tenant);
|
|
732
|
+
const mintRemote = async () => {
|
|
733
|
+
try {
|
|
734
|
+
const res = await checkoutTenant(client, { tenant, tag, cloneDir: null, redact: redactUrl });
|
|
735
|
+
return res.gitRemote || null;
|
|
736
|
+
} catch (e) {
|
|
737
|
+
console.error(
|
|
738
|
+
`~ couldn't mint a fresh push credential (${redactUrl(String(e?.message || e))}) — using the checkout's remote`,
|
|
739
|
+
);
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
console.error(`~ pushing ${short} → ${args.ref} (origin, fresh credential)`);
|
|
542
745
|
try {
|
|
543
|
-
const out = git
|
|
544
|
-
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
746
|
+
const { out } = await pushPreviewRef(git, mintRemote, { ref: args.ref });
|
|
747
|
+
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
545
748
|
} catch (e) {
|
|
546
749
|
console.error(
|
|
547
750
|
fail(
|
|
@@ -555,16 +758,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
555
758
|
printChangeSummary(changeSummary);
|
|
556
759
|
|
|
557
760
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
558
|
-
// compliance + preview URL from the MCP
|
|
559
|
-
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
560
|
-
const client = createMcpClient(baseUrl);
|
|
761
|
+
// compliance + preview URL from the MCP — reusing the session established above.
|
|
561
762
|
let progress = null;
|
|
562
763
|
try {
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
const session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
566
|
-
// Set the active tenant so preview_status/candidate_open read the right scope
|
|
567
|
-
// (both key on the session's bound tenant/app — no tenant arg of their own).
|
|
764
|
+
// Bind the active tenant so preview_status/candidate_open read the right scope
|
|
765
|
+
// (idempotent — checkoutTenant already switched when the fresh mint succeeded).
|
|
568
766
|
await client.callTool("client_switch", { tenant });
|
|
569
767
|
|
|
570
768
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
@@ -576,12 +774,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
576
774
|
// otherwise → the remembered active candidate (from a prior --new /
|
|
577
775
|
// roll), else the STABLE per-dev-per-tenant default.
|
|
578
776
|
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
579
|
-
// so a re-submit is never wedged on a dead PR.
|
|
580
|
-
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
777
|
+
// so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
|
|
581
778
|
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
582
779
|
const statePath = defaultCandidateStatePath(env);
|
|
583
|
-
|
|
584
|
-
|
|
780
|
+
// Branch-bound (u4): the candidate handle + active-pointer namespace fold in the
|
|
781
|
+
// current git branch on a non-default branch, so a feature branch gets its OWN
|
|
782
|
+
// candidate; the default branch keeps today's exact id (zero migration).
|
|
783
|
+
const branch = currentBranch(gitSafe);
|
|
784
|
+
const stableId = deriveChangeId(tenant, actorKeyFor(session), branch);
|
|
785
|
+
const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
585
786
|
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
586
787
|
// Persist when we diverge from the stable default (a --new fork, or a
|
|
587
788
|
// previously-remembered active pointer) so the next plain submit follows it.
|
|
@@ -601,7 +802,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
601
802
|
// never let a state-write failure break the submit).
|
|
602
803
|
if (persist && repo && candidate && !isTerminalCandidateState(candidate.state)) {
|
|
603
804
|
try {
|
|
604
|
-
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, changeId });
|
|
805
|
+
writeActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch, changeId });
|
|
605
806
|
} catch { /* best-effort local hint — a miss just re-derives the stable id */ }
|
|
606
807
|
}
|
|
607
808
|
|
|
@@ -722,6 +923,38 @@ function reportComplianceCheck(c) {
|
|
|
722
923
|
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
723
924
|
}
|
|
724
925
|
|
|
926
|
+
/**
|
|
927
|
+
* Build the printed lines for the headline "share this with your reviewer" block —
|
|
928
|
+
* the whole point of U14: on a successful preview, the SHAREABLE deep link
|
|
929
|
+
* (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
|
|
930
|
+
* by the reconcile report and threaded through as `previewUrl`) is what a developer
|
|
931
|
+
* hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
|
|
932
|
+
* in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
|
|
933
|
+
* reference. Labeled prominently + paired with an honest auth caveat: the share
|
|
934
|
+
* target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
|
|
935
|
+
* public/anonymous link.
|
|
936
|
+
*
|
|
937
|
+
* Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
|
|
938
|
+
* MCP, or a reconcile that hasn't finished minting it — with a note instead of a
|
|
939
|
+
* crash or a silent blank. Pure — unit-tested.
|
|
940
|
+
* @param {{ previewUrl?: string|null, status?: string }|null} s
|
|
941
|
+
* @param {string} tenant
|
|
942
|
+
* @returns {string[]}
|
|
943
|
+
*/
|
|
944
|
+
export function formatShareableUrlBlock(s, tenant) {
|
|
945
|
+
if (s?.previewUrl) {
|
|
946
|
+
return [
|
|
947
|
+
`\n ✓ Preview ready — share this with your reviewer:`,
|
|
948
|
+
` ${s.previewUrl}`,
|
|
949
|
+
` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
|
|
950
|
+
];
|
|
951
|
+
}
|
|
952
|
+
if (s?.status === "reconciled") {
|
|
953
|
+
return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
|
|
954
|
+
}
|
|
955
|
+
return [];
|
|
956
|
+
}
|
|
957
|
+
|
|
725
958
|
/**
|
|
726
959
|
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
727
960
|
* preview URL, open it in the browser (unless opts.open === false).
|
|
@@ -755,10 +988,8 @@ function reportStatus(s, tenant, { open = true } = {}) {
|
|
|
755
988
|
} else if (s.status === "reconciled") {
|
|
756
989
|
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
757
990
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
console.log(" (opened in your browser)");
|
|
762
|
-
}
|
|
991
|
+
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
992
|
+
if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
993
|
+
console.log(" (opened in your browser)");
|
|
763
994
|
}
|
|
764
995
|
}
|
package/src/plan.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared "operation plan" affordance (unit U10) — the load-bearing
|
|
3
|
+
* cross-cutting requirement from decision `operator-verb-and-hosting-model`:
|
|
4
|
+
* every MUTATING operator verb (build / accept / ship / retire) must STATE
|
|
5
|
+
* EXACTLY what it will do — which PR moves to main, which deploy targets
|
|
6
|
+
* (preview / live) are touched, and their URLs — and get an explicit confirm
|
|
7
|
+
* before acting. No silent multi-step mutations, in either surface (the CLI
|
|
8
|
+
* here, and `AdminPublishTab.astro`'s confirm dialog, which renders the same
|
|
9
|
+
* shape of plan text server-side/inline).
|
|
10
|
+
*
|
|
11
|
+
* `planForAction` is PURE (no I/O, no prompt) so it's trivially unit-tested
|
|
12
|
+
* and reusable anywhere a plan needs to be rendered (CLI stdout, an admin
|
|
13
|
+
* confirm() dialog, a future dry-run flag). `printPlanAndConfirm` is the CLI
|
|
14
|
+
* half — print the plan, then gate on an explicit yes (reusing `prompt.mjs`'s
|
|
15
|
+
* TTY-safe `promptYesNo`; a non-TTY without `--yes` never silently proceeds).
|
|
16
|
+
*
|
|
17
|
+
* SHIP is context-dependent (decision `ship-context-dependent-semantics`):
|
|
18
|
+
* run by the developer in their own checkout, the candidate is still open, so
|
|
19
|
+
* shipping means accept-then-deploy (merge PR → main, THEN deploy main to
|
|
20
|
+
* preview + live). Run by an operator already targeting a PR that's merged,
|
|
21
|
+
* shipping means just deploy (main → preview + live) — there's nothing left
|
|
22
|
+
* to merge. `planForAction` takes an explicit `context` so the caller (which
|
|
23
|
+
* knows which situation it's in) picks the right narration; it never guesses.
|
|
24
|
+
*
|
|
25
|
+
* Dependency-free (no imports besides the sibling `prompt.mjs`).
|
|
26
|
+
*/
|
|
27
|
+
import { isInteractive, promptYesNo } from "./prompt.mjs";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A human-readable label for the thing an action targets: "PR #N" when a PR
|
|
31
|
+
* number is known, else the change id, else a neutral fallback. Pure.
|
|
32
|
+
* @param {{ pr?: number|string|null, changeId?: string|null }} p
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function targetLabel({ pr, changeId }) {
|
|
36
|
+
if (pr != null && `${pr}`.trim()) return `PR #${pr}`;
|
|
37
|
+
if (changeId) return changeId;
|
|
38
|
+
return "this change";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the EXACT plan for a mutating operator verb — structured lines ready
|
|
43
|
+
* to print verbatim (CLI) or join for a confirm() dialog (admin). Pure: no
|
|
44
|
+
* console output, no network, no prompting.
|
|
45
|
+
*
|
|
46
|
+
* @param {{
|
|
47
|
+
* action: "build"|"accept"|"ship"|"retire",
|
|
48
|
+
* tenant: string,
|
|
49
|
+
* pr?: number|string|null,
|
|
50
|
+
* changeId?: string|null,
|
|
51
|
+
* headSha?: string|null,
|
|
52
|
+
* endpoint?: string|null,
|
|
53
|
+
* targets?: { preview?: string|null, live?: string|null },
|
|
54
|
+
* context?: "developer"|"operator",
|
|
55
|
+
* }} params
|
|
56
|
+
* @returns {string[]} plan lines (no leading/trailing blank line)
|
|
57
|
+
*/
|
|
58
|
+
export function planForAction({
|
|
59
|
+
action,
|
|
60
|
+
tenant,
|
|
61
|
+
pr = null,
|
|
62
|
+
changeId = null,
|
|
63
|
+
headSha = null,
|
|
64
|
+
endpoint = null,
|
|
65
|
+
targets = {},
|
|
66
|
+
context = "operator",
|
|
67
|
+
}) {
|
|
68
|
+
const label = targetLabel({ pr, changeId });
|
|
69
|
+
const lines = [`${titleFor(action)} plan:`];
|
|
70
|
+
if (tenant) lines.push(` tenant: ${tenant}`);
|
|
71
|
+
if (pr != null && `${pr}`.trim()) lines.push(` PR: #${pr}`);
|
|
72
|
+
if (changeId) lines.push(` change id: ${changeId}`);
|
|
73
|
+
if (headSha) lines.push(` head sha: ${headSha}`);
|
|
74
|
+
if (endpoint) lines.push(` endpoint: POST ${endpoint}`);
|
|
75
|
+
|
|
76
|
+
switch (action) {
|
|
77
|
+
case "build": {
|
|
78
|
+
lines.push(
|
|
79
|
+
` effect: materialize ${label}'s candidate preview — NO merge, NO go-live, NO channel flip.`,
|
|
80
|
+
);
|
|
81
|
+
if (targets.preview) lines.push(` viewable: ${targets.preview}`);
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
case "accept": {
|
|
85
|
+
lines.push(` effect: merge ${label} into main.`);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
case "ship": {
|
|
89
|
+
const deployTargets = deployTargetsLine(targets);
|
|
90
|
+
if (context === "developer") {
|
|
91
|
+
// The candidate is still open in the developer's own checkout — ship
|
|
92
|
+
// is accept-then-deploy in one gated step.
|
|
93
|
+
lines.push(
|
|
94
|
+
` effect: merge ${label} into main, then deploy main → ${deployTargets}.`,
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
// Operator targeting a PR that's already merged — nothing left to
|
|
98
|
+
// merge, so ship is just the deploy half.
|
|
99
|
+
lines.push(` effect: deploy main → ${deployTargets}.`);
|
|
100
|
+
}
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "retire": {
|
|
104
|
+
lines.push(` effect: evict ${label}'s preview environment + version (rebuildable).`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
default: {
|
|
108
|
+
lines.push(` effect: ${action} ${label}.`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** "build" → "Build-on-demand", "accept" → "Accept", "ship" → "Ship", "retire" → "Retire". Pure. */
|
|
116
|
+
function titleFor(action) {
|
|
117
|
+
if (action === "build") return "Build-on-demand";
|
|
118
|
+
if (action === "accept") return "Accept";
|
|
119
|
+
if (action === "ship") return "Ship";
|
|
120
|
+
if (action === "retire") return "Retire";
|
|
121
|
+
return action ? action[0].toUpperCase() + action.slice(1) : "Operation";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Render "preview (<url>) + live (<url>)", degrading gracefully when a URL is unknown. Pure. */
|
|
125
|
+
function deployTargetsLine(targets = {}) {
|
|
126
|
+
const preview = targets.preview ? `preview (${targets.preview})` : "preview";
|
|
127
|
+
const live = targets.live ? `live (${targets.live})` : "live";
|
|
128
|
+
return `${preview} + ${live}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Print a plan and gate on an explicit confirm — the CLI half of the shared
|
|
133
|
+
* affordance. Prints every line, a trailing blank line, then:
|
|
134
|
+
*
|
|
135
|
+
* - `yes: true` → confirmed immediately, no prompt (the verb's `--yes`).
|
|
136
|
+
* - a non-TTY (CI, piped) → refuses without prompting (never silently acts).
|
|
137
|
+
* - otherwise → asks `question` via `promptYesNo` (default NO unless the
|
|
138
|
+
* caller opts in with `defaultYes`).
|
|
139
|
+
*
|
|
140
|
+
* Returns a reason alongside the boolean so the caller can render its own
|
|
141
|
+
* house-style refusal/abort message (verbs differ: "nothing was built" vs
|
|
142
|
+
* "nothing shipped" etc.) — this helper only owns the plan + the gate.
|
|
143
|
+
*
|
|
144
|
+
* @param {string[]} planLines
|
|
145
|
+
* @param {{ yes?: boolean, question?: string, defaultYes?: boolean }} [opts]
|
|
146
|
+
* @returns {Promise<{ confirmed: boolean, reason: "yes-flag"|"confirmed"|"declined"|"non-tty" }>}
|
|
147
|
+
*/
|
|
148
|
+
export async function printPlanAndConfirm(planLines, { yes = false, question = "Proceed?", defaultYes = false } = {}) {
|
|
149
|
+
for (const line of planLines) console.log(line);
|
|
150
|
+
console.log("");
|
|
151
|
+
|
|
152
|
+
if (yes) return { confirmed: true, reason: "yes-flag" };
|
|
153
|
+
|
|
154
|
+
if (!isInteractive()) {
|
|
155
|
+
return { confirmed: false, reason: "non-tty" };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const ok = await promptYesNo(question, defaultYes);
|
|
159
|
+
return { confirmed: ok, reason: ok ? "confirmed" : "declined" };
|
|
160
|
+
}
|
package/src/sample.mjs
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* Dependency-free (node:fs + node:path only).
|
|
27
27
|
*/
|
|
28
28
|
import {
|
|
29
|
-
cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
|
|
29
|
+
appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync,
|
|
30
30
|
} from "node:fs";
|
|
31
31
|
import { homedir } from "node:os";
|
|
32
32
|
import { fileURLToPath } from "node:url";
|
|
@@ -41,6 +41,11 @@ const here = dirname(fileURLToPath(import.meta.url)); // packages/cli/src
|
|
|
41
41
|
* version-manager shell hooks land on a supported Node just by cd-ing into
|
|
42
42
|
* their store, and a plain `nvm use` works with no argument. Best-effort:
|
|
43
43
|
* never fails the checkout.
|
|
44
|
+
*
|
|
45
|
+
* When `dir` is a git working copy (true for `tot clone`, not for the
|
|
46
|
+
* non-git sample scaffold), the dropped file is also excluded LOCALLY
|
|
47
|
+
* (`.git/info/exclude`) so it doesn't leave a fresh `tot clone` dirty —
|
|
48
|
+
* `git status` right after cloning must read clean. See `excludeLocally`.
|
|
44
49
|
* @param {string} dir @param {NodeJS.ProcessEnv} [env]
|
|
45
50
|
*/
|
|
46
51
|
export function writeNvmrc(dir, env = process.env) {
|
|
@@ -48,11 +53,32 @@ export function writeNvmrc(dir, env = process.env) {
|
|
|
48
53
|
const p = join(dir, ".nvmrc");
|
|
49
54
|
if (existsSync(p)) return; // the store repo's own pin wins
|
|
50
55
|
writeFileSync(p, pickNvmrcVersion(env) + "\n");
|
|
56
|
+
excludeLocally(dir, ".nvmrc");
|
|
51
57
|
} catch {
|
|
52
58
|
/* a missing .nvmrc never blocks the loop */
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Add `pattern` to `<dir>/.git/info/exclude` — a LOCAL-only ignore list that
|
|
64
|
+
* never touches the repo's own committed `.gitignore` (so we don't mutate a
|
|
65
|
+
* tenant's tracked files just to keep our own convenience drop-in out of
|
|
66
|
+
* their way). No-op when `dir` isn't a git working copy, or `pattern` is
|
|
67
|
+
* already excluded (a repeat `tot clone` into the same dir, or the repo's
|
|
68
|
+
* own `.gitignore` already covering it — appending again would just be
|
|
69
|
+
* redundant, not wrong). Best-effort: never throws past its caller's `try`.
|
|
70
|
+
* @param {string} dir @param {string} pattern
|
|
71
|
+
*/
|
|
72
|
+
function excludeLocally(dir, pattern) {
|
|
73
|
+
const gitDir = join(dir, ".git");
|
|
74
|
+
if (!existsSync(gitDir) || !statSync(gitDir).isDirectory()) return; // no .git, or a submodule-style .git FILE — skip
|
|
75
|
+
const excludePath = join(gitDir, "info", "exclude");
|
|
76
|
+
const existing = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
|
|
77
|
+
if (existing.split("\n").some((l) => l.trim() === pattern)) return; // already excluded
|
|
78
|
+
mkdirSync(join(gitDir, "info"), { recursive: true });
|
|
79
|
+
appendFileSync(excludePath, (existing && !existing.endsWith("\n") ? "\n" : "") + pattern + "\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
56
82
|
/**
|
|
57
83
|
* The version `.nvmrc` should pin: the NEWEST Node the developer ALREADY has
|
|
58
84
|
* installed under nvm that meets the floor — so `nvm use` succeeds with zero
|