toolroll 0.9.54 → 0.9.56

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 (350) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -12
  3. package/dist/action-ledger.d.ts +2 -4
  4. package/dist/action-ledger.js +2 -10
  5. package/dist/agent-onboard.js +4 -1
  6. package/dist/approval-policy.d.ts +5 -5
  7. package/dist/approval-policy.js +4 -4
  8. package/dist/approval-rules-ui.d.ts +3 -2
  9. package/dist/approval-rules-ui.js +17 -22
  10. package/dist/assignment-ui.d.ts +3 -2
  11. package/dist/assignment-ui.js +4 -11
  12. package/dist/backup-ui.d.ts +2 -1
  13. package/dist/backup-ui.js +16 -27
  14. package/dist/brand-mark.d.ts +3 -2
  15. package/dist/brand-mark.js +4 -4
  16. package/dist/browser/workspace.css +1 -1
  17. package/dist/browser/workspace.js +25 -25
  18. package/dist/browser-shell.d.ts +2 -1
  19. package/dist/browser-shell.js +9 -9
  20. package/dist/browser-workspace.d.ts +14 -15
  21. package/dist/browser-workspace.js +0 -3
  22. package/dist/budget-alerts.js +3 -3
  23. package/dist/chat-actions.d.ts +23 -18
  24. package/dist/chat-actions.js +110 -88
  25. package/dist/chat-ask.d.ts +2 -2
  26. package/dist/chat-ask.js +5 -5
  27. package/dist/chat-channel.d.ts +15 -15
  28. package/dist/chat-channel.js +20 -20
  29. package/dist/chat-controls.d.ts +0 -4
  30. package/dist/chat-controls.js +0 -1
  31. package/dist/chat-core.js +1 -1
  32. package/dist/chat-delivery-state.js +2 -2
  33. package/dist/chat-delivery.d.ts +2 -2
  34. package/dist/chat-delivery.js +27 -34
  35. package/dist/chat-flow.js +3 -3
  36. package/dist/chat-messages.d.ts +1 -1
  37. package/dist/chat-messages.js +4 -4
  38. package/dist/chat-migration.d.ts +1 -1
  39. package/dist/chat-migration.js +11 -11
  40. package/dist/chat-polish.d.ts +5 -4
  41. package/dist/chat-polish.js +9 -10
  42. package/dist/chat-rooms.js +3 -3
  43. package/dist/chat-warmth.d.ts +2 -2
  44. package/dist/check-levels-ui.d.ts +2 -1
  45. package/dist/check-levels-ui.js +26 -53
  46. package/dist/cli.d.ts +3 -1
  47. package/dist/cli.js +15 -10
  48. package/dist/coding-shipping-ui.d.ts +2 -1
  49. package/dist/coding-shipping-ui.js +6 -6
  50. package/dist/coding-ui.d.ts +3 -1
  51. package/dist/coding-ui.js +30 -29
  52. package/dist/contracts/chat-actions.d.ts +32 -27
  53. package/dist/contracts/chat-actions.js +29 -11
  54. package/dist/contracts/chat-content.d.ts +1 -1
  55. package/dist/contracts/chat-content.js +1 -1
  56. package/dist/contracts/chat-tables.js +4 -4
  57. package/dist/contracts/cli.d.ts +1 -3
  58. package/dist/contracts/console-api.d.ts +7 -7
  59. package/dist/contracts/console-api.js +13 -12
  60. package/dist/contracts/flow-propose.d.ts +3 -3
  61. package/dist/contracts/flow.d.ts +132 -125
  62. package/dist/contracts/flow.js +27 -12
  63. package/dist/contracts/health.d.ts +1 -3
  64. package/dist/contracts/lead-context.d.ts +1 -1
  65. package/dist/contracts/lead-context.js +2 -2
  66. package/dist/contracts/lead-tools.d.ts +20 -18
  67. package/dist/contracts/lead-tools.js +15 -15
  68. package/dist/contracts/stage-output.d.ts +1 -1
  69. package/dist/contracts/stage-output.js +1 -1
  70. package/dist/contracts/store-json.d.ts +23 -22
  71. package/dist/contracts/store-json.js +17 -17
  72. package/dist/contracts/{teammate-turn.d.ts → subagent-turn.d.ts} +5 -5
  73. package/dist/contracts/{teammate-turn.js → subagent-turn.js} +7 -7
  74. package/dist/control-ui.d.ts +5 -4
  75. package/dist/control-ui.js +9 -34
  76. package/dist/converse.d.ts +23 -23
  77. package/dist/converse.js +28 -28
  78. package/dist/coordinator-proposals.d.ts +2 -2
  79. package/dist/coordinator-proposals.js +1 -1
  80. package/dist/coordinator.js +1 -1
  81. package/dist/credentials-ui.d.ts +5 -4
  82. package/dist/credentials-ui.js +23 -24
  83. package/dist/demo-chat.d.ts +3 -3
  84. package/dist/demo-chat.js +29 -76
  85. package/dist/deprecations.d.ts +12 -0
  86. package/dist/deprecations.js +20 -0
  87. package/dist/desktop-update-gate.js +8 -4
  88. package/dist/desktop-update.js +6 -1
  89. package/dist/discord-chat.js +6 -6
  90. package/dist/discord-settings.d.ts +3 -2
  91. package/dist/discord-settings.js +17 -42
  92. package/dist/dispatch.d.ts +1 -1
  93. package/dist/dispatch.js +1 -1
  94. package/dist/envelope.js +10 -1
  95. package/dist/evidence-pack.d.ts +2 -1
  96. package/dist/evidence-pack.js +45 -49
  97. package/dist/export-ui.d.ts +7 -2
  98. package/dist/export-ui.js +4 -10
  99. package/dist/export.js +7 -7
  100. package/dist/flow-engine.d.ts +2 -2
  101. package/dist/flow-engine.js +21 -21
  102. package/dist/flow-gallery-ui.d.ts +4 -3
  103. package/dist/flow-gallery-ui.js +28 -43
  104. package/dist/flow-insights.d.ts +1 -1
  105. package/dist/flow-share.js +13 -8
  106. package/dist/flow-starters-ui.d.ts +2 -1
  107. package/dist/flow-starters-ui.js +12 -15
  108. package/dist/flow-steps.d.ts +5 -5
  109. package/dist/flow-steps.js +19 -19
  110. package/dist/flow-triggers.d.ts +2 -1
  111. package/dist/flow-triggers.js +17 -15
  112. package/dist/flows-cli.js +1 -1
  113. package/dist/flows-ui.d.ts +4 -3
  114. package/dist/flows-ui.js +38 -49
  115. package/dist/flows.js +21 -20
  116. package/dist/guides.js +112 -6
  117. package/dist/html.d.ts +76 -0
  118. package/dist/html.js +173 -0
  119. package/dist/integrations-ui.d.ts +2 -1
  120. package/dist/integrations-ui.js +19 -23
  121. package/dist/invoke.js +1 -1
  122. package/dist/kits-ui.d.ts +5 -4
  123. package/dist/kits-ui.js +22 -24
  124. package/dist/kits.d.ts +12 -12
  125. package/dist/kits.js +31 -31
  126. package/dist/knowledge-ui.d.ts +6 -5
  127. package/dist/knowledge-ui.js +26 -25
  128. package/dist/{mate-cli.d.ts → lead-cli.d.ts} +11 -11
  129. package/dist/{mate-cli.js → lead-cli.js} +38 -38
  130. package/dist/lead-commitments.js +4 -4
  131. package/dist/lead-context.d.ts +12 -2
  132. package/dist/lead-context.js +15 -17
  133. package/dist/{mate-contract.d.ts → lead-contract.d.ts} +3 -3
  134. package/dist/{mate-contract.js → lead-contract.js} +7 -6
  135. package/dist/{mate-doors.d.ts → lead-doors.d.ts} +6 -6
  136. package/dist/{mate-doors.js → lead-doors.js} +24 -24
  137. package/dist/lead-follow.d.ts +5 -5
  138. package/dist/lead-follow.js +9 -9
  139. package/dist/lead-people.d.ts +5 -5
  140. package/dist/lead-people.js +14 -14
  141. package/dist/{mate-progress.d.ts → lead-progress.d.ts} +9 -9
  142. package/dist/{mate-progress.js → lead-progress.js} +3 -3
  143. package/dist/{mate-tools.d.ts → lead-tools.d.ts} +19 -19
  144. package/dist/{mate-tools.js → lead-tools.js} +109 -101
  145. package/dist/{mate.d.ts → lead.d.ts} +27 -27
  146. package/dist/{mate.js → lead.js} +102 -102
  147. package/dist/ledger-view.d.ts +2 -1
  148. package/dist/ledger-view.js +27 -29
  149. package/dist/limits-ui.d.ts +3 -2
  150. package/dist/limits-ui.js +8 -8
  151. package/dist/live-bus.d.ts +50 -8
  152. package/dist/live-bus.js +133 -50
  153. package/dist/mcp-connect.d.ts +1 -1
  154. package/dist/mcp-connect.js +3 -3
  155. package/dist/mcp-core.js +1 -1
  156. package/dist/mcp-oauth.js +35 -36
  157. package/dist/memory-pass.d.ts +2 -2
  158. package/dist/memory-pass.js +7 -7
  159. package/dist/models-ui.d.ts +2 -1
  160. package/dist/models-ui.js +25 -35
  161. package/dist/monitoring-ui.d.ts +4 -3
  162. package/dist/monitoring-ui.js +13 -26
  163. package/dist/notification-rows.d.ts +2 -2
  164. package/dist/openrouter-models.d.ts +2 -1
  165. package/dist/openrouter-models.js +5 -9
  166. package/dist/operate.d.ts +3 -3
  167. package/dist/operate.js +46 -41
  168. package/dist/people-audit-ui.d.ts +2 -1
  169. package/dist/people-audit-ui.js +13 -13
  170. package/dist/policy-ui.d.ts +3 -2
  171. package/dist/policy-ui.js +17 -23
  172. package/dist/policy.d.ts +1 -1
  173. package/dist/principal.d.ts +1 -1
  174. package/dist/principal.js +2 -2
  175. package/dist/project-delete-ui.d.ts +4 -3
  176. package/dist/project-delete-ui.js +16 -28
  177. package/dist/project-delete.d.ts +1 -1
  178. package/dist/project-delete.js +26 -26
  179. package/dist/project-memory.js +1 -1
  180. package/dist/project-tools.d.ts +1 -1
  181. package/dist/project-tools.js +1 -1
  182. package/dist/pull-request-ui.d.ts +2 -1
  183. package/dist/pull-request-ui.js +17 -32
  184. package/dist/recipe-ui.d.ts +8 -7
  185. package/dist/recipe-ui.js +39 -32
  186. package/dist/release-adapters.d.ts +60 -0
  187. package/dist/release-adapters.js +435 -0
  188. package/dist/release-cli.d.ts +24 -0
  189. package/dist/release-cli.js +181 -0
  190. package/dist/release-gate.d.ts +80 -0
  191. package/dist/release-gate.js +150 -0
  192. package/dist/release-links.d.ts +29 -0
  193. package/dist/release-links.js +102 -0
  194. package/dist/release.d.ts +331 -0
  195. package/dist/release.js +550 -0
  196. package/dist/reply-shape.d.ts +2 -1
  197. package/dist/reply-shape.js +5 -5
  198. package/dist/repository-context-ui.d.ts +2 -1
  199. package/dist/repository-context-ui.js +2 -7
  200. package/dist/request-budget-ui.d.ts +2 -1
  201. package/dist/request-budget-ui.js +16 -16
  202. package/dist/result-review.d.ts +2 -3
  203. package/dist/result-review.js +3 -9
  204. package/dist/retention-ui.d.ts +7 -1
  205. package/dist/retention-ui.js +7 -11
  206. package/dist/retention.js +4 -4
  207. package/dist/scope.d.ts +2 -2
  208. package/dist/scout-report.d.ts +1 -1
  209. package/dist/scout-report.js +1 -1
  210. package/dist/serve.d.ts +11 -2
  211. package/dist/serve.js +105 -99
  212. package/dist/server/chat.d.ts +7 -10
  213. package/dist/server/chat.js +984 -1021
  214. package/dist/server/chrome.d.ts +291 -0
  215. package/dist/server/chrome.js +747 -0
  216. package/dist/server/console.css +2819 -0
  217. package/dist/server/flows.d.ts +2 -6
  218. package/dist/server/flows.js +854 -773
  219. package/dist/server/guards.d.ts +1 -1
  220. package/dist/server/guards.js +8 -5
  221. package/dist/server/handler-context.d.ts +1 -1
  222. package/dist/server/handler-context.js +1 -1
  223. package/dist/server/handler-registry.d.ts +8 -6
  224. package/dist/server/handler-registry.js +14 -2
  225. package/dist/server/http.d.ts +274 -0
  226. package/dist/server/http.js +239 -0
  227. package/dist/server/live.d.ts +34 -0
  228. package/dist/server/live.js +309 -0
  229. package/dist/server/pages.d.ts +2 -6
  230. package/dist/server/pages.js +808 -789
  231. package/dist/server/people-tokens.d.ts +1 -6
  232. package/dist/server/people-tokens.js +581 -573
  233. package/dist/server/remote.d.ts +4 -7
  234. package/dist/server/remote.js +39 -70
  235. package/dist/server/render-chat.d.ts +402 -0
  236. package/dist/server/render-chat.js +927 -0
  237. package/dist/server/render-pages.d.ts +535 -0
  238. package/dist/server/render-pages.js +1277 -0
  239. package/dist/server/render-people.d.ts +32 -0
  240. package/dist/server/render-people.js +121 -0
  241. package/dist/server/render-results.d.ts +744 -0
  242. package/dist/server/render-results.js +2237 -0
  243. package/dist/server/render-settings.d.ts +154 -0
  244. package/dist/server/render-settings.js +580 -0
  245. package/dist/server/render-tasks.d.ts +632 -0
  246. package/dist/server/render-tasks.js +2345 -0
  247. package/dist/server/request-context.d.ts +46 -0
  248. package/dist/server/request-context.js +17 -0
  249. package/dist/server/route-table.d.ts +25 -3
  250. package/dist/server/route-table.js +167 -77
  251. package/dist/server/runtime.d.ts +28 -20
  252. package/dist/server/runtime.js +7 -1
  253. package/dist/server/session.d.ts +138 -0
  254. package/dist/server/session.js +94 -0
  255. package/dist/server/settings.d.ts +1 -5
  256. package/dist/server/settings.js +2186 -2121
  257. package/dist/server/task-views.d.ts +11 -4
  258. package/dist/server/task-views.js +26 -22
  259. package/dist/server/tasks.d.ts +9 -9
  260. package/dist/server/tasks.js +1489 -1420
  261. package/dist/server-telemetry.d.ts +4 -3
  262. package/dist/server-telemetry.js +4 -8
  263. package/dist/setup-guide.d.ts +2 -2
  264. package/dist/skills-ui.d.ts +4 -3
  265. package/dist/skills-ui.js +14 -17
  266. package/dist/skills.d.ts +1 -1
  267. package/dist/skills.js +2 -1
  268. package/dist/slack-chat.d.ts +2 -2
  269. package/dist/slack-chat.js +6 -6
  270. package/dist/slack-settings.d.ts +3 -2
  271. package/dist/slack-settings.js +17 -42
  272. package/dist/spend-ui.d.ts +5 -4
  273. package/dist/spend-ui.js +17 -40
  274. package/dist/spend.d.ts +14 -14
  275. package/dist/spend.js +31 -31
  276. package/dist/sso-ui.d.ts +3 -2
  277. package/dist/sso-ui.js +26 -27
  278. package/dist/storage-ui.d.ts +2 -1
  279. package/dist/storage-ui.js +14 -33
  280. package/dist/store-baseline.d.ts +19 -0
  281. package/dist/store-baseline.js +2873 -0
  282. package/dist/store.d.ts +197 -249
  283. package/dist/store.js +747 -3084
  284. package/dist/subagent-admin.d.ts +53 -0
  285. package/dist/{teammate-admin.js → subagent-admin.js} +37 -37
  286. package/dist/{teammate-desk.d.ts → subagent-desk.d.ts} +22 -23
  287. package/dist/{teammate-desk.js → subagent-desk.js} +26 -35
  288. package/dist/{teammate-memory.d.ts → subagent-memory.d.ts} +17 -17
  289. package/dist/{teammate-memory.js → subagent-memory.js} +41 -41
  290. package/dist/{teammate-question.d.ts → subagent-question.d.ts} +6 -6
  291. package/dist/{teammate-question.js → subagent-question.js} +17 -17
  292. package/dist/{teammate-tools.d.ts → subagent-tools.d.ts} +18 -18
  293. package/dist/{teammate-tools.js → subagent-tools.js} +36 -36
  294. package/dist/{teammate-week.d.ts → subagent-week.d.ts} +10 -10
  295. package/dist/{teammate-week.js → subagent-week.js} +23 -23
  296. package/dist/{teammate-work.d.ts → subagent-work.d.ts} +21 -21
  297. package/dist/{teammate-work.js → subagent-work.js} +50 -50
  298. package/dist/subagents-ui.d.ts +22 -0
  299. package/dist/subagents-ui.js +137 -0
  300. package/dist/{teammates.d.ts → subagents.d.ts} +11 -11
  301. package/dist/{teammates.js → subagents.js} +21 -21
  302. package/dist/subscription-chat.d.ts +13 -13
  303. package/dist/subscription-chat.js +19 -19
  304. package/dist/surface.d.ts +19 -2
  305. package/dist/surface.js +20 -33
  306. package/dist/task-composer.d.ts +2 -1
  307. package/dist/task-composer.js +7 -7
  308. package/dist/task-status.d.ts +4 -3
  309. package/dist/task-status.js +15 -16
  310. package/dist/team-cli.d.ts +1 -0
  311. package/dist/team-cli.js +14 -2
  312. package/dist/team-http.d.ts +5 -9
  313. package/dist/team-http.js +5 -63
  314. package/dist/team-leads.js +13 -13
  315. package/dist/team-runtime.d.ts +2 -2
  316. package/dist/team-runtime.js +8 -8
  317. package/dist/team-ui.d.ts +2 -1
  318. package/dist/team-ui.js +2 -5
  319. package/dist/teams-chat.js +6 -6
  320. package/dist/teams-settings.d.ts +3 -2
  321. package/dist/teams-settings.js +20 -31
  322. package/dist/telegram-flow.d.ts +3 -3
  323. package/dist/telegram-flow.js +3 -3
  324. package/dist/{telegram-mate.d.ts → telegram-lead.d.ts} +5 -5
  325. package/dist/{telegram-mate.js → telegram-lead.js} +27 -27
  326. package/dist/telegram-settings.d.ts +3 -2
  327. package/dist/telegram-settings.js +14 -25
  328. package/dist/telegram-status.js +1 -1
  329. package/dist/telegram-team.d.ts +1 -1
  330. package/dist/telegram-team.js +5 -5
  331. package/dist/telegram.d.ts +1 -1
  332. package/dist/telegram.js +9 -30
  333. package/dist/text-limits.d.ts +18 -18
  334. package/dist/text-limits.js +18 -18
  335. package/dist/toolroll-update-ui.d.ts +3 -2
  336. package/dist/toolroll-update-ui.js +35 -39
  337. package/dist/toolroll-update.d.ts +8 -0
  338. package/dist/toolroll-update.js +45 -8
  339. package/dist/tools-ui.d.ts +5 -2
  340. package/dist/tools-ui.js +31 -31
  341. package/dist/when-html.d.ts +11 -3
  342. package/dist/when-html.js +5 -6
  343. package/dist/workspace-ui.d.ts +4 -2
  344. package/dist/workspace-ui.js +10 -10
  345. package/package.json +1 -2
  346. package/dist/server/shared.d.ts +0 -3151
  347. package/dist/server/shared.js +0 -12001
  348. package/dist/teammate-admin.d.ts +0 -53
  349. package/dist/teammates-ui.d.ts +0 -20
  350. package/dist/teammates-ui.js +0 -166
