@zgeoff/atc 2.16.0 → 2.19.0

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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/build-workspace-destination.ts +38 -0
  4. package/src/client/collect-target-picks.ts +53 -0
  5. package/src/client/dirs.ts +1 -1
  6. package/src/client/pick-refusal-step.ts +29 -0
  7. package/src/client/resolve-workspace-root.ts +49 -0
  8. package/src/client/spawn-picker.ts +1683 -84
  9. package/src/client/ui.ts +6 -1
  10. package/src/daemon/daemon-connection.ts +129 -0
  11. package/src/daemon/daemon-context.ts +23 -0
  12. package/src/daemon/daemon.ts +29 -0
  13. package/src/daemon/materialize-workspace.ts +42 -18
  14. package/src/daemon/require-git-transports.ts +23 -0
  15. package/src/mcp/require-daemon-features.ts +2 -0
  16. package/src/protocol/daemon-features.ts +8 -0
  17. package/src/protocol/protocol.ts +2 -0
  18. package/src/protocol/request-param-schemas.ts +67 -15
  19. package/src/shared/collect-workspaces-config.ts +120 -0
  20. package/src/shared/config.ts +30 -1
  21. package/src/shared/default-git-transports.ts +5 -0
  22. package/src/shared/is-git-url.ts +11 -0
  23. package/src/sources/build-sources.ts +40 -0
  24. package/src/sources/collect-builtin-sources.ts +41 -0
  25. package/src/sources/dirs/build-dirs-source.ts +70 -0
  26. package/src/sources/git/build-git-source.ts +25 -0
  27. package/src/sources/github/build-github-source.ts +113 -0
  28. package/src/sources/github/collect-github-repos.ts +165 -0
  29. package/src/sources/github/find-github-alternate-url.ts +20 -0
  30. package/src/sources/github/read-git-protocol.ts +11 -0
  31. package/src/sources/github/run-gh.ts +66 -0
  32. package/src/sources/types.ts +70 -0
  33. package/src/workspace/check-git-transport.ts +61 -0
  34. package/src/workspace/check-repository-access.ts +108 -0
  35. package/src/workspace/collect-remote-refs.ts +154 -0
  36. package/src/workspace/create-git-askpass.ts +84 -0
  37. package/src/workspace/create-workspace-clone.ts +29 -76
  38. package/src/workspace/expand-git-shorthand.ts +13 -0
  39. package/src/workspace/find-remote-ref.ts +34 -0
  40. package/src/workspace/normalize-git-url.ts +2 -7
  41. package/src/workspace/resolve-git-url.ts +46 -0
  42. package/src/workspace/resolve-path-source.ts +11 -4
  43. package/src/workspace/run-git.ts +52 -4
  44. package/src/workspace/workspace-source.ts +10 -2
  45. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  46. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -1,19 +1,18 @@
1
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
- import { tmpdir } from 'node:os';
3
- import { join } from 'node:path';
1
+ import { rm } from 'node:fs/promises';
4
2
  import { checkWorkspaceCompleteness } from './check-workspace-completeness';
3
+ import { createGitAskpass } from './create-git-askpass';
4
+ import type { GitCredential } from './create-git-askpass';
5
+ import { findRemoteRef } from './find-remote-ref';
5
6
  import { runGit } from './run-git';
6
7
  import type { WorkspaceSource } from './workspace-source';
7
8
 
8
- interface GitCredential {
9
- readonly kind: 'env';
10
- readonly name: string;
11
- }
12
-
13
9
  interface CloneRequest {
14
10
  readonly source: Extract<WorkspaceSource, { readonly kind: 'git' }>;
15
11
  readonly dir: string;
16
12
  readonly credential?: GitCredential;
13
+
14
+ // The transports git may fetch over.
15
+ readonly transports: readonly string[];
17
16
  }
18
17
 
