@a2f0/agent-tool 0.1.46 → 0.1.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a2f0/agent-tool",
3
- "version": "0.1.46",
3
+ "version": "0.1.48",
4
4
  "packageManager": "bun@1.4.4",
5
5
  "type": "module",
6
6
  "description": "Portable cross-agent review, PR workflows, and shared skills",
package/src/cli.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { execFileSync } from "node:child_process";
2
1
  import { readFileSync, writeFileSync, writeSync } from "node:fs";
3
2
  import path from "node:path";
4
3
  import { DEFAULT_CONFIG, loadConfig } from "./config";
@@ -28,6 +27,7 @@ import { hooksCommand } from "./workflows/hooks";
28
27
  import { repoCommand, policyCommand, toolchainCommand } from "./workflows/repository";
29
28
  import { prWorkflowCommand } from "./workflows/pr";
30
29
  import { releaseCommand } from "./release/commands";
30
+ import { checkoutRoot } from "./workflows/core";
31
31
  import { procCommand, teardownCommand } from "./processes/commands";
32
32
 
33
33
  export const HELP = `agent-tool ${metadata.version}
@@ -96,9 +96,7 @@ returned; inspect VERDICT before shipping. Skill install previews unless --apply
96
96
 
97
97
  function output(value: unknown): void { writeSync(1, `${JSON.stringify(value, null, 2)}\n`); }
98
98
  function usage(condition: boolean, message: string): void { if (!condition) throw new Error(message); }
99
- function gitRoot(): string {
100
- return execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
101
- }
99
+ function gitRoot(): string { return checkoutRoot(); }
102
100
 
103
101
  export function main(argv = process.argv.slice(2)): number | Promise<number> {
104
102
  const args = [...argv];
@@ -1,5 +1,6 @@
1
1
  import { gitEnvironment } from "./environment";
2
- import { execFileSync, spawnSync } from "node:child_process";
2
+ import { spawnSync } from "node:child_process";
3
+ import { boundedExecFileSync } from "../system/boundedCalls";
3
4
  import { randomUUID } from "node:crypto";
4
5
 
5
6
  import { viewCurrentBranchPr } from "./currentBranchPr";
@@ -30,7 +31,7 @@ interface SpawnResult {
30
31
  export const MAX_BUFFER_BYTES = 512 * 1024 * 1024;
31
32
 
32
33
  export function run(command: string, args: string[]): string {
33
- return execFileSync(command, args, {
34
+ return boundedExecFileSync(command, args, {
34
35
  env: gitEnvironment(),
35
36
  encoding: "utf8",
36
37
  stdio: ["ignore", "pipe", "pipe"],
@@ -5,6 +5,7 @@ import { decidePublish, readRegistryState } from "./npm";
5
5
  import { assert, flag, json, oid, option, output, poll, repository, timeoutOptions } from "../workflows/core";
6
6
 
7
7
  function packageName(name: unknown): asserts name is string { assert(typeof name === "string" && /^(?:@[a-z0-9._-]+\/)?[a-z0-9][a-z0-9._-]*$/.test(name), "Invalid npm package name."); }
8
+ export const RELEASE_TIMEOUT_SECONDS = 3600;
8
9
  export interface ReleaseRun { databaseId: number; status: string; conclusion: string; headSha: string; url: string }
9
10
  /**
10
11
  * Downloads and checks the published tarball. Resolves null only when the
@@ -64,7 +65,8 @@ export async function releaseCommand(args: string[]): Promise<number> {
64
65
  output({ name: manifest.name, version: manifest.version, privateSource: manifest.private === true, ...decision }); return 0;
65
66
  }
66
67
  const workflow = option(args, "--workflow");
67
- const options = timeoutOptions(args);
68
+ // Publishing runs commonly take 35-45 minutes before npm propagation, so wait an hour unless told otherwise.
69
+ const options = timeoutOptions(args, RELEASE_TIMEOUT_SECONDS);
68
70
  assert(action === "verify" && args.length === 3 && workflow, "Usage: release verify <package> <version> <merge-oid> --workflow <name> [--timeout seconds] [--interval seconds]");
69
71
  const [name, version, commit] = args as [string, string, string];
70
72
  packageName(name); oid(commit);
@@ -0,0 +1,44 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from "node:child_process";
3
+
4
+ /**
5
+ * Bounds for each external command a wait runs. A poll checks its deadline only
6
+ * between inspections, so one hung `gh` or `git ls-remote` would otherwise keep
7
+ * the wait running forever. Each call may take the time left before the
8
+ * deadline, but at least `minMs` so the last inspection can still finish, and
9
+ * never more than `maxMs`. Mutable for tests.
10
+ */
11
+ export const CALL_LIMITS = { minMs: 15_000, maxMs: 120_000 };
12
+
13
+ // Scoped per async context, so overlapping waits never see each other's deadline.
14
+ const deadlines = new AsyncLocalStorage<number>();
15
+
16
+ /** Run `inspect` with every bounded call limited by `until`, the wait's deadline. */
17
+ export async function withCallDeadline<T>(until: number, inspect: () => T | Promise<T>): Promise<T> {
18
+ return await deadlines.run(until, inspect);
19
+ }
20
+
21
+ function currentLimit(): number | undefined {
22
+ const deadline = deadlines.getStore();
23
+ if (deadline === undefined) return undefined;
24
+ return Math.min(CALL_LIMITS.maxMs, Math.max(CALL_LIMITS.minMs, deadline - Date.now()));
25
+ }
26
+
27
+ /**
28
+ * `execFileSync` that, inside a wait, is killed and fails once its call limit
29
+ * passes. Bun returns as soon as the command itself is killed, even when a
30
+ * descendant such as ssh still holds its output pipes; that descendant is left
31
+ * to exit on its own, as it would after an interrupted command.
32
+ */
33
+ export function boundedExecFileSync(command: string, args: string[], options: ExecFileSyncOptionsWithStringEncoding): string {
34
+ const timeout = currentLimit();
35
+ try {
36
+ return execFileSync(command, args, timeout === undefined ? options : { ...options, timeout, killSignal: "SIGKILL" });
37
+ } catch (error) {
38
+ const failure = error as NodeJS.ErrnoException;
39
+ if (timeout !== undefined && failure.code === "ETIMEDOUT") {
40
+ throw new Error(`${[command, ...args.slice(0, 2)].join(" ")} did not finish within ${Math.ceil(timeout / 1000)} seconds; stopped it.`);
41
+ }
42
+ throw error;
43
+ }
44
+ }
@@ -1,12 +1,14 @@
1
- import { execFileSync, spawnSync } from "node:child_process";
2
- import { writeSync } from "node:fs";
1
+ import { spawnSync } from "node:child_process";
2
+ import { lstatSync, realpathSync, writeSync } from "node:fs";
3
+ import path from "node:path";
3
4
  import { gitEnvironment } from "../git/environment";
5
+ import { boundedExecFileSync, withCallDeadline } from "../system/boundedCalls";
4
6
 
5
7
  export function output(value: unknown): void { writeSync(1, `${JSON.stringify(value, null, 2)}\n`); }
6
8
  export function assert(condition: unknown, message: string): asserts condition { if (!condition) throw new Error(message); }
7
9
  export function oid(value: string): string { assert(/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/i.test(value), "Expected a full commit OID."); return value; }
8
10
  export function read(command: string, args: string[], cwd = process.cwd()): string {
9
- return execFileSync(command, args, { cwd, env: gitEnvironment(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trimEnd();
11
+ return boundedExecFileSync(command, args, { cwd, env: gitEnvironment(), encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: 64 * 1024 * 1024 }).trimEnd();
10
12
  }
11
13
  export function git(args: string[], cwd = process.cwd()): string { return read("git", args, cwd); }
12
14
  export function json<T>(args: string[]): T { return JSON.parse(read("gh", args)) as T; }
@@ -23,8 +25,37 @@ export function currentBranch(): string {
23
25
  assert(branch, "Detached HEAD; supply a branch name to checks or attach a branch before mutations.");
24
26
  return branch;
25
27
  }
28
+ /**
29
+ * Git skips a `.git` it cannot open (unreadable, or without HEAD while it is
30
+ * rewritten) and keeps searching upward, so a checkout nested inside another
31
+ * repository, such as a workspace's `checkouts/<name>`, would silently resolve
32
+ * to the outer one. Refuse that instead of acting on another repository.
33
+ */
34
+ export function assertOwnCheckout(root: string, cwd = process.cwd()): void {
35
+ const top = realpathSync(root);
36
+ const relative = path.relative(top, realpathSync(cwd));
37
+ if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return;
38
+ let current = top;
39
+ for (const part of relative.split(path.sep)) {
40
+ current = path.join(current, part);
41
+ assert(!hasEntry(path.join(current, ".git")), `${current} has its own .git, but Git resolved the enclosing repository ${top}; the nested checkout's Git directory is unreadable or incomplete (mid-checkout?). Retry once it is readable.`);
42
+ }
43
+ }
44
+ /** Whether anything, even a dangling link, exists at `file`; an entry that cannot be inspected counts. */
45
+ function hasEntry(file: string): boolean {
46
+ try { lstatSync(file); return true; } catch (error) { return (error as NodeJS.ErrnoException).code !== "ENOENT"; }
47
+ }
48
+ /** The top level of the checkout Git resolves from `cwd`, never an enclosing repository. */
49
+ export function checkoutRoot(cwd = process.cwd()): string {
50
+ const root = git(["rev-parse", "--show-toplevel"], cwd);
51
+ assertOwnCheckout(root, cwd);
52
+ return root;
53
+ }
26
54
  export interface Repository { nameWithOwner: string; defaultBranchRef: { name: string }; url: string; sshUrl: string }