@@ -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>;
@@ -0,0 +1,181 @@
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 { existsSync, readFileSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join, resolve } from "node:path";
11
+ import { createRequire } from "node:module";
12
+ // Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
13
+ const sqlite = () => createRequire(import.meta.url)("node:sqlite");
14
+ import { checkedEnvelopeJson } from "./contracts/cli.js";
15
+ import { RELEASE_LIMITS, runRelease } from "./release.js";
16
+ import { processes, releaseAdapters } from "./release-adapters.js";
17
+ import { releaseGate } from "./release-gate.js";
18
+ import { databasePath } from "./store.js";
19
+ export const RELEASE_HELP = `toolroll release <branch> — release Toolroll itself, from a pushed branch with an open pull request
20
+
21
+ Requires an active approver's scope approval (the only human step) and a passing Full check on the exact commit.
22
+ Completes that result, drains, backs up, deploys and relinks both CLI names to that commit. After PR checks pass,
23
+ squash-merges, deletes the matching remote branch, verifies the merged tree and tags. Both macOS legs gate npm
24
+ publication; Homebrew waits for its checks to register and all pass before merging.
25
+
26
+ Run the same command to resume after a stop. Interrupted deployments recover their saved stage before retrying.
27
+
28
+ toolroll release --release-gate <id> [--yes] — lift the update pause a deployment left before its swap
29
+
30
+ Shows why it is safe first: the pause is that deployment's, its journal stopped before the swap, and the
31
+ database, service definition and running service are all still the previous build's. --yes lifts that one pause.
32
+ Any missing or contradicting evidence refuses and leaves the pause in place.
33
+
34
+ Options
35
+ --repo <path> the gate checkout the release check runs in (default: this checkout)
36
+ --new release a new commit on the branch, setting the earlier unfinished release aside
37
+ --limit <step>=<min> change one step's time limit (${Object.entries(RELEASE_LIMITS).map(([step, minutes]) => `${step} ${minutes}`).join(", ")})
38
+ --tap <owner/name> the Homebrew tap (default ap9000/homebrew-toolroll)
39
+ --release-gate <id> check (and with --yes, lift) one update pause left before a deployment's swap
40
+ --yes with --release-gate: lift the pause once it is proved safe
41
+ --json answer with one machine envelope
42
+
43
+ A runner's lease is returned with \`toolroll worker release <lease>\`.`;
44
+ /** A runner lease id (claim.ts mints randomUUID()). */
45
+ export const LEASE_SHAPED = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
46
+ export const LEASE_DEPRECATION = "`toolroll release <lease>` is now `toolroll worker release <lease>`; the old form goes in the next minor version.";
47
+ const VALUE_FLAGS = new Set(["--repo", "--limit", "--tap", "--release-gate"]);
48
+ const BARE_FLAGS = new Set(["--new", "--json", "--help", "--yes"]);
49
+ export async function runReleaseCommand(args, write, deps = {}) {
50
+ const json = args.includes("--json");
51
+ const refuse = (reason, message, code) => {
52
+ write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason, message }) : message);
53
+ return code;
54
+ };
55
+ const positional = [];
56
+ const limits = {};
57
+ let repo, tap, gate;
58
+ for (let i = 0; i < args.length; i += 1) {
59
+ const arg = args[i];
60
+ if (BARE_FLAGS.has(arg))
61
+ continue;
62
+ if (VALUE_FLAGS.has(arg)) {
63
+ const value = args[i + 1];
64
+ if (value === undefined || value.startsWith("--"))
65
+ return refuse("usage", `${arg} takes a value.`, 2);
66
+ i += 1;
67
+ if (arg === "--repo")
68
+ repo = value;
69
+ else if (arg === "--tap")
70
+ tap = value;
71
+ else if (arg === "--release-gate")
72
+ gate = value;
73
+ else {
74
+ const match = /^([a-z]+)=([0-9]+)$/.exec(value);
75
+ if (match === null || !(match[1] in RELEASE_LIMITS) || Number(match[2]) < 1)
76
+ return refuse("usage", `--limit takes <step>=<minutes> for one of: ${Object.keys(RELEASE_LIMITS).join(", ")}.`, 2);
77
+ limits[match[1]] = Number(match[2]);
78
+ }
79
+ continue;
80
+ }
81
+ if (arg.startsWith("--"))
82
+ return refuse("usage", `Unknown option ${arg}.\n${RELEASE_HELP}`, 2);
83
+ positional.push(arg);
84
+ }
85
+ if (args.includes("--help")) {
86
+ write(RELEASE_HELP);
87
+ return 0;
88
+ }
89
+ if (gate !== undefined) {
90
+ if (positional.length > 0 || args.some(arg => ["--repo", "--limit", "--tap", "--new"].includes(arg)))
91
+ return refuse("usage", "--release-gate <id> takes no branch or release options.", 2);
92
+ return runGateRelease(gate, args.includes("--yes"), write, json, deps.gateWorld ?? installedGateWorld());
93
+ }
94
+ if (args.includes("--yes"))
95
+ return refuse("usage", "--yes goes with --release-gate <id>; a release needs no confirmation beyond the owner's approval.", 2);
96
+ if (positional.length !== 1)
97
+ return refuse("usage", `Which branch? \`toolroll release <branch>\`.\n${RELEASE_HELP}`, 2);
98
+ const branch = positional[0];
99
+ if (LEASE_SHAPED.test(branch)) {
100
+ const releaseOptions = args.filter(arg => VALUE_FLAGS.has(arg) || arg === "--new");
101
+ if (releaseOptions.length > 0)
102
+ return refuse("ambiguous", `${branch} looks like a lease id, but ${releaseOptions[0]} is a release option. Return a lease with \`toolroll worker release <lease>\`; a branch shaped like a lease id can't be released.`, 2);
103
+ (deps.warn ?? (line => process.stderr.write(`${line}\n`)))(LEASE_DEPRECATION);
104
+ if (deps.releaseLease === undefined)
105
+ return refuse("usage", "This build can't return leases from here: use `toolroll worker release <lease>`.", 2);
106
+ return deps.releaseLease(args);
107
+ }
108
+ if (!/^[A-Za-z0-9._/-]+$/.test(branch) || branch.startsWith("-") || branch.includes(".."))
109
+ return refuse("usage", `${branch} is not a branch name this command takes.`, 2);
110
+ const checkout = resolve(deps.cwd ?? process.cwd(), repo ?? ".");
111
+ const stateDir = deps.stateDir ?? dirname(databasePath(process.env, homedir()));
112
+ const lines = [];
113
+ const say = (line) => { if (json)
114
+ lines.push(line);
115
+ else
116
+ write(line); };
117
+ const adapters = deps.adapters === undefined
118
+ ? releaseAdapters({ checkout, say, ...(tap === undefined ? {} : { tap }), ...(limits["deploy"] === undefined ? {} : { deployMinutes: limits["deploy"] }) })
119
+ : { ...deps.adapters, say };
120
+ const outcome = await runRelease({ branch, checkout, stateDir, limits, fresh: args.includes("--new") }, adapters);
121
+ return report(outcome, write, json, lines);
122
+ }
123
+ function report(outcome, write, json, lines) {
124
+ const release = outcome.journal === null ? null : {
125
+ branch: outcome.journal.branch, candidate: outcome.journal.candidate.sha, tree: outcome.journal.candidate.tree, version: outcome.journal.packageVersion,
126
+ tag: outcome.journal.tag, pullRequest: outcome.journal.pr, task: outcome.journal.task.id, step: outcome.journal.step,
127
+ merge: outcome.journal.merge?.sha ?? null, runtime: outcome.journal.deploy?.runtime ?? null,
128
+ };
129
+ if (outcome.ok) {
130
+ if (json)
131
+ write(checkedEnvelopeJson({ ok: true, command: "release", release, journal: outcome.file, said: lines }));
132
+ return 0;
133
+ }
134
+ if (json) {
135
+ write(checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message, step: outcome.step, release, journal: outcome.file, resume: outcome.resume, said: lines }));
136
+ }
137
+ else {
138
+ write(`✗ Stopped at ${outcome.step}: ${outcome.message}`);
139
+ write(` Continue from there: ${outcome.resume}`);
140
+ }
141
+ // A usage slip or refusal before anything began is 2; a stop along the way is 3 ("no", not "broken"), as elsewhere.
142
+ return outcome.reason === "usage" ? 2 : 3;
143
+ }
144
+ /** The installation's live database, LaunchAgents and processes, for --release-gate. */
145
+ function installedGateWorld() {
146
+ const database = databasePath(process.env, homedir());
147
+ const agents = join(homedir(), "Library", "LaunchAgents");
148
+ return {
149
+ database, stateDir: dirname(database), now: () => new Date(), processes,
150
+ open: () => {
151
+ const db = new (sqlite().DatabaseSync)(database);
152
+ db.exec("PRAGMA busy_timeout=5000");
153
+ return db;
154
+ },
155
+ servicePlists: () => ["com.toolroll.browser", "com.standing-orders.browser"].map(label => join(agents, `${label}.plist`))
156
+ .filter(path => existsSync(path)).map(path => ({ path, text: readFileSync(path, "utf8") })),
157
+ };
158
+ }
159
+ function runGateRelease(id, yes, write, json, world) {
160
+ let outcome;
161
+ try {
162
+ outcome = releaseGate(id, world, yes);
163
+ }
164
+ catch (error) {
165
+ outcome = { ok: false, reason: "error", message: `${error.message} The update pause was left as it is.` };
166
+ }
167
+ if (!outcome.ok) {
168
+ write(json ? checkedEnvelopeJson({ ok: false, command: "release", reason: outcome.reason, message: outcome.message }) : `✗ ${outcome.message}`);
169
+ return outcome.reason === "usage" ? 2 : 3;
170
+ }
171
+ if (json) {
172
+ write(checkedEnvelopeJson({ ok: true, command: "release", gate: { id: outcome.id, released: outcome.released, stage: outcome.stage, proof: outcome.proof } }));
173
+ return 0;
174
+ }
175
+ write(outcome.released ? `✓ Lifted update pause ${outcome.id}: new work can start again.` : `Update pause ${outcome.id} was left before its deployment's swap:`);
176
+ for (const line of outcome.proof)
177
+ write(` • ${line}`);
178
+ if (!outcome.released)
179
+ write(`Lift it: toolroll release --release-gate ${outcome.id} --yes`);
180
+ return 0;
181
+ }