@tokenoftrust/cli 1.4.0-rc.11 → 1.4.0-rc.13
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 +33 -0
- package/package.json +1 -1
- package/src/commands/accept.mjs +247 -0
- package/src/commands/go-live.mjs +482 -0
- 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 +753 -43
- package/src/commands/submit.mjs +146 -46
- package/src/plan.mjs +160 -0
- package/src/sample.mjs +27 -1
package/src/commands/submit.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* From inside a tenant checkout:
|
|
5
5
|
* 1. validate locally and refuse on errors (fail fast before anything leaves the machine),
|
|
6
|
-
* 2. push your committed work to
|
|
6
|
+
* 2. push your committed work to YOUR OWN isolated candidate ref — `candidate/<changeId>`
|
|
7
|
+
* (b03 — no shared `preview` ref is ever force-pushed; an explicit `--ref` can still
|
|
8
|
+
* target a literal ref name for back-compat) — which triggers reconcile,
|
|
7
9
|
* 2b. open/update a PR-BACKED CANDIDATE for the same committed diff (g1b's
|
|
8
10
|
* `candidate_open`, unit c1 — the local-dev-loop half of the "PR-Backed Hosted
|
|
9
11
|
* Review Loop" milestone, symmetric with the hosted s6 draft-as-PR path), and
|
|
@@ -57,10 +59,25 @@ import {
|
|
|
57
59
|
} from "../candidate-state.mjs";
|
|
58
60
|
|
|
59
61
|
const DEFAULT_MCP_URL = "https://mcp.tokenoftrust.com";
|
|
60
|
-
|
|
62
|
+
|
|
63
|
+
// The git ref prefix an isolated candidate push lands under (b03 — stop
|
|
64
|
+
// force-pushing the SHARED `preview` ref). ONE constant so a rename is trivial —
|
|
65
|
+
// provisionally coordinated with the MCP-side candidate_open resolution (b02/b04),
|
|
66
|
+
// which already names its PR-backed branch `candidate/<changeId>` (see
|
|
67
|
+
// submitCandidate, below): the raw git push here and the PR-backed candidate it
|
|
68
|
+
// opens always target the SAME branch, never two.
|
|
69
|
+
export const CANDIDATE_REF_PREFIX = "candidate/";
|
|
70
|
+
|
|
71
|
+
/** The isolated git ref a candidate's preview push lands under. Pure. */
|
|
72
|
+
export function candidateRefFor(changeId) {
|
|
73
|
+
return `${CANDIDATE_REF_PREFIX}${changeId}`;
|
|
74
|
+
}
|
|
61
75
|
|
|
62
76
|
export function parseArgs(argv) {
|
|
63
|
-
|
|
77
|
+
// `ref: null` — an explicit `--ref` always wins; otherwise the push target is
|
|
78
|
+
// derived per-run as YOUR OWN isolated candidate ref (resolvePushRef, below),
|
|
79
|
+
// never a fixed shared default.
|
|
80
|
+
const a = { mcp: null, identity: null, ref: null, skipValidate: false, noWait: false, watch: false, noOpen: false, noCommit: false, message: null, summary: null, new: false, help: false };
|
|
64
81
|
for (let i = 0; i < argv.length; i++) {
|
|
65
82
|
const t = argv[i];
|
|
66
83
|
if (t === "--mcp") a.mcp = argv[++i];
|
|
@@ -93,7 +110,7 @@ export function renderUsage(verb = "preview") {
|
|
|
93
110
|
tot ${verb} --watch stay attached through reconcile + compliance + accept (long-poll)
|
|
94
111
|
tot ${verb} --skip-validate push without the local lint (not recommended)
|
|
95
112
|
tot ${verb} --no-commit don't auto-commit a dirty tree — preview only what's already committed
|
|
96
|
-
tot ${verb} --ref <name> push ref (default:
|
|
113
|
+
tot ${verb} --ref <name> push ref (default: your own isolated candidate ref — see \`tot pr\`)
|
|
97
114
|
tot ${verb} -m "<title>" one-line summary of what changed (the approver sees this)
|
|
98
115
|
tot ${verb} --summary "<text>" longer description to accompany the title
|
|
99
116
|
tot ${verb} --no-wait push and exit without polling for the reconcile result
|
|
@@ -513,6 +530,44 @@ export function actorKeyFor(session) {
|
|
|
513
530
|
return session?.email || session?.token || "developer";
|
|
514
531
|
}
|
|
515
532
|
|
|
533
|
+
/**
|
|
534
|
+
* Which candidate this submit lands on (gh-pr-like) — decided UP FRONT, before any
|
|
535
|
+
* network call, because it also determines the isolated git ref we push to
|
|
536
|
+
* (resolvePushRef, below): a re-submit updates the SAME candidate/ref by default;
|
|
537
|
+
* `--new` forks a fresh one.
|
|
538
|
+
* --new → fork a FRESH candidate id;
|
|
539
|
+
* otherwise → the remembered active candidate (from a prior --new / terminal
|
|
540
|
+
* roll), else the STABLE per-dev-per-tenant(-per-branch) default.
|
|
541
|
+
* `persist` reports whether the choice diverges from the stable default, so the
|
|
542
|
+
* caller knows whether to remember it as the new active pointer. `mint` is
|
|
543
|
+
* injected (defaults to mintFreshChangeId) so this is pure/deterministic in tests.
|
|
544
|
+
* Pure — unit-tested.
|
|
545
|
+
* @param {{ tenant: string, actorKey: string, branch?: string|null, active?: string|null,
|
|
546
|
+
* isNew?: boolean, mint?: (baseId: string) => string }} opts
|
|
547
|
+
* @returns {{ changeId: string, stableId: string, persist: boolean }}
|
|
548
|
+
*/
|
|
549
|
+
export function chooseChangeId({ tenant, actorKey, branch = null, active = null, isNew = false, mint = mintFreshChangeId }) {
|
|
550
|
+
const stableId = deriveChangeId(tenant, actorKey, branch);
|
|
551
|
+
const changeId = isNew ? mint(stableId) : (active || stableId);
|
|
552
|
+
const persist = isNew || (!!active && active !== stableId);
|
|
553
|
+
return { changeId, stableId, persist };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* The ref `tot preview`/`tot submit` pushes to (b03 — stop force-pushing the
|
|
558
|
+
* SHARED `preview` ref). An explicit `--ref` always wins — the escape hatch /
|
|
559
|
+
* back-compat path (e.g. `--ref preview` reproduces the old shared-ref push for
|
|
560
|
+
* any tooling that still reads it by that literal name); otherwise it's YOUR OWN
|
|
561
|
+
* isolated candidate ref, so two developers — or the same developer on two
|
|
562
|
+
* branches — never force-push over each other or each other's preview. Pure —
|
|
563
|
+
* unit-tested.
|
|
564
|
+
* @param {{ ref?: string|null, changeId: string }} opts
|
|
565
|
+
* @returns {string}
|
|
566
|
+
*/
|
|
567
|
+
export function resolvePushRef({ ref, changeId }) {
|
|
568
|
+
return ref || candidateRefFor(changeId);
|
|
569
|
+
}
|
|
570
|
+
|
|
516
571
|
/**
|
|
517
572
|
* Open/update the PR-backed candidate for this submit (g1b `candidate_open`,
|
|
518
573
|
* unit c1 — the local-dev-loop half of the "PR-Backed Hosted Review Loop"
|
|
@@ -663,6 +718,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
663
718
|
// same diff (name-status, so candidate_open also knows adds/deletes/renames)
|
|
664
719
|
// doubles as the source of the PR-backed candidate's file patch (step 2b, below)
|
|
665
720
|
// — one git read, two consumers, so the PR always matches what's printed here.
|
|
721
|
+
// Parameterized on `ref` (b03 — isolated candidate refs): the diff base is YOUR
|
|
722
|
+
// candidate ref's own tracking ref, not a shared one, so the summary always reads
|
|
723
|
+
// "vs your own last push" once the push ref is known (computed just below).
|
|
666
724
|
const gitSafe = (cargs) => {
|
|
667
725
|
try {
|
|
668
726
|
return git(cargs);
|
|
@@ -670,18 +728,21 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
670
728
|
return "";
|
|
671
729
|
}
|
|
672
730
|
};
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
731
|
+
function buildSummaryAndPatch(ref) {
|
|
732
|
+
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
733
|
+
const trackingRef = `refs/remotes/origin/${ref}`;
|
|
734
|
+
const base = gitSafe(["rev-parse", "--verify", "--quiet", trackingRef]).trim()
|
|
735
|
+
? trackingRef
|
|
736
|
+
: gitSafe(["rev-parse", "--verify", "--quiet", "HEAD~1"]).trim()
|
|
737
|
+
? "HEAD~1"
|
|
738
|
+
: "";
|
|
739
|
+
const statusCmd = base ? ["diff", "--name-status", `${base}..HEAD`] : ["show", "--name-status", "--format=", "HEAD"];
|
|
740
|
+
const patchEntries = parseNameStatus(gitSafe(statusCmd));
|
|
741
|
+
const files = patchEntries.map((e) => e.path);
|
|
742
|
+
const statLine = base ? gitSafe(["diff", "--shortstat", `${base}..HEAD`]).trim() : "";
|
|
743
|
+
const changeSummary = buildChangeSummary({ message: args.message, summary: args.summary, headSubject, statLine, files });
|
|
744
|
+
return { changeSummary, patchEntries };
|
|
745
|
+
}
|
|
685
746
|
|
|
686
747
|
// The MCP session is needed BOTH to mint a fresh forge push credential (decision
|
|
687
748
|
// B — right below) and for the candidate/preview read-back after, so establish it
|
|
@@ -690,6 +751,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
690
751
|
const client = createMcpClient(baseUrl);
|
|
691
752
|
const repo = repoNameFromRemote(gitSafe(["remote", "get-url", "origin"]).trim());
|
|
692
753
|
|
|
754
|
+
// Branch-bound (u4) + the isolated candidate ref (b03): resolved BEFORE the push,
|
|
755
|
+
// since the push target itself depends on it — so the raw git push and the
|
|
756
|
+
// PR-backed candidate (step 2b, below) always land on the SAME branch. `active`/
|
|
757
|
+
// `statePath` are local filesystem reads (candidate-state.mjs) — no session
|
|
758
|
+
// needed — so they're available even on the no-session fallback path below.
|
|
759
|
+
const branch = currentBranch(gitSafe);
|
|
760
|
+
const statePath = defaultCandidateStatePath(env);
|
|
761
|
+
const active = repo ? readActiveChangeId(statePath, { mcpUrl: baseUrl, repo, branch }) : null;
|
|
762
|
+
|
|
693
763
|
let session;
|
|
694
764
|
try {
|
|
695
765
|
session = await establishSession(client, { env, prefer: args.identity || undefined });
|
|
@@ -697,10 +767,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
697
767
|
// Not signed in / MCP unreachable — we can't mint a fresh credential, so fall
|
|
698
768
|
// back to pushing over the checkout's EXISTING embedded remote (pre-B behavior:
|
|
699
769
|
// 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
|
-
|
|
770
|
+
// still lands if that embedded token is live. actorKeyFor(null) degrades to the
|
|
771
|
+
// generic "developer" key — still isolated PER BRANCH (never the shared ref),
|
|
772
|
+
// just not per-developer until sign-in succeeds.
|
|
773
|
+
const { changeId } = chooseChangeId({ tenant, actorKey: actorKeyFor(null), branch, active, isNew: args.new });
|
|
774
|
+
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
775
|
+
const { changeSummary } = buildSummaryAndPatch(ref);
|
|
776
|
+
console.error(`~ pushing ${short} → ${ref} (origin)`);
|
|
702
777
|
try {
|
|
703
|
-
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${
|
|
778
|
+
const out = git(["push", "-f", "origin", `HEAD:refs/heads/${ref}`]);
|
|
704
779
|
if (out.trim()) console.error(redactUrl(out.trim()));
|
|
705
780
|
} catch (pushErr) {
|
|
706
781
|
console.error(
|
|
@@ -711,7 +786,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
711
786
|
);
|
|
712
787
|
return 1;
|
|
713
788
|
}
|
|
714
|
-
console.log(`\n+ submitted ${short} to ${
|
|
789
|
+
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
715
790
|
printChangeSummary(changeSummary);
|
|
716
791
|
if (e instanceof AuthUnavailableError) {
|
|
717
792
|
console.log(` (sign in to see the reconcile/compliance/preview result — ${e.hint || "developer sign-in pending"})`);
|
|
@@ -722,6 +797,15 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
722
797
|
return 0;
|
|
723
798
|
}
|
|
724
799
|
|
|
800
|
+
// Which candidate (and therefore which isolated ref, b03) this submit targets —
|
|
801
|
+
// decided now, with a real session, so the SAME id backs both the raw git push
|
|
802
|
+
// (right below) and the PR-backed candidate (step 2b): the two never point at
|
|
803
|
+
// different branches. See chooseChangeId's doc for the --new / active-pointer
|
|
804
|
+
// rules.
|
|
805
|
+
let { changeId, stableId, persist } = chooseChangeId({ tenant, actorKey: actorKeyFor(session), branch, active, isNew: args.new });
|
|
806
|
+
const ref = resolvePushRef({ ref: args.ref, changeId });
|
|
807
|
+
const { changeSummary, patchEntries } = buildSummaryAndPatch(ref);
|
|
808
|
+
|
|
725
809
|
// 2. push the preview ref with a FRESHLY-MINTED, short-lived forge credential
|
|
726
810
|
// (decision B). The token baked into `.git/config` at clone time expires within
|
|
727
811
|
// hours, so we re-mint right before the push and hand it to git ephemerally
|
|
@@ -741,9 +825,9 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
741
825
|
}
|
|
742
826
|
};
|
|
743
827
|
|
|
744
|
-
console.error(`~ pushing ${short} → ${
|
|
828
|
+
console.error(`~ pushing ${short} → ${ref} (origin, fresh credential)`);
|
|
745
829
|
try {
|
|
746
|
-
const { out } = await pushPreviewRef(git, mintRemote, { ref
|
|
830
|
+
const { out } = await pushPreviewRef(git, mintRemote, { ref });
|
|
747
831
|
if (out && out.trim()) console.error(redactUrl(out.trim()));
|
|
748
832
|
} catch (e) {
|
|
749
833
|
console.error(
|
|
@@ -754,7 +838,7 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
754
838
|
);
|
|
755
839
|
return 1;
|
|
756
840
|
}
|
|
757
|
-
console.log(`\n+ submitted ${short} to ${
|
|
841
|
+
console.log(`\n+ submitted ${short} to ${ref}.`);
|
|
758
842
|
printChangeSummary(changeSummary);
|
|
759
843
|
|
|
760
844
|
// 2b + 3. open/update the PR-backed candidate, then report reconcile +
|
|
@@ -768,25 +852,11 @@ export async function run(argv, ctx, { verb = "preview" } = {}) {
|
|
|
768
852
|
// 2b. PR-backed candidate (g1b candidate_open, unit c1) — best-effort: a
|
|
769
853
|
// failure here (older MCP, VC not configured, preview-access capability) is
|
|
770
854
|
// reported and swallowed, never blocking the preview push that already landed.
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
// roll), else the STABLE per-dev-per-tenant default.
|
|
776
|
-
// If the chosen candidate turns out to be merged/closed, roll to a fresh one
|
|
777
|
-
// so a re-submit is never wedged on a dead PR. (`repo` was derived above.)
|
|
855
|
+
// `changeId`/`stableId`/`persist` were already decided above (they picked the
|
|
856
|
+
// push ref too); if the chosen candidate turns out to be merged/closed, roll to
|
|
857
|
+
// a fresh one so a re-submit is never wedged on a dead PR. (`repo` was derived
|
|
858
|
+
// above.)
|
|
778
859
|
const readBlob = (path) => execFileSync("git", ["-C", workspace, "show", `HEAD:${path}`], { stdio: ["ignore", "pipe", "pipe"] });
|
|
779
|
-
const statePath = defaultCandidateStatePath(env);
|
|
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;
|
|
786
|
-
let changeId = args.new ? mintFreshChangeId(stableId) : (active || stableId);
|
|
787
|
-
// Persist when we diverge from the stable default (a --new fork, or a
|
|
788
|
-
// previously-remembered active pointer) so the next plain submit follows it.
|
|
789
|
-
let persist = args.new || (!!active && active !== stableId);
|
|
790
860
|
|
|
791
861
|
let candidate = await submitCandidate(client, { repo, changeId, changeSummary, patchEntries, readBlob });
|
|
792
862
|
|
|
@@ -923,6 +993,38 @@ function reportComplianceCheck(c) {
|
|
|
923
993
|
if (c.hint) console.log(` → fix: ${c.hint}`);
|
|
924
994
|
}
|
|
925
995
|
|
|
996
|
+
/**
|
|
997
|
+
* Build the printed lines for the headline "share this with your reviewer" block —
|
|
998
|
+
* the whole point of U14: on a successful preview, the SHAREABLE deep link
|
|
999
|
+
* (`https://storefront.tokenoftrust.store/preview/<tenant>/pr/<N>`, built server-side
|
|
1000
|
+
* by the reconcile report and threaded through as `previewUrl`) is what a developer
|
|
1001
|
+
* hands to a teammate, NOT the internal Gitea PR url `reportCandidate` prints earlier
|
|
1002
|
+
* in the flow (step 2b) — that one stays as-is, secondary, for the developer's own
|
|
1003
|
+
* reference. Labeled prominently + paired with an honest auth caveat: the share
|
|
1004
|
+
* target is a TEAMMATE WITH STORE ACCESS (member/staff of the tenant), never a
|
|
1005
|
+
* public/anonymous link.
|
|
1006
|
+
*
|
|
1007
|
+
* Degrades gracefully when `previewUrl` isn't (yet) on the status result — an older
|
|
1008
|
+
* MCP, or a reconcile that hasn't finished minting it — with a note instead of a
|
|
1009
|
+
* crash or a silent blank. Pure — unit-tested.
|
|
1010
|
+
* @param {{ previewUrl?: string|null, status?: string }|null} s
|
|
1011
|
+
* @param {string} tenant
|
|
1012
|
+
* @returns {string[]}
|
|
1013
|
+
*/
|
|
1014
|
+
export function formatShareableUrlBlock(s, tenant) {
|
|
1015
|
+
if (s?.previewUrl) {
|
|
1016
|
+
return [
|
|
1017
|
+
`\n ✓ Preview ready — share this with your reviewer:`,
|
|
1018
|
+
` ${s.previewUrl}`,
|
|
1019
|
+
` ℹ your reviewer needs store access (a member/staff of ${tenant}) to view it — it's not a public link.`,
|
|
1020
|
+
];
|
|
1021
|
+
}
|
|
1022
|
+
if (s?.status === "reconciled") {
|
|
1023
|
+
return [`\n ~ reconciled, but no shareable preview URL yet — it'll show up here once available.`];
|
|
1024
|
+
}
|
|
1025
|
+
return [];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
926
1028
|
/**
|
|
927
1029
|
* Print the reconcile/compliance/preview result and, on a clean reconcile with a
|
|
928
1030
|
* preview URL, open it in the browser (unless opts.open === false).
|
|
@@ -956,10 +1058,8 @@ function reportStatus(s, tenant, { open = true } = {}) {
|
|
|
956
1058
|
} else if (s.status === "reconciled") {
|
|
957
1059
|
console.log(`\n ~ not yet shipped — a reviewer still needs to run change_accept.`);
|
|
958
1060
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
console.log(" (opened in your browser)");
|
|
963
|
-
}
|
|
1061
|
+
for (const line of formatShareableUrlBlock(s, tenant)) console.log(line);
|
|
1062
|
+
if (s.previewUrl && open && s.status === "reconciled" && openBrowser(s.previewUrl)) {
|
|
1063
|
+
console.log(" (opened in your browser)");
|
|
964
1064
|
}
|
|
965
1065
|
}
|
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
|