@zgeoff/atc 2.16.0 → 2.17.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 (43) hide show
  1. package/package.json +1 -1
  2. package/src/cli.ts +24 -1
  3. package/src/client/collect-target-picks.ts +53 -0
  4. package/src/client/dirs.ts +1 -1
  5. package/src/client/spawn-picker.ts +132 -6
  6. package/src/client/ui.ts +6 -1
  7. package/src/daemon/daemon-connection.ts +129 -0
  8. package/src/daemon/daemon-context.ts +23 -0
  9. package/src/daemon/daemon.ts +29 -0
  10. package/src/daemon/materialize-workspace.ts +42 -18
  11. package/src/daemon/require-git-transports.ts +23 -0
  12. package/src/mcp/require-daemon-features.ts +2 -0
  13. package/src/protocol/daemon-features.ts +8 -0
  14. package/src/protocol/protocol.ts +2 -0
  15. package/src/protocol/request-param-schemas.ts +67 -15
  16. package/src/shared/collect-workspaces-config.ts +105 -0
  17. package/src/shared/config.ts +14 -0
  18. package/src/shared/default-git-transports.ts +5 -0
  19. package/src/shared/is-git-url.ts +11 -0
  20. package/src/sources/build-sources.ts +40 -0
  21. package/src/sources/collect-builtin-sources.ts +41 -0
  22. package/src/sources/dirs/build-dirs-source.ts +70 -0
  23. package/src/sources/git/build-git-source.ts +25 -0
  24. package/src/sources/github/build-github-source.ts +113 -0
  25. package/src/sources/github/collect-github-repos.ts +165 -0
  26. package/src/sources/github/find-github-alternate-url.ts +20 -0
  27. package/src/sources/github/read-git-protocol.ts +11 -0
  28. package/src/sources/github/run-gh.ts +66 -0
  29. package/src/sources/types.ts +70 -0
  30. package/src/workspace/check-git-transport.ts +61 -0
  31. package/src/workspace/check-repository-access.ts +108 -0
  32. package/src/workspace/collect-remote-refs.ts +154 -0
  33. package/src/workspace/create-git-askpass.ts +84 -0
  34. package/src/workspace/create-workspace-clone.ts +29 -76
  35. package/src/workspace/expand-git-shorthand.ts +13 -0
  36. package/src/workspace/find-remote-ref.ts +34 -0
  37. package/src/workspace/normalize-git-url.ts +2 -7
  38. package/src/workspace/resolve-git-url.ts +46 -0
  39. package/src/workspace/resolve-path-source.ts +11 -4
  40. package/src/workspace/run-git.ts +52 -4
  41. package/src/workspace/workspace-source.ts +10 -2
  42. /package/src/{client → shared}/collect-root-dirs.ts +0 -0
  43. /package/src/{client → shared}/collect-zoxide-dirs.ts +0 -0
@@ -3,18 +3,22 @@ import { dirname, join, resolve } from 'node:path';
3
3
  import { DaemonError } from '../protocol/daemon-error';
4
4
  import type { ErrorCode } from '../protocol/protocol';
5
5
  import type { SpawnWorkspaceSource } from '../protocol/request-param-schemas';
6
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
6
7
  import type { SessionID } from '../shared/session-id';
7
8
  import type { StateStore } from '../store/state-store';
8
9
  import type { MaterializationPhase, SessionWorkspace } from '../store/workspace-materialization';
9
10
  import { checkURLCredentials } from '../workspace/check-url-credentials';
10
11
  import { createWorkspaceClone } from '../workspace/create-workspace-clone';
12
+ import { expandGitShorthand } from '../workspace/expand-git-shorthand';
11
13
  import { normalizeGitURL } from '../workspace/normalize-git-url';
12
14
  import { readWorkspaceTar } from '../workspace/read-workspace-tar';
13
15
  import { REPOSITORY_ENV_VARS } from '../workspace/repository-env-vars';
16
+ import { resolveGitURL } from '../workspace/resolve-git-url';
14
17
  import { resolvePathSource } from '../workspace/resolve-path-source';
15
18
  import { runGit } from '../workspace/run-git';
16
19
  import { sanitizeWorkspaceClone } from '../workspace/sanitize-workspace-clone';
17
20
  import type { ExecutionProvider } from './execution-provider';
21
+ import { requireGitTransports } from './require-git-transports';
18
22
 
