@zgeoff/atc 3.3.1 → 3.4.1

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 (206) hide show
  1. package/package.json +5 -3
  2. package/src/agents/agent-adapter.ts +10 -0
  3. package/src/agents/build-agent-adapters.ts +21 -3
  4. package/src/agents/build-cli-argv.ts +2 -4
  5. package/src/agents/claude-adapter.ts +13 -3
  6. package/src/agents/codex-adapter.ts +31 -2
  7. package/src/agents/gateway-adapter.ts +20 -8
  8. package/src/agents/grok-adapter.ts +16 -13
  9. package/src/agents/load-claude-config-bundle.ts +6 -6
  10. package/src/agents/print-codex-hook-file.ts +7 -3
  11. package/src/agents/print-grok-hook-file.ts +8 -3
  12. package/src/agents/render-sdk-message.ts +57 -0
  13. package/src/agents/resolve-agent-home.ts +7 -3
  14. package/src/agents/resolve-claude-global-config-path.ts +4 -4
  15. package/src/agents/start-claude-headless-run.ts +2 -57
  16. package/src/agents/update-claude-project-trust.ts +41 -30
  17. package/src/agents/with-claude-config-lock.ts +37 -12
  18. package/src/agents/write-atc-bridge.ts +10 -7
  19. package/src/agents/write-hook-settings.ts +13 -7
  20. package/src/client/boot-daemon.ts +122 -39
  21. package/src/client/dirs.ts +4 -2
  22. package/src/client/index.ts +17 -1
  23. package/src/client/log-client-event.ts +20 -0
  24. package/src/client/resolve-workspace-root.ts +10 -5
  25. package/src/client/spawn-picker.ts +200 -121
  26. package/src/client/ui.ts +7 -7
  27. package/src/clients.ts +41 -11
  28. package/src/daemon/daemon.ts +143 -33
  29. package/src/daemon/event-signal.ts +19 -3
  30. package/src/daemon/make-hook-runner.ts +82 -22
  31. package/src/daemon/permission-registry.ts +11 -6
  32. package/src/daemon/restore-fleet.ts +62 -17
  33. package/src/daemon/run-eject-handoff.ts +20 -2
  34. package/src/daemon/session-runtime.ts +0 -4
  35. package/src/daemon/sessions.ts +6 -0
  36. package/src/daemon/start-tcp-listener.ts +12 -10
  37. package/src/federation/daemon-caller.ts +25 -4
  38. package/src/federation/daemon-pool.ts +3 -1
  39. package/src/federation/open-gateway-caller.ts +6 -1
  40. package/src/gateway.ts +2 -179
  41. package/src/grants.ts +36 -11
  42. package/src/hook-report.ts +17 -3
  43. package/src/mcp/read-report-texts.ts +11 -7
  44. package/src/mcp/start-mcp-http-server.ts +5 -1
  45. package/src/mcp-http-server.ts +88 -32
  46. package/src/report.ts +21 -3
  47. package/src/run-bridge-tap.ts +36 -7
  48. package/src/run-config-migrate.ts +35 -11
  49. package/src/run-gateway-cli.ts +327 -0
  50. package/src/run-gateway.ts +51 -18
  51. package/src/shared/build-atc-command.ts +7 -3
  52. package/src/shared/bun-sqlite-driver.ts +10 -0
  53. package/src/shared/claim-daemon-lock.ts +10 -4
  54. package/src/shared/collect-dir-roots.ts +6 -6
  55. package/src/shared/collect-hooks.ts +7 -6
  56. package/src/shared/config.ts +19 -9
  57. package/src/shared/system-clock.ts +24 -0
  58. package/src/sources/github/collect-github-repos.ts +1 -1
  59. package/src/sources/github/read-git-protocol.ts +1 -1
  60. package/src/sources/github/run-gh.ts +24 -9
  61. package/src/store/state-store.ts +57 -7
  62. package/src/tap.ts +44 -13
  63. package/src/test-utils/build-mock-agent-adapter.ts +25 -0
  64. package/src/test-utils/build-mock-agent-entry.ts +32 -0
  65. package/src/test-utils/build-mock-auth-binding.ts +28 -0
  66. package/src/test-utils/build-mock-auth-profile.ts +62 -0
  67. package/src/test-utils/build-mock-daemon-record.ts +26 -0
  68. package/src/test-utils/build-mock-execution-target.ts +25 -0
  69. package/src/test-utils/build-mock-fleet-entry.ts +33 -0
  70. package/src/test-utils/build-mock-gateway-config.ts +32 -0
  71. package/src/test-utils/build-mock-imp-identity.ts +18 -0
  72. package/src/test-utils/build-mock-imp-session-request.ts +53 -0
  73. package/src/test-utils/build-mock-key-binding-claim.ts +34 -0
  74. package/src/test-utils/build-mock-message-record.ts +26 -0
  75. package/src/test-utils/build-mock-mirror-session.ts +42 -0
  76. package/src/test-utils/build-mock-registry-daemon.ts +28 -0
  77. package/src/test-utils/build-mock-session-descriptor.ts +66 -0
  78. package/src/test-utils/build-mock-session.ts +43 -0
  79. package/src/test-utils/build-mock-sortable-session-view.ts +20 -0
  80. package/src/test-utils/build-mock-stored-event.ts +20 -0
  81. package/src/test-utils/build-mock-stored-row.ts +23 -0
  82. package/src/test-utils/build-stub-attention-adapter.ts +22 -0
  83. package/src/test-utils/build-stub-bridge-context.ts +20 -0
  84. package/src/test-utils/build-stub-brokered-agent-adapter.ts +96 -0
  85. package/src/test-utils/build-stub-brokered-gateway-adapter.ts +50 -0
  86. package/src/test-utils/build-stub-channel-opener.ts +45 -0
  87. package/src/test-utils/build-stub-claude-headless-run.ts +13 -0
  88. package/src/test-utils/build-stub-client-screens.ts +33 -0
  89. package/src/test-utils/build-stub-clock.ts +67 -0
  90. package/src/test-utils/build-stub-daemon-context.ts +81 -0
  91. package/src/test-utils/build-stub-daemon-requests.ts +103 -0
  92. package/src/test-utils/{fixture-dir-provider.ts → build-stub-dir-provider.ts} +20 -12
  93. package/src/test-utils/build-stub-execution-provider.ts +112 -0
  94. package/src/test-utils/build-stub-fleet-caller.ts +60 -0
  95. package/src/test-utils/build-stub-forking-gh.ts +10 -0
  96. package/src/test-utils/build-stub-gh.ts +50 -0
  97. package/src/test-utils/build-stub-handoff-daemon.ts +11 -0
  98. package/src/test-utils/build-stub-harness-relay.ts +50 -0
  99. package/src/test-utils/build-stub-headless-runner.ts +60 -0
  100. package/src/test-utils/build-stub-held-gh.ts +19 -0
  101. package/src/test-utils/build-stub-held-inbox-source.ts +36 -0
  102. package/src/test-utils/build-stub-held-provider.ts +58 -0
  103. package/src/test-utils/build-stub-held-zoxide.ts +13 -0
  104. package/src/test-utils/build-stub-host-hold.ts +31 -0
  105. package/src/test-utils/build-stub-host-provider.ts +26 -0
  106. package/src/test-utils/build-stub-log.ts +15 -0
  107. package/src/test-utils/build-stub-mcp-claude.ts +26 -0
  108. package/src/test-utils/build-stub-mcp-grok.ts +19 -0
  109. package/src/test-utils/build-stub-mcp-stdio-server.ts +20 -0
  110. package/src/test-utils/build-stub-peer-socket.ts +81 -0
  111. package/src/test-utils/build-stub-picker-host.ts +36 -0
  112. package/src/test-utils/build-stub-proxied-agent-adapter.ts +29 -0
  113. package/src/test-utils/build-stub-pty-provider.ts +53 -0
  114. package/src/test-utils/build-stub-recording-atc.ts +10 -0
  115. package/src/test-utils/build-stub-recording-filter.ts +9 -0
  116. package/src/test-utils/build-stub-recording-git.ts +8 -0
  117. package/src/test-utils/build-stub-signed-in-gh.ts +18 -0
  118. package/src/test-utils/build-stub-signed-out-gh.ts +8 -0
  119. package/src/test-utils/build-stub-soft-kill-provider.ts +29 -0
  120. package/src/test-utils/build-stub-targets.ts +58 -0
  121. package/src/test-utils/build-stub-terminal.ts +19 -0
  122. package/src/test-utils/build-stub-timeout-scheduler.ts +48 -0
  123. package/src/test-utils/build-stub-tui-claude.ts +47 -0
  124. package/src/test-utils/build-stub-tui-grok.ts +53 -0
  125. package/src/test-utils/build-stub-wait-clock.ts +23 -0
  126. package/src/test-utils/build-target-options-from-config.ts +37 -0
  127. package/src/test-utils/collect-process-tree.ts +75 -0
  128. package/src/test-utils/create-git-fixture.ts +117 -0
  129. package/src/test-utils/create-migrated-state-db.ts +12 -10
  130. package/src/test-utils/create-stub-bin.ts +19 -0
  131. package/src/test-utils/create-stub-claude.ts +75 -0
  132. package/src/test-utils/create-stub-codex.ts +31 -0
  133. package/src/test-utils/create-stub-composer.ts +65 -0
  134. package/src/test-utils/create-stub-echo-claude.ts +21 -0
  135. package/src/test-utils/create-stub-failing-agent-adapter.ts +111 -0
  136. package/src/test-utils/create-stub-grok.ts +44 -0
  137. package/src/test-utils/create-stub-guest-clis.ts +55 -0
  138. package/src/test-utils/create-stub-harness-guest.ts +35 -0
  139. package/src/test-utils/{fixture-imp-port.ts → create-stub-imp-port.ts} +144 -63
  140. package/src/test-utils/create-stub-recording-claude.ts +20 -0
  141. package/src/test-utils/create-stub-recording-codex.ts +20 -0
  142. package/src/test-utils/create-stub-shared-server-codex.ts +56 -0
  143. package/src/test-utils/{setup-fake-systemd.ts → create-stub-systemd.ts} +15 -9
  144. package/src/test-utils/get-gateway-config.ts +3 -3
  145. package/src/test-utils/get-only-effect-ref.ts +21 -0
  146. package/src/test-utils/get-only-imp-name.ts +19 -0
  147. package/src/test-utils/get-records.ts +20 -0
  148. package/src/test-utils/get-string.ts +14 -0
  149. package/src/test-utils/keys.ts +26 -0
  150. package/src/test-utils/merge-deep.ts +36 -0
  151. package/src/test-utils/mock-overrides.ts +13 -0
  152. package/src/test-utils/open-repo-step.ts +24 -0
  153. package/src/test-utils/read-query-plan.ts +22 -0
  154. package/src/test-utils/register-test-cleanup.ts +63 -0
  155. package/src/test-utils/resolve-atc-command.ts +13 -0
  156. package/src/test-utils/resolve-gateway-command.ts +13 -0
  157. package/src/test-utils/run-atc.ts +56 -0
  158. package/src/test-utils/run-legacy-daemon.ts +27 -5
  159. package/src/test-utils/run-listener-daemon.ts +5 -10
  160. package/src/test-utils/run-mcp-authorization.ts +7 -4
  161. package/src/test-utils/run-source-daemon.ts +2 -2
  162. package/src/test-utils/seed-faker.ts +7 -0
  163. package/src/test-utils/send-lines-before-handshake.ts +35 -0
  164. package/src/test-utils/send-mcp-request.ts +25 -0
  165. package/src/test-utils/setup-mcp-home.ts +110 -0
  166. package/src/test-utils/setup-mcp-http.ts +26 -30
  167. package/src/test-utils/setup-temp-dir.ts +7 -6
  168. package/src/test-utils/spawn-claude-session.ts +34 -0
  169. package/src/test-utils/spawn-grok-session.ts +39 -0
  170. package/src/test-utils/start-cut-proxy.ts +29 -8
  171. package/src/test-utils/start-daemon-process.ts +193 -0
  172. package/src/test-utils/start-git-http-server.ts +28 -4
  173. package/src/test-utils/start-mcp-stdio.ts +190 -0
  174. package/src/test-utils/start-output-capture.ts +20 -0
  175. package/src/test-utils/start-stub-answering-listener.ts +65 -0
  176. package/src/test-utils/start-stub-closing-listener.ts +31 -0
  177. package/src/test-utils/start-stub-dropping-daemon.ts +105 -0
  178. package/src/test-utils/start-stub-impd-info.ts +38 -0
  179. package/src/test-utils/start-stub-impd.ts +143 -0
  180. package/src/test-utils/start-stub-legacy-daemon.ts +171 -0
  181. package/src/test-utils/start-stub-mcp-server.ts +55 -0
  182. package/src/test-utils/start-stub-recording-listener.ts +47 -0
  183. package/src/test-utils/start-stub-refusing-listener.ts +43 -0
  184. package/src/test-utils/start-stub-reporter-socket.ts +79 -0
  185. package/src/test-utils/start-stub-restore-daemon.ts +59 -0
  186. package/src/test-utils/start-stub-session-bridge.ts +100 -0
  187. package/src/test-utils/start-stub-silent-server.ts +38 -0
  188. package/src/test-utils/start-stub-source-daemon.ts +41 -0
  189. package/src/test-utils/start-stub-stalled-client.ts +61 -0
  190. package/src/test-utils/start-stub-stalled-listener.ts +44 -0
  191. package/src/test-utils/start-stub-tap.ts +38 -0
  192. package/src/test-utils/start-stub-tcp-daemon.ts +75 -0
  193. package/src/test-utils/start-stub-telemetry-collector.ts +32 -0
  194. package/src/test-utils/start-stub-unanswering-listener.ts +41 -0
  195. package/src/test-utils/start-stub-unanswering-unix-listener.ts +38 -0
  196. package/src/test-utils/start-test-daemon.ts +229 -0
  197. package/src/test-utils/start-tui-harness.ts +291 -0
  198. package/src/test-utils/subscribe-to-socket-lines.ts +81 -14
  199. package/src/test-utils/try-send-request.ts +29 -0
  200. package/src/test-utils/wait-for-event.ts +41 -0
  201. package/src/test-utils/wait-for.ts +15 -3
  202. package/src/verify-restored-fleet.ts +30 -17
  203. package/src/workspace/check-repository-access.ts +4 -0
  204. package/src/workspace/collect-remote-refs.ts +4 -2
  205. package/src/workspace/run-git.ts +6 -0
  206. package/src/test-utils/start-legacy-daemon.ts +0 -111
