@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.
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/spawn-picker.ts +132 -6
- package/src/client/ui.ts +6 -1
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +29 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/protocol.ts +2 -0
- package/src/protocol/request-param-schemas.ts +67 -15
- package/src/shared/collect-workspaces-config.ts +105 -0
- package/src/shared/config.ts +14 -0
- package/src/shared/default-git-transports.ts +5 -0
- package/src/shared/is-git-url.ts +11 -0
- package/src/sources/build-sources.ts +40 -0
- package/src/sources/collect-builtin-sources.ts +41 -0
- package/src/sources/dirs/build-dirs-source.ts +70 -0
- package/src/sources/git/build-git-source.ts +25 -0
- package/src/sources/github/build-github-source.ts +113 -0
- package/src/sources/github/collect-github-repos.ts +165 -0
- package/src/sources/github/find-github-alternate-url.ts +20 -0
- package/src/sources/github/read-git-protocol.ts +11 -0
- package/src/sources/github/run-gh.ts +66 -0
- package/src/sources/types.ts +70 -0
- package/src/workspace/check-git-transport.ts +61 -0
- package/src/workspace/check-repository-access.ts +108 -0
- package/src/workspace/collect-remote-refs.ts +154 -0
- package/src/workspace/create-git-askpass.ts +84 -0
- package/src/workspace/create-workspace-clone.ts +29 -76
- package/src/workspace/expand-git-shorthand.ts +13 -0
- package/src/workspace/find-remote-ref.ts +34 -0
- package/src/workspace/normalize-git-url.ts +2 -7
- package/src/workspace/resolve-git-url.ts +46 -0
- package/src/workspace/resolve-path-source.ts +11 -4
- package/src/workspace/run-git.ts +52 -4
- package/src/workspace/workspace-source.ts +10 -2
- /package/src/{client → shared}/collect-root-dirs.ts +0 -0
- /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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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:
|
|
306
|
-
repoURL:
|
|
307
|
-
checkout: source.
|
|
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(
|
|
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: {
|
|
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];
|
package/src/protocol/protocol.ts
CHANGED
|
@@ -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
|
|
42
|
-
*
|
|
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:
|
|
57
|
-
|
|
58
|
-
|
|
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) =>
|
|
69
|
-
message: 'a git workspace takes
|
|
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
|
+
}
|
package/src/shared/config.ts
CHANGED
|
@@ -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,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
|
+
}
|