@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runGH } from './run-gh';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The clone protocol the gh config on this host prefers: `ssh` when it says
|
|
5
|
+
* so, else `https`, which a gh that fails or takes too long also gives.
|
|
6
|
+
*/
|
|
7
|
+
export async function readGitProtocol(bin: string, timeoutMs: number): Promise<'https' | 'ssh'> {
|
|
8
|
+
const run = await runGH(bin, timeoutMs, ['config', 'get', 'git_protocol']);
|
|
9
|
+
|
|
10
|
+
return run.exitCode === 0 && run.stdout.trim() === 'ssh' ? 'ssh' : 'https';
|
|
11
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export interface GHRun {
|
|
2
|
+
readonly exitCode: number;
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
readonly stderr: string;
|
|
5
|
+
readonly timedOut: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs gh with arguments and a time limit, killing its whole process group
|
|
10
|
+
* once the limit passes, so a wrapper or extension leaves no process
|
|
11
|
+
* behind. gh reads its own config and the host's environment for its
|
|
12
|
+
* token, and is kept from prompting, colouring, or checking for updates.
|
|
13
|
+
*/
|
|
14
|
+
export async function runGH(
|
|
15
|
+
bin: string,
|
|
16
|
+
timeoutMs: number,
|
|
17
|
+
args: readonly string[],
|
|
18
|
+
): Promise<GHRun> {
|
|
19
|
+
const proc = Bun.spawn([bin, ...args], {
|
|
20
|
+
env: {
|
|
21
|
+
...process.env,
|
|
22
|
+
GH_PROMPT_DISABLED: '1',
|
|
23
|
+
GH_NO_UPDATE_NOTIFIER: '1',
|
|
24
|
+
GH_SPINNER_DISABLED: '1',
|
|
25
|
+
NO_COLOR: '1',
|
|
26
|
+
LC_ALL: 'C',
|
|
27
|
+
},
|
|
28
|
+
stdin: 'ignore',
|
|
29
|
+
stdout: 'pipe',
|
|
30
|
+
stderr: 'pipe',
|
|
31
|
+
|
|
32
|
+
// gh leads its own process group, so stopping it stops what it
|
|
33
|
+
// started too.
|
|
34
|
+
detached: true,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const finished = Promise.all([
|
|
38
|
+
new Response(proc.stdout).text(),
|
|
39
|
+
new Response(proc.stderr).text(),
|
|
40
|
+
proc.exited,
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const limit = Promise.withResolvers<null>();
|
|
44
|
+
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
limit.resolve(null);
|
|
47
|
+
}, timeoutMs);
|
|
48
|
+
|
|
49
|
+
const settled = await Promise.race([finished, limit.promise]);
|
|
50
|
+
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
|
|
53
|
+
if (settled === null) {
|
|
54
|
+
try {
|
|
55
|
+
process.kill(-proc.pid, 'SIGKILL');
|
|
56
|
+
} catch {
|
|
57
|
+
// The group already exited.
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { exitCode: -1, stdout: '', stderr: '', timedOut: true };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const [stdout, stderr, exitCode] = settled;
|
|
64
|
+
|
|
65
|
+
return { exitCode, stdout, stderr, timedOut: false };
|
|
66
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a candidate or a typed input resolves to: a directory on the
|
|
3
|
+
* daemon's host, or a git repository URL that a probe pins to a commit
|
|
4
|
+
* before any spawn uses it.
|
|
5
|
+
*/
|
|
6
|
+
type SourcePick =
|
|
7
|
+
| { readonly kind: 'path'; readonly dir: string }
|
|
8
|
+
| { readonly kind: 'git'; readonly url: string };
|
|
9
|
+
|
|
10
|
+
// The kind of pick every candidate of one source resolves to.
|
|
11
|
+
export type SourceKind = SourcePick['kind'];
|
|
12
|
+
|
|
13
|
+
// One entry a source lists for the spawn picker.
|
|
14
|
+
interface SourceCandidate {
|
|
15
|
+
readonly label: string;
|
|
16
|
+
readonly detail?: string;
|
|
17
|
+
readonly pick: SourcePick;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// One listing: the candidates, and the scope they were listed under, or
|
|
21
|
+
// null when the source lists without one.
|
|
22
|
+
interface SourceListing {
|
|
23
|
+
readonly candidates: readonly SourceCandidate[];
|
|
24
|
+
readonly scope: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// What a listing asks for: a scope the source defines, such as one account,
|
|
28
|
+
// and filter text for a source that filters on the daemon.
|
|
29
|
+
interface SourceQuery {
|
|
30
|
+
readonly scope?: string;
|
|
31
|
+
readonly text?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* What a source reads typed input as: a scope to list, a pick, or nothing
|
|
36
|
+
* it recognizes.
|
|
37
|
+
*/
|
|
38
|
+
export type SourceInterpretation =
|
|
39
|
+
| { readonly kind: 'browse'; readonly scope: string }
|
|
40
|
+
| SourcePick
|
|
41
|
+
| { readonly kind: 'none' };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The request a source serves: the execution target the spawn will use,
|
|
45
|
+
* and the daemon's spawn history as the requesting principal may read it.
|
|
46
|
+
*/
|
|
47
|
+
export interface SourceRequest {
|
|
48
|
+
readonly target: string;
|
|
49
|
+
readonly collectSpawnDirs: () => Promise<string[]>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A discovery source for the spawn picker. It lists candidates and reads
|
|
54
|
+
* typed input, and every candidate resolves to a pick of its kind. A source
|
|
55
|
+
* runs on the daemon, the host that materializes the workspace, and gets
|
|
56
|
+
* the services it uses when it is built. A failure throws the error the
|
|
57
|
+
* source defines for it.
|
|
58
|
+
*/
|
|
59
|
+
export interface SourceProvider {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly label: string;
|
|
62
|
+
readonly kind: SourceKind;
|
|
63
|
+
readonly list: (query: SourceQuery, request: SourceRequest) => Promise<SourceListing>;
|
|
64
|
+
readonly interpret: (input: string, request: SourceRequest) => Promise<SourceInterpretation>;
|
|
65
|
+
|
|
66
|
+
// The other URLs of the repository at a git URL the daemon's host could
|
|
67
|
+
// not read, for a source that knows its host's URL forms; none when it
|
|
68
|
+
// does not recognize the URL.
|
|
69
|
+
readonly findAlternateURLs?: (url: string) => readonly string[];
|
|
70
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
type TransportCheck =
|
|
2
|
+
| { readonly ok: true }
|
|
3
|
+
| { readonly ok: false; readonly code: 'invalid_git_url'; readonly message: string };
|
|
4
|
+
|
|
5
|
+
// `<transport>::<address>`, which runs git's remote helper for the
|
|
6
|
+
// transport, such as `ext::` or `fd::`.
|
|
7
|
+
const HELPER_PATTERN = /^(?<name>[A-Za-z][\w+.-]*)::/u;
|
|
8
|
+
|
|
9
|
+
// `<scheme>://…`.
|
|
10
|
+
const SCHEME_PATTERN = /^(?<name>[A-Za-z][\w+.-]*):\/\//u;
|
|
11
|
+
|
|
12
|
+
// The scp-style `user@host:path` or `host:path`, which git reads as ssh.
|
|
13
|
+
const SCP_PATTERN = /^(?:[^@/:\s]+@)?[^@/:\s]+:/u;
|
|
14
|
+
|
|
15
|
+
// Schemes git treats as ssh.
|
|
16
|
+
const SSH_SCHEMES: ReadonlySet<string> = new Set(['ssh', 'git+ssh', 'ssh+git']);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checks that a repository URL uses one of the allowed git transports,
|
|
20
|
+
* without running git. The scp-style form is ssh, a remote-helper URL is
|
|
21
|
+
* its helper's transport, and a path is the `file` transport. A URL that
|
|
22
|
+
* git could read as an option is refused whatever the transports.
|
|
23
|
+
*/
|
|
24
|
+
export function checkGitTransport(url: string, transports: readonly string[]): TransportCheck {
|
|
25
|
+
if (url.startsWith('-')) {
|
|
26
|
+
return { ok: false, code: 'invalid_git_url', message: 'a git URL must not start with -' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const transport = findTransport(url);
|
|
30
|
+
|
|
31
|
+
if (transports.includes(transport)) {
|
|
32
|
+
return { ok: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const allowed =
|
|
36
|
+
transports.length === 0
|
|
37
|
+
? 'workspaces.gitTransports allows no transports'
|
|
38
|
+
: `the daemon fetches over ${transports.join(' and ')}`;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
code: 'invalid_git_url',
|
|
43
|
+
message: `git transport '${transport}' is not allowed; ${allowed}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function findTransport(url: string): string {
|
|
48
|
+
const helper = HELPER_PATTERN.exec(url)?.groups?.['name'];
|
|
49
|
+
|
|
50
|
+
if (helper !== undefined) {
|
|
51
|
+
return helper.toLowerCase();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const scheme = SCHEME_PATTERN.exec(url)?.groups?.['name']?.toLowerCase();
|
|
55
|
+
|
|
56
|
+
if (scheme !== undefined) {
|
|
57
|
+
return SSH_SCHEMES.has(scheme) ? 'ssh' : scheme;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return SCP_PATTERN.test(url) ? 'ssh' : 'file';
|
|
61
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { collectRemoteRefs } from './collect-remote-refs';
|
|
5
|
+
import type { RemoteRef } from './collect-remote-refs';
|
|
6
|
+
import type { GitCredential } from './create-git-askpass';
|
|
7
|
+
import { findRemoteRef } from './find-remote-ref';
|
|
8
|
+
import { resolveGitURL } from './resolve-git-url';
|
|
9
|
+
|
|
10
|
+
interface AccessRequest {
|
|
11
|
+
readonly url: string;
|
|
12
|
+
|
|
13
|
+
// At most one: a branch or tag to resolve to its commit, or a full
|
|
14
|
+
// commit id taken as it is.
|
|
15
|
+
readonly ref?: string | undefined;
|
|
16
|
+
readonly sha?: string | undefined;
|
|
17
|
+
readonly credential?: GitCredential | undefined;
|
|
18
|
+
|
|
19
|
+
// How long the listing may take; 20 s when unset.
|
|
20
|
+
readonly timeoutMs?: number | undefined;
|
|
21
|
+
|
|
22
|
+
// The transports the URL may use and git may fetch over.
|
|
23
|
+
readonly transports: readonly string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface RepositoryAccess {
|
|
27
|
+
readonly ok: true;
|
|
28
|
+
|
|
29
|
+
// The URL a workspace spawn from this source clones.
|
|
30
|
+
readonly url: string;
|
|
31
|
+
readonly head: string | null;
|
|
32
|
+
readonly refs: readonly RemoteRef[];
|
|
33
|
+
|
|
34
|
+
// The commit the requested ref or sha selects, and the branch it is on;
|
|
35
|
+
// null when the request holds neither.
|
|
36
|
+
readonly resolved: {
|
|
37
|
+
readonly sha: string;
|
|
38
|
+
readonly branch: string | null;
|
|
39
|
+
} | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AccessRefusal {
|
|
43
|
+
readonly ok: false;
|
|
44
|
+
readonly code:
|
|
45
|
+
| 'clone_failed'
|
|
46
|
+
| 'credential_in_url'
|
|
47
|
+
| 'credential_missing'
|
|
48
|
+
| 'invalid_git_url'
|
|
49
|
+
| 'ref_not_found';
|
|
50
|
+
readonly message: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Checks that the daemon's host can read a git workspace source the way a
|
|
55
|
+
* workspace spawn from it would, without cloning: the URL resolves exactly
|
|
56
|
+
* as the materializer resolves it, against the git config of a fresh empty
|
|
57
|
+
* directory, and one `git ls-remote` authenticates exactly as the clone
|
|
58
|
+
* does. A readable upstream answers with its branches, tags, and default
|
|
59
|
+
* branch, and with the commit the requested ref points at now. A full
|
|
60
|
+
* commit id is taken as it is: a listing holds refs, not objects, so only
|
|
61
|
+
* the clone can tell that the upstream lacks it.
|
|
62
|
+
*/
|
|
63
|
+
export async function checkRepositoryAccess(
|
|
64
|
+
request: AccessRequest,
|
|
65
|
+
): Promise<AccessRefusal | RepositoryAccess> {
|
|
66
|
+
const cwd = await mkdtemp(join(tmpdir(), 'atc-repo-access-'));
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const resolved = await resolveGitURL(request.url, cwd, request.transports);
|
|
70
|
+
|
|
71
|
+
if (!resolved.ok) {
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const listing = await collectRemoteRefs(
|
|
76
|
+
resolved.url,
|
|
77
|
+
request.credential,
|
|
78
|
+
request.transports,
|
|
79
|
+
request.timeoutMs,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
if (!listing.ok) {
|
|
83
|
+
return listing;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const access = { ok: true, url: resolved.url, head: listing.head, refs: listing.refs } as const;
|
|
87
|
+
|
|
88
|
+
if (request.sha !== undefined) {
|
|
89
|
+
return { ...access, resolved: { sha: request.sha, branch: null } };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (request.ref === undefined) {
|
|
93
|
+
return { ...access, resolved: null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const match = findRemoteRef(listing.byName, request.ref);
|
|
97
|
+
|
|
98
|
+
if (match === null) {
|
|
99
|
+
const name = request.ref.replace(/^refs\/(?:heads|tags)\//u, '');
|
|
100
|
+
|
|
101
|
+
return { ok: false, code: 'ref_not_found', message: `origin has no branch or tag '${name}'` };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { ...access, resolved: match };
|
|
105
|
+
} finally {
|
|
106
|
+
await rm(cwd, { recursive: true, force: true });
|
|
107
|
+
}
|
|
108
|
+
}
|