19
23
  interface MaterializeRequest {
20
24
  readonly sessionID: SessionID;
@@ -39,6 +43,10 @@ interface MaterializeDeps {
39
43
  // The directory on the daemon's host that holds each clone's staging
40
44
  // directory while the workspace is built.
41
45
  readonly stagingRoot: string;
46
+
47
+ // The transports a source may use and git may fetch over, or the invalid
48
+ // list the config holds, which refuses every source that needs git.
49
+ readonly gitTransports: readonly string[] | InvalidGitTransports;
42
50
  }
43
51
 
44
52
  type MaterializedWorkspace = { readonly kind: 'in_place' } | ReadyWorkspace;
@@ -203,7 +211,9 @@ async function runMaterialization(
203
211
  updateProgress: ProgressTracker,
204
212
  secret: string | null,
205
213
  ): Promise<Omit<ReadyWorkspace, 'withheldEnv'>> {
206
- const pinned = await resolveSource(request.source, staging);
214
+ const transports = requireGitTransports(deps.gitTransports, { phase: 'resolving' });
215
+
216
+ const pinned = await resolveSource(request.source, staging, transports);
207
217
 
208
218
  // What is recorded and returned is scrubbed of the credential, even
209
219
  // where a caller's own ref happens to spell it.
@@ -213,7 +223,7 @@ async function runMaterialization(
213
223
  await claimTargetDir(request, deps, updateProgress);
214
224
  await recordPhase(request, deps, updateProgress, 'cloning', { repoURL, ref });
215
225
 
216
- const clone = await createCleanClone(pinned, join(staging, 'clone'));
226
+ const clone = await createCleanClone(pinned, join(staging, 'clone'), transports);
217
227
 
218
228
  // The archive is in memory, so the clone leaves the daemon's host before
219
229
  // the target is touched.
@@ -251,8 +261,10 @@ interface PinnedSource {
251
261
  readonly cloneURL: string;
252
262
  readonly repoURL: string;
253
263
 
254
- // The ref or commit the clone checks out, and the branch or tag recorded.
264
+ // The ref or commit the clone checks out, the commit it is pinned to when
265
+ // the ref only names its branch, and the branch or tag recorded.
255
266
  readonly checkout: string;
267
+ readonly sha?: string | undefined;
256
268
  readonly ref: string | null;
257
269
  readonly credential: { readonly kind: 'env'; readonly name: string } | undefined;
258
270
  readonly warnings: readonly string[];
@@ -263,7 +275,11 @@ interface PinnedSource {
263
275
  * not a pushed, complete, credential-free repository. A path source pins
264
276
  * its pushed HEAD; a git source keeps its ref, which the clone pins.
265
277
  */
266
- async function resolveSource(source: SpawnWorkspaceSource, staging: string): Promise<PinnedSource> {
278
+ async function resolveSource(
279
+ source: SpawnWorkspaceSource,
280
+ staging: string,
281
+ transports: readonly string[],
282
+ ): Promise<PinnedSource> {
267
283
  if (source.kind === 'path') {
268
284
  // The origin is checked as configured, before resolving it applies any
269
285
  // rewrite: a rewrite the checkout's own config holds expands it too.
@@ -271,6 +287,7 @@ async function resolveSource(source: SpawnWorkspaceSource, staging: string): Pro
271
287
 
272
288
  const resolved = await resolvePathSource(source.path, {
273
289
  allowDirty: source.allowDirty ?? 'refuse',
290
+ transports,
274
291
  });
275
292
 
276
293
  if (!resolved.ok) {
@@ -289,22 +306,19 @@ async function resolveSource(source: SpawnWorkspaceSource, staging: string): Pro
289
306
  };
290
307
  }
291
308
 
292
- await requireNoURLCredentials(source.url, staging);
309
+ // The clone fetches the URL it records, so the spawn API's `owner/repo`
310
+ // shorthand reaches the repository it expands to.
311
+ const resolved = await resolveGitURL(expandGitShorthand(source.url), staging, transports);
293
312
 
294
- // The clone fetches the URL it records, so an `owner/repo` shorthand
295
- // reaches the repository it expands to.
296
- const normalized = normalizeGitURL(source.url);
297
-
298
- if (!normalized.ok) {
299
- throw new DaemonError(normalized.code, normalized.message, { phase: 'resolving' });
313
+ if (!resolved.ok) {
314
+ throw new DaemonError(resolved.code, resolved.message, { phase: 'resolving' });
300
315
  }
301
316
 
302
- await requireNoURLCredentials(normalized.url, staging);
303
-
304
317
  return {
305
- cloneURL: normalized.url,
306
- repoURL: normalized.url,
307
- checkout: source.sha ?? source.ref ?? '',
318
+ cloneURL: resolved.url,
319
+ repoURL: resolved.url,
320
+ checkout: source.ref ?? source.sha ?? '',
321
+ sha: source.ref === undefined ? undefined : source.sha,
308
322
  ref: source.ref ?? null,
309
323
  credential: source.credentialRef,
310
324
  warnings: [],
@@ -400,10 +414,20 @@ interface CleanClone {
400
414
  * Clones the pinned source into a staging directory on the daemon's host,
401
415
  * sanitizes it, and reads it back as a tar archive.
402
416
  */
403
- async function createCleanClone(pinned: PinnedSource, dir: string): Promise<CleanClone> {
417
+ async function createCleanClone(
418
+ pinned: PinnedSource,
419
+ dir: string,
420
+ transports: readonly string[],
421
+ ): Promise<CleanClone> {
404
422
  const clone = await createWorkspaceClone({
405
- source: { kind: 'git', url: pinned.cloneURL, ref: pinned.checkout },
423
+ source: {
424
+ kind: 'git',
425
+ url: pinned.cloneURL,
426
+ ref: pinned.checkout,
427
+ ...(pinned.sha === undefined ? {} : { sha: pinned.sha }),
428
+ },
406
429
  dir,
430
+ transports,
407
431
  ...(pinned.credential === undefined ? {} : { credential: pinned.credential }),
408
432
  });
409
433
 
@@ -0,0 +1,23 @@
1
+ import { DaemonError } from '../protocol/daemon-error';
2
+ import type { InvalidGitTransports } from '../shared/collect-workspaces-config';
3
+
4
+ /**
5
+ * The transports git may fetch over, or the refusal of a git operation
6
+ * when the configured list is invalid: the daemon then runs no git at all,
7
+ * and the error quotes the config errors it printed at startup, with
8
+ * `data` as its detail.
9
+ */
10
+ export function requireGitTransports(
11
+ policy: readonly string[] | InvalidGitTransports,
12
+ data?: Readonly<Record<string, unknown>>,
13
+ ): readonly string[] {
14
+ if ('invalid' in policy) {
15
+ throw new DaemonError(
16
+ 'git_transports_invalid',
17
+ `workspaces.gitTransports in config.json is invalid, so the daemon runs no git: ${policy.invalid}`,
18
+ data,
19
+ );
20
+ }
21
+
22
+ return policy;
23
+ }
@@ -19,6 +19,8 @@ const FEATURE_USES: Readonly<Record<DaemonFeature, string>> = {
19
19
  'report.get': 'atc_report_get',
20
20
  'request.principal': 'the target limits of a remote MCP client',
21
21
  'spawn.workspace': "atc_session_spawn's workspace",
22
+ sources: 'sources.list and sources.interpret',
23
+ 'git.probe': 'git.probe',
22
24
  };
23
25
 
24
26
  /**
@@ -56,6 +56,14 @@ export const DAEMON_FEATURES = [
56
56
 
57
57
  // `report.get` exists.
58
58
  'report.get',
59
+
60
+ // `sources.list` and `sources.interpret` exist, and `agents.list` returns
61
+ // `sources`.
62
+ 'sources',
63
+
64
+ // `git.probe` exists, and `session.spawn` takes a git workspace with both
65
+ // `ref` and `sha`.
66
+ 'git.probe',
59
67
  ] as const;
60
68
 
61
69
  export type DaemonFeature = (typeof DAEMON_FEATURES)[number];
@@ -34,6 +34,7 @@ const ERROR_CODES = [
34
34
  'workspace_dirty',
35
35
  'no_origin',
36
36
  'invalid_git_url',
37
+ 'git_transports_invalid',
37
38
  'unpushed_head',
38
39
  'credential_in_url',
39
40
  'credential_missing',
@@ -44,6 +45,7 @@ const ERROR_CODES = [
44
45
  'workspace_exists',
45
46
  'transfer_failed',
46
47
  'workspace_mismatch',
48
+ 'github_unavailable',
47
49
  'host_unavailable',
48
50
  'auth_not_configured',
49
51
  'host_leased',
@@ -32,15 +32,30 @@ const CREDENTIAL_REF = z.strictObject({
32
32
  name: z.string().regex(/^[A-Za-z_]\w*$/u, 'a credentialRef names an environment variable'),
33
33
  });
34
34
 
35
+ // A git workspace's repository URL, which git must never read as an option.
36
+ const GIT_URL = z
37
+ .string({ error: 'a git workspace requires a url' })
38
+ .min(1, 'a git workspace requires a url')
39
+ .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -');
40
+
41
+ // A git workspace's branch or tag, which git must never read as an option.
42
+ const GIT_REF = z
43
+ .string()
44
+ .min(1, 'a git workspace ref must not be empty')
45
+ .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -');
46
+
47
+ const GIT_SHA = z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id');
48
+
35
49
  /**
36
50
  * Where a spawn's working directory comes from, materialized as a clean
37
51
  * checkout into the spawn's `cwd` on its execution target. A `path` source
38
52
  * is a directory on the daemon's host, resolved to its origin URL and
39
53
  * pushed HEAD; `allowDirty: 'warn'` resolves a tree with uncommitted
40
54
  * changes to HEAD and leaves the changes behind with a warning. A `git`
41
- * source is a repository URL with exactly one of a branch or tag `ref` or
42
- * a full commit `sha`, and an optional `credentialRef` naming the daemon
43
- * environment variable that holds its token.
55
+ * source is a repository URL with a branch or tag `ref`, a full commit
56
+ * `sha`, or both, and an optional `credentialRef` naming the daemon
57
+ * environment variable that holds its token. With both, the sha is the
58
+ * commit checked out and the ref is recorded as what it was resolved from.
44
59
  */
45
60
  const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
46
61
  z.strictObject({
@@ -53,23 +68,33 @@ const WORKSPACE_SOURCE = z.discriminatedUnion('kind', [
53
68
  z
54
69
  .strictObject({
55
70
  kind: z.literal('git'),
56
- url: z
57
- .string({ error: 'a git workspace requires a url' })
58
- .min(1, 'a git workspace requires a url')
59
- .refine((url) => !url.startsWith('-'), 'a git workspace url must not start with -'),
60
- ref: z
61
- .string()
62
- .min(1, 'a git workspace ref must not be empty')
63
- .refine((ref) => !ref.startsWith('-'), 'a git workspace ref must not start with -')
64
- .optional(),
65
- sha: z.string().regex(COMMIT_ID, 'a git workspace sha is a full commit id').optional(),
71
+ url: GIT_URL,
72
+ ref: GIT_REF.optional(),
73
+ sha: GIT_SHA.optional(),
66
74
  credentialRef: CREDENTIAL_REF.optional(),
67
75
  })
68
- .refine((source) => (source.ref === undefined) !== (source.sha === undefined), {
69
- message: 'a git workspace takes exactly one of ref or sha',
76
+ .refine((source) => source.ref !== undefined || source.sha !== undefined, {
77
+ message: 'a git workspace takes a ref, a sha, or both',
70
78
  }),
71
79
  ]);
72
80
 
81
+ // The execution target a request acts for; absent is the default target.
82
+ const TARGET = z
83
+ .string({ error: 'target must be a non-empty target id' })
84
+ .min(1, 'target must be a non-empty target id')
85
+ .optional();
86
+
87
+ // The id of a source the daemon offers the spawn picker.
88
+ const SOURCE_ID = z
89
+ .string({ error: 'source must be a source id' })
90
+ .min(1, 'source must be a source id')
91
+ .max(64, 'source must be a source id');
92
+
93
+ // Text a source reads, at most 4096 characters.
94
+ const SOURCE_TEXT = z
95
+ .string({ error: 'source text must be a string' })
96
+ .max(4096, 'source text must be at most 4096 characters');
97
+
73
98
  export type SpawnWorkspaceSource = z.infer<typeof WORKSPACE_SOURCE>;
74
99
 
75
100
  // The refusal of a terminal size outside the range a terminal takes.
@@ -88,6 +113,33 @@ export const REQUEST_PARAM_SCHEMAS = {
88
113
  'session.list': z.object({}),
89
114
  'dirs.list': z.object({}),
90
115
  'agents.list': z.object({}),
116
+
117
+ // One source's candidates for a spawn to the target, under the scope the
118
+ // source defines and filtered by text when it filters.
119
+ 'sources.list': z.object({
120
+ source: SOURCE_ID,
121
+ target: TARGET,
122
+ scope: SOURCE_TEXT.min(1, 'scope must be non-empty').optional(),
123
+ text: SOURCE_TEXT.optional(),
124
+ }),
125
+
126
+ // What one source reads typed input as, for a spawn to the target.
127
+ 'sources.interpret': z.object({ source: SOURCE_ID, input: SOURCE_TEXT, target: TARGET }),
128
+
129
+ // Whether the daemon's host can read a git workspace source the way a
130
+ // spawn to the target would, with its refs and the commit a ref or sha
131
+ // selects; at most one of the two.
132
+ 'git.probe': z
133
+ .object({
134
+ url: GIT_URL,
135
+ ref: GIT_REF.optional(),
136
+ sha: GIT_SHA.optional(),
137
+ credentialRef: CREDENTIAL_REF.optional(),
138
+ target: TARGET,
139
+ })
140
+ .refine((probe) => probe.ref === undefined || probe.sha === undefined, {
141
+ message: 'git.probe takes at most one of ref or sha',
142
+ }),
91
143
  'fleet.list': z.object({}),
92
144
  'fleet.restore': z.object({
93
145
  cols: buildTerminalSize(80),
@@ -0,0 +1,105 @@
1
+ import { DEFAULT_GIT_TRANSPORTS } from './default-git-transports';
2
+ import { isRecord } from './report';
3
+
4
+ /**
5
+ * Where workspace sources come from: the GitHub owner whose repositories
6
+ * the spawn picker lists by default, or null to list the gh account's own;
7
+ * the ids of the sources the picker offers, in order, or null for the
8
+ * default order; and the git transports the daemon fetches over.
9
+ */
10
+ export interface WorkspacesConfig {
11
+ readonly githubOwner: string | null;
12
+ readonly sources: readonly string[] | null;
13
+ readonly gitTransports: readonly string[] | InvalidGitTransports;
14
+ }
15
+
16
+ /**
17
+ * A transport list the config holds that atc cannot use, with the config
18
+ * errors it raised. The daemon runs no git while the list is invalid.
19
+ */
20
+ export interface InvalidGitTransports {
21
+ readonly invalid: string;
22
+ }
23
+
24
+ interface CollectedWorkspacesConfig {
25
+ readonly workspaces: WorkspacesConfig;
26
+
27
+ // The config problems the section holds, one line each.
28
+ readonly errors: readonly string[];
29
+ }
30
+
31
+ // A GitHub account or organization login.
32
+ const GITHUB_OWNER_PATTERN = /^[A-Za-z\d][A-Za-z\d-]{0,38}$/u;
33
+
34
+ /**
35
+ * Reads the `workspaces` section of config.json. An owner that is not a
36
+ * GitHub login is dropped, so a typo lists the gh account's own
37
+ * repositories instead of failing, and a source order that is not a list
38
+ * of ids falls back to the default order. A transport list that is not a
39
+ * list of transports atc allows is a config error, and the list is then
40
+ * invalid, never the default, so the daemon runs no git until it is fixed.
41
+ * An empty list is valid and allows no transport.
42
+ */
43
+ export function collectWorkspacesConfig(raw: unknown): CollectedWorkspacesConfig {
44
+ const owner = isRecord(raw) ? raw['githubOwner'] : undefined;
45
+ const sources = isRecord(raw) ? raw['sources'] : undefined;
46
+ const rawTransports = isRecord(raw) ? raw['gitTransports'] : undefined;
47
+ const transports = collectGitTransports(rawTransports);
48
+
49
+ return {
50
+ workspaces: {
51
+ githubOwner: typeof owner === 'string' && GITHUB_OWNER_PATTERN.test(owner) ? owner : null,
52
+ sources:
53
+ Array.isArray(sources) &&
54
+ sources.every((id): id is string => typeof id === 'string' && id !== '')
55
+ ? sources
56
+ : null,
57
+ gitTransports: transports.transports,
58
+ },
59
+ errors: transports.errors,
60
+ };
61
+ }
62
+
63
+ // The git transports a config may allow: the defaults, and the two
64
+ // opt-ins.
65
+ const KNOWN_TRANSPORTS: ReadonlySet<string> = new Set(['https', 'ssh', 'http', 'file']);
66
+
67
+ // Transports that run a command or read a descriptor on the daemon's host.
68
+ const REFUSED_TRANSPORTS: ReadonlySet<string> = new Set(['ext', 'fd']);
69
+
70
+ function collectGitTransports(raw: unknown): {
71
+ readonly transports: readonly string[] | InvalidGitTransports;
72
+ readonly errors: readonly string[];
73
+ } {
74
+ if (raw === undefined) {
75
+ return { transports: DEFAULT_GIT_TRANSPORTS, errors: [] };
76
+ }
77
+
78
+ const fallback = 'the daemon runs no git until it is fixed';
79
+
80
+ if (!Array.isArray(raw)) {
81
+ const error = `workspaces.gitTransports is not a list of git transports; ${fallback}`;
82
+
83
+ return { transports: { invalid: error }, errors: [error] };
84
+ }
85
+
86
+ const errors = raw.flatMap((name: unknown) => {
87
+ if (typeof name === 'string' && REFUSED_TRANSPORTS.has(name)) {
88
+ return [
89
+ `workspaces.gitTransports holds '${name}', which atc never allows because it runs a command or reads a descriptor on the daemon host; ${fallback}`,
90
+ ];
91
+ }
92
+
93
+ if (typeof name !== 'string' || !KNOWN_TRANSPORTS.has(name)) {
94
+ return [
95
+ `workspaces.gitTransports holds ${JSON.stringify(name)}, which is not a git transport atc allows; ${fallback}`,
96
+ ];
97
+ }
98
+
99
+ return [];
100
+ });
101
+
102
+ return errors.length === 0
103
+ ? { transports: raw.filter((name): name is string => typeof name === 'string'), errors }
104
+ : { transports: { invalid: errors.join('; ') }, errors };
105
+ }
@@ -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,7 @@ const DEFAULTS: Config = {
63
70
  codexBin: 'codex',
64
71
  codexArgs: [],
65
72
  dirs: { roots: [] },
73
+ workspaces: { githubOwner: null, sources: null, gitTransports: DEFAULT_GIT_TRANSPORTS },
66
74
  gateways: [],
67
75
  hooks: {},
68
76
  leader: { code: 0, label: '^Space' },
@@ -71,6 +79,7 @@ const DEFAULTS: Config = {
71
79
  targetErrors: [],
72
80
  principals: null,
73
81
  principalErrors: [],
82
+ workspaceErrors: [],
74
83
  };
75
84
 
76
85
  const configDir = join(resolveHomeDir(), '.config', 'atc');
@@ -101,6 +110,7 @@ const CONFIG_SCHEMA = z.object({
101
110
  codexBin: buildOptionalString(),
102
111
  codexArgs: buildOptionalStringArray(),
103
112
  dirs: z.unknown().optional(),
113
+ workspaces: z.unknown().optional(),
104
114
  gateways: z.unknown().optional(),
105
115
  hooks: z.unknown().optional(),
106
116
  leader: buildOptionalString(),
@@ -200,6 +210,7 @@ export function renderDefaultConfig(): string {
200
210
  targetErrors: _errors,
201
211
  principals: _principals,
202
212
  principalErrors: _principalErrors,
213
+ workspaceErrors: _workspaceErrors,
203
214
  ...written
204
215
  } = DEFAULTS;
205
216
 
@@ -239,6 +250,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
239
250
  const codexBin = parsed.data.codexBin ?? DEFAULTS.codexBin;
240
251
  const codexArgs = parsed.data.codexArgs ?? DEFAULTS.codexArgs;
241
252
  const dirs = { roots: collectDirRoots(parsed.data.dirs) };
253
+ const workspaces = collectWorkspacesConfig(parsed.data.workspaces);
242
254
  const gateways = collectGateways(parsed.data.gateways, claudeBin, claudeArgs);
243
255
  const hooks = collectHooks(parsed.data.hooks);
244
256
  const targets = collectTargets(parsed.data.targets, parsed.data.defaultTarget);
@@ -255,6 +267,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
255
267
  codexBin,
256
268
  codexArgs,
257
269
  dirs,
270
+ workspaces: workspaces.workspaces,
258
271
  gateways,
259
272
  hooks,
260
273
  leader,
@@ -263,6 +276,7 @@ export function parseConfig(raw: unknown, file: string = configFile): Config {
263
276
  targetErrors: targets.errors,
264
277
  principals: principals.principals,
265
278
  principalErrors: principals.errors,
279
+ workspaceErrors: workspaces.errors,
266
280
  };
267
281
  }
268
282
 
@@ -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
+ }