@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
@@ -0,0 +1,120 @@
1
+ import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
2
+ import { isRecord } from './report';
3
+
4
+ /**
5
+ * Where workspace sources come from and where their checkouts land: the
6
+ * GitHub owner whose repositories the spawn picker lists by default, or
7
+ * null to list the gh account's own; the ids of the sources the picker
8
+ * offers, in order, or null for the default order; the git transports the
9
+ * daemon fetches over; the root a checkout lands under on any target
10
+ * without its own, or null for the default; and each target's own root, by
11
+ * target id. A root is kept as written: whether it fits its target, such
12
+ * as `~` on a remote target, is checked where the target is known.
13
+ */
14
+ export interface WorkspacesConfig {
15
+ readonly githubOwner: string | null;
16
+ readonly sources: readonly string[] | null;
17
+ readonly gitTransports: readonly string[] | InvalidGitTransports;
18
+ readonly root: string | null;
19
+ readonly targetRoots: ReadonlyMap<string, string>;
20
+ }
21
+
22
+ /**
23
+ * A transport list the config holds that atc cannot use, with the config
24
+ * errors it raised. The daemon runs no git while the list is invalid.
25
+ */
26
+ export interface InvalidGitTransports {
27
+ readonly invalid: string;
28
+ }
29
+
30
+ interface CollectedWorkspacesConfig {
31
+ readonly workspaces: WorkspacesConfig;
32
+
33
+ // The config problems the section holds, one line each.
34
+ readonly errors: readonly string[];
35
+ }
36
+
37
+ // A GitHub account or organization login.
38
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z\d][A-Za-z\d-]{0,38}$/u;
39
+
40
+ /**
41
+ * Reads the `workspaces` section of config.json. An owner that is not a
42
+ * GitHub login is dropped, so a typo lists the gh account's own
43
+ * repositories instead of failing, a source order that is not a list of
44
+ * ids falls back to the default order, and a root that is not a non-empty
45
+ * string is dropped. A transport list that is not a
46
+ * list of transports atc allows is a config error, and the list is then
47
+ * invalid, never the default, so the daemon runs no git until it is fixed.
48
+ * An empty list is valid and allows no transport.
49
+ */
50
+ export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig {
51
+ const section = isRecord(raw) ? raw : {};
52
+ const owner = section['githubOwner'];
53
+ const sources = section['sources'];
54
+ const root = section['root'];
55
+ const targets = isRecord(section['targets']) ? section['targets'] : {};
56
+ const transports = collectGitTransports(section['gitTransports']);
57
+
58
+ return {
59
+ workspaces: {
60
+ githubOwner: typeof owner === 'string' && GITHUB_OWNER_PATTERN.test(owner) ? owner : null,
61
+ sources:
62
+ Array.isArray(sources) &&
63
+ sources.every((id): id is string => typeof id === 'string' && id !== '')
64
+ ? sources
65
+ : null,
66
+ gitTransports: transports.transports,
67
+ root: typeof root === 'string' && root !== '' ? root : null,
68
+ targetRoots: new Map(
69
+ Object.entries(targets).flatMap(([id, dir]) =>
70
+ typeof dir === 'string' && dir !== '' ? [[id, dir] as const] : [],
71
+ ),
72
+ ),
73
+ },
74
+ errors: transports.errors,
75
+ };
76
+ }
77
+
78
+ // The git transports a config may allow: the defaults, and the two
79
+ // opt-ins.
80
+ const KNOWN_TRANSPORTS: ReadonlySet<string> = new Set(['https', 'ssh', 'http', 'file']);
81
+
82
+ // Transports that run a command or read a descriptor on the daemon's host.
83
+ const REFUSED_TRANSPORTS: ReadonlySet<string> = new Set(['ext', 'fd']);
84
+
85
+ function collectGitTransports(raw: unknown): {
86
+ readonly transports: readonly string[] | InvalidGitTransports;
87
+ readonly errors: readonly string[];
88
+ } {
89
+ if (raw === undefined) {
90
+ return { transports: DEFAULT_GIT_TRANSPORTS, errors: [] };
91
+ }
92
+
93
+ const fallback = 'the daemon runs no git until it is fixed';
94
+
95
+ if (!Array.isArray(raw)) {
96
+ const error = `workspaces.gitTransports is not a list of git transports; ${fallback}`;
97
+
98
+ return { transports: { invalid: error }, errors: [error] };
99
+ }
100
+
101
+ const errors = raw.flatMap((name: unknown) => {
102
+ if (typeof name === 'string' && REFUSED_TRANSPORTS.has(name)) {
103
+ return [
104
+ `workspaces.gitTransports holds '${name}', which atc never allows because it runs a command or reads a descriptor on the daemon host; ${fallback}`,
105
+ ];
106
+ }
107
+
108
+ if (typeof name !== 'string' || !KNOWN_TRANSPORTS.has(name)) {
109
+ return [
110
+ `workspaces.gitTransports holds ${JSON.stringify(name)}, which is not a git transport atc allows; ${fallback}`,
111
+ ];
112
+ }
113
+
114
+ return [];
115
+ });
116
+
117
+ return errors.length === 0
118
+ ? { transports: raw.filter((name): name is string => typeof name === 'string'), errors }
119
+ : { transports: { invalid: errors.join('; ') }, errors };
120
+ }
@@ -11,6 +11,9 @@ import type { HooksConfig } from './collect-hooks';
11
11
  import { collectPrincipals } from './collect-principals';
