toolroll 0.9.54 → 0.9.55

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.
Files changed (199) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -4
  3. package/dist/agent-onboard.js +4 -1
  4. package/dist/approval-rules-ui.d.ts +3 -2
  5. package/dist/approval-rules-ui.js +15 -20
  6. package/dist/assignment-ui.d.ts +3 -2
  7. package/dist/assignment-ui.js +4 -11
  8. package/dist/backup-ui.d.ts +2 -1
  9. package/dist/backup-ui.js +16 -27
  10. package/dist/brand-mark.d.ts +3 -2
  11. package/dist/brand-mark.js +4 -4
  12. package/dist/browser/workspace.css +1 -1
  13. package/dist/browser/workspace.js +25 -25
  14. package/dist/browser-shell.d.ts +2 -1
  15. package/dist/browser-shell.js +9 -9
  16. package/dist/browser-workspace.d.ts +2 -3
  17. package/dist/browser-workspace.js +0 -3
  18. package/dist/chat-polish.d.ts +5 -4
  19. package/dist/chat-polish.js +9 -10
  20. package/dist/check-levels-ui.d.ts +2 -1
  21. package/dist/check-levels-ui.js +26 -53
  22. package/dist/cli.d.ts +3 -1
  23. package/dist/cli.js +9 -3
  24. package/dist/coding-shipping-ui.d.ts +2 -1
  25. package/dist/coding-shipping-ui.js +6 -6
  26. package/dist/coding-ui.d.ts +3 -1
  27. package/dist/coding-ui.js +30 -29
  28. package/dist/contracts/cli.d.ts +1 -3
  29. package/dist/contracts/console-api.d.ts +1 -1
  30. package/dist/contracts/console-api.js +6 -5
  31. package/dist/contracts/health.d.ts +1 -3
  32. package/dist/control-ui.d.ts +5 -4
  33. package/dist/control-ui.js +9 -34
  34. package/dist/credentials-ui.d.ts +5 -4
  35. package/dist/credentials-ui.js +23 -24
  36. package/dist/demo-chat.d.ts +3 -3
  37. package/dist/demo-chat.js +29 -76
  38. package/dist/discord-settings.d.ts +3 -2
  39. package/dist/discord-settings.js +17 -42
  40. package/dist/envelope.js +10 -0
  41. package/dist/evidence-pack.d.ts +2 -1
  42. package/dist/evidence-pack.js +45 -49
  43. package/dist/export-ui.d.ts +7 -2
  44. package/dist/export-ui.js +4 -10
  45. package/dist/flow-gallery-ui.d.ts +4 -3
  46. package/dist/flow-gallery-ui.js +28 -43
  47. package/dist/flow-starters-ui.d.ts +2 -1
  48. package/dist/flow-starters-ui.js +12 -15
  49. package/dist/flow-triggers.d.ts +1 -0
  50. package/dist/flow-triggers.js +16 -14
  51. package/dist/flows-ui.d.ts +4 -3
  52. package/dist/flows-ui.js +19 -30
  53. package/dist/flows.js +2 -1
  54. package/dist/guides.js +108 -3
  55. package/dist/html.d.ts +76 -0
  56. package/dist/html.js +173 -0
  57. package/dist/integrations-ui.d.ts +2 -1
  58. package/dist/integrations-ui.js +19 -23
  59. package/dist/kits-ui.d.ts +5 -4
  60. package/dist/kits-ui.js +18 -20
  61. package/dist/knowledge-ui.d.ts +6 -5
  62. package/dist/knowledge-ui.js +26 -25
  63. package/dist/lead-context.d.ts +11 -1
  64. package/dist/lead-context.js +6 -8
  65. package/dist/ledger-view.d.ts +2 -1
  66. package/dist/ledger-view.js +27 -29
  67. package/dist/limits-ui.d.ts +2 -1
  68. package/dist/limits-ui.js +7 -7
  69. package/dist/live-bus.d.ts +50 -8
  70. package/dist/live-bus.js +133 -50
  71. package/dist/mcp-oauth.js +35 -36
  72. package/dist/models-ui.d.ts +2 -1
  73. package/dist/models-ui.js +25 -35
  74. package/dist/monitoring-ui.d.ts +4 -3
  75. package/dist/monitoring-ui.js +13 -26
  76. package/dist/openrouter-models.d.ts +2 -1
  77. package/dist/openrouter-models.js +5 -9
  78. package/dist/operate.js +11 -7
  79. package/dist/people-audit-ui.d.ts +2 -1
  80. package/dist/people-audit-ui.js +13 -13
  81. package/dist/policy-ui.d.ts +3 -2
  82. package/dist/policy-ui.js +17 -23
  83. package/dist/project-delete-ui.d.ts +4 -3
  84. package/dist/project-delete-ui.js +15 -27
  85. package/dist/pull-request-ui.d.ts +2 -1
  86. package/dist/pull-request-ui.js +17 -32
  87. package/dist/recipe-ui.d.ts +8 -7
  88. package/dist/recipe-ui.js +39 -32
  89. package/dist/release-adapters.d.ts +60 -0
  90. package/dist/release-adapters.js +435 -0
  91. package/dist/release-cli.d.ts +24 -0
  92. package/dist/release-cli.js +181 -0
  93. package/dist/release-gate.d.ts +80 -0
  94. package/dist/release-gate.js +150 -0
  95. package/dist/release-links.d.ts +29 -0
  96. package/dist/release-links.js +102 -0
  97. package/dist/release.d.ts +331 -0
  98. package/dist/release.js +550 -0
  99. package/dist/reply-shape.d.ts +2 -1
  100. package/dist/reply-shape.js +5 -5
  101. package/dist/repository-context-ui.d.ts +2 -1
  102. package/dist/repository-context-ui.js +2 -7
  103. package/dist/request-budget-ui.d.ts +2 -1
  104. package/dist/request-budget-ui.js +16 -16
  105. package/dist/result-review.d.ts +2 -3
  106. package/dist/result-review.js +3 -9
  107. package/dist/retention-ui.d.ts +7 -1
  108. package/dist/retention-ui.js +7 -11
  109. package/dist/serve.d.ts +11 -2
  110. package/dist/serve.js +69 -58
  111. package/dist/server/chat.d.ts +3 -6
  112. package/dist/server/chat.js +969 -1006
  113. package/dist/server/chrome.d.ts +291 -0
  114. package/dist/server/chrome.js +747 -0
  115. package/dist/server/console.css +2819 -0
  116. package/dist/server/flows.d.ts +2 -6
  117. package/dist/server/flows.js +840 -766
  118. package/dist/server/guards.d.ts +1 -1
  119. package/dist/server/guards.js +5 -2
  120. package/dist/server/handler-context.d.ts +1 -1
  121. package/dist/server/handler-context.js +1 -1
  122. package/dist/server/handler-registry.d.ts +8 -6
  123. package/dist/server/handler-registry.js +14 -2
  124. package/dist/server/http.d.ts +274 -0
  125. package/dist/server/http.js +239 -0
  126. package/dist/server/live.d.ts +34 -0
  127. package/dist/server/live.js +309 -0
  128. package/dist/server/pages.d.ts +2 -6
  129. package/dist/server/pages.js +808 -789
  130. package/dist/server/people-tokens.d.ts +1 -6
  131. package/dist/server/people-tokens.js +580 -572
  132. package/dist/server/remote.d.ts +4 -7
  133. package/dist/server/remote.js +37 -70
  134. package/dist/server/render-chat.d.ts +402 -0
  135. package/dist/server/render-chat.js +927 -0
  136. package/dist/server/render-pages.d.ts +535 -0
  137. package/dist/server/render-pages.js +1277 -0
  138. package/dist/server/render-people.d.ts +32 -0
  139. package/dist/server/render-people.js +121 -0
  140. package/dist/server/render-results.d.ts +744 -0
  141. package/dist/server/render-results.js +2237 -0
  142. package/dist/server/render-settings.d.ts +147 -0
  143. package/dist/server/render-settings.js +577 -0
  144. package/dist/server/render-tasks.d.ts +632 -0
  145. package/dist/server/render-tasks.js +2345 -0
  146. package/dist/server/request-context.d.ts +46 -0
  147. package/dist/server/request-context.js +17 -0
  148. package/dist/server/route-table.d.ts +25 -3
  149. package/dist/server/route-table.js +163 -75
  150. package/dist/server/runtime.d.ts +15 -7
  151. package/dist/server/runtime.js +7 -1
  152. package/dist/server/session.d.ts +138 -0
  153. package/dist/server/session.js +94 -0
  154. package/dist/server/settings.d.ts +1 -5
  155. package/dist/server/settings.js +2181 -2117
  156. package/dist/server/task-views.d.ts +11 -4
  157. package/dist/server/task-views.js +26 -22
  158. package/dist/server/tasks.d.ts +9 -9
  159. package/dist/server/tasks.js +1486 -1418
  160. package/dist/server-telemetry.d.ts +4 -3
  161. package/dist/server-telemetry.js +4 -8
  162. package/dist/skills-ui.d.ts +4 -3
  163. package/dist/skills-ui.js +14 -17
  164. package/dist/skills.d.ts +1 -1
  165. package/dist/skills.js +2 -1
  166. package/dist/slack-settings.d.ts +3 -2
  167. package/dist/slack-settings.js +17 -42
  168. package/dist/spend-ui.d.ts +2 -1
  169. package/dist/spend-ui.js +9 -32
  170. package/dist/sso-ui.d.ts +3 -2
  171. package/dist/sso-ui.js +26 -27
  172. package/dist/storage-ui.d.ts +2 -1
  173. package/dist/storage-ui.js +14 -33
  174. package/dist/surface.js +14 -3
  175. package/dist/task-composer.d.ts +2 -1
  176. package/dist/task-composer.js +7 -7
  177. package/dist/task-status.d.ts +4 -3
  178. package/dist/task-status.js +15 -16
  179. package/dist/team-http.d.ts +5 -9
  180. package/dist/team-http.js +5 -63
  181. package/dist/team-ui.d.ts +2 -1
  182. package/dist/team-ui.js +2 -5
  183. package/dist/teammates-ui.d.ts +5 -4
  184. package/dist/teammates-ui.js +61 -90
  185. package/dist/teams-settings.d.ts +3 -2
  186. package/dist/teams-settings.js +20 -31
  187. package/dist/telegram-settings.d.ts +3 -2
  188. package/dist/telegram-settings.js +14 -25
  189. package/dist/toolroll-update-ui.d.ts +3 -2
  190. package/dist/toolroll-update-ui.js +35 -39
  191. package/dist/tools-ui.d.ts +5 -2
  192. package/dist/tools-ui.js +31 -31
  193. package/dist/when-html.d.ts +11 -3
  194. package/dist/when-html.js +5 -6
  195. package/dist/workspace-ui.d.ts +4 -2
  196. package/dist/workspace-ui.js +10 -10
  197. package/package.json +1 -1
  198. package/dist/server/shared.d.ts +0 -3151
  199. package/dist/server/shared.js +0 -12001
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The world `toolroll release` acts on (release.ts): the gate checkout through git, GitHub through the signed-in `gh`,
3
+ * the npm registry over HTTPS, the installed Toolroll through its own CLI (`--json`) and launchd definitions, and the
4
+ * Homebrew tap through `gh`. Each call is bounded; none asks for or stores a credential — `gh`, npm and Toolroll use
5
+ * the sign-ins this machine already has.
6
+ */
7
+ import type { GateState, Installed, ReleaseAdapters } from "./release.js";
8
+ export type Exec = (command: string, args: readonly string[], options?: {
9
+ cwd?: string;
10
+ timeoutMs?: number;
11
+ env?: NodeJS.ProcessEnv;
12
+ }) => Promise<{
13
+ code: number;
14
+ stdout: string;
15
+ stderr: string;
16
+ }>;
17
+ /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
18
+ export declare const exec: Exec;
19
+ /** owner/name from an https or ssh GitHub remote. */
20
+ export declare function githubSlug(url: string): string | null;
21
+ /** The runtime (a `dist` folder, resolved) each launchd service of Toolroll runs: its definition names `<dist>/cli.js`. */
22
+ export declare function serviceRuntimes(agents?: string): string[];
23
+ /** The runtime each CLI name resolves to on PATH: the `dist` folder its `bin.js` sits in, or null when not linked there. */
24
+ export declare function cliRuntimes(path?: string, names?: string[]): Installed["clis"];
25
+ type Json = Record<string, unknown>;
26
+ /** `task show --json`, read as the release needs it. */
27
+ export declare function gateStateOf(answer: Json, accounts?: readonly unknown[]): GateState;
28
+ /** Every process on this machine (`ps`): pid and full command line. */
29
+ export declare function processes(): {
30
+ pid: number;
31
+ command: string;
32
+ }[];
33
+ /** The final line of deploy-browser's output that is a JSON object: its `runtime` (a dist folder), when it has one. */
34
+ export declare function deployedRuntime(stdout: string): string | null;
35
+ /** What a deployment's stage says about the update pause: its id and phase when the journal is before the swap and the
36
+ * live database's pause is that deployment's. `owner` reads the live pause's owner. */
37
+ export declare function stageGate(stage: string, owner: () => string | null): {
38
+ id: string;
39
+ phase: string;
40
+ } | null;
41
+ export type AdapterOptions = {
42
+ checkout: string;
43
+ /** The live database (default: this installation's). */
44
+ database?: string;
45
+ processes?: typeof processes;
46
+ /** owner/name of the Homebrew tap and its formula's path there. */
47
+ tap?: string;
48
+ formula?: string;
49
+ /** How long the deployment itself may run, in minutes. */
50
+ deployMinutes?: number;
51
+ run?: Exec;
52
+ fetch?: typeof fetch;
53
+ say?: (line: string) => void;
54
+ now?: () => Date;
55
+ sleep?: (ms: number) => Promise<void>;
56
+ };
57
+ export declare function releaseAdapters(options: AdapterOptions): ReleaseAdapters;
58
+ /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
59
+ export declare function bumpFormula(text: string, url: string, sha256: string): string;
60
+ export {};
@@ -0,0 +1,435 @@
1
+ /**
2
+ * The world `toolroll release` acts on (release.ts): the gate checkout through git, GitHub through the signed-in `gh`,
3
+ * the npm registry over HTTPS, the installed Toolroll through its own CLI (`--json`) and launchd definitions, and the
4
+ * Homebrew tap through `gh`. Each call is bounded; none asks for or stores a credential — `gh`, npm and Toolroll use
5
+ * the sign-ins this machine already has.
6
+ */
7
+ import { execFile, spawnSync } from "node:child_process";
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { basename, delimiter, dirname, join } from "node:path";
12
+ import { createRequire } from "node:module";
13
+ // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
14
+ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
15
+ import { activeUpdateWork } from "./desktop-update-gate.js";
16
+ import { BEFORE_SWAP, gateOwner } from "./release-gate.js";
17
+ import { findCliLinks, switchCliLinks } from "./release-links.js";
18
+ import { databasePath } from "./store.js";
19
+ /** A child process, its output kept whole; a missing program or a time limit is exit 127 or 124, never a throw. */
20
+ export const exec = (command, args, options = {}) => new Promise(done => {
21
+ execFile(command, [...args], { cwd: options.cwd, env: options.env ?? process.env, timeout: options.timeoutMs ?? 120_000, maxBuffer: 64 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
22
+ const failure = error;
23
+ const code = failure === null ? 0 : failure.code === "ENOENT" ? 127 : failure.killed === true ? 124 : typeof failure.code === "number" ? failure.code : 1;
24
+ done({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
25
+ });
26
+ });
27
+ /** Without NODE_OPTIONS, which a test runner or debugger may have left for this process. */
28
+ const cleanEnv = (extra = {}) => {
29
+ const env = { ...process.env, ...extra };
30
+ delete env["NODE_OPTIONS"];
31
+ return env;
32
+ };
33
+ const must = async (run, what) => {
34
+ const result = await run;
35
+ if (result.code !== 0)
36
+ throw new Error(`${what} failed (exit ${result.code}): ${(result.stderr || result.stdout).trim().split("\n").slice(-3).join(" ")}`);
37
+ return result.stdout;
38
+ };
39
+ /** owner/name from an https or ssh GitHub remote. */
40
+ export function githubSlug(url) {
41
+ const match = /github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?\/?$/.exec(url.trim());
42
+ return match === null ? null : `${match[1]}/${match[2]}`;
43
+ }
44
+ /** The runtime (a `dist` folder, resolved) each launchd service of Toolroll runs: its definition names `<dist>/cli.js`. */
45
+ export function serviceRuntimes(agents = join(homedir(), "Library", "LaunchAgents")) {
46
+ let names = [];
47
+ try {
48
+ names = readdirSync(agents).filter(one => /^com\.(toolroll|standing-orders)\..+\.plist$/.test(one));
49
+ }
50
+ catch {
51
+ return [];
52
+ }
53
+ const found = [];
54
+ for (const name of names) {
55
+ const text = readFileSync(join(agents, name), "utf8");
56
+ for (const match of text.matchAll(/<string>([^<]*\/dist)\/cli\.js<\/string>/g))
57
+ found.push(resolved(match[1]));
58
+ }
59
+ return [...new Set(found)];
60
+ }
61
+ /** The runtime each CLI name resolves to on PATH: the `dist` folder its `bin.js` sits in, or null when not linked there. */
62
+ export function cliRuntimes(path = process.env["PATH"] ?? "", names = ["toolroll", "standing-orders"]) {
63
+ return names.map(name => {
64
+ for (const dir of path.split(delimiter).filter(Boolean)) {
65
+ const candidate = join(dir, name);
66
+ if (!existsSync(candidate))
67
+ continue;
68
+ const real = resolved(candidate);
69
+ return { name, runtime: basename(real) === "bin.js" && basename(dirname(real)) === "dist" ? dirname(real) : null };
70
+ }
71
+ return { name, runtime: null };
72
+ });
73
+ }
74
+ const resolved = (path) => { try {
75
+ return realpathSync(path);
76
+ }
77
+ catch {
78
+ return path;
79
+ } };
80
+ const object = (value) => (value !== null && typeof value === "object" && !Array.isArray(value) ? value : {});
81
+ const textOr = (value) => (typeof value === "string" && value !== "" ? value : null);
82
+ /** `task show --json`, read as the release needs it. */
83
+ export function gateStateOf(answer, accounts = []) {
84
+ if (answer["ok"] !== true)
85
+ return { exists: false, candidate: null, scopeDigest: null, approval: null, result: null, failed: null, completion: null };
86
+ const scope = object(answer["scope"]);
87
+ const assignment = object(answer["assignment"]);
88
+ const result = object(assignment["result"]);
89
+ const checks = object(result["checks"]);
90
+ const runs = Array.isArray(answer["runs"]) ? answer["runs"].map(object) : [];
91
+ const builders = runs.filter(run => run["role"] === "builder");
92
+ const newest = builders.sort((a, b) => Number(b["id"]) - Number(a["id"]))[0];
93
+ const resultRun = typeof result["runId"] === "number" ? result["runId"] : null;
94
+ const completion = object(assignment["completion"]);
95
+ const approvedAt = textOr(scope["approvedAt"]), approvedBy = textOr(scope["approvedBy"]), approvedDigest = textOr(scope["approvedDigest"]);
96
+ const account = accounts.map(object).find(one => one["name"] === approvedBy);
97
+ // A newer finished build than the result, with no result of its own, failed before its check could seal one.
98
+ const failed = newest !== undefined && newest["finishedAt"] != null && newest["outcome"] !== "built" && newest["outcome"] !== "no-change" && Number(newest["id"]) !== resultRun
99
+ ? `run ${String(newest["id"])} ended ${String(newest["outcome"] ?? "without an outcome")}` : null;
100
+ const worktree = runs.find(run => run["id"] === resultRun)?.["worktree"];
101
+ return {
102
+ exists: true,
103
+ candidate: textOr(scope["candidate"]),
104
+ scopeDigest: textOr(scope["digest"]),
105
+ approval: approvedAt === null || approvedBy === null || approvedDigest === null ? null : {
106
+ by: approvedBy, at: approvedAt, digest: approvedDigest, basis: textOr(scope["approvalBasis"]),
107
+ role: textOr(account?.["role"]), active: account?.["revokedAt"] === null,
108
+ },
109
+ result: resultRun === null || textOr(result["digest"]) === null ? null : {
110
+ run: resultRun, head: textOr(result["head"]), receipt: String(result["digest"]), worktree: textOr(worktree),
111
+ level: textOr(checks["level"]),
112
+ check: textOr(checks["running"]) !== null ? "running"
113
+ : checks["status"] === "passed" && checks["level"] !== "full" ? "not-full" : String(checks["status"] ?? "not-run"),
114
+ },
115
+ failed,
116
+ completion: textOr(completion["digest"]) === null ? null : { actor: String(completion["actor"]), at: String(completion["at"]), digest: String(completion["digest"]) },
117
+ };
118
+ }
119
+ /** Every process on this machine (`ps`): pid and full command line. */
120
+ export function processes() {
121
+ const listed = spawnSync("/bin/ps", ["-axww", "-o", "pid=,command="], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
122
+ if (listed.status !== 0)
123
+ throw new Error(`ps failed: ${listed.stderr.trim()}`);
124
+ return listed.stdout.split("\n").map(line => /^\s*(\d+)\s+(.*)$/.exec(line)).filter(match => match !== null).map(match => ({ pid: Number(match[1]), command: match[2] }));
125
+ }
126
+ /** The final line of deploy-browser's output that is a JSON object: its `runtime` (a dist folder), when it has one. */
127
+ export function deployedRuntime(stdout) {
128
+ const last = stdout.trim().split("\n").reverse().find(line => line.startsWith("{"));
129
+ try {
130
+ const runtime = object(JSON.parse(last ?? ""))["runtime"];
131
+ return typeof runtime === "string" && /[\\/]dist$/.test(runtime) ? runtime : null;
132
+ }
133
+ catch {
134
+ return null;
135
+ }
136
+ }
137
+ /** What a deployment's stage says about the update pause: its id and phase when the journal is before the swap and the
138
+ * live database's pause is that deployment's. `owner` reads the live pause's owner. */
139
+ export function stageGate(stage, owner) {
140
+ let journal;
141
+ try {
142
+ journal = object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")));
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ const id = textOr(journal["id"]), phase = textOr(journal["phase"]);
148
+ return id !== null && phase !== null && BEFORE_SWAP.includes(phase) && owner() === id ? { id, phase } : null;
149
+ }
150
+ export function releaseAdapters(options) {
151
+ const run = options.run ?? exec;
152
+ const get = options.fetch ?? fetch;
153
+ const { checkout } = options;
154
+ const tap = options.tap ?? "ap9000/homebrew-toolroll";
155
+ const formula = options.formula ?? "Formula/toolroll.rb";
156
+ const git = (...args) => run("git", ["-C", checkout, ...args]);
157
+ const gh = (...args) => run("gh", args);
158
+ const ghJson = async (what, ...args) => JSON.parse(await must(gh(...args), what));
159
+ const toolroll = async (...args) => {
160
+ const result = await run("toolroll", [...args, "--json"], { env: cleanEnv(), timeoutMs: 120_000 });
161
+ try {
162
+ return object(JSON.parse(result.stdout));
163
+ }
164
+ catch {
165
+ throw new Error(`toolroll ${args[0]} ${args[1] ?? ""} answered no JSON (exit ${result.code}): ${(result.stderr || result.stdout).trim().slice(0, 300)}`);
166
+ }
167
+ };
168
+ const refused = (answer) => `${String(answer["message"] ?? answer["reason"] ?? "refused")}`;
169
+ const database = options.database ?? databasePath(process.env, homedir());
170
+ const liveOwner = () => {
171
+ if (!existsSync(database))
172
+ return null;
173
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
174
+ try {
175
+ return gateOwner(db);
176
+ }
177
+ finally {
178
+ db.close();
179
+ }
180
+ };
181
+ const tail = (result) => (result.stderr || result.stdout).trim().split("\n").slice(-4).join(" ") || `exit ${result.code}`;
182
+ /** A deployment that stopped: in words, with the pause it left when that pause is before the swap. */
183
+ const stopped = (stage, message) => {
184
+ const gate = stageGate(stage, liveOwner);
185
+ return gate === null ? { ok: false, message } : { ok: false, message, gate };
186
+ };
187
+ const deployScript = (worktree, stage, runId, extra) => run(process.execPath, ["scripts/deploy-browser.mjs", "--run", String(runId), "--stage", stage, ...extra], {
188
+ cwd: worktree, env: cleanEnv({ TMPDIR: resolved(process.env["TMPDIR"] ?? "/tmp") }), timeoutMs: (options.deployMinutes ?? 30) * 60_000,
189
+ });
190
+ return {
191
+ now: options.now ?? (() => new Date()),
192
+ sleep: options.sleep ?? (ms => new Promise(done => setTimeout(done, ms))),
193
+ say: options.say ?? (line => process.stdout.write(`${line}\n`)),
194
+ repo: {
195
+ async github() {
196
+ const slug = githubSlug(await must(git("remote", "get-url", "origin"), "git remote get-url origin"));
197
+ if (slug === null)
198
+ throw new Error(`${checkout}'s origin is not a GitHub repository`);
199
+ return slug;
200
+ },
201
+ async sync(branch) {
202
+ await must(git("fetch", "--quiet", "origin", "+refs/heads/main:refs/remotes/origin/main"), "git fetch origin main");
203
+ // A merged branch may be gone from GitHub; its commit is already here.
204
+ await git("fetch", "--quiet", "origin", `+refs/heads/${branch}:refs/remotes/origin/${branch}`);
205
+ const current = (await git("symbolic-ref", "--quiet", "--short", "HEAD")).stdout.trim();
206
+ if (current === "main") {
207
+ await must(git("merge", "--ff-only", "--quiet", "origin/main"), "git merge --ff-only origin/main");
208
+ return;
209
+ }
210
+ const local = (await git("rev-parse", "--verify", "--quiet", "refs/heads/main")).stdout.trim();
211
+ if (local === "" || (await git("merge-base", "--is-ancestor", local, "origin/main")).code === 0) {
212
+ await must(git("update-ref", "refs/heads/main", "origin/main", ...(local === "" ? [] : [local])), "git update-ref main");
213
+ }
214
+ else
215
+ throw new Error(`${checkout}'s local main has commits origin/main doesn't; the gate diffs from it, so fix it first`);
216
+ },
217
+ async remoteHead(branch) {
218
+ const out = await must(git("ls-remote", "origin", `refs/heads/${branch}`), "git ls-remote");
219
+ return out.trim().split(/\s+/)[0] || null;
220
+ },
221
+ async deleteBranch(branch, head) {
222
+ const ref = `refs/heads/${branch}`;
223
+ const current = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
224
+ if (current === null)
225
+ return;
226
+ if (current !== head)
227
+ throw new Error(`${branch} moved after merging; its new head was not deleted.`);
228
+ await must(git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`), `deleting merged branch ${branch}`);
229
+ },
230
+ async tree(sha) { return (await must(git("rev-parse", `${sha}^{tree}`), `git rev-parse ${sha.slice(0, 7)}^{tree}`)).trim(); },
231
+ async fileAt(sha, path) {
232
+ const result = await git("show", `${sha}:${path}`);
233
+ return result.code === 0 ? result.stdout : null;
234
+ },
235
+ async mainHead() { return (await must(git("rev-parse", "refs/remotes/origin/main"), "git rev-parse origin/main")).trim(); },
236
+ async contains(sha, ancestor) { return (await git("merge-base", "--is-ancestor", ancestor, sha)).code === 0; },
237
+ },
238
+ toolroll: {
239
+ async installed() { return { services: serviceRuntimes(), clis: cliRuntimes() }; },
240
+ async runtimeCommit(runtime) {
241
+ // deploy-browser writes this beside node_modules, not in the package's own manifest (whose version can repeat).
242
+ try {
243
+ const candidate = object(JSON.parse(readFileSync(join(runtime, "..", "..", "..", "package.json"), "utf8")))["candidate"];
244
+ return typeof candidate === "string" && /^[0-9a-f]{40}$/.test(candidate) ? candidate : null;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
249
+ },
250
+ async gate(taskId) {
251
+ const answer = await toolroll("task", "show", taskId);
252
+ if (textOr(object(answer["scope"])["approvedBy"]) === null)
253
+ return gateStateOf(answer);
254
+ const people = await toolroll("people", "list");
255
+ if (people["ok"] !== true || !Array.isArray(people["accounts"]))
256
+ throw new Error(`Cannot verify the release approver: ${refused(people)}`);
257
+ return gateStateOf(answer, people["accounts"]);
258
+ },
259
+ async fileGate(input) {
260
+ const added = await toolroll("task", "add", input.title, "--id", input.taskId, "--repo", input.checkout, "--checks", "full");
261
+ if (added["ok"] !== true)
262
+ throw new Error(`task add: ${refused(added)}`);
263
+ const scoped = await toolroll("task", "scope", input.taskId, "--candidate", input.candidate, "--goal", input.goal, "--acceptance", input.acceptance);
264
+ if (scoped["ok"] !== true)
265
+ throw new Error(`task scope: ${refused(scoped)}`);
266
+ const digest = gateStateOf(await toolroll("task", "show", input.taskId)).scopeDigest;
267
+ if (digest === null)
268
+ throw new Error(`task ${input.taskId} has no scope after filing it`);
269
+ return { scopeDigest: digest };
270
+ },
271
+ async complete(taskId, receipt) {
272
+ const answer = await toolroll("task", "complete", taskId, "--digest", receipt);
273
+ return answer["ok"] === true ? { ok: true } : { ok: false, message: refused(answer) };
274
+ },
275
+ async busy() {
276
+ const db = new (sqlite().DatabaseSync)(database, { readOnly: true });
277
+ try {
278
+ // Match the deploy guard, including unfinished runs whose claim is gone. Keep one read snapshot.
279
+ db.exec("BEGIN");
280
+ const work = activeUpdateWork(db);
281
+ const tasks = db.prepare(`SELECT DISTINCT external_id FROM task_ref WHERE id IN (
282
+ SELECT task_ref FROM run WHERE outcome IS NULL
283
+ UNION SELECT task_ref FROM claim WHERE released_at IS NULL
284
+ UNION SELECT task_ref FROM run_stop WHERE settled_at IS NULL
285
+ ) ORDER BY external_id`).all().map(row => String(row["external_id"]));
286
+ db.exec("COMMIT");
287
+ return { running: Object.values(work).reduce((total, count) => total + count, 0), tasks };
288
+ }
289
+ finally {
290
+ db.close();
291
+ }
292
+ },
293
+ async deploy({ run: runId, worktree, stage }) {
294
+ // The candidate's own deployment, from its own checkout: drain, backup, rehearsal, swap, health. Its last JSON
295
+ // line names the runtime it installed; the CLI names are moved to it by the release's link step.
296
+ const result = await deployScript(worktree, stage, runId, ["--yes"]);
297
+ const runtime = deployedRuntime(result.stdout);
298
+ if (result.code !== 0 || runtime === null)
299
+ return stopped(stage, tail(result));
300
+ return { ok: true, runtime };
301
+ },
302
+ async recoverDeploy({ run: runId, worktree, stage }) {
303
+ // Before prepare saves deployment.json, staging has not touched the service or admission gate.
304
+ if (!existsSync(join(stage, "deployment.json")))
305
+ return { ok: true };
306
+ // A deployment that outlived a killed release is still at work: recovering under it would race it.
307
+ const running = (options.processes ?? processes)().filter(one => one.command.includes("deploy-browser.mjs") && one.command.includes(stage));
308
+ if (running.length > 0)
309
+ return { ok: false, message: `the deployment is still running (process ${running.map(one => one.pid).join(", ")}); let it finish, then rerun` };
310
+ const result = await deployScript(worktree, stage, runId, ["--phase", "recover"]);
311
+ if (result.code === 0)
312
+ return { ok: true };
313
+ // Killed while preparing, before it paused anything: nothing to undo (its journal is kept).
314
+ const journal = (() => { try {
315
+ return object(JSON.parse(readFileSync(join(stage, "deployment.json"), "utf8")));
316
+ }
317
+ catch {
318
+ return {};
319
+ } })();
320
+ if (journal["phase"] === "preparing" && liveOwner() !== journal["id"])
321
+ return { ok: true };
322
+ return stopped(stage, `${tail(result)} (the deployment journal is at ${String(journal["phase"] ?? "an unreadable phase")}; ${join(stage, "deployment.json")})`);
323
+ },
324
+ async cliLinks() { return findCliLinks(); },
325
+ async switchLinks(links, bin, version) {
326
+ const answers = (path) => spawnSync(path, ["--version"], { encoding: "utf8", env: cleanEnv(), timeout: 30_000 }).stdout?.trim() === version;
327
+ switchCliLinks(links, bin, answers);
328
+ },
329
+ },
330
+ github: {
331
+ async authenticated() { return (await gh("auth", "status")).code === 0; },
332
+ async pullRequest(repo, branch) {
333
+ const rows = (await ghJson("gh pr list", "pr", "list", "--repo", repo, "--head", branch, "--state", "all", "--limit", "10", "--json", "number,state,headRefOid,baseRefName,mergeCommit"));
334
+ const prs = rows.map(row => ({
335
+ number: Number(row["number"]), state: row["state"] === "MERGED" ? "merged" : row["state"] === "OPEN" ? "open" : "closed",
336
+ head: String(row["headRefOid"]), base: String(row["baseRefName"]), mergeCommit: textOr(object(row["mergeCommit"])["oid"]),
337
+ }));
338
+ return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
339
+ },
340
+ async checks(repo, sha) {
341
+ const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion}"), "gh api check-runs");
342
+ const runs = out.split("\n").filter(Boolean).map(line => object(JSON.parse(line)));
343
+ // Oldest first, so a rerun's attempt is the last one listed for its name.
344
+ return runs.sort((a, b) => Number(a["id"]) - Number(b["id"])).map((one) => ({ name: String(one["name"]), status: String(one["status"]), conclusion: textOr(one["conclusion"]) }));
345
+ },
346
+ async merge(repo, pr, head) {
347
+ const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
348
+ if (typeof merged["sha"] !== "string")
349
+ throw new Error(`GitHub did not merge #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
350
+ return merged["sha"];
351
+ },
352
+ async tag(repo, tag) {
353
+ const result = await gh("api", `repos/${repo}/git/ref/tags/${tag}`);
354
+ if (result.code !== 0) {
355
+ if (/Not Found|HTTP 404/.test(result.stderr + result.stdout))
356
+ return null;
357
+ throw new Error(`gh api git/ref/tags/${tag} failed: ${result.stderr.trim()}`);
358
+ }
359
+ const target = object(object(JSON.parse(result.stdout))["object"]);
360
+ if (target["type"] !== "tag")
361
+ return String(target["sha"]);
362
+ // An annotated tag: the commit it names.
363
+ return String(object(object(await ghJson("gh api git/tags", "api", `repos/${repo}/git/tags/${String(target["sha"])}`))["object"])["sha"]);
364
+ },
365
+ async createTag(repo, tag, sha) {
366
+ await must(gh("api", "-X", "POST", `repos/${repo}/git/refs`, "-f", `ref=refs/tags/${tag}`, "-f", `sha=${sha}`), `creating ${tag}`);
367
+ },
368
+ async release(repo, tag) { return (await gh("release", "view", tag, "--repo", repo, "--json", "tagName")).code === 0; },
369
+ },
370
+ registry: {
371
+ async published(name, version) {
372
+ const response = await get(`https://registry.npmjs.org/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { signal: AbortSignal.timeout(20_000) });
373
+ if (response.status === 404)
374
+ return false;
375
+ if (!response.ok)
376
+ throw new Error(`the npm registry answered ${response.status} for ${name}@${version}`);
377
+ return object(await response.json())["version"] === version;
378
+ },
379
+ },
380
+ homebrew: {
381
+ repo: tap,
382
+ async state(version) {
383
+ const branch = await defaultBranch();
384
+ const current = await formulaAt(branch);
385
+ if (current.text.includes(`-${version}.tgz`))
386
+ return { kind: "current" };
387
+ const open = (await ghJson("gh pr list", "pr", "list", "--repo", tap, "--head", brewBranch(version), "--state", "open", "--json", "number,headRefOid"));
388
+ const pr = open[0];
389
+ return pr === undefined ? { kind: "missing" } : { kind: "proposed", pr: Number(pr["number"]), head: String(pr["headRefOid"]) };
390
+ },
391
+ async propose(version) {
392
+ const tarball = `https://registry.npmjs.org/toolroll/-/toolroll-${version}.tgz`;
393
+ const response = await get(tarball, { signal: AbortSignal.timeout(60_000) });
394
+ if (!response.ok)
395
+ throw new Error(`downloading ${tarball} answered ${response.status}`);
396
+ const sha256 = createHash("sha256").update(Buffer.from(await response.arrayBuffer())).digest("hex");
397
+ const base = await defaultBranch();
398
+ const current = await formulaAt(base);
399
+ const next = bumpFormula(current.text, tarball, sha256);
400
+ const branch = brewBranch(version);
401
+ const baseSha = String(object(object(await ghJson("gh api git/ref", "api", `repos/${tap}/git/ref/heads/${base}`))["object"])["sha"]);
402
+ // A branch left by an earlier attempt is reused.
403
+ const made = await gh("api", "-X", "POST", `repos/${tap}/git/refs`, "-f", `ref=refs/heads/${branch}`, "-f", `sha=${baseSha}`);
404
+ if (made.code !== 0 && !/Reference already exists/.test(made.stderr + made.stdout))
405
+ throw new Error(`creating ${tap} ${branch}: ${made.stderr.trim()}`);
406
+ const onBranch = await formulaAt(branch);
407
+ if (onBranch.text !== next) {
408
+ await must(gh("api", "-X", "PUT", `repos/${tap}/contents/${formula}`, "-f", `message=toolroll ${version}`, "-f", `branch=${branch}`, "-f", `sha=${onBranch.sha}`, "-f", `content=${Buffer.from(next).toString("base64")}`), `updating ${formula}`);
409
+ }
410
+ await must(gh("pr", "create", "--repo", tap, "--head", branch, "--base", base, "--title", `toolroll ${version}`, "--body", `Bumps the formula to toolroll ${version} (${tarball}, sha256 ${sha256}).`), "gh pr create");
411
+ const opened = (await ghJson("gh pr view", "pr", "view", branch, "--repo", tap, "--json", "number,headRefOid"));
412
+ return { pr: Number(opened["number"]), head: String(opened["headRefOid"]) };
413
+ },
414
+ async merge(pr, head) {
415
+ const merged = object(await ghJson(`merging ${tap} #${pr}`, "api", "-X", "PUT", `repos/${tap}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
416
+ if (merged["merged"] !== true || typeof merged["sha"] !== "string")
417
+ throw new Error(`GitHub did not merge ${tap} #${pr}: ${String(merged["message"] ?? "no merge commit")}`);
418
+ },
419
+ },
420
+ };
421
+ async function defaultBranch() {
422
+ return (await must(gh("api", `repos/${tap}`, "--jq", ".default_branch"), `gh api repos/${tap}`)).trim();
423
+ }
424
+ async function formulaAt(ref) {
425
+ const file = object(await ghJson(`reading ${formula}`, "api", `repos/${tap}/contents/${formula}?ref=${encodeURIComponent(ref)}`));
426
+ return { text: Buffer.from(String(file["content"] ?? ""), "base64").toString("utf8"), sha: String(file["sha"]) };
427
+ }
428
+ }
429
+ const brewBranch = (version) => `toolroll-${version}`;
430
+ /** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
431
+ export function bumpFormula(text, url, sha256) {
432
+ if (!/^\s*url "[^"]*"/m.test(text) || !/^\s*sha256 "[0-9a-f]{64}"/m.test(text))
433
+ throw new Error("the formula has no url and sha256 lines to update");
434
+ return text.replace(/^(\s*url ")[^"]*(")/m, `$1${url}$2`).replace(/^(\s*sha256 ")[0-9a-f]{64}(")/m, `$1${sha256}$2`);
435
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * `toolroll release <branch>` (release.ts): the command line around the release coordinator.
3
+ *
4
+ * `release <lease>` used to return a runner's lease; that is `toolroll worker release <lease>` now. The old form still
5
+ * works for a value shaped like a lease id (a UUID), with a one-line warning, until the next minor version. A
6
+ * UUID-shaped value given with release options is refused rather than guessed at.
7
+ */
8
+ import { type ReleaseAdapters } from "./release.js";
9
+ import { type GateReleaseWorld } from "./release-gate.js";
10
+ export declare const RELEASE_HELP: string;
11
+ /** A runner lease id (claim.ts mints randomUUID()). */
12
+ export declare const LEASE_SHAPED: RegExp;
13
+ export declare const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
14
+ export type ReleaseCliDeps = {
15
+ adapters?: ReleaseAdapters;
16
+ stateDir?: string;
17
+ cwd?: string;
18
+ /** The deprecated lease form, handed to the worker command. */
19
+ releaseLease?: (args: readonly string[]) => Promise<number>;
20
+ warn?: (line: string) => void;
21
+ /** What --release-gate reads and changes (release-gate.ts); the installation's own by default. */
22
+ gateWorld?: GateReleaseWorld;
23
+ };
24
+ export declare function runReleaseCommand(args: readonly string[], write: (line: string) => void, deps?: ReleaseCliDeps): Promise<number>;