@zgeoff/atc 2.15.2 → 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 +2 -2
- package/src/agents/build-hook-settings.ts +12 -3
- package/src/agents/print-codex-hook-file.ts +1 -1
- package/src/agents/print-grok-hook-file.ts +1 -1
- package/src/cli.ts +39 -5
- 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/broker-authority-error.ts +41 -0
- package/src/daemon/collect-secret-rule-mismatches.ts +83 -0
- package/src/daemon/daemon-connection.ts +129 -0
- package/src/daemon/daemon-context.ts +23 -0
- package/src/daemon/daemon.ts +46 -1
- package/src/daemon/imp-client-port.ts +75 -3
- package/src/daemon/imp-port.ts +60 -0
- package/src/daemon/is-imp-name-allowed.ts +38 -0
- package/src/daemon/is-own-hook-event.ts +19 -0
- package/src/daemon/materialize-workspace.ts +42 -18
- package/src/daemon/parse-hook-line.ts +3 -0
- package/src/daemon/require-git-transports.ts +23 -0
- package/src/daemon/session-runtime.ts +6 -0
- package/src/daemon/verify-broker-authority.ts +52 -0
- package/src/daemon/verify-cleanup-authority.ts +44 -0
- package/src/daemon/verify-token-imp-authority.ts +74 -0
- package/src/hook-report.ts +6 -4
- package/src/mcp/require-daemon-features.ts +2 -0
- package/src/protocol/daemon-features.ts +8 -0
- package/src/protocol/hook-event.ts +6 -2
- 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/statusline.ts +8 -6
- 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
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const HTTPS_PATTERN = /^https:\/\/github\.com\/(?<owner>[\w.-]+)\/(?<repo>[\w.-]+?)(?:\.git)?\/?$/u;
|
|
2
|
+
|
|
3
|
+
const SSH_PATTERN =
|
|
4
|
+
/^(?:ssh:\/\/)?git@github\.com[:/](?<owner>[\w.-]+)\/(?<repo>[\w.-]+?)(?:\.git)?$/u;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The other URL form of a GitHub repository: the ssh form of an https URL,
|
|
8
|
+
* and the https form of an ssh URL. Null for a URL not on GitHub.
|
|
9
|
+
*/
|
|
10
|
+
export function findGitHubAlternateURL(url: string): string | null {
|
|
11
|
+
const ssh = SSH_PATTERN.exec(url)?.groups;
|
|
12
|
+
|
|
13
|
+
if (ssh !== undefined) {
|
|
14
|
+
return `https://github.com/${ssh['owner']}/${ssh['repo']}.git`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const https = HTTPS_PATTERN.exec(url)?.groups;
|
|
18
|
+
|
|
19
|
+
return https === undefined ? null : `git@github.com:${https['owner']}/${https['repo']}.git`;
|
|
20
|
+
}
|