12
12
  import { collectTargets } from './collect-targets';
13
13
  import type { TargetConfig, TargetConfigError } from './collect-targets';
14
+ import { collectWorkspacesConfig } from './collect-workspaces-config';
15
+ import type { WorkspacesConfig } from './collect-workspaces-config';
16
+ import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
14
17
  import { formatJSONKind } from './format-json-kind';
15
18
  import { isRecord } from './report';
16
19
  import { resolveHomeDir } from './resolve-home-dir';
@@ -23,6 +26,7 @@ export interface Config {
23
26
  codexBin: string;
24
27
  codexArgs: string[];
25
28
  dirs: DirsConfig;
29
+ workspaces: WorkspacesConfig;
26
30
  gateways: GatewayConfig[];
27
31
  hooks: HooksConfig;
28
32
  leader: LeaderKey;
@@ -40,6 +44,9 @@ export interface Config {
40
44
  // principals, which leaves every principal the implicit local target.
41
45
  principals: ReadonlyMap<string, readonly string[]> | null;
42
46
  principalErrors: readonly string[];
47
+
48
+ // The workspace config problems, one line each.
49
+ workspaceErrors: readonly string[];
43
50
  }
44
51
 
45
52
  /**
@@ -63,6 +70,13 @@ const DEFAULTS: Config = {
63
70
  codexBin: 'codex',
64
71
  codexArgs: [],
65
72
  dirs: { roots: [] },
73
+ workspaces: {
74
+ githubOwner: null,
75
+ sources: null,
76
+ gitTransports: DEFAULT_GIT_TRANSPORTS,
77
+ root: null,
78
+ targetRoots: new Map(),
79
+ },
66
80
  gateways: [],
67
81
  hooks: {},
68
82
  leader: { code: 0, label: '^Space' },
@@ -71,6 +85,7 @@ const DEFAULTS: Config = {
71
85
  targetErrors: [],
72
86
  principals: null,
73
87
  principalErrors: [],
88
+ workspaceErrors: [],
74
89
  };
75
90
 
76
91
  const configDir = join(resolveHomeDir(), '.config', 'atc');
@@ -101,6 +116,7 @@ const CONFIG_SCHEMA = z.object({
101
116
  codexBin: buildOptionalString(),
102
117
  codexArgs: buildOptionalStringArray(),
103
118
  dirs: z.unknown().optional(),
119
+ workspaces: z.unknown().optional(),
104
120
  gateways: z.unknown().optional(),
105
121
  hooks: z.unknown().optional(),
106
122
  leader: buildOptionalString(),
@@ -200,10 +216,20 @@ export function renderDefaultConfig(): string {
200
216
  targetErrors: _errors,
201
217
  principals: _principals,
202
218
  principalErrors: _principalErrors,
219
+ workspaceErrors: _workspaceErrors,
220
+ workspaces,
203
221
  ...written
204
222
  } = DEFAULTS;
205
223
 
206
- return `${JSON.stringify(written, null, 2)}\n`;
224
+ // The target roots are a map in memory and an object of target ids in
225
+ // the file.
226
+ const { targetRoots, ...rest } = workspaces;
227
+
228
+ return `${JSON.stringify(
229
+ { ...written, workspaces: { ...rest, targets: Object.fromEntries(targetRoots) } },
230
+ null,
231
+ 2,
232
+ )}\n`;
207
233
  }
208
234
 
209
235
  /**
@@ -239,6 +265,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
239
265
  const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
240
266
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
241
267
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
268
+ const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
242
269
  const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
243
270
  const hooks = collectHooks(parsed.data.hooks);
244
271
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
@@ -255,6 +282,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
255
282
  codexBin,
256
283
  codexArgs,
257
284
  dirs,
285
+ workspaces: workspaces.workspaces,
258
286
  gateways,
259
287
  hooks,
260
288
  leader,
@@ -263,6 +291,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
263
291
  targetErrors: targets.errors,
264
292
  principals: principals.principals,
265
293
  principalErrors: principals.errors,
294
+ workspaceErrors: workspaces.errors,
266
295
  };
267
296
  }
268
297
 
@@ -0,0 +1,5 @@
1
+ /**
2
+ * The git transports the daemon fetches workspace sources over when the
3
+ * config sets none.
4
+ */
5
+ export const DEFAULT_GIT_TRANSPORTS: readonly string[] = ['https', 'ssh'];
@@ -0,0 +1,11 @@
1
+ // A URL with a scheme, an scp-style `user@host:path`, or an absolute path.
2
+ const GIT_URL_PATTERN = /^(?:[a-z][a-z\d+.-]*:\/\/|[^@/:\s]+@[^@/:\s]+:|\/)/iu;
3
+
4
+ /**
5
+ * Whether typed text is a git repository URL in a form git itself reads:
6
+ * one with a scheme, the scp-style `user@host:path`, or an absolute path
7
+ * to a repository on the daemon's host.
8
+ */
9
+ export function isGitURL(input: string): boolean {
10
+ return GIT_URL_PATTERN.test(input.trim());
11
+ }
@@ -0,0 +1,40 @@
1
+ import type { SourceProvider } from './types';
2
+
3
+ // The order sources take when the config gives none.
4
+ const DEFAULT_SOURCE_ORDER = ['dirs', 'github', 'git'];
5
+
6
+ interface BuiltSources {
7
+ readonly sources: readonly SourceProvider[];
8
+
9
+ // The ids a configured order holds that no available source has.
10
+ readonly missing: readonly string[];
11
+ }
12
+
13
+ /**
14
+ * The sources the spawn picker offers, in the configured order, else the
15
+ * default one. A source the order leaves out is not offered, and an id
16
+ * with no available source is skipped, so a source that cannot run on
17
+ * this host is simply not offered; a configured order reports such ids as
18
+ * missing.
19
+ */
20
+ export function buildSources(
21
+ available: readonly SourceProvider[],
22
+ order: readonly string[] | null,
23
+ ): BuiltSources {
24
+ const byID = new Map(available.map((source) => [source.id, source]));
25
+
26
+ const sources: SourceProvider[] = [];
27
+ const missing: string[] = [];
28
+
29
+ for (const id of new Set(order ?? DEFAULT_SOURCE_ORDER)) {
30
+ const source = byID.get(id);
31
+
32
+ if (source !== undefined) {
33
+ sources.push(source);
34
+ } else if (order !== null) {
35
+ missing.push(id);
36
+ }
37
+ }
38
+
39
+ return { sources, missing };
40
+ }
@@ -0,0 +1,41 @@
1
+ import { buildDirsSource } from './dirs/build-dirs-source';
2
+ import { buildGitSource } from './git/build-git-source';
3
+ import { buildGitHubSource } from './github/build-github-source';
4
+ import type { SourceProvider } from './types';
5
+
6
+ interface BuiltinSourceOptions {
7
+ // The configured directory roots.
8
+ readonly roots: readonly string[];
9
+
10
+ // The GitHub owner a listing without a scope lists, or null for the gh
11
+ // account's own repositories.
12
+ readonly githubOwner: string | null;
13
+
14
+ // The gh executable: a name looked up on PATH, or a path.
15
+ readonly ghBin: string;
16
+
17
+ // The daemon user's home directory.
18
+ readonly homeDir: string;
19
+
20
+ // zoxide's frecency list on the daemon's host.
21
+ readonly collectZoxideDirs: () => Promise<string[]>;
22
+ }
23
+
24
+ /**
25
+ * The built-in sources this host can run, each with its services: the
26
+ * directories on the daemon's host, GitHub when gh is on this host, and
27
+ * a typed git URL, which needs nothing beyond git.
28
+ */
29
+ export function collectBuiltinSources(options: BuiltinSourceOptions): SourceProvider[] {
30
+ return [
31
+ buildDirsSource({
32
+ roots: options.roots,
33
+ collectZoxideDirs: options.collectZoxideDirs,
34
+ homeDir: options.homeDir,
35
+ }),
36
+ ...(Bun.which(options.ghBin) === null
37
+ ? []
38
+ : [buildGitHubSource({ bin: options.ghBin, owner: options.githubOwner })]),
39
+ buildGitSource(),
40
+ ];
41
+ }
@@ -0,0 +1,70 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { isAbsolute } from 'node:path';
3
+ import { collectRootDirs } from '../../shared/collect-root-dirs';
4
+ import type { SourceInterpretation, SourceProvider } from '../types';
5
+
6
+ // What the source reads besides the spawn history a request brings.
7
+ interface DirsSourceServices {
8
+ // The configured roots, each listing its child directories and their
9
+ // worktrees.
10
+ readonly roots: readonly string[];
11
+
12
+ // zoxide's frecency list on the daemon's host, most visited first.
13
+ readonly collectZoxideDirs: () => Promise<string[]>;
14
+
15
+ // The daemon user's home directory, which a leading `~` stands for.
16
+ readonly homeDir: string;
17
+ }
18
+
19
+ /**
20
+ * The directories on the daemon's host a session can run in: the spawn
21
+ * history the requesting principal may read, most recent first, then the
22
+ * configured roots, then zoxide's list. A directory that no longer exists
23
+ * is left out, and a duplicate keeps its first place. Typed input is a
24
+ * directory when it is absolute or starts with `~`, which stands for the
25
+ * daemon user's home.
26
+ */
27
+ export function buildDirsSource(services: DirsSourceServices): SourceProvider {
28
+ return {
29
+ id: 'dirs',
30
+ label: 'directory on the daemon host',
31
+ kind: 'path',
32
+ async list(_query, request) {
33
+ const seen = new Set<string>();
34
+
35
+ for (const dir of [
36
+ ...(await request.collectSpawnDirs()),
37
+ ...collectRootDirs(services.roots),
38
+ ...(await services.collectZoxideDirs()),
39
+ ]) {
40
+ if (!seen.has(dir) && existsSync(dir)) {
41
+ seen.add(dir);
42
+ }
43
+ }
44
+
45
+ return {
46
+ candidates: [...seen].map((dir) => ({
47
+ label: formatHomePath(dir, services.homeDir),
48
+ pick: { kind: 'path', dir },
49
+ })),
50
+ scope: null,
51
+ };
52
+ },
53
+ interpret(input) {
54
+ const text = input.trim();
55
+
56
+ const dir =
57
+ text === '~' || text.startsWith('~/') ? `${services.homeDir}${text.slice(1)}` : text;
58
+
59
+ const interpretation: SourceInterpretation = isAbsolute(dir)
60
+ ? { kind: 'path', dir }
61
+ : { kind: 'none' };
62
+
63
+ return Promise.resolve(interpretation);
64
+ },
65
+ };
66
+ }
67
+
68
+ function formatHomePath(dir: string, home: string): string {
69
+ return dir === home || dir.startsWith(`${home}/`) ? `~${dir.slice(home.length)}` : dir;
70
+ }
@@ -0,0 +1,25 @@
1
+ import { isGitURL } from '../../shared/is-git-url';
2
+ import type { SourceInterpretation, SourceProvider } from '../types';
3
+
4
+ /**
5
+ * A git repository at a typed URL, which needs nothing installed beyond
6
+ * git. It lists nothing; typed input is a repository when it is a URL git
7
+ * reads.
8
+ */
9
+ export function buildGitSource(): SourceProvider {
10
+ return {
11
+ id: 'git',
12
+ label: 'git URL',
13
+ kind: 'git',
14
+ list: () => Promise.resolve({ candidates: [], scope: null }),
15
+ interpret(input) {
16
+ const url = input.trim();
17
+
18
+ const interpretation: SourceInterpretation = isGitURL(url)
19
+ ? { kind: 'git', url }
20
+ : { kind: 'none' };
21
+
22
+ return Promise.resolve(interpretation);
23
+ },
24
+ };
25
+ }
@@ -0,0 +1,113 @@
1
+ import { DaemonError } from '../../protocol/daemon-error';
2
+ import type { SourceProvider } from '../types';
3
+ import { collectGitHubRepos } from './collect-github-repos';
4
+ import { findGitHubAlternateURL } from './find-github-alternate-url';
5
+ import { readGitProtocol } from './read-git-protocol';
6
+
7
+ interface GitHubSourceOptions {
8
+ // The gh executable: a name looked up on PATH, or a path.
9
+ readonly bin: string;
10
+
11
+ // The owner a listing without a scope lists; null lists the gh account's
12
+ // own repositories.
13
+ readonly owner: string | null;
14
+
15
+ // How long each gh command may take; 20 s when unset.
16
+ readonly timeoutMs?: number;
17
+ }
18
+
19
+ // How long a gh command may take before the request is refused.
20
+ const GH_TIMEOUT_MS = 20_000;
21
+
22
+ // A GitHub account or organization login.
23
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z\d][A-Za-z\d-]{0,38}$/u;
24
+
25
+ // Typed `owner/`, which lists that owner.
26
+ const OWNER_INPUT_PATTERN = /^(?<owner>[A-Za-z\d][A-Za-z\d-]{0,38})\/$/u;
27
+
28
+ // Typed `owner/repo`, with or without `.git`.
29
+ const REPO_INPUT_PATTERN =
30
+ /^(?<owner>[A-Za-z\d][A-Za-z\d-]{0,38})\/(?<repo>[\w-][\w.-]*?)(?:\.git)?$/u;
31
+
32
+ /**
33
+ * The GitHub repositories gh on the daemon's host can see. A listing's
34
+ * scope is the owner to list. Typed `owner/` lists that owner, and typed
35
+ * `owner/repo` is that repository at the clone URL the gh config prefers.
36
+ * A GitHub URL the host cannot read has the other URL form as its
37
+ * alternate.
38
+ * A listing gh cannot give throws `github_unavailable` with the problem.
39
+ */
40
+ export function buildGitHubSource(options: GitHubSourceOptions): SourceProvider {
41
+ const timeoutMs = options.timeoutMs ?? GH_TIMEOUT_MS;
42
+
43
+ return {
44
+ id: 'github',
45
+ label: 'GitHub repository',
46
+ kind: 'git',
47
+ async list(query) {
48
+ const owner = query.scope ?? options.owner;
49
+
50
+ if (owner !== null && !GITHUB_OWNER_PATTERN.test(owner)) {
51
+ throw new DaemonError('bad_args', 'scope must be a GitHub account or organization');
52
+ }
53
+
54
+ const listed = await collectGitHubRepos({ bin: options.bin, owner, timeoutMs });
55
+
56
+ if (!listed.ok) {
57
+ throw new DaemonError(listed.code, listed.message, { problem: listed.problem });
58
+ }
59
+
60
+ return {
61
+ candidates: listed.repos.map((repo) => {
62
+ const notes = [
63
+ ...(repo.isPrivate ? ['private'] : []),
64
+ ...(repo.description === '' ? [] : [repo.description]),
65
+ ];
66
+
67
+ return {
68
+ label: repo.nameWithOwner,
69
+ ...(notes.length === 0 ? {} : { detail: notes.join(' · ') }),
70
+ pick: {
71
+ kind: 'git',
72
+ url: listed.gitProtocol === 'ssh' ? repo.sshUrl : toCloneURL(repo.url),
73
+ },
74
+ };
75
+ }),
76
+ scope: listed.owner,
77
+ };
78
+ },
79
+ async interpret(input) {
80
+ const text = input.trim();
81
+ const owner = OWNER_INPUT_PATTERN.exec(text)?.groups?.['owner'];
82
+
83
+ if (owner !== undefined) {
84
+ return { kind: 'browse', scope: owner };
85
+ }
86
+
87
+ const repo = REPO_INPUT_PATTERN.exec(text)?.groups;
88
+
89
+ if (repo === undefined) {
90
+ return { kind: 'none' };
91
+ }
92
+
93
+ const name = `${repo['owner']}/${repo['repo']}`;
94
+
95
+ const protocol = await readGitProtocol(options.bin, timeoutMs);
96
+
97
+ return {
98
+ kind: 'git',
99
+ url: protocol === 'ssh' ? `git@github.com:${name}.git` : `https://github.com/${name}.git`,
100
+ };
101
+ },
102
+ findAlternateURLs(url) {
103
+ const alternate = findGitHubAlternateURL(url);
104
+
105
+ return alternate === null ? [] : [alternate];
106
+ },
107
+ };
108
+ }
109
+
110
+ // The https clone URL of a repository's web URL.
111
+ function toCloneURL(url: string): string {
112
+ return url.endsWith('.git') ? url : `${url}.git`;
113
+ }
@@ -0,0 +1,165 @@
1
+ import { z } from 'zod';
2
+ import { readGitProtocol } from './read-git-protocol';
3
+ import { runGH } from './run-gh';
4
+ import type { GHRun } from './run-gh';
5
+
6
+ interface GitHubRepo {
7
+ // `owner/repo`, which a git workspace source takes as its URL.
8
+ readonly nameWithOwner: string;
9
+ readonly description: string;
10
+ readonly isPrivate: boolean;
11
+
12
+ // The repository's https and ssh clone URLs.
13
+ readonly url: string;
14
+ readonly sshUrl: string;
15
+ }
16
+
17
+ interface GitHubRepoListing {
18
+ readonly ok: true;
19
+
20
+ // The owner listed: the requested one, else the gh account's own login
21
+ // as the first repository holds it, else null for an empty own list.
22
+ readonly owner: string | null;
23
+ readonly repos: readonly GitHubRepo[];
24
+
25
+ // The clone protocol the gh config prefers, `https` unless it says `ssh`.
26
+ readonly gitProtocol: 'https' | 'ssh';
27
+ }
28
+
29
+ interface GitHubUnavailable {
30
+ readonly ok: false;
31
+ readonly code: 'github_unavailable';
32
+ readonly problem: 'failed' | 'not_authenticated' | 'not_installed';
33
+ readonly message: string;
34
+ }
35
+
36
+ interface GitHubListRequest {
37
+ // The gh executable: a name looked up on PATH, or a path.
38
+ readonly bin: string;
39
+
40
+ // The account or organization to list; null lists the gh account's own.
41
+ readonly owner: string | null;
42
+
43
+ // How long each gh command may take; 20 s when unset.
44
+ readonly timeoutMs?: number;
45
+ }
46
+
47
+ // How long a gh command may take before the listing is refused.
48
+ const GH_TIMEOUT_MS = 20_000;
49
+
50
+ // How many repositories one listing holds at most.
51
+ const REPO_LIMIT = 500;
52
+
53
+ // The fields of each repository gh repo list prints that a listing keeps.
54
+ const REPO_LIST_SCHEMA = z.array(
55
+ z.object({
56
+ nameWithOwner: z.string().regex(/^[^/\s]+\/[^/\s]+$/u),
57
+ description: z
58
+ .string()
59
+ .nullable()
60
+ .optional()
61
+ .transform((description) => description ?? ''),
62
+ isPrivate: z.boolean(),
63
+ url: z.string(),
64
+ sshUrl: z.string(),
65
+ }),
66
+ );
67
+
68
+ /**
69
+ * Lists one owner's GitHub repositories through the gh CLI on this host,
70
+ * as the gh account signed in there sees them. gh is optional: a host
71
+ * without it, or with gh signed out, is refused as `github_unavailable`
72
+ * with the problem, and any other gh failure carries gh's own message. A gh
73
+ * that takes longer than the time limit, 20 s unless given, is refused as
74
+ * failed. gh never prompts here.
75
+ */
76
+ export async function collectGitHubRepos(
77
+ request: GitHubListRequest,
78
+ ): Promise<GitHubRepoListing | GitHubUnavailable> {
79
+ const bin = Bun.which(request.bin);
80
+
81
+ if (bin === null) {
82
+ return {
83
+ ok: false,
84
+ code: 'github_unavailable',
85
+ problem: 'not_installed',
86
+ message: `gh is not installed on the daemon host (no '${request.bin}' on PATH)`,
87
+ };
88
+ }
89
+
90
+ const timeoutMs = request.timeoutMs ?? GH_TIMEOUT_MS;
91
+
92
+ const listed = await runGH(bin, timeoutMs, [
93
+ 'repo',
94
+ 'list',
95
+ ...(request.owner === null ? [] : [request.owner]),
96
+ '--limit',
97
+ String(REPO_LIMIT),
98
+ '--json',
99
+ 'nameWithOwner,description,isPrivate,url,sshUrl',
100
+ ]);
101
+
102
+ if (listed.timedOut) {
103
+ return {
104
+ ok: false,
105
+ code: 'github_unavailable',
106
+ problem: 'failed',
107
+ message: `gh did not answer within ${timeoutMs / 1000} s`,
108
+ };
109
+ }
110
+
111
+ if (listed.exitCode !== 0) {
112
+ return buildUnavailable(listed);
113
+ }
114
+
115
+ const parsed = REPO_LIST_SCHEMA.safeParse(parseJSON(listed.stdout));
116
+
117
+ if (!parsed.success) {
118
+ return {
119
+ ok: false,
120
+ code: 'github_unavailable',
121
+ problem: 'failed',
122
+ message: 'gh repo list printed no repository list',
123
+ };
124
+ }
125
+
126
+ const repos = parsed.data;
127
+
128
+ return {
129
+ ok: true,
130
+ owner: request.owner ?? repos[0]?.nameWithOwner.split('/')[0] ?? null,
131
+ repos,
132
+ gitProtocol: await readGitProtocol(bin, timeoutMs),
133
+ };
134
+ }
135
+
136
+ // gh exits 4 when it needs a sign-in it does not have.
137
+ const GH_AUTH_EXIT = 4;
138
+
139
+ function buildUnavailable(run: GHRun): GitHubUnavailable {
140
+ const detail = run.stderr.trim();
141
+
142
+ if (run.exitCode === GH_AUTH_EXIT) {
143
+ return {
144
+ ok: false,
145
+ code: 'github_unavailable',
146
+ problem: 'not_authenticated',
147
+ message: `gh is not signed in on the daemon host; run gh auth login there${detail === '' ? '' : `: ${detail}`}`,
148
+ };
149
+ }
150
+
151
+ return {
152
+ ok: false,
153
+ code: 'github_unavailable',
154
+ problem: 'failed',
155
+ message: detail === '' ? `gh exited with ${run.exitCode}` : detail,
156
+ };
157
+ }
158
+
159
+ function parseJSON(text: string): unknown {
160
+ try {
161
+ return JSON.parse(text);
162
+ } catch {
163
+ return null;
164
+ }
165
+ }