27
55
  export function repository(): Repository {
56
+ // gh resolves the repository from the same Git discovery; outside any checkout it may still use GH_REPO.
57
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], env: gitEnvironment() });
58
+ if (top.status === 0) assertOwnCheckout(top.stdout.trim());
28
59
  const repo = json<Repository>(["repo", "view", "--json", "nameWithOwner,defaultBranchRef,url,sshUrl"]);
29
60
  assert(/^[^/\s]+\/[^/\s]+$/.test(repo.nameWithOwner) && repo.defaultBranchRef?.name, "Could not resolve repository identity and default branch.");
30
61
  return repo;
@@ -58,9 +89,10 @@ export function timeoutOptions(args: string[], defaultSeconds = 900): { timeoutM
58
89
  export async function poll<T>(inspect: () => { done: boolean; value: T } | Promise<{ done: boolean; value: T }>, options: { timeoutMs: number; intervalMs: number }): Promise<T> {
59
90
  const deadline = Date.now() + options.timeoutMs;
60
91
  while (true) {
61
- const result = await inspect();
92
+ // Each external call inside an inspection is bounded too, so a hung one cannot outlast the wait.
93
+ const result = await withCallDeadline(deadline, inspect);
62
94
  if (result.done) return result.value;
63
- assert(Date.now() < deadline, `Timed out waiting: ${JSON.stringify(result.value)}`);
95
+ assert(Date.now() < deadline, `Timed out waiting: ${JSON.stringify(result.value)} Pass --timeout <seconds> to wait longer.`);
64
96
  await new Promise(resolve => setTimeout(resolve, Math.min(options.intervalMs, deadline - Date.now())));
65
97
  }
66
98
  }