toolroll 0.9.65 → 0.9.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -2
- package/dist/agent-fence.d.ts +69 -2
- package/dist/agent-fence.js +291 -11
- package/dist/agentconfig.js +4 -1
- package/dist/batch-checks.js +3 -2
- package/dist/browser/THIRD_PARTY_NOTICES.txt +46 -0
- package/dist/browser/terminal.js +134 -23
- package/dist/browser/workspace.css +1 -1
- package/dist/builder.d.ts +4 -0
- package/dist/builder.js +54 -12
- package/dist/contracts/console-api.d.ts +1 -0
- package/dist/contracts/console-api.js +1 -0
- package/dist/invoke.d.ts +18 -0
- package/dist/invoke.js +137 -14
- package/dist/live-terminal.d.ts +24 -13
- package/dist/live-terminal.js +79 -42
- package/dist/operate.d.ts +2 -0
- package/dist/operate.js +60 -2
- package/dist/project-delete-ui.d.ts +6 -0
- package/dist/project-delete-ui.js +20 -1
- package/dist/release-adapters.d.ts +6 -0
- package/dist/release-adapters.js +70 -3
- package/dist/release.d.ts +7 -1
- package/dist/release.js +20 -7
- package/dist/result-follow-ups.js +3 -2
- package/dist/scope.d.ts +13 -0
- package/dist/scope.js +10 -5
- package/dist/serve.js +1 -0
- package/dist/server/chrome.d.ts +10 -1
- package/dist/server/chrome.js +45 -4
- package/dist/server/console.css +55 -4
- package/dist/server/render-pages.d.ts +28 -3
- package/dist/server/render-pages.js +78 -9
- package/dist/server/render-tasks.js +2 -1
- package/dist/server/route-table.js +1 -0
- package/dist/server/settings.js +21 -0
- package/dist/server/tasks.js +7 -4
- package/dist/store.d.ts +11 -1
- package/dist/store.js +33 -2
- package/dist/teardown-git.d.ts +22 -0
- package/dist/teardown-git.js +136 -2
- package/dist/tool-broker.d.ts +12 -0
- package/dist/tool-broker.js +66 -2
- package/dist/unfenced-test-support.d.ts +2 -0
- package/dist/unfenced-test-support.js +11 -0
- package/dist/writes-mode.d.ts +9 -0
- package/dist/writes-mode.js +8 -0
- package/package.json +5 -1
package/dist/live-terminal.js
CHANGED
|
@@ -19,8 +19,6 @@ export const MAX_LIVE_TERMINALS = 8;
|
|
|
19
19
|
export const MAX_KEPT_TERMINALS = 16;
|
|
20
20
|
/** At most this many pages attached to one session (a desktop, a phone, a spare tab or two). */
|
|
21
21
|
export const MAX_VIEWERS = 4;
|
|
22
|
-
/** Raw output kept per session for a page that attaches later. */
|
|
23
|
-
export const SCROLLBACK_BYTES = 1024 * 1024;
|
|
24
22
|
/** After SIGHUP, the terminal's processes have this long before SIGKILL. */
|
|
25
23
|
export const CLOSE_GRACE_MS = 2_000;
|
|
26
24
|
/** Typed input not yet read by the program, per terminal: past this, more is refused until it reads. */
|
|
@@ -70,37 +68,45 @@ export function loadPtySpawn() {
|
|
|
70
68
|
return null;
|
|
71
69
|
}
|
|
72
70
|
}
|
|
73
|
-
/**
|
|
74
|
-
export
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
71
|
+
/** How many lines above the screen a terminal keeps for a page that attaches later. */
|
|
72
|
+
export const SCREEN_SCROLLBACK_LINES = 5000;
|
|
73
|
+
const require = createRequire(import.meta.url);
|
|
74
|
+
/**
|
|
75
|
+
* The terminal as a page would see it now: every byte the program writes is parsed by a headless xterm here, so a page
|
|
76
|
+
* that attaches later gets the screen, its scrollback and its modes (the alternate screen of a full-screen program, the
|
|
77
|
+
* cursor, colours, bracketed paste, mouse reporting) — never a replay of raw output cut at an arbitrary byte, which
|
|
78
|
+
* could split an escape sequence or redraw a full-screen program garbled.
|
|
79
|
+
*/
|
|
80
|
+
export class ScreenMirror {
|
|
81
|
+
lines;
|
|
82
|
+
terminal;
|
|
83
|
+
serializer;
|
|
84
|
+
constructor(cols, rows, lines = SCREEN_SCROLLBACK_LINES) {
|
|
85
|
+
this.lines = lines;
|
|
86
|
+
const { Terminal } = require("@xterm/headless");
|
|
87
|
+
const { SerializeAddon } = require("@xterm/addon-serialize");
|
|
88
|
+
this.terminal = new Terminal({ cols, rows, scrollback: lines, allowProposedApi: true });
|
|
89
|
+
this.serializer = new SerializeAddon();
|
|
90
|
+
this.terminal.loadAddon(this.serializer);
|
|
91
|
+
}
|
|
92
|
+
write(data) { this.terminal.write(data); }
|
|
93
|
+
resize(cols, rows) { try {
|
|
94
|
+
this.terminal.resize(cols, rows);
|
|
95
|
+
}
|
|
96
|
+
catch { /* sizes are clamped before this */ } }
|
|
97
|
+
/** The screen as of this call (after everything written before it), handed to `done` once parsed. */
|
|
98
|
+
snapshot(done) {
|
|
99
|
+
this.terminal.write("", () => { let screen = ""; try {
|
|
100
|
+
screen = this.serializer.serialize({ scrollback: this.lines });
|
|
100
101
|
}
|
|
102
|
+
catch {
|
|
103
|
+
screen = "";
|
|
104
|
+
} done(screen); });
|
|
105
|
+
}
|
|
106
|
+
dispose() { try {
|
|
107
|
+
this.terminal.dispose();
|
|
101
108
|
}
|
|
102
|
-
|
|
103
|
-
get length() { return this.size; }
|
|
109
|
+
catch { /* already */ } }
|
|
104
110
|
}
|
|
105
111
|
const NO_LEDGER = { reserve() { }, running() { }, release() { }, forget() { } };
|
|
106
112
|
export class TerminalError extends Error {
|
|
@@ -237,7 +243,7 @@ export class TerminalHost {
|
|
|
237
243
|
}
|
|
238
244
|
let resolveExited;
|
|
239
245
|
const session = { id, owner, generation, repo, program, branch, worktree, startedAt: this.clock().toISOString(), endedAt: null, exit: null, endedBy: null, cleanup: null,
|
|
240
|
-
pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows,
|
|
246
|
+
pty, leader: pty.pid, tty: pty.tty, known: new Map(), exitedAt: null, worktreeReal: realpathSync(worktree), startedMs: Date.now(), residentsOnly: false, residentsSeen: new Map(), cols, rows, screen: new ScreenMirror(cols, rows, this.options.scrollbackLines), viewers: new Set(), closing: null,
|
|
241
247
|
exited: new Promise(resolve => { resolveExited = resolve; }) };
|
|
242
248
|
this.sessions.set(id, session);
|
|
243
249
|
this.ledger.running(id);
|
|
@@ -246,7 +252,7 @@ export class TerminalHost {
|
|
|
246
252
|
pty.onData(data => {
|
|
247
253
|
this.noteSoon();
|
|
248
254
|
const chunk = Buffer.from(data, "utf8");
|
|
249
|
-
session.
|
|
255
|
+
session.screen.write(data);
|
|
250
256
|
for (const viewer of session.viewers) {
|
|
251
257
|
try {
|
|
252
258
|
viewer.output(chunk);
|
|
@@ -280,21 +286,50 @@ export class TerminalHost {
|
|
|
280
286
|
this.ledger.release(id, null);
|
|
281
287
|
}
|
|
282
288
|
}
|
|
283
|
-
/**
|
|
289
|
+
/**
|
|
290
|
+
* Attach a page: it gets the screen as it is now (ScreenMirror), then `hello`, then live output — what the program
|
|
291
|
+
* writes meanwhile is held and sent after the screen, in order. Returns its detach.
|
|
292
|
+
*/
|
|
284
293
|
attach(id, viewer) {
|
|
285
294
|
const session = this.sessions.get(id);
|
|
286
295
|
if (session === undefined)
|
|
287
296
|
throw new TerminalError("No such terminal.");
|
|
288
297
|
if (session.viewers.size >= MAX_VIEWERS)
|
|
289
298
|
throw new TerminalError(`This terminal is open in ${MAX_VIEWERS} places already. Close one of them.`);
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
299
|
+
const held = [];
|
|
300
|
+
let live = false;
|
|
301
|
+
const gate = {
|
|
302
|
+
output: data => { if (live)
|
|
303
|
+
viewer.output(data);
|
|
304
|
+
else
|
|
305
|
+
held.push({ output: data }); },
|
|
306
|
+
control: message => { if (live)
|
|
307
|
+
viewer.control(message);
|
|
308
|
+
else
|
|
309
|
+
held.push({ control: message }); },
|
|
310
|
+
};
|
|
311
|
+
session.viewers.add(gate);
|
|
312
|
+
session.screen.snapshot(screen => {
|
|
313
|
+
if (!session.viewers.has(gate))
|
|
314
|
+
return;
|
|
315
|
+
try {
|
|
316
|
+
if (screen !== "")
|
|
317
|
+
viewer.output(Buffer.from(screen, "utf8"));
|
|
318
|
+
viewer.control({ t: "hello", cols: session.cols, rows: session.rows, program: session.program, ended: session.endedAt !== null });
|
|
319
|
+
if (session.exit !== null && !held.some(one => "control" in one && one.control.t === "exit"))
|
|
320
|
+
viewer.control({ t: "exit", code: session.exit.code, signal: session.exit.signal });
|
|
321
|
+
for (const one of held) {
|
|
322
|
+
if ("output" in one)
|
|
323
|
+
viewer.output(one.output);
|
|
324
|
+
else
|
|
325
|
+
viewer.control(one.control);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch { /* the viewer's own close handles it */ }
|
|
329
|
+
held.length = 0;
|
|
330
|
+
live = true;
|
|
331
|
+
});
|
|
332
|
+
return () => { session.viewers.delete(gate); };
|
|
298
333
|
}
|
|
299
334
|
/** How many pages are attached now. */
|
|
300
335
|
viewerCount(id) { return this.sessions.get(id)?.viewers.size ?? 0; }
|
|
@@ -319,6 +354,7 @@ export class TerminalHost {
|
|
|
319
354
|
session.pty.resize(session.cols, session.rows);
|
|
320
355
|
}
|
|
321
356
|
catch { /* it exited between */ }
|
|
357
|
+
session.screen.resize(session.cols, session.rows);
|
|
322
358
|
}
|
|
323
359
|
noteTimer = null;
|
|
324
360
|
/** Activity (output, typing) usually means a command started: note processes shortly after, at most every 250 ms. */
|
|
@@ -487,6 +523,7 @@ export class TerminalHost {
|
|
|
487
523
|
catch { /* gone */ }
|
|
488
524
|
}
|
|
489
525
|
session.viewers.clear();
|
|
526
|
+
session.screen.dispose();
|
|
490
527
|
const result = await (this.options.git ?? teardownGit())(session.repo, ["worktree", "remove", "--force", "--", session.worktree]);
|
|
491
528
|
this.options.record?.("remove", this.summary(session));
|
|
492
529
|
if (result.code !== 0 && existsSync(session.worktree)) {
|
package/dist/operate.d.ts
CHANGED
|
@@ -162,3 +162,5 @@ export declare function readUpConsoleUrl(dir: string): string | null;
|
|
|
162
162
|
* missing credentials stay a usage refusal, exactly as before.
|
|
163
163
|
*/
|
|
164
164
|
export declare const PROPOSALS_ACTIONS: readonly ["list", "confirm", "dismiss"];
|
|
165
|
+
/** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
|
|
166
|
+
export declare function ownValueFlags(command: string, argv: readonly string[]): ReadonlySet<string> | undefined;
|
package/dist/operate.js
CHANGED
|
@@ -111,6 +111,7 @@ import { BRANCH_PREFIX, envTwins, envValue, existingOrFirst, taskBranches } from
|
|
|
111
111
|
import { claimActor, currentActor, withActor } from "./actor.js";
|
|
112
112
|
import { parseLeadToken } from "./api-tokens.js";
|
|
113
113
|
import { MAX_NETWORK_HOSTS, normalizeHost } from "./egress.js";
|
|
114
|
+
import { isWritesMode, writesWords } from "./writes-mode.js";
|
|
114
115
|
import { CLI_PASSWORD_SOURCE, withPasswordSource } from "./sign-in-guard.js";
|
|
115
116
|
import { admissionRecorded, admitProject, commandLineActor } from "./project-admission.js";
|
|
116
117
|
import { leadClaim, leadSay, noteLeadWork } from "./lead-voice.js";
|
|
@@ -260,6 +261,10 @@ export const OPERATE_HELP = `toolroll — operating the queue
|
|
|
260
261
|
toolroll project network --repo <p> [--allow h,…] [--remove h,…] [--open|--allowlist] [--local any|none]
|
|
261
262
|
what the project's builds may reach on the network; an approver changes it
|
|
262
263
|
(new scopes seal it; approved ones keep theirs)
|
|
264
|
+
toolroll project writes --repo <p> [workspace|anywhere]
|
|
265
|
+
where the project's builds may write: only their checkout, caches and temp
|
|
266
|
+
(workspace, the default), or anywhere outside Toolroll's fenced folders;
|
|
267
|
+
an approver changes it (new scopes seal it; approved ones keep theirs)
|
|
263
268
|
toolroll config unconfined-agents [allow|refuse]
|
|
264
269
|
whether builds run where this machine can't confine them (instance operator)
|
|
265
270
|
toolroll project concurrency [<n>] --repo <p>
|
|
@@ -688,6 +693,8 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
688
693
|
"no-open", "remove", "no-verify", "no-follow", "end", "report", "off", "tmux",
|
|
689
694
|
"self-heal", "plan-auto", "chat-approve", "full-access", "repair-auto", "review-retry-auto", "no-local",
|
|
690
695
|
"html", "csv", "alerts-only",
|
|
696
|
+
// project network: what its builds may reach (--open | --allowlist).
|
|
697
|
+
"open", "allowlist",
|
|
691
698
|
// v105: the full export.
|
|
692
699
|
"zip",
|
|
693
700
|
// Settings → Integrations: the last checks, without checking again.
|
|
@@ -709,6 +716,9 @@ export const OPERATE_BOOLEAN_FLAGS = new Set([
|
|
|
709
716
|
// task steer: pause the running Claude build, then send the note.
|
|
710
717
|
"now",
|
|
711
718
|
]);
|
|
719
|
+
/** `project network` reads --remove <hosts> and --local any|none as values, where every other verb's --remove and
|
|
720
|
+
* --local are switches. */
|
|
721
|
+
const PROJECT_NETWORK_VALUE_FLAGS = new Set(["remove", "local"]);
|
|
712
722
|
export function parseOperateArgs(argv, ownValues = new Set()) {
|
|
713
723
|
const positional = [];
|
|
714
724
|
const flags = new Map();
|
|
@@ -767,7 +777,7 @@ export async function runOperate(command, argv, write, options = {}) {
|
|
|
767
777
|
return withPasswordSource(CLI_PASSWORD_SOURCE, () => rememberedLogin.run({ login: null }, () => runOperateLocal(command, argv, write, options)));
|
|
768
778
|
}
|
|
769
779
|
async function runOperateLocal(command, argv, write, options = {}) {
|
|
770
|
-
const parsed = parseOperateArgs(argv, command
|
|
780
|
+
const parsed = parseOperateArgs(argv, ownValueFlags(command, argv));
|
|
771
781
|
// A parse error precedes the flags map, so JSON mode is read from the raw
|
|
772
782
|
// argv — the envelope contract holds even for the earliest refusal.
|
|
773
783
|
if ("error" in parsed)
|
|
@@ -979,7 +989,7 @@ export async function runOperateAs(argv, opts) {
|
|
|
979
989
|
const scope = REMOTE_SCOPES.get(row.invocation);
|
|
980
990
|
if (row.remote !== "yes" || scope === undefined)
|
|
981
991
|
return refuse("not-remote", REMOTE_MESSAGES.no);
|
|
982
|
-
const parsed = parseOperateArgs(rest, root
|
|
992
|
+
const parsed = parseOperateArgs(rest, ownValueFlags(root, rest));
|
|
983
993
|
if ("error" in parsed)
|
|
984
994
|
return refuse("usage", parsed.error);
|
|
985
995
|
// The parser's positionals must spell the same row (a value flag can't hide a subcommand).
|
|
@@ -1159,6 +1169,8 @@ async function dispatch(command, positional, flags, context) {
|
|
|
1159
1169
|
return projectConcurrencyCommand(positional, flags, context);
|
|
1160
1170
|
if (positional[0] === "network")
|
|
1161
1171
|
return projectNetworkCommand(positional, flags, context);
|
|
1172
|
+
if (positional[0] === "writes")
|
|
1173
|
+
return projectWritesCommand(positional, flags, context);
|
|
1162
1174
|
if (positional[0] === "checks")
|
|
1163
1175
|
return projectChecksCommand(positional, flags, context);
|
|
1164
1176
|
if (positional[0] === "demo")
|
|
@@ -10970,6 +10982,43 @@ async function projectNetworkCommand(positional, flags, context) {
|
|
|
10970
10982
|
"Scopes written from now on use this; scopes already approved keep the network they were approved with.",
|
|
10971
10983
|
]);
|
|
10972
10984
|
}
|
|
10985
|
+
/**
|
|
10986
|
+
* `project writes --repo <p> [workspace|anywhere]`: where the project's builds may write (writes-mode.ts). `workspace`:
|
|
10987
|
+
* only their checkout and its Git data, their caches, their temp folder and the provider state their CLI needs;
|
|
10988
|
+
* `anywhere`: anywhere outside Toolroll's fenced folders. New scopes seal it; approved ones keep theirs. Changing it is
|
|
10989
|
+
* an approver's act with a password typed now, kept in the ledger as before → after.
|
|
10990
|
+
*/
|
|
10991
|
+
async function projectWritesCommand(positional, flags, context) {
|
|
10992
|
+
const command = "project writes";
|
|
10993
|
+
const allowed = new Set(["repo", "as", "token", "token-file", "token-env", "db", "json"]);
|
|
10994
|
+
for (const name of flags.keys())
|
|
10995
|
+
if (!allowed.has(name))
|
|
10996
|
+
return fail(context.write, context.json, command, "usage", `--${name} is not a project writes option.`, EXIT.usage);
|
|
10997
|
+
const repoFlag = text(flags, "repo");
|
|
10998
|
+
const wanted = positional[1];
|
|
10999
|
+
if (positional.length > 2 || repoFlag === undefined || (wanted !== undefined && !isWritesMode(wanted))) {
|
|
11000
|
+
return fail(context.write, context.json, command, "usage", "Use project writes --repo <project path> [workspace | anywhere].", EXIT.usage);
|
|
11001
|
+
}
|
|
11002
|
+
const registered = await loadRepos(registryPathOf(context)).catch(() => ({ error: "unreadable" }));
|
|
11003
|
+
const known = [...new Set([...context.store.knownRepos(), ...context.store.listProjects().map(one => one.path), ...("error" in registered ? [] : registered.repos)])];
|
|
11004
|
+
const repo = known.find(one => one === repoFlag || one === resolve(repoFlag) || one === canonicalProject(repoFlag));
|
|
11005
|
+
if (repo === undefined)
|
|
11006
|
+
return fail(context.write, context.json, command, "not-found", "That isn't a project Toolroll knows.", EXIT.refused);
|
|
11007
|
+
const words = (one) => [`${repo}: builds write ${writesWords(one)}.`];
|
|
11008
|
+
const current = context.store.projectWrites(repo);
|
|
11009
|
+
if (wanted === undefined)
|
|
11010
|
+
return succeed(context.write, context.json, command, { repo, writes: current }, () => words(current));
|
|
11011
|
+
const acting = await askCredentials(flags, context);
|
|
11012
|
+
const verified = acting === null ? null : credentialApprover(context.store, acting.name, acting.token, repo);
|
|
11013
|
+
if (acting === null || verified === null || !verified.ok || !context.store.accountCanAccess(acting.name, repo)) {
|
|
11014
|
+
return fail(context.write, context.json, command, "refused", "An approver for this project changes where its builds may write, with their password: pass --as and --token.", EXIT.refused);
|
|
11015
|
+
}
|
|
11016
|
+
const changed = context.store.setProjectWrites(repo, wanted, acting.name, context.clock());
|
|
11017
|
+
return succeed(context.write, context.json, command, { repo, before: changed.before, writes: changed.after }, () => [
|
|
11018
|
+
...words(changed.after),
|
|
11019
|
+
"Scopes written from now on use this; scopes already approved keep what they were approved with.",
|
|
11020
|
+
]);
|
|
11021
|
+
}
|
|
10973
11022
|
/** `config unconfined-agents [allow|refuse]`: whether a build may run on a machine that can't confine it at the OS
|
|
10974
11023
|
* (no macOS sandbox, no working bubblewrap). Refused unless an instance operator allows it, with a password. */
|
|
10975
11024
|
async function unconfinedAgentsCommand(positional, flags, context) {
|
|
@@ -11876,3 +11925,12 @@ function databaseFailure(write, json, command, file, error) {
|
|
|
11876
11925
|
return fail(write, json, command, "database", describe(error), EXIT.failed);
|
|
11877
11926
|
return fail(write, json, command, "database-busy", `The database ${file} stayed busy for ${CONCURRENT_WRITER_WAIT_MS / 1_000} seconds. Wait for the other Toolroll process to finish, then try again.`, EXIT.failed);
|
|
11878
11927
|
}
|
|
11928
|
+
// Last in the file, so the password tripwire's function positions (password-freshness.test.ts) stay as reviewed.
|
|
11929
|
+
/** The value flags a verb reads under a global switch's name, for parseOperateArgs. */
|
|
11930
|
+
export function ownValueFlags(command, argv) {
|
|
11931
|
+
if (command === "flows")
|
|
11932
|
+
return FLOWS_VALUE_FLAGS;
|
|
11933
|
+
if (command === "project" && argv.find(arg => !arg.startsWith("-")) === "network")
|
|
11934
|
+
return PROJECT_NETWORK_VALUE_FLAGS;
|
|
11935
|
+
return undefined;
|
|
11936
|
+
}
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* sign-in with the identity provider). The repository itself stays.
|
|
6
6
|
*/
|
|
7
7
|
import type { ProjectNetwork } from "./egress.js";
|
|
8
|
+
import type { WritesMode } from "./writes-mode.js";
|
|
8
9
|
import { type Html } from "./html.js";
|
|
9
10
|
import { type ProjectHoldings } from "./project-delete.js";
|
|
10
11
|
export declare const PROJECT_DELETE_CSS: string;
|
|
@@ -44,6 +45,11 @@ export type ProjectSettingsView = {
|
|
|
44
45
|
setting: ProjectNetwork;
|
|
45
46
|
canChange: boolean;
|
|
46
47
|
};
|
|
48
|
+
/** Where its builds may write (writes-mode.ts), and whether this person may change it. */
|
|
49
|
+
writes?: {
|
|
50
|
+
setting: WritesMode;
|
|
51
|
+
canChange: boolean;
|
|
52
|
+
};
|
|
47
53
|
};
|
|
48
54
|
/** The project's page: its path, what Toolroll holds for it, and (for an instance operator) Delete project. */
|
|
49
55
|
export declare function projectSettingsHtml(view: ProjectSettingsView, notice: {
|
|
@@ -14,6 +14,25 @@ export const PROJECT_DELETE_CSS = `.project-settings,.project-delete{max-width:7
|
|
|
14
14
|
`.project-settings .build-access ul{margin:0 0 4px;padding-left:20px;font:var(--so-typography-body);color:var(--so-ink)}.project-settings .build-access li{margin:2px 0}.project-settings .build-access li .meta{color:var(--so-muted)}` +
|
|
15
15
|
`.project-settings .build-access a.manage-tools{display:inline-flex;align-items:center;justify-content:center;min-height:44px;margin-top:4px;padding:0 14px;white-space:nowrap;background:var(--so-paper);color:var(--so-ink);border:1px solid var(--so-input-line);border-radius:var(--so-radius-md);font:var(--so-typography-label);text-decoration:none}` +
|
|
16
16
|
`.project-settings .build-access a.manage-tools:hover{background:var(--so-raised)}.project-settings .build-access a.manage-tools:focus-visible,.project-settings .build-access summary:focus-visible{outline:2px solid var(--so-ink);outline-offset:2px}`;
|
|
17
|
+
/** Where builds may write, said plainly; an approver changes it with their password. */
|
|
18
|
+
function writesHtml(view) {
|
|
19
|
+
const writes = view.writes;
|
|
20
|
+
if (writes === undefined)
|
|
21
|
+
return "";
|
|
22
|
+
const said = writes.setting === "workspace"
|
|
23
|
+
? html `<p>Builds write <strong>only their own checkout</strong>, its caches and its temp folder. Your shell profile, login items, agent settings and the repository's Git config and hooks stay as they are.</p>`
|
|
24
|
+
: html `<p>Builds may write <strong>anywhere</strong> outside Toolroll's own folders, your credentials and other projects.</p>`;
|
|
25
|
+
if (!writes.canChange)
|
|
26
|
+
return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<p class="meta">An approver for this project can change this.</p></section>`;
|
|
27
|
+
const option = (value, label, detail) => html `<label class="check"><input type="radio" name="writes" value="${value}"${writes.setting === value ? html ` checked` : ""}> ${label}</label><p class="meta">${detail}</p>`;
|
|
28
|
+
const form = postForm("/settings/project/writes", html `
|
|
29
|
+
${option("workspace", "Only their workspace (recommended)", "The checkout and its Git data, the project's own package caches, the build's temp folder, and what its agent needs to keep its session.")}
|
|
30
|
+
${option("anywhere", "Anywhere outside Toolroll's fenced folders", "For a project whose tools must write elsewhere in your home folder or the shared temp folder. Credentials, Toolroll's own folders and other projects stay fenced.")}
|
|
31
|
+
<p class="meta">Tasks scoped from now on use this; tasks already approved keep what they were approved with.</p>
|
|
32
|
+
${reauth(html `<label>Your Toolroll password<input type="password" name="password" autocomplete="current-password" required></label>`)}
|
|
33
|
+
<button type="submit">Save</button>`, { hidden: { repo: view.repo } });
|
|
34
|
+
return html `<section class="build-access writes-setting" id="writes" aria-labelledby="writes-title"><h2 id="writes-title">Where builds write</h2>${said}<details><summary>Change</summary>${form}</details></section>`;
|
|
35
|
+
}
|
|
17
36
|
/** Network: what the project's builds may reach, said plainly; an approver changes it with their password. */
|
|
18
37
|
function networkHtml(view) {
|
|
19
38
|
const network = view.network;
|
|
@@ -92,7 +111,7 @@ export function projectSettingsHtml(view, notice) {
|
|
|
92
111
|
const remote = html `<section class="build-access recorded-remote" aria-labelledby="recorded-remote-title"><h2 id="recorded-remote-title">Recorded remote</h2>${view.remoteUrl
|
|
93
112
|
? html `<p class="path">${view.remoteUrl}</p>`
|
|
94
113
|
: html `<p>No remote recorded. Landed work waits for your review.</p>`}<details><summary>Change remote</summary><p>An owner or instance operator can run:</p><p class="path">toolroll repos add <project> --remote <url></p></details></section>`;
|
|
95
|
-
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${liveViewHtml(view)}`;
|
|
114
|
+
const head = html `${note(notice)}<p class="path">${view.repo}</p>${held}${remote}${buildsHtml(view)}${accessHtml(view)}${networkHtml(view)}${writesHtml(view)}${liveViewHtml(view)}`;
|
|
96
115
|
if (!view.canDelete)
|
|
97
116
|
return html `<section class="project-settings">${head}<p class="meta">An instance operator can delete a project.</p></section>`;
|
|
98
117
|
const body = view.running.length > 0
|
|
@@ -55,6 +55,12 @@ export type AdapterOptions = {
|
|
|
55
55
|
sleep?: (ms: number) => Promise<void>;
|
|
56
56
|
};
|
|
57
57
|
export declare function releaseAdapters(options: AdapterOptions): ReleaseAdapters;
|
|
58
|
+
/**
|
|
59
|
+
* Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
|
|
60
|
+
* A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
|
|
61
|
+
* rather than skipping checks it owes.
|
|
62
|
+
*/
|
|
63
|
+
export declare function workflowRunsOnChange(text: string): boolean;
|
|
58
64
|
/** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
|
|
59
65
|
export declare function bumpFormula(text: string, url: string, sha256: string): string;
|
|
60
66
|
export {};
|
package/dist/release-adapters.js
CHANGED
|
@@ -10,6 +10,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
|
|
|
10
10
|
import { homedir } from "node:os";
|
|
11
11
|
import { basename, delimiter, dirname, join } from "node:path";
|
|
12
12
|
import { createRequire } from "node:module";
|
|
13
|
+
import { parse as parseYaml } from "yaml";
|
|
13
14
|
// Loaded on first use, like the store: a static import prints Node's SQLite warning before the CLI can quiet it.
|
|
14
15
|
const sqlite = () => createRequire(import.meta.url)("node:sqlite");
|
|
15
16
|
import { activeUpdateWork } from "./desktop-update-gate.js";
|
|
@@ -225,7 +226,14 @@ export function releaseAdapters(options) {
|
|
|
225
226
|
return;
|
|
226
227
|
if (current !== head)
|
|
227
228
|
throw new Error(`${branch} moved after merging; its new head was not deleted.`);
|
|
228
|
-
await
|
|
229
|
+
const pushed = await git("push", `--force-with-lease=${ref}:${head}`, "origin", `:${ref}`);
|
|
230
|
+
if (pushed.code === 0)
|
|
231
|
+
return;
|
|
232
|
+
// GitHub may delete a merged branch itself between the look and the push; gone is what was wanted.
|
|
233
|
+
const after = (await must(git("ls-remote", "origin", ref), "git ls-remote")).trim().split(/\s+/)[0] || null;
|
|
234
|
+
if (after === null)
|
|
235
|
+
return;
|
|
236
|
+
throw new Error(`deleting merged branch ${branch} failed: ${pushed.stderr.trim() || `exit ${pushed.code}`}`);
|
|
229
237
|
},
|
|
230
238
|
async tree(sha) { return (await must(git("rev-parse", `${sha}^{tree}`), `git rev-parse ${sha.slice(0, 7)}^{tree}`)).trim(); },
|
|
231
239
|
async fileAt(sha, path) {
|
|
@@ -338,10 +346,10 @@ export function releaseAdapters(options) {
|
|
|
338
346
|
return prs.find(one => one.state === "open") ?? prs.filter(one => one.state === "merged").sort((a, b) => b.number - a.number)[0] ?? null;
|
|
339
347
|
},
|
|
340
348
|
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");
|
|
349
|
+
const out = await must(gh("api", "--paginate", `repos/${repo}/commits/${sha}/check-runs?per_page=100`, "--jq", ".check_runs[] | {id, name, status, conclusion, app: .app.slug}"), "gh api check-runs");
|
|
342
350
|
const runs = out.split("\n").filter(Boolean).map(line => object(JSON.parse(line)));
|
|
343
351
|
// 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"]) }));
|
|
352
|
+
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"]), app: textOr(one["app"]) }));
|
|
345
353
|
},
|
|
346
354
|
async merge(repo, pr, head) {
|
|
347
355
|
const merged = object(await ghJson("gh api pulls merge", "api", "-X", "PUT", `repos/${repo}/pulls/${pr}/merge`, "-f", "merge_method=squash", "-f", `sha=${head}`));
|
|
@@ -379,6 +387,37 @@ export function releaseAdapters(options) {
|
|
|
379
387
|
},
|
|
380
388
|
homebrew: {
|
|
381
389
|
repo: tap,
|
|
390
|
+
async checked(head) {
|
|
391
|
+
// Only a workflow that runs on pull requests (or pushes) puts checks on the tap's change; apps that open a
|
|
392
|
+
// check suite and never run it don't count. Workflows are read as the change has them and as the tap's default
|
|
393
|
+
// branch has them (pull_request_target runs from there); a file this can't read counts as checking.
|
|
394
|
+
for (const ref of [head, await defaultBranch()]) {
|
|
395
|
+
const listed = await gh("api", `repos/${tap}/contents/.github/workflows?ref=${encodeURIComponent(ref)}`, "--jq", ".[] | select(.type == \"file\") | .path");
|
|
396
|
+
if (listed.code !== 0) {
|
|
397
|
+
if (/Not Found|HTTP 404/.test(listed.stderr + listed.stdout))
|
|
398
|
+
continue;
|
|
399
|
+
throw new Error(`listing ${tap}'s workflows at ${ref}: ${listed.stderr.trim()}`);
|
|
400
|
+
}
|
|
401
|
+
for (const path of listed.stdout.split("\n").filter(Boolean)) {
|
|
402
|
+
if (!/\.ya?ml$/.test(path))
|
|
403
|
+
continue;
|
|
404
|
+
const file = await gh("api", `repos/${tap}/contents/${path}?ref=${encodeURIComponent(ref)}`, "--jq", ".content");
|
|
405
|
+
if (file.code !== 0)
|
|
406
|
+
return true;
|
|
407
|
+
if (workflowRunsOnChange(Buffer.from(file.stdout.replace(/\\n|\s/g, ""), "base64").toString("utf8")))
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return false;
|
|
412
|
+
},
|
|
413
|
+
async statuses(head) {
|
|
414
|
+
// The older commit-status API, which some CI reports through instead of checks; newest first, so reversed.
|
|
415
|
+
const out = await must(gh("api", "--paginate", `repos/${tap}/commits/${head}/statuses?per_page=100`, "--jq", ".[] | {context, state}"), "gh api statuses");
|
|
416
|
+
return out.split("\n").filter(Boolean).map(line => object(JSON.parse(line))).reverse().map((one) => {
|
|
417
|
+
const state = String(one["state"]);
|
|
418
|
+
return { name: `status: ${String(one["context"])}`, status: state === "pending" ? "in_progress" : "completed", conclusion: state === "pending" ? null : state === "success" ? "success" : "failure" };
|
|
419
|
+
});
|
|
420
|
+
},
|
|
382
421
|
async state(version) {
|
|
383
422
|
const branch = await defaultBranch();
|
|
384
423
|
const current = await formulaAt(branch);
|
|
@@ -427,6 +466,34 @@ export function releaseAdapters(options) {
|
|
|
427
466
|
}
|
|
428
467
|
}
|
|
429
468
|
const brewBranch = (version) => `toolroll-${version}`;
|
|
469
|
+
/** The events that put checks on a change. */
|
|
470
|
+
const CHANGE_EVENTS = new Set(["pull_request", "pull_request_target", "push", "merge_group"]);
|
|
471
|
+
/**
|
|
472
|
+
* Whether a workflow's triggers (its top-level `on:` as a name, a list or a map) include a change to the repository.
|
|
473
|
+
* A file that doesn't parse, or whose triggers aren't one of those shapes, counts as running, so the release waits
|
|
474
|
+
* rather than skipping checks it owes.
|
|
475
|
+
*/
|
|
476
|
+
export function workflowRunsOnChange(text) {
|
|
477
|
+
let parsed;
|
|
478
|
+
try {
|
|
479
|
+
parsed = parseYaml(text);
|
|
480
|
+
}
|
|
481
|
+
catch {
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
485
|
+
return true;
|
|
486
|
+
const workflow = parsed;
|
|
487
|
+
// YAML 1.1 readers take a bare `on` for true; either key names the triggers.
|
|
488
|
+
const on = "on" in workflow ? workflow["on"] : workflow["true"];
|
|
489
|
+
const events = typeof on === "string" ? [on]
|
|
490
|
+
: Array.isArray(on) ? on
|
|
491
|
+
: on !== null && typeof on === "object" ? Object.keys(on)
|
|
492
|
+
: null;
|
|
493
|
+
if (events === null || !events.every(one => typeof one === "string"))
|
|
494
|
+
return true;
|
|
495
|
+
return events.some(one => CHANGE_EVENTS.has(one));
|
|
496
|
+
}
|
|
430
497
|
/** The formula with its first `url` and `sha256` replaced; anything else is left exactly as it was. */
|
|
431
498
|
export function bumpFormula(text, url, sha256) {
|
|
432
499
|
if (!/^\s*url "[^"]*"/m.test(text) || !/^\s*sha256 "[0-9a-f]{64}"/m.test(text))
|
package/dist/release.d.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* skipped or failed never merges. merge
|
|
18
18
|
* squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
|
|
19
19
|
* and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
|
|
20
|
-
* waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
|
|
20
|
+
* waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
|
|
21
21
|
*
|
|
22
22
|
* A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
|
|
23
23
|
* anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
|
|
@@ -38,10 +38,12 @@ export type ReleaseStep = typeof RELEASE_STEPS[number];
|
|
|
38
38
|
type WaitingStep = "approval" | "check" | "quiet" | "deploy" | "ci" | "publish" | "homebrew";
|
|
39
39
|
/** How long each waiting step may take, in minutes, before the release stops there (rerunning continues from it). */
|
|
40
40
|
export declare const RELEASE_LIMITS: Readonly<Record<WaitingStep, number>>;
|
|
41
|
+
/** One check attempt; `app` is the GitHub App that made it (github-actions for a workflow), when known. */
|
|
41
42
|
export type CheckRun = {
|
|
42
43
|
name: string;
|
|
43
44
|
status: string;
|
|
44
45
|
conclusion: string | null;
|
|
46
|
+
app?: string | null;
|
|
45
47
|
};
|
|
46
48
|
export type PullRequest = {
|
|
47
49
|
number: number;
|
|
@@ -195,6 +197,10 @@ export type ReleaseAdapters = {
|
|
|
195
197
|
};
|
|
196
198
|
homebrew: {
|
|
197
199
|
repo: string;
|
|
200
|
+
/** Whether any of the tap's workflows, at the change or its base, runs on a change; if none does, no checks are owed. */
|
|
201
|
+
checked(head: string): Promise<boolean>;
|
|
202
|
+
/** The change's commit statuses, as check runs oldest first. */
|
|
203
|
+
statuses(head: string): Promise<CheckRun[]>;
|
|
198
204
|
state(version: string): Promise<HomebrewState>;
|
|
199
205
|
propose(version: string): Promise<{
|
|
200
206
|
pr: number;
|
package/dist/release.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* skipped or failed never merges. merge
|
|
18
18
|
* squash-merges with the head pinned to the gated commit, tree proves main's new commit has exactly the gated tree,
|
|
19
19
|
* and only then tag pushes the version tag, whose macOS matrix must pass before npm publication (publish.yml). publish
|
|
20
|
-
* waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it.
|
|
20
|
+
* waits for npm, the GitHub release and the macOS checks; homebrew updates the tap's formula and merges it (after its checks, when the tap runs any).
|
|
21
21
|
*
|
|
22
22
|
* A private journal (mode 0600, written whole then renamed) records what each step is about to do before it does
|
|
23
23
|
* anything outside this machine, and what it saw after. A stop — a time limit, a failure, a crash, Ctrl-C — leaves the
|
|
@@ -454,15 +454,28 @@ export async function runRelease(options, adapters) {
|
|
|
454
454
|
const proposed = state;
|
|
455
455
|
j.homebrew = { pr: proposed.pr, head: proposed.head, merged: false };
|
|
456
456
|
save();
|
|
457
|
+
// Whatever checks do register are always judged; an empty set passes only for a tap whose workflows never run
|
|
458
|
+
// on a change, and only when it's still empty a poll later.
|
|
459
|
+
const checked = await brew.checked(proposed.head);
|
|
460
|
+
let emptyLooks = 0;
|
|
457
461
|
await waitFor("homebrew", `the Homebrew change's checks (${brew.repo} #${proposed.pr})`, async () => {
|
|
458
|
-
|
|
462
|
+
// Check runs and commit statuses are judged apart, so neither stands in for the other or hides its failure.
|
|
463
|
+
// Two apps may name a check alike; each app's attempts stand for themselves.
|
|
464
|
+
const runs = (await adapters.github.checks(brew.repo, proposed.head)).map(run => ({ ...run, name: `${run.app ?? "unknown app"}: ${run.name}` }));
|
|
465
|
+
const statuses = await brew.statuses(proposed.head);
|
|
466
|
+
const verdicts = [runs, statuses].filter(one => one.length > 0).map(one => checksVerdict(one, [...new Set(one.map(run => run.name))]));
|
|
467
|
+
const failed = verdicts.flatMap(one => one.state === "failed" ? one.failed : []);
|
|
468
|
+
if (failed.length > 0)
|
|
469
|
+
throw new Stop("homebrew-failed", `The Homebrew change's checks failed: ${failed.join(", ")}.`);
|
|
470
|
+
if (verdicts.some(one => one.state === "pending"))
|
|
471
|
+
return null;
|
|
459
472
|
// GitHub registers checks asynchronously; an empty set says nothing about success.
|
|
460
|
-
|
|
473
|
+
// A workflow is owed: only its own run counts, not another app's green check.
|
|
474
|
+
if (checked && !runs.some(run => run.app === "github-actions"))
|
|
461
475
|
return null;
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
return verdict.state === "passed" ? true : null;
|
|
476
|
+
if (runs.length === 0 && statuses.length === 0)
|
|
477
|
+
return ++emptyLooks >= 2 ? true : null;
|
|
478
|
+
return true;
|
|
466
479
|
});
|
|
467
480
|
await brew.merge(proposed.pr, proposed.head);
|
|
468
481
|
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Add tests files a small task, unapproved like any other filing, to write
|
|
11
11
|
* tests for that change. */
|
|
12
|
+
import { fencedCheckoutCommands } from "./invoke.js";
|
|
12
13
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
13
14
|
import { tmpdir } from "node:os";
|
|
14
15
|
import { join } from "node:path";
|
|
@@ -201,7 +202,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
201
202
|
let ready = true;
|
|
202
203
|
if (setup !== null) {
|
|
203
204
|
const shell = approvedCommandShell(setup.command);
|
|
204
|
-
const prepared = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
205
|
+
const prepared = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: setup.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
205
206
|
note("Approved project setup", setup.command, prepared);
|
|
206
207
|
if (prepared.code !== 0 || prepared.timedOut || prepared.notFound) {
|
|
207
208
|
ready = false;
|
|
@@ -216,7 +217,7 @@ export async function runFollowUpCheck(store, root, request, deps = {}) {
|
|
|
216
217
|
}
|
|
217
218
|
else {
|
|
218
219
|
const shell = approvedCommandShell(live.command);
|
|
219
|
-
const ran = await runWithIsolatedDatabase(runner, shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
220
|
+
const ran = await runWithIsolatedDatabase(fencedCheckoutCommands(store, target.repo, checkout, runner), shell.file, shell.args, { cwd: checkout, timeoutMs: live.timeoutMs, processGroup: true, envAllowlist: SETUP_ENV_ALLOWLIST, omitEnv: SETUP_ENV_DENYLIST });
|
|
220
221
|
note(`${CHECK_LEVEL_WORDS[target.level]} check`, live.command, ran);
|
|
221
222
|
result = ran.timedOut ? { ran: false, failure: "The check ran out of time." } : ran.notFound ? { ran: false, failure: "The check couldn't start." } : { ran: true, exitCode: ran.code };
|
|
222
223
|
}
|