toolroll 0.9.93 → 0.9.94
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/CHANGELOG.md +68 -0
- package/dist/activity-line.d.ts +7 -4
- package/dist/activity-line.js +9 -7
- package/dist/agent-fence.d.ts +4 -0
- package/dist/agent-fence.js +4 -1
- package/dist/approval-authority.d.ts +7 -1
- package/dist/approval-authority.js +11 -2
- package/dist/approval-password.d.ts +31 -0
- package/dist/approval-password.js +26 -0
- package/dist/assignment-ui.js +1 -1
- package/dist/assignment.js +5 -0
- package/dist/boot-identity.d.ts +2 -1
- package/dist/boot-identity.js +6 -4
- package/dist/bounded-spawn.d.ts +57 -0
- package/dist/bounded-spawn.js +220 -0
- package/dist/browser/terminal.js +42 -42
- package/dist/browser/workspace.css +1 -1
- package/dist/browser/workspace.js +32 -32
- package/dist/browser-shell.d.ts +1 -1
- package/dist/browser-shell.js +2 -2
- package/dist/browser-workspace.d.ts +45 -2
- package/dist/browser-workspace.js +3 -3
- package/dist/builder-restart.d.ts +21 -0
- package/dist/builder-restart.js +50 -0
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +53 -24
- package/dist/chat-acceptance.js +1 -1
- package/dist/chat-actions.d.ts +12 -2
- package/dist/chat-actions.js +25 -2
- package/dist/chat-channel.js +11 -0
- package/dist/chat-decide.d.ts +7 -2
- package/dist/chat-decide.js +37 -9
- package/dist/chat-quiet.js +3 -3
- package/dist/chat-voice.d.ts +1 -1
- package/dist/chat-voice.js +2 -2
- package/dist/check-facts.d.ts +3 -1
- package/dist/check-facts.js +57 -11
- package/dist/check-failure.d.ts +22 -3
- package/dist/check-failure.js +57 -13
- package/dist/check-levels-ui.d.ts +4 -0
- package/dist/check-levels-ui.js +10 -1
- package/dist/check-load.d.ts +85 -0
- package/dist/check-load.js +147 -0
- package/dist/check-only-standing.d.ts +36 -0
- package/dist/check-only-standing.js +39 -0
- package/dist/check-only.d.ts +15 -0
- package/dist/check-only.js +99 -0
- package/dist/check-report.d.ts +2 -0
- package/dist/check-report.js +1 -1
- package/dist/check-stamp.d.ts +46 -0
- package/dist/check-stamp.js +93 -0
- package/dist/checkout-cleanup.d.ts +36 -5
- package/dist/checkout-cleanup.js +166 -14
- package/dist/cli-help.d.ts +4 -1
- package/dist/cli-help.js +47 -30
- package/dist/cli.js +11 -1
- package/dist/code-extract.d.ts +44 -0
- package/dist/code-extract.js +88 -0
- package/dist/code-map-acts.d.ts +24 -0
- package/dist/code-map-acts.js +53 -0
- package/dist/code-map-admission.d.ts +52 -0
- package/dist/code-map-admission.js +295 -0
- package/dist/code-map-build.d.ts +63 -0
- package/dist/code-map-build.js +415 -0
- package/dist/code-map-cli.d.ts +153 -0
- package/dist/code-map-cli.js +103 -0
- package/dist/code-map-main.d.ts +1 -0
- package/dist/code-map-main.js +48 -0
- package/dist/code-map-read.d.ts +106 -0
- package/dist/code-map-read.js +171 -0
- package/dist/code-map-report.d.ts +37 -0
- package/dist/code-map-report.js +95 -0
- package/dist/code-map-shape.d.ts +172 -0
- package/dist/code-map-shape.js +327 -0
- package/dist/code-map-store.d.ts +136 -0
- package/dist/code-map-store.js +233 -0
- package/dist/coding-provider.d.ts +2 -0
- package/dist/coding-provider.js +3 -2
- package/dist/coding-update.js +3 -2
- package/dist/coding-workspace.js +4 -4
- package/dist/console-chrome.js +2 -2
- package/dist/containment.d.ts +5 -0
- package/dist/containment.js +14 -1
- package/dist/contracts/chat-actions.d.ts +9 -0
- package/dist/contracts/chat-actions.js +2 -0
- package/dist/contracts/cli.d.ts +52 -0
- package/dist/contracts/cli.js +4 -0
- package/dist/contracts/console-api.d.ts +10 -3
- package/dist/contracts/console-api.js +10 -3
- package/dist/contracts/gateway-tools.d.ts +17 -0
- package/dist/contracts/gateway-tools.js +3 -1
- package/dist/contracts/lead-tools.d.ts +61 -0
- package/dist/contracts/lead-tools.js +10 -1
- package/dist/contracts/project-knowledge.d.ts +38 -5
- package/dist/contracts/project-knowledge.js +11 -5
- package/dist/contracts/project-memory.d.ts +3 -1
- package/dist/contracts/project-memory.js +3 -2
- package/dist/contracts/storage.d.ts +14 -0
- package/dist/contracts/storage.js +3 -1
- package/dist/contracts/store-json.d.ts +2 -0
- package/dist/contracts/youiyoux-request.d.ts +1 -1
- package/dist/contracts/youiyoux-request.js +1 -1
- package/dist/desktop-access.js +6 -2
- package/dist/dispatch.js +18 -15
- package/dist/dispose.js +8 -2
- package/dist/easy-start.js +2 -3
- package/dist/evals.js +2 -1
- package/dist/fast-start.d.ts +136 -0
- package/dist/fast-start.js +431 -0
- package/dist/fast-terms.d.ts +9 -0
- package/dist/fast-terms.js +23 -0
- package/dist/fenced-check-coverage.d.ts +62 -0
- package/dist/fenced-check-coverage.js +54 -0
- package/dist/first-run.js +1 -1
- package/dist/flaky-ci.d.ts +31 -0
- package/dist/flaky-ci.js +149 -0
- package/dist/flaky-cli.d.ts +60 -0
- package/dist/flaky-cli.js +60 -0
- package/dist/flaky.d.ts +75 -9
- package/dist/flaky.js +255 -0
- package/dist/flow-card-task.js +3 -0
- package/dist/flow-triggers.d.ts +1 -1
- package/dist/flow-triggers.js +1 -1
- package/dist/github-import.d.ts +62 -0
- package/dist/github-import.js +146 -0
- package/dist/guides.js +15 -10
- package/dist/invoke.d.ts +5 -1
- package/dist/invoke.js +49 -14
- package/dist/job-object-helper.ps1 +10 -3
- package/dist/keymap.d.ts +41 -0
- package/dist/keymap.js +51 -0
- package/dist/knowledge-cli.js +1 -1
- package/dist/knowledge-ui.d.ts +21 -4
- package/dist/knowledge-ui.js +75 -7
- package/dist/laptop-background.d.ts +16 -0
- package/dist/laptop-background.js +108 -2
- package/dist/lead-context.js +1 -1
- package/dist/lead-contract.d.ts +1 -1
- package/dist/lead-contract.js +2 -2
- package/dist/lead-progress.js +3 -0
- package/dist/lead-status.d.ts +2 -0
- package/dist/lead-status.js +6 -2
- package/dist/lead-tools.js +51 -2
- package/dist/lead.js +1 -1
- package/dist/lesson-notes.d.ts +81 -0
- package/dist/lesson-notes.js +336 -0
- package/dist/limits-ui.js +1 -1
- package/dist/local-owner.d.ts +1 -1
- package/dist/local-owner.js +11 -0
- package/dist/machine-check.d.ts +16 -0
- package/dist/machine-check.js +26 -0
- package/dist/machine-identity.d.ts +75 -0
- package/dist/machine-identity.js +170 -0
- package/dist/mcp-core.js +11 -1
- package/dist/memory-cli.d.ts +300 -1
- package/dist/memory-cli.js +62 -9
- package/dist/memory-pass.d.ts +16 -0
- package/dist/memory-pass.js +67 -3
- package/dist/memory-tidy.d.ts +181 -0
- package/dist/memory-tidy.js +523 -0
- package/dist/memory-use.d.ts +35 -0
- package/dist/memory-use.js +109 -0
- package/dist/merge-checklist.d.ts +55 -0
- package/dist/merge-checklist.js +182 -0
- package/dist/needs-you.d.ts +27 -8
- package/dist/needs-you.js +96 -39
- package/dist/nightly-github.d.ts +2 -0
- package/dist/nightly-github.js +4 -4
- package/dist/operate-remote-arguments.js +6 -1
- package/dist/operate-remote.js +3 -1
- package/dist/operate.d.ts +6 -1
- package/dist/operate.js +402 -52
- package/dist/phase-routing.js +2 -2
- package/dist/plain-schema.d.ts +23 -0
- package/dist/plain-schema.js +94 -0
- package/dist/plain-words.d.ts +20 -0
- package/dist/plain-words.js +66 -0
- package/dist/planner.js +2 -1
- package/dist/process-liveness.d.ts +2 -0
- package/dist/process-liveness.js +61 -17
- package/dist/project-delete-ui.d.ts +7 -2
- package/dist/project-delete-ui.js +12 -1
- package/dist/project-delete.js +8 -1
- package/dist/project-knowledge.d.ts +18 -11
- package/dist/project-knowledge.js +39 -9
- package/dist/project-learning.d.ts +2 -1
- package/dist/project-learning.js +15 -45
- package/dist/project-memory.d.ts +5 -0
- package/dist/project-memory.js +22 -2
- package/dist/proof.js +30 -5
- package/dist/provider-auth.js +1 -1
- package/dist/publish.js +9 -1
- package/dist/pull-request-flow.js +5 -0
- package/dist/push.d.ts +18 -2
- package/dist/push.js +40 -16
- package/dist/release-adapters.d.ts +9 -0
- package/dist/release-adapters.js +76 -0
- package/dist/release-auto.js +13 -13
- package/dist/release-card.d.ts +1 -2
- package/dist/release-card.js +11 -0
- package/dist/release-carry.d.ts +7 -2
- package/dist/release-carry.js +39 -9
- package/dist/release-cli.d.ts +4 -0
- package/dist/release-cli.js +29 -22
- package/dist/release-coverage.js +1 -1
- package/dist/release-next.d.ts +48 -0
- package/dist/release-next.js +161 -0
- package/dist/release.d.ts +20 -1
- package/dist/release.js +76 -8
- package/dist/remote-destination.d.ts +36 -0
- package/dist/remote-destination.js +217 -0
- package/dist/remote-main.d.ts +48 -0
- package/dist/remote-main.js +84 -5
- package/dist/repository-context.d.ts +3 -2
- package/dist/repository-context.js +112 -70
- package/dist/restart-certification.d.ts +1 -0
- package/dist/restart-certification.js +8 -6
- package/dist/result-acts.d.ts +27 -19
- package/dist/result-acts.js +36 -25
- package/dist/result-follow-ups.d.ts +2 -2
- package/dist/result-follow-ups.js +18 -4
- package/dist/result-review.d.ts +1 -1
- package/dist/route-change-check.d.ts +4 -0
- package/dist/route-change-check.js +13 -0
- package/dist/route-change.d.ts +22 -0
- package/dist/route-change.js +89 -0
- package/dist/route-hooks.js +3 -2
- package/dist/route-results.d.ts +6 -1
- package/dist/route-results.js +14 -5
- package/dist/run-closeout.d.ts +1 -1
- package/dist/run-closeout.js +7 -5
- package/dist/runner-process.js +3 -2
- package/dist/runner.js +2 -1
- package/dist/scope.d.ts +6 -1
- package/dist/scope.js +22 -9
- package/dist/serve.d.ts +1 -1
- package/dist/serve.js +24 -18
- package/dist/server/chat.js +14 -12
- package/dist/server/chrome.d.ts +1 -1
- package/dist/server/chrome.js +26 -35
- package/dist/server/console.css +23 -0
- package/dist/server/guards.js +3 -0
- package/dist/server/home.d.ts +6 -0
- package/dist/server/home.js +21 -13
- package/dist/server/http.js +1 -1
- package/dist/server/kit.js +1 -1
- package/dist/server/merge-ready-view.d.ts +10 -0
- package/dist/server/merge-ready-view.js +30 -0
- package/dist/server/pages-assets.d.ts +1 -0
- package/dist/server/pages-assets.js +8 -5
- package/dist/server/pages.js +103 -82
- package/dist/server/render-chat.js +2 -2
- package/dist/server/render-code-map.d.ts +30 -0
- package/dist/server/render-code-map.js +137 -0
- package/dist/server/render-flaky.d.ts +20 -0
- package/dist/server/render-flaky.js +59 -0
- package/dist/server/render-history.js +1 -1
- package/dist/server/render-new-task.d.ts +26 -1
- package/dist/server/render-new-task.js +30 -5
- package/dist/server/render-pages.d.ts +1 -175
- package/dist/server/render-pages.js +38 -313
- package/dist/server/render-projects.d.ts +2 -1
- package/dist/server/render-projects.js +9 -7
- package/dist/server/render-release.d.ts +2 -0
- package/dist/server/render-release.js +3 -3
- package/dist/server/render-results.d.ts +2 -0
- package/dist/server/render-results.js +67 -67
- package/dist/server/render-tasks.d.ts +13 -2
- package/dist/server/render-tasks.js +153 -136
- package/dist/server/retired-routes.d.ts +17 -0
- package/dist/server/retired-routes.js +53 -0
- package/dist/server/route-table.d.ts +3 -0
- package/dist/server/route-table.js +45 -12
- package/dist/server/settings.js +129 -22
- package/dist/server/task-views.js +61 -26
- package/dist/server/tasks.d.ts +2 -6
- package/dist/server/tasks.js +174 -321
- package/dist/server/welcome.js +1 -1
- package/dist/settings-groups-ui.js +1 -1
- package/dist/setup-guide.d.ts +2 -2
- package/dist/stop-proof.js +6 -4
- package/dist/storage-sweep-process.js +1 -1
- package/dist/storage-sweep.d.ts +5 -3
- package/dist/storage-sweep.js +30 -9
- package/dist/storage-ui.js +28 -17
- package/dist/storage.d.ts +27 -2
- package/dist/storage.js +39 -2
- package/dist/store.d.ts +80 -5
- package/dist/store.js +296 -87
- package/dist/surface.js +45 -4
- package/dist/sweep-quarantine.d.ts +1 -0
- package/dist/sweep-quarantine.js +6 -4
- package/dist/tamper-scan.d.ts +4 -0
- package/dist/tamper-scan.js +90 -0
- package/dist/task-aliases.d.ts +5 -0
- package/dist/task-aliases.js +9 -0
- package/dist/task-control.js +4 -1
- package/dist/task-outcome-cli.d.ts +2 -0
- package/dist/task-outcome-cli.js +17 -17
- package/dist/task-sizing.d.ts +5 -0
- package/dist/task-sizing.js +2 -0
- package/dist/task-source.d.ts +7 -0
- package/dist/task-source.js +14 -0
- package/dist/task-status.d.ts +9 -0
- package/dist/task-status.js +40 -49
- package/dist/teardown-git.d.ts +16 -0
- package/dist/teardown-git.js +63 -5
- package/dist/telegram-progress.js +5 -3
- package/dist/telegram-status.js +6 -4
- package/dist/test-infra.d.ts +8 -0
- package/dist/test-infra.js +18 -0
- package/dist/tidy.js +1 -1
- package/dist/toolroll-update.js +17 -5
- package/dist/tui-contract.d.ts +24 -0
- package/dist/tui-source.js +67 -8
- package/dist/tui.js +51 -51
- package/dist/vanished-retry.d.ts +30 -0
- package/dist/vanished-retry.js +174 -0
- package/dist/work-index.d.ts +2 -0
- package/dist/work-index.js +76 -62
- package/dist/work-summary.js +2 -0
- package/dist/workspace-ui.d.ts +4 -2
- package/dist/workspace-ui.js +28 -22
- package/dist/worktree.d.ts +12 -3
- package/dist/worktree.js +68 -13
- package/package.json +1 -1
- package/dist/server/render-next.d.ts +0 -63
- package/dist/server/render-next.js +0 -109
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `toolroll release <branch>` does before its journal's next step (docs/plans/plain-toolroll.md P2.6): one command,
|
|
3
|
+
* so a person never chooses between --recover-deploy, --release-gate and rerunning.
|
|
4
|
+
*
|
|
5
|
+
* - **Another run**: a run lock per branch, by host and pid. A live one is waited for, named; two coordinators never act
|
|
6
|
+
* on one release at once. A lock whose process is proved gone on this host is taken over.
|
|
7
|
+
* - **A deployment left open**: the journal decides. Before the swap: its pause is lifted, once proved. Past the new
|
|
8
|
+
* service's start: forward, by the release. Between: rolled back through its own recovery. Each of those re-proves
|
|
9
|
+
* itself, so a crash at any phase ends the same on the next run.
|
|
10
|
+
*/
|
|
11
|
+
import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, writeSync } from "node:fs";
|
|
12
|
+
import { hostname } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { FORWARD_ONLY, rescueDeployment } from "./deploy-rescue.js";
|
|
15
|
+
import { BEFORE_SWAP, releaseGate } from "./release-gate.js";
|
|
16
|
+
import { journalFile, readJournal } from "./release.js";
|
|
17
|
+
import { autoPublishHeld, PUBLISH_ALLOWED_ACTION } from "./release-auto.js";
|
|
18
|
+
import { sameMachineHost } from "./machine-identity.js";
|
|
19
|
+
const lockFile = (stateDir, branch) => journalFile(stateDir, branch).replace(/\.json$/, ".running");
|
|
20
|
+
/** How long a run waits for another before it says so and stops. */
|
|
21
|
+
export const RELEASE_WAIT_MS = 30 * 60_000;
|
|
22
|
+
const processAlive = (pid) => {
|
|
23
|
+
try {
|
|
24
|
+
process.kill(pid, 0);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
return error.code === "EPERM";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
function readHolder(file) {
|
|
32
|
+
try {
|
|
33
|
+
const read = JSON.parse(readFileSync(file, "utf8"));
|
|
34
|
+
return typeof read.host === "string" && Number.isSafeInteger(read.pid) ? { host: read.host, pid: read.pid } : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Take this branch's run lock, waiting for a live holder. Answers how to give it back, or who kept it. */
|
|
41
|
+
export async function takeReleaseRun(stateDir, branch, say, seams = {}) {
|
|
42
|
+
const file = lockFile(stateDir, branch);
|
|
43
|
+
const host = seams.host ?? hostname(), pid = seams.pid ?? process.pid;
|
|
44
|
+
const alive = seams.alive ?? processAlive, sleep = seams.sleep ?? (ms => new Promise(done => setTimeout(done, ms)));
|
|
45
|
+
const now = seams.now ?? Date.now;
|
|
46
|
+
const deadline = now() + (seams.waitMs ?? RELEASE_WAIT_MS);
|
|
47
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
48
|
+
let said = false;
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync(file, "wx", 0o600);
|
|
52
|
+
try {
|
|
53
|
+
writeSync(fd, JSON.stringify({ host, pid }));
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, release: () => { const mine = readHolder(file); if (mine?.pid === pid && mine.host === host)
|
|
59
|
+
rmSync(file, { force: true }); } };
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
if (error.code !== "EEXIST")
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const holder = readHolder(file);
|
|
66
|
+
// A lock with no readable holder, or a process proved gone on this computer, holds nothing. Another computer's can't
|
|
67
|
+
// be proved gone from here, so it is waited for.
|
|
68
|
+
if (holder === null || (sameMachineHost(holder.host, host) && !alive(holder.pid))) {
|
|
69
|
+
rmSync(file, { force: true });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (!said) {
|
|
73
|
+
say(`Waiting: another release of ${branch} is running on ${holder.host} (process ${holder.pid}).`);
|
|
74
|
+
said = true;
|
|
75
|
+
}
|
|
76
|
+
if (now() >= deadline)
|
|
77
|
+
return { ok: false, holder };
|
|
78
|
+
await sleep(seams.pollMs ?? 5_000);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The one action a deployment's journal calls for. */
|
|
82
|
+
export function deployActionOf(phase) {
|
|
83
|
+
if (typeof phase !== "string")
|
|
84
|
+
return "refuse";
|
|
85
|
+
if (phase === "released" || phase === "deployed")
|
|
86
|
+
return "none";
|
|
87
|
+
if (BEFORE_SWAP.includes(phase))
|
|
88
|
+
return "lift-pause";
|
|
89
|
+
if (FORWARD_ONLY.includes(phase))
|
|
90
|
+
return "forward";
|
|
91
|
+
return "roll-back";
|
|
92
|
+
}
|
|
93
|
+
/** Settle what a stopped deployment left, saying what was done. A refusal stops the release before it does anything. */
|
|
94
|
+
export async function settleDeployment(world, say) {
|
|
95
|
+
const open = world.rescue.journals().filter(one => deployActionOf(one.journal?.["phase"]) !== "none");
|
|
96
|
+
if (open.length === 0)
|
|
97
|
+
return { ok: true };
|
|
98
|
+
if (open.length > 1)
|
|
99
|
+
return { ok: false, message: `${open.length} deployments are unfinished: ${open.map(one => one.stage).join(", ")}. Nothing was changed.` };
|
|
100
|
+
const { stage, journal } = open[0];
|
|
101
|
+
const action = deployActionOf(journal?.["phase"]);
|
|
102
|
+
if (action === "refuse")
|
|
103
|
+
return { ok: false, message: `The deployment journal in ${stage} can't be read. Nothing was changed.` };
|
|
104
|
+
if (action === "forward") {
|
|
105
|
+
say("Finishing the deployment that was under way.");
|
|
106
|
+
return { ok: true };
|
|
107
|
+
}
|
|
108
|
+
if (action === "lift-pause") {
|
|
109
|
+
const lifted = releaseGate(String(journal?.["id"] ?? ""), world.gate, true);
|
|
110
|
+
if (!lifted.ok)
|
|
111
|
+
return { ok: false, message: lifted.message };
|
|
112
|
+
say("Lifted the pause an unfinished deployment left before its swap.");
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
const back = await rescueDeployment(stage, true, world.rescue);
|
|
116
|
+
if (!back.ok)
|
|
117
|
+
return { ok: false, message: back.message };
|
|
118
|
+
say("Put back the deployment that stopped half way.");
|
|
119
|
+
return { ok: true };
|
|
120
|
+
}
|
|
121
|
+
// ---- a held release resumes itself -----------------------------------------------------------------------------------
|
|
122
|
+
export const RELEASE_RESUMED_ACTION = "release resumed";
|
|
123
|
+
/**
|
|
124
|
+
* A person let a held release go on (release-auto.ts allowAutoPublish; the Release sheet's button or `publish-ok`): each
|
|
125
|
+
* sweep, its journal goes on from where it stopped, once per that person's act, under the same run lock. It was merged
|
|
126
|
+
* already, so only the tag, publication and Homebrew are left, and each re-proves itself.
|
|
127
|
+
*/
|
|
128
|
+
export async function resumeAllowedReleases(store, stateDir, run, now) {
|
|
129
|
+
let folder;
|
|
130
|
+
try {
|
|
131
|
+
folder = readdirSync(join(stateDir, "releases")).filter(name => name.endsWith(".json") && !name.includes(".superseded-"));
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
let resumed = 0;
|
|
137
|
+
for (const name of folder) {
|
|
138
|
+
const journal = readJournal(join(stateDir, "releases", name));
|
|
139
|
+
if (journal === null || !["tag", "publish", "homebrew"].includes(journal.step) || autoPublishHeld(store, journal.task.id) !== null)
|
|
140
|
+
continue;
|
|
141
|
+
const allowed = store.handle.prepare("SELECT id FROM action_ledger WHERE task_id = ? AND action = ? ORDER BY id DESC LIMIT 1").get(journal.task.id, PUBLISH_ALLOWED_ACTION);
|
|
142
|
+
if (allowed === undefined)
|
|
143
|
+
continue;
|
|
144
|
+
// Once per act: a resume that stops again waits for a person, never for the next sweep.
|
|
145
|
+
const done = store.handle.prepare("SELECT 1 AS hit FROM action_ledger WHERE task_id = ? AND action = ? AND outcome = ?").get(journal.task.id, RELEASE_RESUMED_ACTION, String(allowed["id"]));
|
|
146
|
+
if (done !== undefined)
|
|
147
|
+
continue;
|
|
148
|
+
const taken = await takeReleaseRun(stateDir, journal.branch, () => { }, { waitMs: 0 });
|
|
149
|
+
if (!taken.ok)
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
store.recordAction({ at: now.toISOString(), actor: "system", repo: store.lookupRef(journal.task.id)?.repo ?? null, taskId: journal.task.id, runId: null, action: RELEASE_RESUMED_ACTION, outcome: String(allowed["id"]), source: "work", detail: journal.tag });
|
|
153
|
+
await run(journal);
|
|
154
|
+
resumed++;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
taken.release();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return resumed;
|
|
161
|
+
}
|
package/dist/release.d.ts
CHANGED
|
@@ -77,7 +77,9 @@ export declare const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
|
|
|
77
77
|
export declare const RELEASE_STEPS: readonly ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
|
78
78
|
export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
79
79
|
type WaitingStep = "gate" | "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
80
|
-
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it).
|
|
80
|
+
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). The
|
|
81
|
+
* check's covers the first attempt and the two same-commit reruns the plane may give it, each of which a busy machine
|
|
82
|
+
* may stretch to two hours (check-load.ts). */
|
|
81
83
|
export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
|
|
82
84
|
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known; `id` its check
|
|
83
85
|
* run id, `suite` its check suite (one workflow run: its re-runs stay in it) and `head` the commit GitHub filed it
|
|
@@ -332,6 +334,15 @@ export type ReleaseAuthorityAdapter = {
|
|
|
332
334
|
why: string;
|
|
333
335
|
warning: string | null;
|
|
334
336
|
}>;
|
|
337
|
+
/** Whether a sandboxed Full check's skipped cases are covered (fenced-check-coverage.ts, H3). Run `run`'s stamped level
|
|
338
|
+
* decides: an unsandboxed check answers at once. Absent: a plane that can't say, as for every older one. */
|
|
339
|
+
fencedCoverage?(taskId: string, run: number, candidate: string): Promise<{
|
|
340
|
+
ok: true;
|
|
341
|
+
covered: number;
|
|
342
|
+
} | {
|
|
343
|
+
ok: false;
|
|
344
|
+
why: string;
|
|
345
|
+
}>;
|
|
335
346
|
/** One-tap release approval (docs/plans/trusted-release-reports.md, slice A): what the plane tells a person and
|
|
336
347
|
* keeps for the approval sheet. Best effort: a failure here never changes the release. Absent on an older plane. */
|
|
337
348
|
ready?: ReleaseReadyAdapter;
|
|
@@ -536,6 +547,14 @@ export type ReleaseAdapters = {
|
|
|
536
547
|
mainHead(repo: string): Promise<string>;
|
|
537
548
|
/** Whether `sha` already holds `ancestor`, by GitHub's own comparison. */
|
|
538
549
|
contains(repo: string, sha: string, ancestor: string): Promise<boolean>;
|
|
550
|
+
/** Bring a pull request's branch up to main, only while its head is `expectedHead` (GitHub's own compare-and-swap: a
|
|
551
|
+
* push that got there first refuses). Absent: a behind branch stops, for a person to update. */
|
|
552
|
+
updateBranch?(repo: string, pr: number, expectedHead: string): Promise<{
|
|
553
|
+
ok: true;
|
|
554
|
+
} | {
|
|
555
|
+
ok: false;
|
|
556
|
+
why: string;
|
|
557
|
+
}>;
|
|
539
558
|
/** Whether GitHub refuses to merge a branch that is behind main ("Require branches to be up to date before
|
|
540
559
|
* merging"); null when it can't be read. Read only. */
|
|
541
560
|
upToDateRequired(repo: string): Promise<MainProtection | null>;
|
package/dist/release.js
CHANGED
|
@@ -101,8 +101,10 @@ export const ADVISORY_PR_CHECKS = {
|
|
|
101
101
|
export const RELEASE_TAG_CHECKS = ["release proof", "publish"];
|
|
102
102
|
export const RELEASE_TAG_WORKFLOW = ".github/workflows/publish.yml";
|
|
103
103
|
export const RELEASE_STEPS = ["gate", "approval", "check", "complete", "quiet", "deploy", "link", "ci", "merge", "tree", "tag", "publish", "homebrew", "done"];
|
|
104
|
-
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it).
|
|
105
|
-
|
|
104
|
+
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). The
|
|
105
|
+
* check's covers the first attempt and the two same-commit reruns the plane may give it, each of which a busy machine
|
|
106
|
+
* may stretch to two hours (check-load.ts). */
|
|
107
|
+
export const RELEASE_LIMITS = { gate: 60, approval: 24 * 60, check: 360, quiet: 60, deploy: 30, ci: 60, publish: 45, homebrew: 45 };
|
|
106
108
|
const POLL_MS = 15_000;
|
|
107
109
|
/**
|
|
108
110
|
* Failure classes replace a flat flake allowance (docs/plans/eval-harness.md; check-failure.ts says the classes): only a
|
|
@@ -129,9 +131,12 @@ class HomebrewFailed extends Error {
|
|
|
129
131
|
/** The branch moved before the deploy reservation: carry the approval to the new head, or file it for a person. */
|
|
130
132
|
class Restart extends Error {
|
|
131
133
|
head;
|
|
132
|
-
|
|
134
|
+
updated;
|
|
135
|
+
/** `updated`: Toolroll itself brought the branch up to main; the new commit is a new candidate and nothing carries. */
|
|
136
|
+
constructor(head, updated = false) {
|
|
133
137
|
super(`restart at ${head}`);
|
|
134
138
|
this.head = head;
|
|
139
|
+
this.updated = updated;
|
|
135
140
|
}
|
|
136
141
|
}
|
|
137
142
|
/** Every commit that landed after the approval, in words: the summary and the merge message list them all. */
|
|
@@ -626,11 +631,41 @@ export async function runRelease(options, adapters) {
|
|
|
626
631
|
/** identity(), on a plane that keeps release authority; a legacy plane keeps the old rules. */
|
|
627
632
|
const watch = async (action) => { if (rel !== undefined)
|
|
628
633
|
await identity(action, true); };
|
|
629
|
-
|
|
634
|
+
/**
|
|
635
|
+
* Behind main (plain-toolroll.md P2.6): update the branch under this release's lease and the head it was gated on.
|
|
636
|
+
* A concurrent push aborts it. The approval stops first, so nothing can ride it across the update; the updated head
|
|
637
|
+
* is a new candidate with a new approval, fresh CI and a fresh check, and there is no carry.
|
|
638
|
+
*/
|
|
639
|
+
const updateFromMain = async () => {
|
|
640
|
+
const update = adapters.github.updateBranch;
|
|
641
|
+
if (update === undefined)
|
|
642
|
+
return null;
|
|
643
|
+
if (rel !== undefined && j.authority) {
|
|
644
|
+
if (epoch === null || !await rel.renew(j.authority.id, owner, epoch))
|
|
645
|
+
throw new Stop("lease", "Another release coordinator took over this release.");
|
|
646
|
+
}
|
|
647
|
+
const pr = await adapters.github.pullRequest(j.github, j.branch);
|
|
648
|
+
if (pr === null || pr.number !== j.pr || pr.state !== "open" || pr.head !== j.candidate.sha)
|
|
649
|
+
throw new Stop("drift", `${j.branch} moved while it was being brought up to main. Nothing was changed; rerun \`${resume}\`.`);
|
|
650
|
+
if (rel !== undefined && j.authority && !await rel.needsApproval(j.authority.id, "the branch was brought up to main, so its new commit needs a fresh approval", epoch)) {
|
|
651
|
+
throw new Stop("lease", "The release's approval couldn't be stopped before the update, so the branch wasn't updated.");
|
|
652
|
+
}
|
|
653
|
+
say(` ${j.branch} is behind main: bringing it up to date`);
|
|
654
|
+
const moved = await update(j.github, j.pr, j.candidate.sha);
|
|
655
|
+
if (!moved.ok)
|
|
656
|
+
throw new Stop("drift", `${j.branch} couldn't be brought up to main: ${moved.why}. Rerun \`${resume}\`.`);
|
|
657
|
+
return waitFor("ci", `${j.branch} to move past ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
658
|
+
const head = await adapters.repo.remoteHead(j.branch);
|
|
659
|
+
return head !== null && head !== j.candidate.sha ? head : null;
|
|
660
|
+
});
|
|
661
|
+
};
|
|
662
|
+
const restart = async (head, updated = false) => {
|
|
630
663
|
// The step this commit was at ends here: the release goes on with the new commit (and, filed again, a new task).
|
|
631
664
|
mark(j.step, "failed", `${j.branch} moved to ${head.slice(0, 7)}`);
|
|
632
665
|
const tree = await adapters.repo.tree(head);
|
|
633
|
-
if (
|
|
666
|
+
if (updated)
|
|
667
|
+
say(` ${j.branch} is at ${head.slice(0, 7)}: a new commit, so it needs its own approval, CI and check`);
|
|
668
|
+
else if (rel !== undefined && j.authority && epoch !== null) {
|
|
634
669
|
say(` ${j.branch} moved to ${head.slice(0, 7)}: checking whether the approval carries`);
|
|
635
670
|
const carried = await rel.carry(j.authority.id, head, randomUUID(), epoch);
|
|
636
671
|
if (carried.ok) {
|
|
@@ -808,7 +843,17 @@ export async function runRelease(options, adapters) {
|
|
|
808
843
|
}
|
|
809
844
|
// The tree CI tested (the pull request's head, not GitHub's merge commit) is the tree that merges only when the
|
|
810
845
|
// branch already contains main's head: refused here, before anything is filed, and again right before merging.
|
|
811
|
-
|
|
846
|
+
try {
|
|
847
|
+
await containsMain(adapters, j, "Nothing was filed or sent for approval");
|
|
848
|
+
}
|
|
849
|
+
catch (error) {
|
|
850
|
+
if (!(error instanceof Stop) || error.reason !== "base-moved")
|
|
851
|
+
throw error;
|
|
852
|
+
const head = await updateFromMain();
|
|
853
|
+
if (head === null)
|
|
854
|
+
throw error;
|
|
855
|
+
throw new Restart(head, true);
|
|
856
|
+
}
|
|
812
857
|
if (j.intents.gate === undefined)
|
|
813
858
|
await mainProtection(adapters, j, false, "Nothing was filed or sent for approval");
|
|
814
859
|
const existing = await adapters.toolroll.gate(j.task.id);
|
|
@@ -963,6 +1008,15 @@ export async function runRelease(options, adapters) {
|
|
|
963
1008
|
else if (standing.retried.length > 0)
|
|
964
1009
|
say(` run ${gate.result.run} passed, but only on a retry: ${standing.retried.join(", ")}`);
|
|
965
1010
|
}
|
|
1011
|
+
// A sandboxed Full check must show that what it skipped passed elsewhere (trusted-release-reports.md H3). The plane
|
|
1012
|
+
// answers at once for a check that ran unsandboxed, as Toolroll's own do: nothing there skips for this reason.
|
|
1013
|
+
if (rel?.fencedCoverage !== undefined) {
|
|
1014
|
+
const covered = await rel.fencedCoverage(j.task.id, gate.result.run, j.candidate.sha);
|
|
1015
|
+
if (!covered.ok)
|
|
1016
|
+
throw new Stop("check-failed", `Run ${gate.result.run} passed on ${j.candidate.sha.slice(0, 7)}, but ${covered.why}.`);
|
|
1017
|
+
if (covered.covered > 0)
|
|
1018
|
+
say(` ${covered.covered} cases skipped inside the sandbox passed in this commit's macOS CI`);
|
|
1019
|
+
}
|
|
966
1020
|
return { run: gate.result.run, receipt: gate.result.receipt, worktree: gate.result.worktree, head };
|
|
967
1021
|
};
|
|
968
1022
|
const result = await waitFor("check", `the release check of ${j.candidate.sha.slice(0, 7)}`, async () => {
|
|
@@ -1175,7 +1229,21 @@ export async function runRelease(options, adapters) {
|
|
|
1175
1229
|
await identity("merge", false);
|
|
1176
1230
|
await pinned();
|
|
1177
1231
|
// A squash merge only reproduces the gated (and CI-tested) tree when main hasn't moved past the branch's base.
|
|
1178
|
-
|
|
1232
|
+
let main;
|
|
1233
|
+
try {
|
|
1234
|
+
main = await containsMain(adapters, j, "Nothing was merged");
|
|
1235
|
+
}
|
|
1236
|
+
catch (error) {
|
|
1237
|
+
if (!(error instanceof Stop) || error.reason !== "base-moved")
|
|
1238
|
+
throw error;
|
|
1239
|
+
// Past the deploy: the updated commit isn't what's running, so it starts over, with a person.
|
|
1240
|
+
const head = await updateFromMain();
|
|
1241
|
+
if (head === null)
|
|
1242
|
+
throw error;
|
|
1243
|
+
j.needsApproval = true;
|
|
1244
|
+
save();
|
|
1245
|
+
throw new Stop("approval", `${j.branch} was behind main, so it's now at ${head.slice(0, 7)}. Nothing was merged. The new commit needs a person's approval: run \`toolroll release ${j.branch} --repo ${j.checkout} --new\`.`);
|
|
1246
|
+
}
|
|
1179
1247
|
// Every release merges only where GitHub itself refuses a branch behind main.
|
|
1180
1248
|
await mainProtection(adapters, j, j.auto?.state === "approved", "Nothing was merged");
|
|
1181
1249
|
// The checks again, at the moment of merging: a rerun that failed since must not merge.
|
|
@@ -1290,7 +1358,7 @@ export async function runRelease(options, adapters) {
|
|
|
1290
1358
|
catch (error) {
|
|
1291
1359
|
if (!(error instanceof Restart))
|
|
1292
1360
|
throw error;
|
|
1293
|
-
await restart(error.head);
|
|
1361
|
+
await restart(error.head, error.updated);
|
|
1294
1362
|
}
|
|
1295
1363
|
}
|
|
1296
1364
|
const left = homebrewLeft;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type Lookup = (host: string) => Promise<readonly string[]>;
|
|
2
|
+
export declare const lookupAll: Lookup;
|
|
3
|
+
export type Destination = {
|
|
4
|
+
scheme: "https" | "ssh";
|
|
5
|
+
host: string;
|
|
6
|
+
port: number;
|
|
7
|
+
literal: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type Pin = {
|
|
10
|
+
/** `-c` settings for git, or nothing for ssh. */
|
|
11
|
+
gitArgs: string[];
|
|
12
|
+
/** Environment on top of remote-main.ts's ENV. */
|
|
13
|
+
env: Record<string, string>;
|
|
14
|
+
/** The proxy's host name when the read goes through the operator's proxy. */
|
|
15
|
+
proxy: string | null;
|
|
16
|
+
};
|
|
17
|
+
export type PinResult = {
|
|
18
|
+
ok: true;
|
|
19
|
+
pin: Pin;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
why: string;
|
|
23
|
+
};
|
|
24
|
+
/** Whether an address is loopback, unspecified, link-local, or an IPv4-mapped or NAT64 form of these. */
|
|
25
|
+
export declare function addressRefused(address: string): boolean;
|
|
26
|
+
/** The host, effective port and scheme a recorded URL connects to; null when it can't be read. The caller has already
|
|
27
|
+
* passed remoteUrlProblem. Node's URL parser normalizes every spelling of a literal host. */
|
|
28
|
+
export declare function destinationOf(url: string): Destination | null;
|
|
29
|
+
/** Whether curl would skip the proxy for this host; "unknown" when an entry can't be decided. */
|
|
30
|
+
export declare function noProxyApplies(noProxy: string, host: string): boolean | "unknown";
|
|
31
|
+
/**
|
|
32
|
+
* Decide the route and pin it. `env` is what git will see (the names remote-main.ts's ENV_ALLOWLIST passes). ssh never
|
|
33
|
+
* uses a proxy. Through a proxy, the proxy's own address is checked and pinned; the operator's proxy then chooses the
|
|
34
|
+
* final destination, and the caller says so.
|
|
35
|
+
*/
|
|
36
|
+
export declare function pinDestination(url: string, env: Readonly<Record<string, string | undefined>>, lookup?: Lookup): Promise<PinResult>;
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a read of a recorded remote really connects, worked out and pinned before git runs (docs/plans/code-map.md §1).
|
|
3
|
+
* remote-main.ts's URL rules look at a host's spelling; a name can still resolve to this machine. Here the name is
|
|
4
|
+
* resolved once, every address is checked, and git is told to use exactly the checked address. Routing is decided from
|
|
5
|
+
* the same environment git gets. Anything that can't be decided or pinned refuses with a reason: there is no fallback to
|
|
6
|
+
* an unpinned read.
|
|
7
|
+
*/
|
|
8
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
9
|
+
import { isIP } from "node:net";
|
|
10
|
+
export const lookupAll = async (host) => (await dnsLookup(host, { all: true, verbatim: true })).map(one => one.address);
|
|
11
|
+
const UNDECIDED = "Toolroll can't tell how this connection would be routed";
|
|
12
|
+
const NOT_ALLOWED = "its address isn't one Toolroll connects to";
|
|
13
|
+
/** The eight 16-bit groups of an IPv6 address; null when it isn't one. */
|
|
14
|
+
function hextets(address) {
|
|
15
|
+
let text = address.toLowerCase().replace(/%.*$/, "");
|
|
16
|
+
const dotted = /(\d+\.\d+\.\d+\.\d+)$/.exec(text);
|
|
17
|
+
if (dotted !== null) {
|
|
18
|
+
const parts = dotted[1].split(".").map(Number);
|
|
19
|
+
if (parts.some(part => part > 255))
|
|
20
|
+
return null;
|
|
21
|
+
text = `${text.slice(0, dotted.index)}${((parts[0] << 8) | parts[1]).toString(16)}:${((parts[2] << 8) | parts[3]).toString(16)}`;
|
|
22
|
+
}
|
|
23
|
+
const halves = text.split("::");
|
|
24
|
+
if (halves.length > 2)
|
|
25
|
+
return null;
|
|
26
|
+
const left = halves[0] === "" ? [] : halves[0].split(":");
|
|
27
|
+
const right = halves.length === 1 || halves[1] === "" ? [] : halves[1].split(":");
|
|
28
|
+
const gap = 8 - left.length - right.length;
|
|
29
|
+
if (halves.length === 1 ? gap !== 0 : gap < 1)
|
|
30
|
+
return null;
|
|
31
|
+
const groups = [...left, ...Array(halves.length === 1 ? 0 : gap).fill("0"), ...right].map(one => parseInt(one, 16));
|
|
32
|
+
return groups.length === 8 && groups.every(one => Number.isInteger(one) && one >= 0 && one <= 0xffff) ? groups : null;
|
|
33
|
+
}
|
|
34
|
+
const refusedV4 = (address) => {
|
|
35
|
+
const [a, b] = address.split(".").map(Number);
|
|
36
|
+
return a === 127 || a === 0 || (a === 169 && b === 254);
|
|
37
|
+
};
|
|
38
|
+
const v4Of = (high, low) => `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
|
|
39
|
+
/** Whether an address is loopback, unspecified, link-local, or an IPv4-mapped or NAT64 form of these. */
|
|
40
|
+
export function addressRefused(address) {
|
|
41
|
+
const family = isIP(address);
|
|
42
|
+
if (family === 4)
|
|
43
|
+
return refusedV4(address);
|
|
44
|
+
if (family !== 6)
|
|
45
|
+
return true;
|
|
46
|
+
const g = hextets(address);
|
|
47
|
+
if (g === null)
|
|
48
|
+
return true;
|
|
49
|
+
const top = g.slice(0, 5).every(one => one === 0);
|
|
50
|
+
if (top && g[5] === 0 && g[6] === 0 && (g[7] === 0 || g[7] === 1))
|
|
51
|
+
return true; // :: and ::1
|
|
52
|
+
if ((g[0] & 0xffc0) === 0xfe80)
|
|
53
|
+
return true; // fe80::/10
|
|
54
|
+
if (top && g[5] === 0xffff)
|
|
55
|
+
return refusedV4(v4Of(g[6], g[7])); // ::ffff:a.b.c.d
|
|
56
|
+
if (top && g[5] === 0)
|
|
57
|
+
return refusedV4(v4Of(g[6], g[7])); // ::a.b.c.d (deprecated compatible form)
|
|
58
|
+
if (g[0] === 0x64 && g[1] === 0xff9b && g.slice(2, 6).every(one => one === 0))
|
|
59
|
+
return refusedV4(v4Of(g[6], g[7])); // 64:ff9b::/96
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/** The host, effective port and scheme a recorded URL connects to; null when it can't be read. The caller has already
|
|
63
|
+
* passed remoteUrlProblem. Node's URL parser normalizes every spelling of a literal host. */
|
|
64
|
+
export function destinationOf(url) {
|
|
65
|
+
const scheme = /^([A-Za-z][A-Za-z0-9+.-]*):\/\//.exec(url)?.[1]?.toLowerCase();
|
|
66
|
+
let host, port;
|
|
67
|
+
if (scheme === "https" || scheme === "ssh") {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = new URL(url);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
host = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
76
|
+
port = parsed.port === "" ? (scheme === "https" ? 443 : 22) : Number(parsed.port);
|
|
77
|
+
return validDestination(scheme, host, port);
|
|
78
|
+
}
|
|
79
|
+
if (scheme !== undefined)
|
|
80
|
+
return null;
|
|
81
|
+
const colon = url.indexOf(":");
|
|
82
|
+
if (colon <= 0)
|
|
83
|
+
return null;
|
|
84
|
+
host = url.slice(0, colon).replace(/^.*@/, "");
|
|
85
|
+
return validDestination("ssh", host, 22);
|
|
86
|
+
}
|
|
87
|
+
function validDestination(scheme, host, port) {
|
|
88
|
+
const literal = isIP(host) !== 0;
|
|
89
|
+
if (!literal && !/^[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?\.?$/.test(host))
|
|
90
|
+
return null;
|
|
91
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
92
|
+
return null;
|
|
93
|
+
return { scheme, host, port, literal };
|
|
94
|
+
}
|
|
95
|
+
async function checkedAddress(host, literal, lookup) {
|
|
96
|
+
if (literal)
|
|
97
|
+
return addressRefused(host) ? { ok: false, why: NOT_ALLOWED } : { ok: true, address: host };
|
|
98
|
+
let found;
|
|
99
|
+
try {
|
|
100
|
+
found = await lookup(host);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return { ok: false, why: "its host name couldn't be resolved" };
|
|
104
|
+
}
|
|
105
|
+
if (found.length === 0)
|
|
106
|
+
return { ok: false, why: "its host name couldn't be resolved" };
|
|
107
|
+
// Every address, not just the first: a name that also answers with this machine isn't connected to.
|
|
108
|
+
if (found.some(addressRefused))
|
|
109
|
+
return { ok: false, why: NOT_ALLOWED };
|
|
110
|
+
return { ok: true, address: found[0] };
|
|
111
|
+
}
|
|
112
|
+
const curlPin = (host, port, address) => ["-c", `http.curloptResolve=${host}:${port}:${isIP(address) === 6 ? `[${address}]` : address}`];
|
|
113
|
+
/** One NO_PROXY entry against a host, as curl compares them; "unknown" for anything Toolroll won't second-guess. */
|
|
114
|
+
function noProxyMatch(entry, host) {
|
|
115
|
+
const raw = entry.replace(/^\[(.*)\]$/, "$1");
|
|
116
|
+
if (raw === "*")
|
|
117
|
+
return true;
|
|
118
|
+
const hostIp = isIP(host);
|
|
119
|
+
const cidr = /^(.+)\/(\d+)$/.exec(raw);
|
|
120
|
+
if (cidr !== null) {
|
|
121
|
+
if (isIP(cidr[1]) === 0)
|
|
122
|
+
return "unknown";
|
|
123
|
+
if (hostIp === 0)
|
|
124
|
+
return false;
|
|
125
|
+
if (hostIp !== isIP(cidr[1]))
|
|
126
|
+
return false;
|
|
127
|
+
const bits = Number(cidr[2]);
|
|
128
|
+
if (hostIp === 4) {
|
|
129
|
+
if (bits > 32)
|
|
130
|
+
return "unknown";
|
|
131
|
+
const value = (ip) => ip.split(".").reduce((n, part) => n * 256 + Number(part), 0);
|
|
132
|
+
const size = 2 ** (32 - bits);
|
|
133
|
+
return Math.floor(value(host) / size) === Math.floor(value(cidr[1]) / size);
|
|
134
|
+
}
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
if (isIP(raw) !== 0)
|
|
138
|
+
return hostIp !== 0 && (hextets(raw)?.join() ?? raw) === (hextets(host)?.join() ?? host);
|
|
139
|
+
if (/[^A-Za-z0-9.-]/.test(raw))
|
|
140
|
+
return "unknown";
|
|
141
|
+
if (hostIp !== 0)
|
|
142
|
+
return false;
|
|
143
|
+
const name = raw.replace(/^\.+/, "").replace(/\.+$/, "").toLowerCase();
|
|
144
|
+
const wanted = host.replace(/\.+$/, "").toLowerCase();
|
|
145
|
+
return name !== "" && (wanted === name || wanted.endsWith(`.${name}`));
|
|
146
|
+
}
|
|
147
|
+
/** Whether curl would skip the proxy for this host; "unknown" when an entry can't be decided. */
|
|
148
|
+
export function noProxyApplies(noProxy, host) {
|
|
149
|
+
let unknown = false;
|
|
150
|
+
for (const entry of noProxy.split(/[,\s]+/).filter(Boolean)) {
|
|
151
|
+
const hit = noProxyMatch(entry, host);
|
|
152
|
+
if (hit === true)
|
|
153
|
+
return true;
|
|
154
|
+
if (hit === "unknown")
|
|
155
|
+
unknown = true;
|
|
156
|
+
}
|
|
157
|
+
return unknown ? "unknown" : false;
|
|
158
|
+
}
|
|
159
|
+
const envOf = (env, lower, upper) => {
|
|
160
|
+
const low = env[lower];
|
|
161
|
+
return low !== undefined && low !== "" ? low : env[upper] ?? "";
|
|
162
|
+
};
|
|
163
|
+
/** The proxy curl would use from `https_proxy`/`HTTPS_PROXY`: http or https schemes only (a SOCKS proxy resolves names
|
|
164
|
+
* on its side, in ways this can't see). */
|
|
165
|
+
function proxyOf(value) {
|
|
166
|
+
const text = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value) ? value : `http://${value}`;
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = new URL(text);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
|
175
|
+
return null;
|
|
176
|
+
const host = parsed.hostname.replace(/^\[(.*)\]$/, "$1");
|
|
177
|
+
const port = parsed.port === "" ? (parsed.protocol === "https:" ? 443 : 1080) : Number(parsed.port);
|
|
178
|
+
const literal = isIP(host) !== 0;
|
|
179
|
+
if (host === "" || (!literal && !/^[A-Za-z0-9.-]+$/.test(host)))
|
|
180
|
+
return null;
|
|
181
|
+
return { host, port, literal };
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Decide the route and pin it. `env` is what git will see (the names remote-main.ts's ENV_ALLOWLIST passes). ssh never
|
|
185
|
+
* uses a proxy. Through a proxy, the proxy's own address is checked and pinned; the operator's proxy then chooses the
|
|
186
|
+
* final destination, and the caller says so.
|
|
187
|
+
*/
|
|
188
|
+
export async function pinDestination(url, env, lookup = lookupAll) {
|
|
189
|
+
const target = destinationOf(url);
|
|
190
|
+
if (target === null)
|
|
191
|
+
return { ok: false, why: UNDECIDED };
|
|
192
|
+
if (target.scheme === "ssh") {
|
|
193
|
+
const checked = await checkedAddress(target.host, target.literal, lookup);
|
|
194
|
+
if (!checked.ok)
|
|
195
|
+
return checked;
|
|
196
|
+
return { ok: true, pin: { gitArgs: [], proxy: null, env: { GIT_SSH_COMMAND: `ssh -o BatchMode=yes -o ProxyCommand=none -o ProxyJump=none -o HostName=${checked.address} -o Port=${target.port} -o HostKeyAlias=${target.host} -o CheckHostIP=no` } } };
|
|
197
|
+
}
|
|
198
|
+
const proxyValue = envOf(env, "https_proxy", "HTTPS_PROXY");
|
|
199
|
+
if (proxyValue !== "") {
|
|
200
|
+
const skip = noProxyApplies(envOf(env, "no_proxy", "NO_PROXY"), target.host);
|
|
201
|
+
if (skip === "unknown")
|
|
202
|
+
return { ok: false, why: UNDECIDED };
|
|
203
|
+
if (!skip) {
|
|
204
|
+
const proxy = proxyOf(proxyValue);
|
|
205
|
+
if (proxy === null)
|
|
206
|
+
return { ok: false, why: UNDECIDED };
|
|
207
|
+
const checked = await checkedAddress(proxy.host, proxy.literal, lookup);
|
|
208
|
+
if (!checked.ok)
|
|
209
|
+
return { ok: false, why: `your proxy's ${checked.why.replace(/^its /, "")}` };
|
|
210
|
+
return { ok: true, pin: { gitArgs: curlPin(proxy.host, proxy.port, checked.address), env: {}, proxy: proxy.host } };
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const checked = await checkedAddress(target.host, target.literal, lookup);
|
|
214
|
+
if (!checked.ok)
|
|
215
|
+
return checked;
|
|
216
|
+
return { ok: true, pin: { gitArgs: curlPin(target.host, target.port, checked.address), env: {}, proxy: null } };
|
|
217
|
+
}
|
package/dist/remote-main.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { type ExecResult } from "./exec.js";
|
|
2
|
+
import type { BoundedOptions, BoundedResult, BoundedRunner } from "./bounded-spawn.js";
|
|
3
|
+
import type { Pin } from "./remote-destination.js";
|
|
2
4
|
import type { Store } from "./store.js";
|
|
3
5
|
import type { Runner } from "./worktree.js";
|
|
4
6
|
/** What the private directory's git may see of this process's environment: nothing that names a repository, an
|
|
@@ -40,7 +42,13 @@ export type RemoteMainRead = {
|
|
|
40
42
|
} | {
|
|
41
43
|
ok: false;
|
|
42
44
|
why: string;
|
|
45
|
+
reason?: "moved";
|
|
43
46
|
};
|
|
47
|
+
/** What every reader of a remote main shows when its address redirects. The target is remote-supplied text: it is never
|
|
48
|
+
* shown, stored or followed, and the new remote is never adopted automatically. */
|
|
49
|
+
export declare const REMOTE_MOVED = "The repository moved: its address now redirects. Update the project's remote in Toolroll (Settings \u2192 Projects), then try again.";
|
|
50
|
+
/** git's words for a refused redirect (http.followRedirects=false), read for the status code alone. */
|
|
51
|
+
export declare function redirected(stderr: string): boolean;
|
|
44
52
|
/**
|
|
45
53
|
* One pass's reads of recorded remotes. Every `read` fetches again (the pre-teardown re-check is never older than
|
|
46
54
|
* the teardown); a project's private directory lasts until `close`, so a fetch after the first only brings what
|
|
@@ -61,3 +69,43 @@ export declare class RemoteMains {
|
|
|
61
69
|
read(repo: string): Promise<RemoteMainRead>;
|
|
62
70
|
close(): void;
|
|
63
71
|
}
|
|
72
|
+
export declare const MAP_FETCH_LIMITS: {
|
|
73
|
+
readonly timeoutMs: 120000;
|
|
74
|
+
readonly memoryBytes: number;
|
|
75
|
+
readonly diskBytes: number;
|
|
76
|
+
readonly fileBytes: number;
|
|
77
|
+
readonly maxStderr: number;
|
|
78
|
+
};
|
|
79
|
+
export type MapGitOptions = {
|
|
80
|
+
helper?: string;
|
|
81
|
+
extra?: readonly string[];
|
|
82
|
+
};
|
|
83
|
+
/** Git in a private directory under the map's rules: the same hardening as `RemoteMains`, plus the pin. */
|
|
84
|
+
export declare function mapGitArgs(dir: string, pin: Pin, args: readonly string[], options?: MapGitOptions): string[];
|
|
85
|
+
/** The environment for it: exactly the allowed names, the fixed settings, and the pin's. */
|
|
86
|
+
export declare function mapGitEnv(pin: Pin, from?: Readonly<Record<string, string | undefined>>): Record<string, string>;
|
|
87
|
+
export declare function initPrivateDir(dir: string, bounded: BoundedRunner): Promise<BoundedResult>;
|
|
88
|
+
export type MapPeek = {
|
|
89
|
+
ok: true;
|
|
90
|
+
commit: string | null;
|
|
91
|
+
} | {
|
|
92
|
+
ok: false;
|
|
93
|
+
why: string;
|
|
94
|
+
reason?: "moved";
|
|
95
|
+
};
|
|
96
|
+
/** The commit the recorded remote's main is at, without fetching: null when the remote has no `main`. */
|
|
97
|
+
export declare function peekMain(url: string, dir: string, pin: Pin, bounded: BoundedRunner, helper?: string): Promise<MapPeek>;
|
|
98
|
+
/** A depth-1 private folder is clean when it holds only what the fetch made: a `shallow` file naming exactly the fetched
|
|
99
|
+
* commit, and no grafts or alternates. */
|
|
100
|
+
export declare function shallowClean(dir: string, commit: string): boolean;
|
|
101
|
+
export type MapFetch = {
|
|
102
|
+
ok: true;
|
|
103
|
+
commit: string;
|
|
104
|
+
enforced: "system" | "checked";
|
|
105
|
+
} | {
|
|
106
|
+
ok: false;
|
|
107
|
+
why: string;
|
|
108
|
+
reason?: "moved";
|
|
109
|
+
};
|
|
110
|
+
/** Fetch main at depth 1 into `dir`, under the map's limits. `onStart` records the spawn for crash cleanup. */
|
|
111
|
+
export declare function readShallowMain(url: string, dir: string, pin: Pin, bounded: BoundedRunner, onStart?: BoundedOptions["onStart"], helper?: string): Promise<MapFetch>;
|