@tokenoftrust/cli 1.4.0-rc.12 → 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/package.json +1 -1
- package/src/commands/ship.mjs +368 -6
- package/src/commands/submit.mjs +111 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "1.4.0-rc.
|
|
3
|
+
"version": "1.4.0-rc.13",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/ship.mjs
CHANGED
|
@@ -86,8 +86,10 @@ const USAGE = `tot ship — deploy main → preview + live (context-dependent)
|
|
|
86
86
|
tot ship ship the preview you last pushed with \`tot preview\`
|
|
87
87
|
tot ship <N> DEVELOPER: ship PR #N from your OWN checkout
|
|
88
88
|
tot ship pr/<N> (same — also accepts \`pr#N\`, \`#N\`)
|
|
89
|
-
tot ship --pr <N> --tenant <t> OPERATOR:
|
|
89
|
+
tot ship --pr <N> --tenant <t> OPERATOR: ship ANY built PR (not just your checkout)
|
|
90
90
|
tot ship --change-id <chg_…> target an explicit change record (operator targeting)
|
|
91
|
+
tot ship --secret <s> operator secret (prefer the env vars below)
|
|
92
|
+
tot ship --url <origin> storefront origin (default: env TOT_STOREFRONT_URL)
|
|
91
93
|
tot ship --yes confirm non-interactively (skip the [y/N] prompt)
|
|
92
94
|
tot ship --identity <id> sign in as a specific identity for this ship
|
|
93
95
|
tot ship --mcp <url> MCP base URL (default: env MCP_BASE_URL / TOT_MCP_URL)
|
|
@@ -98,8 +100,14 @@ const USAGE = `tot ship — deploy main → preview + live (context-dependent)
|
|
|
98
100
|
this branch/PR into main) AND THEN DEPLOY (main → preview + live). A POSITIONAL
|
|
99
101
|
\`tot ship 4\` / \`tot ship pr/4\` names the PR to ship — same developer semantics,
|
|
100
102
|
the tenant is inferred from the checkout.
|
|
101
|
-
• OPERATOR — target a
|
|
102
|
-
|
|
103
|
+
• OPERATOR — target a PR that ISN'T your checkout (\`tot ship 4\` with no local
|
|
104
|
+
candidate, or an explicit \`--pr N --tenant t\`): ship resolves PR N → the built
|
|
105
|
+
candidate from the SHARED queue (GET /api/changes) and ships it via the atomic
|
|
106
|
+
accept (POST /api/changes/accept). This needs an OPERATOR SECRET (env below).
|
|
107
|
+
If PR N has no built candidate, build it first with \`tot preview build\`.
|
|
108
|
+
|
|
109
|
+
Operator secret (from env, first found): PREVIEW_RECONCILE_SECRET,
|
|
110
|
+
GRANTS_ADMIN_SECRET, TOT_OPERATOR_SECRET (or pass --secret).
|
|
103
111
|
|
|
104
112
|
It NEVER silently guesses: it prints the EXACT plan for THAT context and confirms
|
|
105
113
|
first (\`--yes\` to confirm non-interactively; a non-TTY without \`--yes\` refuses).
|
|
@@ -136,6 +144,7 @@ export function parseShipArgs(argv) {
|
|
|
136
144
|
changeId: null,
|
|
137
145
|
headSha: null,
|
|
138
146
|
url: null,
|
|
147
|
+
secret: null,
|
|
139
148
|
help: false,
|
|
140
149
|
};
|
|
141
150
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -149,6 +158,7 @@ export function parseShipArgs(argv) {
|
|
|
149
158
|
else if (t === "--change-id") a.changeId = argv[++i];
|
|
150
159
|
else if (t === "--head-sha" || t === "--head") a.headSha = argv[++i];
|
|
151
160
|
else if (t === "--url") a.url = argv[++i];
|
|
161
|
+
else if (t === "--secret") a.secret = argv[++i];
|
|
152
162
|
else if (t === "--help" || t === "-h") a.help = true;
|
|
153
163
|
else if (!t.startsWith("-") && a.pr == null) {
|
|
154
164
|
// A POSITIONAL PR reference (`tot ship 4` / `tot ship pr/4` / `tot ship #4`).
|
|
@@ -545,7 +555,11 @@ export async function pollChangeShipped(client, { id, tenant }, { attempts = 6,
|
|
|
545
555
|
* changeSummary?:{ title?:string, body?:string[] }, expectedPr?:number|string|null }} params
|
|
546
556
|
* @param {{ confirmPlan?:typeof printPlanAndConfirm,
|
|
547
557
|
* poll?:typeof pollChangeShipped, openUrl?:(u:string)=>boolean, progress?:boolean,
|
|
548
|
-
* resolveRecord?:typeof resolveChangeRecordId
|
|
558
|
+
* resolveRecord?:typeof resolveChangeRecordId,
|
|
559
|
+
* shipByPrFallback?:()=>Promise<number> }} [deps]
|
|
560
|
+
* shipByPrFallback (u16) — when a POSITIONAL `tot ship <N>` finds NO candidate for
|
|
561
|
+
* this checkout, invoke this instead of the "no active candidate" dead-end: it ships
|
|
562
|
+
* the built PR from the shared queue (the operator-by-PR path).
|
|
549
563
|
* @returns {Promise<number>} process exit code
|
|
550
564
|
*/
|
|
551
565
|
export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes = false, storefrontUrl = null, changeSummary, expectedPr = null }, deps = {}) {
|
|
@@ -597,6 +611,16 @@ export async function runShip(client, { tenant, changeId, repo, git, noOpen, yes
|
|
|
597
611
|
: NO_REVIEW;
|
|
598
612
|
const gate = shipReadiness(candidate, review);
|
|
599
613
|
|
|
614
|
+
// u16: `tot ship <N>` in a checkout that has NO candidate matching this PR (the PR
|
|
615
|
+
// isn't yours) — fall through to the OPERATOR-by-PR queue path (resolve PR→changeId
|
|
616
|
+
// from GET /api/changes, ship via POST /api/changes/accept) instead of dead-ending on
|
|
617
|
+
// "no active candidate". Only fires for a POSITIONAL PR (expectedPr) with the fallback
|
|
618
|
+
// wired (run() supplies it). The U15 different-PR guard (step 1a) still REFUSES — this
|
|
619
|
+
// is the distinct "nothing to ship from THIS checkout" case, not a mismatch.
|
|
620
|
+
if (gate.kind === "no-candidate" && expectedPr != null && deps.shipByPrFallback) {
|
|
621
|
+
return await deps.shipByPrFallback();
|
|
622
|
+
}
|
|
623
|
+
|
|
600
624
|
if (gate.kind === "unauthorized") {
|
|
601
625
|
return await handleUnauthorized(client, { id, tenant, approvers: review.approvers });
|
|
602
626
|
}
|
|
@@ -732,6 +756,302 @@ export function liveUrlFor(tenant) {
|
|
|
732
756
|
return t ? `https://${t}` : null;
|
|
733
757
|
}
|
|
734
758
|
|
|
759
|
+
// ─── Operator ship-by-PR via the SHARED candidate queue (unit U16) ──────────────────
|
|
760
|
+
//
|
|
761
|
+
// The real user report: `tot ship 4` for a PR the operator did NOT author fails
|
|
762
|
+
// `✗ no active candidate to ship for this checkout` — U15's developer path resolves
|
|
763
|
+
// the candidate from the LOCAL git checkout, so it can't ship a PR that isn't yours,
|
|
764
|
+
// and U15's operator path needed an explicit `--change-id` (it couldn't resolve
|
|
765
|
+
// PR→changeId — the `// u9:` gap).
|
|
766
|
+
//
|
|
767
|
+
// The insight this unit builds on (NO forge PR-read needed): a BUILT PR already has a
|
|
768
|
+
// `ReviewEnvironment` in the candidate index, and `GET /api/changes` (the operator
|
|
769
|
+
// accept queue) returns the tenant's OPEN candidates — each with `changeId` +
|
|
770
|
+
// `prNumber` + `headSha`. So PR N → changeId is resolvable from the QUEUE. The ship is
|
|
771
|
+
// then the SAME atomic merge→main→reconcile→promote-live the `/admin` Accept button
|
|
772
|
+
// drives — `POST /api/changes/accept` with `{ repo:<appDomain>, changeId,
|
|
773
|
+
// expectedHeadSha }` — NOT the MCP `change_accept` (that's chg_-record-id-keyed +
|
|
774
|
+
// checkout-bound). The go-live HUMAN GATE stays the confirm.
|
|
775
|
+
//
|
|
776
|
+
// AUTH (investigated): `resolveOwnerSession` (apps/storefront/src/lib/grants/session.ts)
|
|
777
|
+
// accepts a SIGNED-IN storefront `tot_session` cookie (path 1) OR the Bearer operator
|
|
778
|
+
// secret + `X-Tot-Owner` (path 2). The CLI holds NO storefront cookie — its OAuth login
|
|
779
|
+
// is with the MCP, a different trust boundary — so the signed-in/OAuth path can't apply
|
|
780
|
+
// here. The Bearer operator-secret path is the CLI's ONLY route, exactly mirroring
|
|
781
|
+
// `tot preview build`'s transport (Bearer + `X-Tot-Owner` + `x-tot-capability`).
|
|
782
|
+
|
|
783
|
+
const OPERATOR_SECRET_ENV = ["PREVIEW_RECONCILE_SECRET", "GRANTS_ADMIN_SECRET", "TOT_OPERATOR_SECRET"];
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Resolve the operator secret from an explicit `--secret` or the env (first found),
|
|
787
|
+
* matching `tot preview build`'s precedence. Pure given its env argument.
|
|
788
|
+
* @param {string|null|undefined} explicit
|
|
789
|
+
* @param {NodeJS.ProcessEnv} env
|
|
790
|
+
* @returns {string}
|
|
791
|
+
*/
|
|
792
|
+
export function resolveOperatorSecret(explicit, env = {}) {
|
|
793
|
+
if (explicit && `${explicit}`.trim()) return `${explicit}`.trim();
|
|
794
|
+
for (const k of OPERATOR_SECRET_ENV) {
|
|
795
|
+
const v = env[k];
|
|
796
|
+
if (v && `${v}`.trim()) return `${v}`.trim();
|
|
797
|
+
}
|
|
798
|
+
return "";
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Normalise a `GET /api/changes` body to the OPEN candidate list. The endpoint
|
|
803
|
+
* answers `{ owner, capability, current, changes }` where `changes` is the open
|
|
804
|
+
* `ReviewEnvironment` queue; read defensively (tolerate a bare array or a `{ changes }`
|
|
805
|
+
* wrapper) and keep only records carrying a `changeId`. Pure — unit-tested.
|
|
806
|
+
* @param {any} data
|
|
807
|
+
* @returns {Array<{changeId:string, prNumber?:number|null, headSha?:string|null, previewUrl?:string|null}>}
|
|
808
|
+
*/
|
|
809
|
+
export function normalizeChangesQueue(data) {
|
|
810
|
+
const list = Array.isArray(data)
|
|
811
|
+
? data
|
|
812
|
+
: data && Array.isArray(data.changes)
|
|
813
|
+
? data.changes
|
|
814
|
+
: [];
|
|
815
|
+
return list.filter((c) => c && typeof c === "object" && typeof c.changeId === "string");
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Find the OPEN candidate whose forge PR number === `pr` and return the descriptor
|
|
820
|
+
* the by-PR ship needs (`{ changeId, headSha, prNumber, previewUrl }`), else null when
|
|
821
|
+
* no built candidate matches. Pure — unit-tested.
|
|
822
|
+
* @param {ReturnType<typeof normalizeChangesQueue>} changes
|
|
823
|
+
* @param {number|string} pr
|
|
824
|
+
* @returns {{changeId:string, headSha:string|null, prNumber:number, previewUrl:string|null}|null}
|
|
825
|
+
*/
|
|
826
|
+
export function findCandidateByPr(changes, pr) {
|
|
827
|
+
const want = Number(pr);
|
|
828
|
+
if (!Number.isFinite(want)) return null;
|
|
829
|
+
for (const c of changes || []) {
|
|
830
|
+
if (c.prNumber != null && Number(c.prNumber) === want) {
|
|
831
|
+
return {
|
|
832
|
+
changeId: c.changeId,
|
|
833
|
+
headSha: c.headSha ?? null,
|
|
834
|
+
prNumber: want,
|
|
835
|
+
previewUrl: c.previewUrl ?? null,
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Normalise a `POST /api/changes/accept` body to { shipped, pending, state, sha,
|
|
844
|
+
* message, error }. The atomic-accept endpoint reports an HONEST terminal `state`:
|
|
845
|
+
* `shipped` (and `live:true`) ONLY when the target is verified live; `publish_pending`
|
|
846
|
+
* = merged + dispatched but NOT yet verified (never a live claim). Pure — unit-tested.
|
|
847
|
+
* @param {any} data
|
|
848
|
+
*/
|
|
849
|
+
export function normalizeAcceptResponse(data) {
|
|
850
|
+
const o = data && typeof data === "object" ? data : {};
|
|
851
|
+
const state = typeof o.state === "string" ? o.state : null;
|
|
852
|
+
const shipped = state === "shipped" || o.live === true;
|
|
853
|
+
return {
|
|
854
|
+
shipped,
|
|
855
|
+
pending: !shipped && (state === "publish_pending" || o.merged === true),
|
|
856
|
+
state,
|
|
857
|
+
sha: typeof o.sha === "string" ? o.sha : null,
|
|
858
|
+
message: typeof o.message === "string" ? o.message : null,
|
|
859
|
+
error: typeof o.error === "string" ? o.error : null,
|
|
860
|
+
raw: data,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* The OPERATOR ship-by-PR flow (unit U16). Ships ANY built PR — not just your
|
|
866
|
+
* checkout — with NO forge PR-read:
|
|
867
|
+
*
|
|
868
|
+
* 1. RESOLVE PR N → { changeId, headSha } by reading the tenant's OPEN candidate
|
|
869
|
+
* queue (`GET /api/changes`) and matching `prNumber === N`. If N isn't in the
|
|
870
|
+
* queue the PR has no built candidate → instruct `tot preview build` first (we
|
|
871
|
+
* NEVER invent a changeId).
|
|
872
|
+
* 2. PLAN + CONFIRM — the go-live HUMAN GATE. `POST /api/changes/accept` is the
|
|
873
|
+
* atomic merge→main + reconcile + promote-live, so the plan states BOTH (the
|
|
874
|
+
* developer-context wording, mirroring the `/admin` Accept plan). `--yes` confirms
|
|
875
|
+
* non-interactively; a non-TTY without `--yes` REFUSES — nothing merges/goes live
|
|
876
|
+
* without an explicit yes.
|
|
877
|
+
* 3. SHIP via `POST /api/changes/accept` `{ repo:<tenant appDomain>, changeId,
|
|
878
|
+
* expectedHeadSha }` — the SAME endpoint the `/admin` Accept button uses.
|
|
879
|
+
*
|
|
880
|
+
* Transport mirrors `tot preview build` exactly (Bearer operator secret + `X-Tot-Owner`
|
|
881
|
+
* + `x-tot-capability: ship-on-behalf`). `fetch`/confirm/openUrl/progress are injected
|
|
882
|
+
* so it's unit-tested with no network/TTY.
|
|
883
|
+
*
|
|
884
|
+
* @param {{ tenant:string, pr:number|string|null, secret:string, storefrontUrl?:string|null,
|
|
885
|
+
* noOpen?:boolean, yes?:boolean }} params
|
|
886
|
+
* @param {{ fetch?:typeof fetch, confirmPlan?:typeof printPlanAndConfirm,
|
|
887
|
+
* openUrl?:(u:string)=>boolean, progress?:boolean }} [deps]
|
|
888
|
+
* @returns {Promise<number>} process exit code
|
|
889
|
+
*/
|
|
890
|
+
export async function runShipByPr(
|
|
891
|
+
{ tenant, pr, secret, storefrontUrl = null, noOpen = false, yes = false },
|
|
892
|
+
deps = {},
|
|
893
|
+
) {
|
|
894
|
+
const fetchImpl = deps.fetch || globalThis.fetch;
|
|
895
|
+
const confirmPlan = deps.confirmPlan || printPlanAndConfirm;
|
|
896
|
+
const base = (storefrontUrl || DEFAULT_STOREFRONT_URL).trim().replace(/\/+$/, "");
|
|
897
|
+
|
|
898
|
+
const want = pr != null && `${pr}`.trim() && Number.isFinite(Number(pr)) ? Number(pr) : null;
|
|
899
|
+
if (want == null) {
|
|
900
|
+
console.error(
|
|
901
|
+
fail(
|
|
902
|
+
"operator ship needs a PR number to resolve the built candidate",
|
|
903
|
+
"pass a PR — `tot ship <N>` or `tot ship --pr <N> --tenant <appDomain>`",
|
|
904
|
+
),
|
|
905
|
+
);
|
|
906
|
+
return 2;
|
|
907
|
+
}
|
|
908
|
+
if (!secret) {
|
|
909
|
+
console.error(
|
|
910
|
+
fail(
|
|
911
|
+
"shipping a PR that isn't your checkout is an OPERATOR action — it needs an operator secret",
|
|
912
|
+
"set PREVIEW_RECONCILE_SECRET (or GRANTS_ADMIN_SECRET / TOT_OPERATOR_SECRET), or pass --secret",
|
|
913
|
+
),
|
|
914
|
+
);
|
|
915
|
+
return 2;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const authHeaders = {
|
|
919
|
+
authorization: `Bearer ${secret}`,
|
|
920
|
+
"x-tot-owner": tenant,
|
|
921
|
+
"x-tot-capability": "ship-on-behalf",
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// 1. Resolve PR N → { changeId, headSha } from the tenant's OPEN candidate queue.
|
|
925
|
+
let queueRes;
|
|
926
|
+
try {
|
|
927
|
+
queueRes = await fetchImpl(`${base}/api/changes`, { method: "GET", headers: authHeaders });
|
|
928
|
+
} catch (e) {
|
|
929
|
+
console.error(
|
|
930
|
+
fail(`couldn't reach the candidate queue at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
931
|
+
);
|
|
932
|
+
return 1;
|
|
933
|
+
}
|
|
934
|
+
const queueData = await readJsonSafe(queueRes);
|
|
935
|
+
if (!queueRes.ok) {
|
|
936
|
+
const msg = queueData?.error || `HTTP ${queueRes.status}`;
|
|
937
|
+
console.error(
|
|
938
|
+
fail(
|
|
939
|
+
`the candidate queue refused the request: ${msg}`,
|
|
940
|
+
queueRes.status === 401 || queueRes.status === 403
|
|
941
|
+
? "check the operator secret and that it's authorised for this tenant"
|
|
942
|
+
: "check --tenant / --url, then re-run",
|
|
943
|
+
),
|
|
944
|
+
);
|
|
945
|
+
return 1;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const candidate = findCandidateByPr(normalizeChangesQueue(queueData), want);
|
|
949
|
+
if (!candidate) {
|
|
950
|
+
const previewUrl = previewUrlFor(base, tenant, want);
|
|
951
|
+
console.error(
|
|
952
|
+
fail(
|
|
953
|
+
`PR #${want} has no built candidate in ${tenant}'s queue — there's nothing to ship`,
|
|
954
|
+
`build it first: \`tot preview build --tenant ${tenant} --pr ${want}\`, then \`tot ship ${want} --tenant ${tenant}\``,
|
|
955
|
+
),
|
|
956
|
+
);
|
|
957
|
+
if (previewUrl) console.error(` Once built it appears at: ${previewUrl}`);
|
|
958
|
+
return 1;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// 2. Plan + confirm — the go-live HUMAN GATE. `POST /api/changes/accept` is the
|
|
962
|
+
// ATOMIC merge→main + promote-live, so the plan narrates BOTH (developer-context
|
|
963
|
+
// wording — the same "merge … then deploy" the /admin Accept plan renders).
|
|
964
|
+
const previewUrl = candidate.previewUrl || previewUrlFor(base, tenant, want);
|
|
965
|
+
const planLines = planForAction({
|
|
966
|
+
action: "ship",
|
|
967
|
+
context: "developer",
|
|
968
|
+
tenant,
|
|
969
|
+
pr: want,
|
|
970
|
+
changeId: candidate.changeId,
|
|
971
|
+
headSha: candidate.headSha,
|
|
972
|
+
targets: { preview: previewUrl, live: liveUrlFor(tenant) },
|
|
973
|
+
});
|
|
974
|
+
const { confirmed, reason } = await confirmPlan(planLines, {
|
|
975
|
+
yes,
|
|
976
|
+
question: `Ship PR #${want} live to ${tenant} (merge → main, then go live)?`,
|
|
977
|
+
});
|
|
978
|
+
if (!confirmed) {
|
|
979
|
+
if (reason === "non-tty") {
|
|
980
|
+
console.error(
|
|
981
|
+
fail(
|
|
982
|
+
"`tot ship` needs an interactive terminal to confirm this live change",
|
|
983
|
+
"re-run from a terminal, or pass --yes to confirm non-interactively",
|
|
984
|
+
),
|
|
985
|
+
);
|
|
986
|
+
return 2;
|
|
987
|
+
}
|
|
988
|
+
console.log(" Ship cancelled — nothing changed.");
|
|
989
|
+
return 0;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// 3. Accept it via the SAME endpoint the /admin Accept button uses (repo=appDomain).
|
|
993
|
+
const progress = deps.progress === false ? null : startProgress("shipping…");
|
|
994
|
+
let acceptRes;
|
|
995
|
+
try {
|
|
996
|
+
acceptRes = await fetchImpl(`${base}/api/changes/accept`, {
|
|
997
|
+
method: "POST",
|
|
998
|
+
headers: { "content-type": "application/json", ...authHeaders },
|
|
999
|
+
body: JSON.stringify({
|
|
1000
|
+
repo: tenant,
|
|
1001
|
+
changeId: candidate.changeId,
|
|
1002
|
+
...(candidate.headSha ? { expectedHeadSha: candidate.headSha } : {}),
|
|
1003
|
+
}),
|
|
1004
|
+
});
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
progress?.stop();
|
|
1007
|
+
console.error(
|
|
1008
|
+
fail(`couldn't reach the ship endpoint at ${base}: ${String(e?.message || e)}`, "check --url / your network, then re-run"),
|
|
1009
|
+
);
|
|
1010
|
+
return 1;
|
|
1011
|
+
}
|
|
1012
|
+
const acceptData = await readJsonSafe(acceptRes);
|
|
1013
|
+
progress?.stop();
|
|
1014
|
+
|
|
1015
|
+
const result = normalizeAcceptResponse(acceptData);
|
|
1016
|
+
if (!acceptRes.ok && !result.shipped && !result.pending) {
|
|
1017
|
+
const why = result.error || result.message || `HTTP ${acceptRes.status}`;
|
|
1018
|
+
console.error(
|
|
1019
|
+
fail(
|
|
1020
|
+
`the ship gate refused this change (HTTP ${acceptRes.status}): ${why}`,
|
|
1021
|
+
"check the PR reconciled cleanly (rebuild with `tot preview build`), then re-run",
|
|
1022
|
+
),
|
|
1023
|
+
);
|
|
1024
|
+
return 1;
|
|
1025
|
+
}
|
|
1026
|
+
return reportShippedByPr(result, { tenant, pr: want, previewUrl, noOpen, openUrl: deps.openUrl });
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/** Read a fetch Response body as JSON, tolerating a non-JSON/empty body. */
|
|
1030
|
+
async function readJsonSafe(res) {
|
|
1031
|
+
try {
|
|
1032
|
+
return await res.json();
|
|
1033
|
+
} catch {
|
|
1034
|
+
return {};
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
/** Report the by-PR ship result — honest terminal state (never a false "shipped live"). */
|
|
1039
|
+
function reportShippedByPr(result, { tenant, pr, previewUrl, noOpen, openUrl }) {
|
|
1040
|
+
const liveUrl = liveUrlFor(tenant);
|
|
1041
|
+
if (result.shipped) {
|
|
1042
|
+
console.log(`\n ✓ shipped PR #${pr} live to ${tenant}.`);
|
|
1043
|
+
if (liveUrl) {
|
|
1044
|
+
console.log(` Live: ${liveUrl}`);
|
|
1045
|
+
if (!noOpen && openUrl && openUrl(liveUrl)) console.log(" (opened in your browser)");
|
|
1046
|
+
}
|
|
1047
|
+
return 0;
|
|
1048
|
+
}
|
|
1049
|
+
// Merged + dispatched but the target isn't verified live yet — honest in-flight.
|
|
1050
|
+
console.log(`\n ~ ship accepted for PR #${pr} on ${tenant}; it's going live now (not yet verified).`);
|
|
1051
|
+
if (previewUrl) console.log(` Track it here: ${previewUrl}`);
|
|
1052
|
+
return 0;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
735
1055
|
// ─── Operator ship (DEPLOY-only) ─────────────────────────────────────────────────────
|
|
736
1056
|
|
|
737
1057
|
/**
|
|
@@ -866,6 +1186,26 @@ async function runOperatorFlow(args, ctx, env) {
|
|
|
866
1186
|
}
|
|
867
1187
|
const pr = args.pr != null && `${args.pr}`.trim() && Number.isFinite(Number(args.pr)) ? Number(args.pr) : args.pr;
|
|
868
1188
|
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
1189
|
+
|
|
1190
|
+
// u16: with NO explicit change-record id, ship ANY built PR via the SHARED candidate
|
|
1191
|
+
// queue — resolve PR→changeId from GET /api/changes and ship via POST
|
|
1192
|
+
// /api/changes/accept (operator secret transport). This is the path that ships a PR
|
|
1193
|
+
// the operator did NOT author. The explicit `--change-id` path below stays the MCP
|
|
1194
|
+
// change_accept targeting (backward-compatible; distinct chg_-record namespace).
|
|
1195
|
+
if (!args.changeId) {
|
|
1196
|
+
return await runShipByPr(
|
|
1197
|
+
{
|
|
1198
|
+
tenant,
|
|
1199
|
+
pr,
|
|
1200
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
1201
|
+
storefrontUrl,
|
|
1202
|
+
noOpen: args.noOpen,
|
|
1203
|
+
yes: args.yes,
|
|
1204
|
+
},
|
|
1205
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
869
1209
|
const baseUrl = args.mcp || env.MCP_BASE_URL || env.TOT_MCP_URL || DEFAULT_MCP_URL;
|
|
870
1210
|
const client = createMcpClient(baseUrl);
|
|
871
1211
|
try {
|
|
@@ -981,6 +1321,7 @@ export async function run(argv, ctx) {
|
|
|
981
1321
|
const headSubject = gitSafe(["log", "-1", "--format=%s"]).trim();
|
|
982
1322
|
const changeSummary = buildChangeSummary({ message: headSubject, headSubject });
|
|
983
1323
|
|
|
1324
|
+
const storefrontUrl = args.url || env.TOT_STOREFRONT_URL || env.STOREFRONT_BASE_URL || DEFAULT_STOREFRONT_URL;
|
|
984
1325
|
return await runShip(
|
|
985
1326
|
client,
|
|
986
1327
|
{
|
|
@@ -990,13 +1331,34 @@ export async function run(argv, ctx) {
|
|
|
990
1331
|
git,
|
|
991
1332
|
noOpen: args.noOpen,
|
|
992
1333
|
yes: args.yes,
|
|
993
|
-
storefrontUrl
|
|
1334
|
+
storefrontUrl,
|
|
994
1335
|
changeSummary,
|
|
995
1336
|
// A positional `tot ship 4` asserts WHICH PR — passed as a safety guard so
|
|
996
1337
|
// ship refuses if this checkout's active candidate is a different PR.
|
|
997
1338
|
expectedPr: args.prPositional ? args.pr : null,
|
|
998
1339
|
},
|
|
999
|
-
{
|
|
1340
|
+
{
|
|
1341
|
+
openUrl: (u) => openBrowser(u),
|
|
1342
|
+
// u16: when `tot ship <N>` names a PR that ISN'T this checkout's candidate (no
|
|
1343
|
+
// local candidate resolves), fall through to the operator-by-PR queue path — so
|
|
1344
|
+
// `tot ship 4` ships someone else's built PR too, using this checkout's tenant.
|
|
1345
|
+
...(args.prPositional
|
|
1346
|
+
? {
|
|
1347
|
+
shipByPrFallback: () =>
|
|
1348
|
+
runShipByPr(
|
|
1349
|
+
{
|
|
1350
|
+
tenant,
|
|
1351
|
+
pr: args.pr,
|
|
1352
|
+
secret: resolveOperatorSecret(args.secret, env),
|
|
1353
|
+
storefrontUrl,
|
|
1354
|
+
noOpen: args.noOpen,
|
|
1355
|
+
yes: args.yes,
|
|
1356
|
+
},
|
|
1357
|
+
{ openUrl: (u) => openBrowser(u) },
|
|
1358
|
+
),
|
|
1359
|
+
}
|
|
1360
|
+
: {}),
|
|
1361
|
+
},
|
|
1000
1362
|
);
|
|
1001
1363
|
} catch (e) {
|
|
1002
1364
|
if (e instanceof AuthUnavailableError) {
|
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
|
|