19
18
  interface CreatedClone {
@@ -59,7 +58,7 @@ type IncompleteCheckout = Exclude<
59
58
  export async function createWorkspaceClone(
60
59
  request: CloneRequest,
61
60
  ): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
62
- const askpass = await createAskpass(request.credential);
61
+ const askpass = await createGitAskpass(request.credential);
63
62
 
64
63
  if (!askpass.ok) {
65
64
  return askpass;
@@ -72,63 +71,12 @@ export async function createWorkspaceClone(
72
71
  }
73
72
  }
74
73
 
75
- interface Askpass {
76
- readonly ok: true;
77
- readonly env: Readonly<Record<string, string>>;
78
- readonly args: readonly string[];
79
- [Symbol.asyncDispose]: () => Promise<void>;
80
- }
81
-
82
- const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
83
-
84
- // Username prompts get a fixed name that GitHub and GitLab both accept
85
- // alongside a token; password prompts get the token.
86
- const ASKPASS_SCRIPT = `#!/bin/sh
87
- case "$1" in
88
- Username*) printf '%s\\n' x-access-token ;;
89
- *) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
90
- esac
91
- `;
92
-
93
- async function createAskpass(
94
- credential: GitCredential | undefined,
95
- ): Promise<Askpass | CloneRefusal> {
96
- if (credential === undefined) {
97
- return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
98
- }
99
-
100
- const secret = process.env[credential.name];
101
-
102
- if (secret === undefined || secret === '') {
103
- return {
104
- ok: false,
105
- code: 'credential_missing',
106
- message: 'the credential environment variable is unset or empty',
107
- };
108
- }
109
-
110
- const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
111
-
112
- const helper = join(dir, 'askpass');
113
-
114
- await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
115
-
116
- return {
117
- ok: true,
118
- env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
119
- args: ['-c', 'credential.helper='],
120
- [Symbol.asyncDispose]: async () => {
121
- await rm(dir, { recursive: true, force: true });
122
- },
123
- };
124
- }
125
-
126
74
  async function createCloneAtRef(
127
75
  request: CloneRequest,
128
76
  env: Readonly<Record<string, string>>,
129
77
  args: readonly string[],
130
78
  ): Promise<CloneRefusal | CreatedClone | IncompleteCheckout> {
131
- const target = await resolveRef(request.source, env, args);
79
+ const target = await resolveRef(request, env, args);
132
80
 
133
81
  if (!target.ok) {
134
82
  return target;
@@ -147,7 +95,7 @@ async function createCloneAtRef(
147
95
  request.source.url,
148
96
  request.dir,
149
97
  ],
150
- { env },
98
+ { env, transports: request.transports },
151
99
  );
152
100
 
153
101
  if (clone.exitCode !== 0) {
@@ -175,9 +123,9 @@ async function createCloneAtRef(
175
123
 
176
124
  await rm(request.dir, { recursive: true, force: true });
177
125
 
178
- return target.branch === null && present.exitCode !== 0
179
- ? { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` }
180
- : { ok: false, code: 'clone_failed', message: checkout.stderr.trim() };
126
+ return present.exitCode === 0
127
+ ? { ok: false, code: 'clone_failed', message: checkout.stderr.trim() }
128
+ : { ok: false, code: 'ref_not_found', message: `origin has no commit ${target.sha}` };
181
129
  }
182
130
 
183
131
  const complete = await checkWorkspaceCompleteness(request.dir, target.sha);
@@ -203,20 +151,26 @@ const SHA_PATTERN = /^(?:[\da-f]{40}|[\da-f]{64})$/u;
203
151
  * Pins a ref to a commit before cloning, so the checkout is exactly the
204
152
  * commit the ref pointed at when asked, whatever lands on the branch meanwhile.
205
153
  * A branch wins over a same-named tag, and an annotated tag resolves to the
206
- * commit it points at.
154
+ * commit it points at. A source already pinned to a commit keeps it, and
155
+ * its ref only decides whether the checkout is on a branch: a ref that
156
+ * names a branch upstream checks the commit out as that branch.
207
157
  */
208
158
  async function resolveRef(
209
- source: Extract<WorkspaceSource, { readonly kind: 'git' }>,
159
+ request: CloneRequest,
210
160
  env: Readonly<Record<string, string>>,
211
161
  args: readonly string[],
212
162
  ): Promise<CloneRefusal | ResolvedRef> {
163
+ const source = request.source;
164
+
213
165
  if (SHA_PATTERN.test(source.ref)) {
214
166
  return { ok: true, sha: source.ref, branch: null };
215
167
  }
216
168
 
169
+ const pinned = source.sha;
170
+
217
171
  const listed = await runGit(
218
172
  [...args, 'ls-remote', '--', source.url, source.ref, `${source.ref}^{}`],
219
- { env },
173
+ { env, transports: request.transports },
220
174
  );
221
175
 
222
176
  if (listed.exitCode !== 0) {
@@ -234,18 +188,17 @@ async function resolveRef(
234
188
  }),
235
189
  );
236
190
 
237
- const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
238
- const branchSHA = refs.get(`refs/heads/${name}`);
191
+ const match = findRemoteRef(refs, source.ref);
239
192
 
240
- if (branchSHA !== undefined && !source.ref.startsWith('refs/tags/')) {
241
- return { ok: true, sha: branchSHA, branch: name };
193
+ if (pinned !== undefined) {
194
+ return { ok: true, sha: pinned, branch: match?.branch ?? null };
242
195
  }
243
196
 
244
- const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
245
-
246
- if (tagSHA !== undefined && !source.ref.startsWith('refs/heads/')) {
247
- return { ok: true, sha: tagSHA, branch: null };
197
+ if (match !== null) {
198
+ return { ok: true, ...match };
248
199
  }
249
200
 
201
+ const name = source.ref.replace(/^refs\/(?:heads|tags)\//u, '');
202
+
250
203
  return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
251
204
  }
@@ -0,0 +1,13 @@
1
+ // A bare `owner/repo`, with or without `.git`.
2
+ const SHORTHAND_PATTERN = /^(?<name>[\w-][\w.-]*\/[\w.-]+?)(?:\.git)?$/u;
3
+
4
+ /**
5
+ * Expands the `owner/repo` shorthand a `session.spawn` git workspace may
6
+ * hold to its GitHub https URL, and returns any other URL as it is. The
7
+ * shorthand is the spawn API's alone: every other path takes full URLs.
8
+ */
9
+ export function expandGitShorthand(raw: string): string {
10
+ const name = SHORTHAND_PATTERN.exec(raw.trim())?.groups?.['name'];
11
+
12
+ return name === undefined ? raw : `https://github.com/${name}.git`;
13
+ }
@@ -0,0 +1,34 @@
1
+ interface RemoteRefMatch {
2
+ readonly sha: string;
3
+
4
+ // The branch the ref names, or null for a tag.
5
+ readonly branch: string | null;
6
+ }
7
+
8
+ /**
9
+ * Finds the commit a ref points at among the refs an upstream lists, keyed
10
+ * by full ref name with each annotated tag's peeled `^{}` entry beside it.
11
+ * A bare name matches a branch before a same-named tag, a `refs/heads/` or
12
+ * `refs/tags/` name matches only that kind, and an annotated tag resolves
13
+ * to the commit it points at. Null when the upstream has no such branch or
14
+ * tag.
15
+ */
16
+ export function findRemoteRef(
17
+ refs: ReadonlyMap<string, string>,
18
+ ref: string,
19
+ ): RemoteRefMatch | null {
20
+ const name = ref.replace(/^refs\/(?:heads|tags)\//u, '');
21
+ const branchSHA = refs.get(`refs/heads/${name}`);
22
+
23
+ if (branchSHA !== undefined && !ref.startsWith('refs/tags/')) {
24
+ return { sha: branchSHA, branch: name };
25
+ }
26
+
27
+ const tagSHA = refs.get(`refs/tags/${name}^{}`) ?? refs.get(`refs/tags/${name}`);
28
+
29
+ if (tagSHA !== undefined && !ref.startsWith('refs/heads/')) {
30
+ return { sha: tagSHA, branch: null };
31
+ }
32
+
33
+ return null;
34
+ }
@@ -4,15 +4,14 @@ type NormalizedGitURL =
4
4
 
5
5
  const SCHEME_PATTERN = /^[a-z][a-z\d+.-]*:\/\//iu;
6
6
  const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:[^@\s]+$/u;
7
- const SHORTHAND_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
8
7
 
9
8
  /**
10
9
  * Reduces a repository URL to a form that carries no credential, so it can be
11
10
  * recorded, shown, and handed to another host. An http(s) URL loses its
12
11
  * userinfo, query, and fragment, where tokens ride. Any other scheme URL
13
12
  * loses its password but keeps its user, which is a login name such as
14
- * `git`. An scp-style `git@host:owner/repo` and a local path pass through,
15
- * and a bare `owner/repo` expands to its GitHub https URL.
13
+ * `git`. An scp-style `git@host:owner/repo` and a local path pass through.
14
+ * Anything else, a bare `owner/repo` included, is refused.
16
15
  */
17
16
  export function normalizeGitURL(raw: string): NormalizedGitURL {
18
17
  const trimmed = raw.trim();
@@ -25,10 +24,6 @@ export function normalizeGitURL(raw: string): NormalizedGitURL {
25
24
  return { ok: true, url: trimmed };
26
25
  }
27
26
 
28
- if (SHORTHAND_PATTERN.test(trimmed) && !trimmed.startsWith('.')) {
29
- return { ok: true, url: `https://github.com/${trimmed.replace(/\.git$/u, '')}.git` };
30
- }
31
-
32
27
  if (SCP_PATTERN.test(trimmed)) {
33
28
  return { ok: true, url: trimmed };
34
29
  }
@@ -0,0 +1,46 @@
1
+ import { checkGitTransport } from './check-git-transport';
2
+ import { checkURLCredentials } from './check-url-credentials';
3
+ import { normalizeGitURL } from './normalize-git-url';
4
+
5
+ type ResolvedGitURL =
6
+ | { readonly ok: true; readonly url: string }
7
+ | {
8
+ readonly ok: false;
9
+ readonly code: 'credential_in_url' | 'invalid_git_url';
10
+ readonly message: string;
11
+ };
12
+
13
+ /**
14
+ * Resolves the URL a git workspace source is fetched from: it normalizes
15
+ * to its credential-free form, and that form must use one of `transports`, both checked
16
+ * before any git runs. Then the raw URL and the normalized form must each
17
+ * reach git without a credential. `cwd` is the directory whose git config
18
+ * the `insteadOf` rewrites are read from.
19
+ */
20
+ export async function resolveGitURL(
21
+ raw: string,
22
+ cwd: string,
23
+ transports: readonly string[],
24
+ ): Promise<ResolvedGitURL> {
25
+ const normalized = normalizeGitURL(raw);
26
+
27
+ if (!normalized.ok) {
28
+ return normalized;
29
+ }
30
+
31
+ const transport = checkGitTransport(normalized.url, transports);
32
+
33
+ if (!transport.ok) {
34
+ return transport;
35
+ }
36
+
37
+ const plain = await checkURLCredentials(raw, cwd);
38
+
39
+ if (!plain.ok) {
40
+ return plain;
41
+ }
42
+
43
+ const expanded = await checkURLCredentials(normalized.url, cwd);
44
+
45
+ return expanded.ok ? normalized : expanded;
46
+ }
@@ -4,6 +4,9 @@ import { runGit } from './run-git';
4
4
 
5
5
  interface PathSourceOptions {
6
6
  readonly allowDirty?: 'refuse' | 'warn';
7
+
8
+ // The transports git may fetch over when it asks origin for its refs.
9
+ readonly transports: readonly string[];
7
10
  }
8
11
 
9
12
  interface ResolvedPathSource {
@@ -42,7 +45,7 @@ interface PathSourceRefusal {
42
45
  */
43
46
  export async function resolvePathSource(
44
47
  path: string,
45
- options: PathSourceOptions = {},
48
+ options: PathSourceOptions,
46
49
  ): Promise<PathSourceRefusal | ResolvedPathSource> {
47
50
  if (statSync(path, { throwIfNoEntry: false })?.isDirectory() !== true) {
48
51
  return { ok: false, code: 'not_a_git_repo', message: `${path} is not a directory` };
@@ -118,7 +121,7 @@ export async function resolvePathSource(
118
121
  return { ok: false, code: url.code, message: `origin of ${root}: ${url.message}` };
119
122
  }
120
123
 
121
- const pushed = await isOnOrigin(root, sha);
124
+ const pushed = await isOnOrigin(root, sha, options.transports);
122
125
 
123
126
  if (!pushed) {
124
127
  return { ok: false, code: 'unpushed_head', message: `${sha} is not on origin; push it first` };
@@ -152,7 +155,11 @@ function hasSubmodules(listing: string): boolean {
152
155
  * it without network access; failing that, origin's advertised ref tips are
153
156
  * checked, which catches a push whose tracking ref was never fetched.
154
157
  */
155
- async function isOnOrigin(root: string, sha: string): Promise<boolean> {
158
+ async function isOnOrigin(
159
+ root: string,
160
+ sha: string,
161
+ transports: readonly string[],
162
+ ): Promise<boolean> {
156
163
  const tracking = await runGit(['branch', '-r', '--contains', sha, '--list', 'origin/*'], {
157
164
  cwd: root,
158
165
  });
@@ -161,7 +168,7 @@ async function isOnOrigin(root: string, sha: string): Promise<boolean> {
161
168
  return true;
162
169
  }
163
170
 
164
- const advertised = await runGit(['ls-remote', 'origin'], { cwd: root });
171
+ const advertised = await runGit(['ls-remote', 'origin'], { cwd: root, transports });
165
172
 
166
173
  return (
167
174
  advertised.exitCode === 0 &&
@@ -1,3 +1,4 @@
1
+ import { DEFAULT_GIT_TRANSPORTS } from '../shared/default-git-transports';
1
2
  import { REPOSITORY_ENV_VARS } from './repository-env-vars';
2
3
 
3
4
  interface GitRunOptions {
@@ -5,19 +6,31 @@ interface GitRunOptions {
5
6
  readonly env?: Readonly<Record<string, string>>;
6
7
  readonly input?: string;
7
8
  readonly isolated?: boolean;
9
+
10
+ // How long the command may run before it is stopped and reported as
11
+ // timed out; unset waits as long as git takes.
12
+ readonly timeoutMs?: number;
13
+
14
+ // The transports git may fetch over; https and ssh when unset.
15
+ readonly transports?: readonly string[];
8
16
  }
9
17
 
10
18
  interface GitRun {
11
19
  readonly exitCode: number;
12
20
  readonly stdout: string;
13
21
  readonly stderr: string;
22
+ readonly timedOut: boolean;
14
23
  }
15
24
 
16
25
  /**
17
26
  * Runs one git command to completion, feeding it any given input, and
18
- * returns its exit code and output.
27
+ * returns its exit code and output. A command given a time limit is
28
+ * stopped once it passes it, with every process it started, and reported
29
+ * as timed out.
19
30
  * git never prompts on a terminal here, since the daemon has none to answer
20
- * with, and its messages stay in the C locale so callers can read them.
31
+ * with, its messages stay in the C locale so callers can read them, and it
32
+ * fetches only over the transports it is given, https and ssh unless told
33
+ * otherwise, whatever URL a host config rewrite or a submodule hands it.
21
34
  * Variables that pin git to some other repository, such as the `GIT_DIR` a
22
35
  * git hook exports, are dropped so the command acts on its own directory.
23
36
  *
@@ -60,19 +73,54 @@ export async function runGit(
60
73
  ...(isolated ? ISOLATED_ENV : {}),
61
74
  GIT_TERMINAL_PROMPT: '0',
62
75
  LC_ALL: 'C',
76
+ GIT_ALLOW_PROTOCOL: (options.transports ?? DEFAULT_GIT_TRANSPORTS).join(':'),
63
77
  },
64
78
  stdin: options.input === undefined ? 'ignore' : Buffer.from(options.input),
65
79
  stdout: 'pipe',
66
80
  stderr: 'pipe',
81
+
82
+ // A command with a time limit leads its own process group, so stopping
83
+ // it stops the helpers it started, such as `git remote-http`, too.
84
+ detached: options.timeoutMs !== undefined,
67
85
  });
68
86
 
69
- const [stdout, stderr, exitCode] = await Promise.all([
87
+ const finished = Promise.all([
70
88
  new Response(proc.stdout).text(),
71
89
  new Response(proc.stderr).text(),
72
90
  proc.exited,
73
91
  ]);
74
92
 
75
- return { exitCode, stdout, stderr };
93
+ if (options.timeoutMs === undefined) {
94
+ const [stdout, stderr, exitCode] = await finished;
95
+
96
+ return { exitCode, stdout, stderr, timedOut: false };
97
+ }
98
+
99
+ // A stopped git can leave a helper holding its pipes open, so a timeout
100
+ // answers without waiting for them to close.
101
+ const limit = Promise.withResolvers<null>();
102
+
103
+ const timer = setTimeout(() => {
104
+ limit.resolve(null);
105
+ }, options.timeoutMs);
106
+
107
+ const settled = await Promise.race([finished, limit.promise]);
108
+
109
+ clearTimeout(timer);
110
+
111
+ if (settled === null) {
112
+ try {
113
+ process.kill(-proc.pid, 'SIGKILL');
114
+ } catch {
115
+ // The group already exited.
116
+ }
117
+
118
+ return { exitCode: -1, stdout: '', stderr: '', timedOut: true };
119
+ }
120
+
121
+ const [stdout, stderr, exitCode] = settled;
122
+
123
+ return { exitCode, stdout, stderr, timedOut: false };
76
124
  }
77
125
 
78
126
  function collectHostEnv(): Record<string, string | undefined> {
@@ -1,8 +1,16 @@
1
1
  /**
2
2
  * Where a session's workspace comes from: a directory on a host, which is
3
3
  * resolved to a pushed commit before anything leaves that host, or a git
4
- * repository and ref that the control side clones itself.
4
+ * repository and ref, optionally pinned to a commit, that the control side
5
+ * clones itself.
5
6
  */
6
7
  export type WorkspaceSource =
7
8
  | { readonly kind: 'path'; readonly host: string; readonly path: string }
8
- | { readonly kind: 'git'; readonly url: string; readonly ref: string };
9
+ | {
10
+ readonly kind: 'git';
11
+ readonly url: string;
12
+ readonly ref: string;
13
+
14
+ // The commit to check out when the ref only names its branch.
15
+ readonly sha?: string;
16
+ };
File without changes
File without changes