@@ -29,6 +29,24 @@ const SEEN_LIMIT = 10_000;
29
29
  // opened the tap.
30
30
  type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
31
31
 
32
+ // Where the tap prints and how it exits: stdout, stderr, and the process by
33
+ // default.
34
+ interface BridgeTapIO {
35
+ readonly writeStdout: (text: string) => Promise<unknown>;
36
+ readonly printError: (line: string) => void;
37
+ readonly exit: (code: number) => void;
38
+ }
39
+
40
+ const PROCESS_IO: BridgeTapIO = {
41
+ writeStdout: (text) => Bun.write(Bun.stdout, text),
42
+ printError: (line) => {
43
+ console.error(line);
44
+ },
45
+ exit: (code) => {
46
+ process.exit(code);
47
+ },
48
+ };
49
+
32
50
  /**
33
51
  * Streams the session's inbox to stdout from inside a remote host, through
34
52
  * the session bridge at the given socket. It prints each message once as
@@ -40,21 +58,29 @@ type TapEnd = 'closed' | 'refused' | 'lost' | 'unreachable';
40
58
  * outbox still holds. Exits 0 once the inbox closes and 1 when the bridge
41
59
  * refuses the tap.
42
60
  */
43
- export async function runBridgeTap(socketPath: string, outbox: string): Promise<void> {
61
+ export async function runBridgeTap(
62
+ socketPath: string,
63
+ outbox: string,
64
+ io: BridgeTapIO = PROCESS_IO,
65
+ ): Promise<void> {
44
66
  const seen = new Set<string>();
45
67
 
46
68
  let wait = FIRST_RETRY_MS;
47
69
 
48
70
  for (;;) {
49
- const end = await runTapConnection(socketPath, outbox, seen);
71
+ const end = await runTapConnection(socketPath, outbox, seen, io);
50
72
 
51
73
  if (end === 'closed') {
52
- process.exit(0);
74
+ io.exit(0);
75
+
76
+ return;
53
77
  }
54
78
 
55
79
  if (end === 'refused') {
56
- console.error('atc tap: the session bridge refused the tap');
57
- process.exit(1);
80
+ io.printError('atc tap: the session bridge refused the tap');
81
+ io.exit(1);
82
+
83
+ return;
58
84
  }
59
85
 
60
86
  if (end === 'lost') {
@@ -73,6 +99,7 @@ async function runTapConnection(
73
99
 
74
100
  // oxlint-disable-next-line prefer-readonly-parameter-types -- the ids the tap printed, which every connection adds to
75
101
  seen: Set<string>,
102
+ io: BridgeTapIO,
76
103
  ): Promise<TapEnd> {
77
104
  const ended = Promise.withResolvers<TapEnd>();
78
105
  let opened = false;
@@ -139,9 +166,11 @@ async function runTapConnection(
139
166
  const printed = `${JSON.stringify({ id: message.message, from: message.from, text: message.text, sentAt: message.sentAt })}\n`;
140
167
 
141
168
  try {
142
- await Bun.write(Bun.stdout, printed);
169
+ await io.writeStdout(printed);
143
170
  } catch {
144
- process.exit(1);
171
+ io.exit(1);
172
+
173
+ return;
145
174
  }
146
175
 
147
176
  updateSeenIDs(seen, message.message);
@@ -1,19 +1,43 @@
1
1
  import { copyFileSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { buildMigratedConfig } from './build-migrated-config';
3
3
 
4
+ // Where the command's output goes, the console by default: text printed as
5
+ // it is, and lines printed to stdout or stderr.
6
+ interface ConfigMigrateIO {
7
+ readonly printText: (text: string) => void;
8
+ readonly print: (line: string) => void;
9
+ readonly printError: (line: string) => void;
10
+ }
11
+
12
+ const CONSOLE_IO: ConfigMigrateIO = {
13
+ printText: (text) => {
14
+ process.stdout.write(text);
15
+ },
16
+ print: (line) => {
17
+ console.log(line);
18
+ },
19
+ printError: (line) => {
20
+ console.error(line);
21
+ },
22
+ };
23
+
4
24
  /**
5
25
  * Runs `atc config migrate`: prints the config.json rewritten around
6
26
  * `agents`, or with `write` copies the file to a timestamped backup beside it
7
27
  * and rewrites it in place. Returns the exit code. Notes and problems go to
8
28
  * stderr and name keys and paths, never values.
9
29
  */
10
- export function runConfigMigrate(file: string, write: boolean): number {
30
+ export function runConfigMigrate(
31
+ file: string,
32
+ write: boolean,
33
+ io: ConfigMigrateIO = CONSOLE_IO,
34
+ ): number {
11
35
  let text: string;
12
36
 
13
37
  try {
14
38
  text = readFileSync(file, 'utf8');
15
39
  } catch {
16
- console.error(`atc config migrate: ${file} cannot be read`);
40
+ io.printError(`atc config migrate: ${file} cannot be read`);
17
41
 
18
42
  return 1;
19
43
  }
@@ -23,7 +47,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
23
47
  try {
24
48
  raw = JSON.parse(text);
25
49
  } catch {
26
- console.error(`atc config migrate: ${file} is not valid JSON`);
50
+ io.printError(`atc config migrate: ${file} is not valid JSON`);
27
51
 
28
52
  return 1;
29
53
  }
@@ -31,23 +55,23 @@ export function runConfigMigrate(file: string, write: boolean): number {
31
55
  const result = buildMigratedConfig(raw);
32
56
 
33
57
  if (result.kind === 'unusable') {
34
- console.error(`atc config migrate: ${file}: ${result.detail}`);
58
+ io.printError(`atc config migrate: ${file}: ${result.detail}`);
35
59
 
36
60
  return 1;
37
61
  }
38
62
 
39
63
  if (result.kind === 'current') {
40
- console.log('config.json already uses agents; nothing to migrate');
64
+ io.print('config.json already uses agents; nothing to migrate');
41
65
 
42
66
  return 0;
43
67
  }
44
68
 
45
69
  for (const note of result.notes) {
46
- console.error(note);
70
+ io.printError(note);
47
71
  }
48
72
 
49
73
  if (!write) {
50
- process.stdout.write(result.text);
74
+ io.printText(result.text);
51
75
 
52
76
  return 0;
53
77
  }
@@ -57,7 +81,7 @@ export function runConfigMigrate(file: string, write: boolean): number {
57
81
  try {
58
82
  copyFileSync(file, backup);
59
83
  } catch {
60
- console.error(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
84
+ io.printError(`atc config migrate: ${backup} cannot be written; ${file} is unchanged`);
61
85
 
62
86
  return 1;
63
87
  }
@@ -65,13 +89,13 @@ export function runConfigMigrate(file: string, write: boolean): number {
65
89
  try {
66
90
  writeFileSync(file, result.text);
67
91
  } catch {
68
- console.error(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
92
+ io.printError(`atc config migrate: ${file} cannot be written; the backup is at ${backup}`);
69
93
 
70
94
  return 1;
71
95
  }
72
96
 
73
- console.log(`backup: ${backup}`);
74
- console.log(`wrote: ${file}`);
97
+ io.print(`backup: ${backup}`);
98
+ io.print(`wrote: ${file}`);
75
99
 
76
100
  return 0;
77
101
  }
@@ -0,0 +1,327 @@
1
+ import { join } from 'node:path';
2
+ import { defineCommand, renderUsage, runCommand } from 'citty';
3
+ import pkg from '../package.json';
4
+ import type { runClients } from './clients';
5
+ import { collectRedirectURIs } from './collect-redirect-uris';
6
+ import { parseGatewayStateDir } from './parse-gateway-state-dir';
7
+ import { parsePort } from './parse-port';
8
+ import type { runGateway } from './run-gateway';
9
+
10
+ // What the command line reaches outside itself, the process's own by
11
+ // default: the subcommands it hands a parsed command to, the console, and
12
+ // the exit.
13
+ interface GatewayCLIIO {
14
+ readonly runGateway: (...args: Readonly<Parameters<typeof runGateway>>) => Promise<void>;
15
+ readonly runClients: (...args: Readonly<Parameters<typeof runClients>>) => Promise<void>;
16
+ readonly print: (line: string) => void;
17
+ readonly printError: (line: string) => void;
18
+ readonly exit: (code: number) => void;
19
+ }
20
+
21
+ const PROCESS_IO: GatewayCLIIO = {
22
+ runGateway: async (...args) => {
23
+ const gateway = await import('./run-gateway');
24
+
25
+ await gateway.runGateway(...args);
26
+ },
27
+ runClients: async (...args) => {
28
+ const clients = await import('./clients');
29
+
30
+ await clients.runClients(...args);
31
+ },
32
+ print: (line) => {
33
+ console.log(line);
34
+ },
35
+ printError: (line) => {
36
+ console.error(line);
37
+ },
38
+ exit: (code) => {
39
+ process.exit(code);
40
+ },
41
+ };
42
+
43
+ // Every flag a subcommand declares, so a flag in any position, or one no
44
+ // subcommand declares, is caught before a subcommand runs.
45
+ const GATEWAY_FLAGS = {
46
+ values: new Set(['host', 'port', 'public-url', 'registry', 'redirect-uri', 'state-dir']),
47
+ switches: new Set(['help', 'h', 'version']),
48
+ };
49
+
50
+ /**
51
+ * Runs the `atc-gateway` command line: reads the state directory from the
52
+ * whole of it, since the argument parser hands a subcommand only the
53
+ * arguments after its name, then parses the rest and runs the subcommand
54
+ * it names. A state directory that cannot be read prints the reason and
55
+ * exits 1 before any subcommand runs. `--help` anywhere prints the usage of
56
+ * the command it follows and exits 0, `--version` alone prints the version,
57
+ * and a command line the parser refuses prints that command's usage and
58
+ * the reason, then exits 1.
59
+ */
60
+ export async function runGatewayCLI(
61
+ argv: readonly string[],
62
+ env: Readonly<Record<string, string | undefined>>,
63
+ io: GatewayCLIIO = PROCESS_IO,
64
+ ): Promise<void> {
65
+ const parsed = parseGatewayStateDir(argv, env, GATEWAY_FLAGS);
66
+
67
+ if (!parsed.ok) {
68
+ io.printError(`atc-gateway: ${parsed.message}`);
69
+ io.exit(1);
70
+
71
+ return;
72
+ }
73
+
74
+ const gateway = buildGatewayCommand(parsed.stateDir, io);
75
+ const renderNamedUsage = pickUsage(gateway.usages, argv);
76
+
77
+ if (argv.includes('--help') || argv.includes('-h')) {
78
+ const usage = await renderNamedUsage();
79
+
80
+ io.print(`${usage}\n`);
81
+ io.exit(0);
82
+
83
+ return;
84
+ }
85
+
86
+ if (argv.length === 1 && argv[0] === '--version') {
87
+ io.print(pkg.version);
88
+
89
+ return;
90
+ }
91
+
92
+ try {
93
+ await runCommand(gateway.command, { rawArgs: [...argv] });
94
+ } catch (error) {
95
+ if (isUsageError(error)) {
96
+ const usage = await renderNamedUsage();
97
+
98
+ io.print(`${usage}\n`);
99
+ }
100
+
101
+ const message = error instanceof Error ? error.message : String(error);
102
+
103
+ io.printError(message);
104
+ io.exit(1);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * The usage renderer of the deepest command the command line names: each
110
+ * word past the flags and their values, joined to the ones before it, while
111
+ * the joined name has a usage.
112
+ */
113
+ function pickUsage(
114
+ usages: Readonly<Record<string, () => Promise<string>>>,
115
+ argv: readonly string[],
116
+ ): () => Promise<string> {
117
+ let name = '';
118
+ let index = 0;
119
+
120
+ while (index < argv.length) {
121
+ const token = argv[index] ?? '';
122
+
123
+ index += 1;
124
+
125
+ if (token.startsWith('-')) {
126
+ // A value flag without `=` takes the next token as its value.
127
+ if (!token.includes('=') && GATEWAY_FLAGS.values.has(token.replace(/^-+/u, ''))) {
128
+ index += 1;
129
+ }
130
+
131
+ continue;
132
+ }
133
+
134
+ const longer = name === '' ? token : `${name} ${token}`;
135
+
136
+ if (usages[longer] === undefined) {
137
+ break;
138
+ }
139
+
140
+ name = longer;
141
+ }
142
+
143
+ return usages[name] ?? usages[''] ?? (() => Promise.resolve(''));
144
+ }
145
+
146
+ // The codes citty gives the errors it throws for a command line it refuses;
147
+ // citty exports no error class to test against.
148
+ const USAGE_ERROR_CODES: ReadonlySet<string> = new Set([
149
+ 'EARG',
150
+ 'E_UNKNOWN_COMMAND',
151
+ 'E_NO_COMMAND',
152
+ ]);
153
+
154
+ function isUsageError(error: unknown): boolean {
155
+ return (
156
+ error instanceof Error &&
157
+ 'code' in error &&
158
+ typeof error.code === 'string' &&
159
+ USAGE_ERROR_CODES.has(error.code)
160
+ );
161
+ }
162
+
163
+ // The flag every subcommand takes for the directory holding `gateway.db` and
164
+ // `mcp-auth.db`.
165
+ const STATE_DIR_ARG = {
166
+ 'state-dir': {
167
+ type: 'string',
168
+ description: 'Directory for gateway.db and mcp-auth.db (default $ATC_GATEWAY_STATE_DIR)',
169
+ },
170
+ } as const;
171
+
172
+ const NO_STATE_DIR = 'atc-gateway: give --state-dir or set ATC_GATEWAY_STATE_DIR';
173
+
174
+ const ROOT_META = {
175
+ name: 'atc-gateway',
176
+ version: pkg.version,
177
+ description: 'Serve the atc MCP tools over HTTP for the daemons a registry lists',
178
+ };
179
+
180
+ const CLIENTS_META = {
181
+ name: 'clients',
182
+ description: 'List, add, or remove the clients that may connect to the gateway',
183
+ };
184
+
185
+ /**
186
+ * The `atc-gateway` command tree, which serves the MCP tools over HTTP for
187
+ * the daemons a registry lists and manages the clients that may connect to
188
+ * it, and the usage of each command in it, keyed by the subcommand names
189
+ * that lead to it. Every subcommand takes the state directory read from
190
+ * the whole command line, or exits 1 without one.
191
+ */
192
+ function buildGatewayCommand(stateDir: string | null, io: GatewayCLIIO) {
193
+ const serve = defineCommand({
194
+ meta: { name: 'serve', description: 'Serve MCP over HTTP behind OAuth' },
195
+ args: {
196
+ host: { type: 'string', description: 'Address to bind (default 127.0.0.1)' },
197
+ port: { type: 'string', description: 'Port to listen on (default 8414)' },
198
+ 'public-url': {
199
+ type: 'string',
200
+ required: true,
201
+ description: 'Origin clients reach the gateway at, such as https://mcp.example.com',
202
+ },
203
+ registry: { type: 'string', required: true, description: 'The registry JSON file' },
204
+ ...STATE_DIR_ARG,
205
+ },
206
+ async run(ctx) {
207
+ const port = ctx.args.port === undefined ? null : parsePort(ctx.args.port);
208
+
209
+ if (port !== null && !port.ok) {
210
+ io.printError(`atc-gateway: ${port.message}`);
211
+ io.exit(1);
212
+
213
+ return;
214
+ }
215
+
216
+ if (stateDir === null) {
217
+ io.printError(NO_STATE_DIR);
218
+ io.exit(1);
219
+
220
+ return;
221
+ }
222
+
223
+ await io.runGateway(`atc-gateway/${pkg.version}`, {
224
+ host: ctx.args.host ?? '127.0.0.1',
225
+ port: port === null ? 8414 : port.port,
226
+ publicURL: ctx.args['public-url'],
227
+ registryPath: ctx.args.registry,
228
+ stateDir,
229
+ });
230
+ },
231
+ });
232
+
233
+ const list = defineCommand({
234
+ meta: { name: 'list', description: 'List the clients', hidden: true },
235
+ args: STATE_DIR_ARG,
236
+ async run() {
237
+ if (stateDir === null) {
238
+ io.printError(NO_STATE_DIR);
239
+ io.exit(1);
240
+
241
+ return;
242
+ }
243
+
244
+ await io.runClients(
245
+ { kind: 'list' },
246
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
247
+ );
248
+ },
249
+ });
250
+
251
+ const add = defineCommand({
252
+ meta: { name: 'add', description: 'Add a client and print its client ID' },
253
+ args: {
254
+ name: { type: 'positional', required: true, description: 'The client name' },
255
+ 'redirect-uri': {
256
+ type: 'string',
257
+ required: true,
258
+ description: 'A redirect URI the client returns to; repeat for more',
259
+ },
260
+ ...STATE_DIR_ARG,
261
+ },
262
+ async run(ctx) {
263
+ if (stateDir === null) {
264
+ io.printError(NO_STATE_DIR);
265
+ io.exit(1);
266
+
267
+ return;
268
+ }
269
+
270
+ await io.runClients(
271
+ { kind: 'add', name: ctx.args.name, redirectURIs: collectRedirectURIs(ctx.rawArgs) },
272
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
273
+ );
274
+ },
275
+ });
276
+
277
+ const remove = defineCommand({
278
+ meta: { name: 'remove', description: 'Remove a client and revoke every grant it holds' },
279
+ args: {
280
+ id: { type: 'positional', required: true, description: 'The client ID' },
281
+ ...STATE_DIR_ARG,
282
+ },
283
+ async run(ctx) {
284
+ if (stateDir === null) {
285
+ io.printError(NO_STATE_DIR);
286
+ io.exit(1);
287
+
288
+ return;
289
+ }
290
+
291
+ await io.runClients(
292
+ { kind: 'remove', clientID: ctx.args.id },
293
+ { dbPath: join(stateDir, 'mcp-auth.db'), command: 'atc-gateway clients' },
294
+ );
295
+ },
296
+ });
297
+
298
+ const clients = defineCommand({
299
+ meta: CLIENTS_META,
300
+ args: STATE_DIR_ARG,
301
+ default: 'list',
302
+ subCommands: { list, add, remove },
303
+ });
304
+
305
+ const command = defineCommand({
306
+ meta: ROOT_META,
307
+
308
+ // Declared at every level, so the argument parser reads `--state-dir <dir>`
309
+ // before a subcommand as a flag and its value, not as a subcommand name.
310
+ args: STATE_DIR_ARG,
311
+ subCommands: { serve, clients },
312
+ });
313
+
314
+ // Usage names a command after its parent, so each renders under a parent
315
+ // holding only that parent's name.
316
+ return {
317
+ command,
318
+ usages: {
319
+ '': () => renderUsage(command),
320
+ serve: () => renderUsage(serve, { meta: ROOT_META }),
321
+ clients: () => renderUsage(clients, { meta: ROOT_META }),
322
+ 'clients list': () => renderUsage(list, { meta: CLIENTS_META }),
323
+ 'clients add': () => renderUsage(add, { meta: CLIENTS_META }),
324
+ 'clients remove': () => renderUsage(remove, { meta: CLIENTS_META }),
325
+ },
326
+ };
327
+ }
@@ -14,6 +14,35 @@ interface GatewayFlags {
14
14
  readonly stateDir: string;
15
15
  }
16
16
 
17
+ // What the gateway reaches outside itself, the process's own by default:
18
+ // the environment its daemon tokens come from, the console, the exit, and
19
+ // the signal listeners that stop it.
20
+ interface GatewayIO {
21
+ readonly env: Readonly<Record<string, string | undefined>>;
22
+ readonly print: (line: string) => void;
23
+ readonly printError: (line: string) => void;
24
+ readonly exit: (code: number) => void;
25
+ readonly registerSignal: (signal: 'SIGINT' | 'SIGTERM', listener: () => Promise<void>) => void;
26
+ }
27
+
28
+ const PROCESS_IO: GatewayIO = {
29
+ env: process.env,
30
+ print: (line) => {
31
+ console.log(line);
32
+ },
33
+ printError: (line) => {
34
+ console.error(line);
35
+ },
36
+ exit: (code) => {
37
+ process.exit(code);
38
+ },
39
+ registerSignal: (signal, listener) => {
40
+ process.on(signal, () => {
41
+ void listener();
42
+ });
43
+ },
44
+ };
45
+
17
46
  /**
18
47
  * Runs `atc-gateway` in the foreground: serves the MCP tools over HTTP for
19
48
  * the daemons the registry lists, routing every call to one of them over
@@ -23,15 +52,21 @@ interface GatewayFlags {
23
52
  * registry that fails to load, or a server that fails to start, prints the
24
53
  * reason and exits 1.
25
54
  */
26
- export async function runGateway(build: string, flags: GatewayFlags): Promise<void> {
27
- const loaded = loadGatewayRegistry(flags.registryPath, process.env);
55
+ export async function runGateway(
56
+ build: string,
57
+ flags: GatewayFlags,
58
+ io: GatewayIO = PROCESS_IO,
59
+ ): Promise<void> {
60
+ const loaded = loadGatewayRegistry(flags.registryPath, io.env);
28
61
 
29
62
  if (!loaded.ok) {
30
63
  for (const error of loaded.errors) {
31
- console.error(`atc-gateway: ${error}`);
64
+ io.printError(`atc-gateway: ${error}`);
32
65
  }
33
66
 
34
- process.exit(1);
67
+ io.exit(1);
68
+
69
+ return;
35
70
  }
36
71
 
37
72
  mkdirSync(flags.stateDir, { recursive: true });
@@ -59,35 +94,33 @@ export async function runGateway(build: string, flags: GatewayFlags): Promise<vo
59
94
  // The line carries a client's name, so control and format characters
60
95
  // are dropped before it reaches the log.
61
96
  printApproval: (line) => {
62
- console.log(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
97
+ io.print(line.replaceAll(/[\p{Cc}\p{Cf}]/gu, ''));
63
98
  },
64
99
 
65
100
  // Request lines go to stderr, so stdout keeps the approval lines alone.
66
101
  printRequest: (line) => {
67
- console.error(line);
102
+ io.printError(line);
68
103
  },
69
104
  });
70
105
  } catch (error) {
71
106
  await gateway.stop();
72
107
 
73
- console.error(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
74
- process.exit(1);
75
- }
108
+ io.printError(`atc-gateway: ${error instanceof Error ? error.message : String(error)}`);
109
+ io.exit(1);
76
110
 
77
- console.log(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
111
+ return;
112
+ }
78
113
 
114
+ // The handlers go in before the serving line, so a signal sent once the
115
+ // line appears always finds them.
79
116
  const stopServing = async () => {
80
117
  await server.stop();
81
118
  await gateway.stop();
82
119
 
83
- process.exit(0);
120
+ io.exit(0);
84
121
  };
85
122
 
86
- process.on('SIGINT', () => {
87
- void stopServing();
88
- });
89
-
90
- process.on('SIGTERM', () => {
91
- void stopServing();
92
- });
123
+ io.registerSignal('SIGINT', stopServing);
124
+ io.registerSignal('SIGTERM', stopServing);
125
+ io.print(`atc-gateway: serving ${server.origin}/mcp, listening on ${server.listening}`);
93
126
  }
@@ -4,10 +4,14 @@ import { isCompiledBinary } from './is-compiled-binary';
4
4
  /**
5
5
  * The argv that runs this build of atc with `args`: the compiled binary is
6
6
  * its own entry, and a source run is bun with the CLI entrypoint ahead of
7
- * the arguments.
7
+ * the arguments. `compiled` tells the two apart, and defaults to how this
8
+ * process runs.
8
9
  */
9
- export function buildATCCommand(args: readonly string[]): string[] {
10
- return isCompiledBinary()
10
+ export function buildATCCommand(
11
+ args: readonly string[],
12
+ compiled: boolean = isCompiledBinary(),
13
+ ): string[] {
14
+ return compiled
11
15
  ? [process.execPath, ...args]
12
16
  : [process.execPath, join(import.meta.dir, '..', 'cli.ts'), ...args];
13
17
  }
@@ -16,6 +16,12 @@ export class BunSqliteDriver implements Driver {
16
16
 
17
17
  private releaseTurn: () => void = () => {};
18
18
 
19
+ /**
20
+ * How many acquires are waiting for an earlier caller to release the
21
+ * connection.
22
+ */
23
+ waiting = 0;
24
+
19
25
  constructor(sqlite: Database) {
20
26
  this.connection = buildConnection(sqlite);
21
27
  }
@@ -32,8 +38,12 @@ export class BunSqliteDriver implements Driver {
32
38
  release = resolve;
33
39
  });
34
40
 
41
+ this.waiting += 1;
42
+
35
43
  await previous;
36
44
 
45
+ this.waiting -= 1;
46
+
37
47
  if (release !== undefined) {
38
48
  this.releaseTurn = release;
39
49
  }