@zgeoff/atc 2.16.0 → 2.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.ts +24 -1
- package/src/client/build-workspace-destination.ts +38 -0
- package/src/client/collect-target-picks.ts +53 -0
- package/src/client/dirs.ts +1 -1
- package/src/client/pick-refusal-step.ts +29 -0
- package/src/client/resolve-workspace-root.ts +49 -0
- package/src/client/spawn-picker.ts +1683 -84
- 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 +120 -0
- package/src/shared/config.ts +30 -1
- 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,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
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createGitAskpass } from './create-git-askpass';
|
|
2
|
+
import type { GitCredential } from './create-git-askpass';
|
|
3
|
+
import { runGit } from './run-git';
|
|
4
|
+
|
|
5
|
+
export interface RemoteRef {
|
|
6
|
+
// The short name: `main`, `feat/x`, `v1.0`.
|
|
7
|
+
readonly name: string;
|
|
8
|
+
readonly kind: 'branch' | 'tag';
|
|
9
|
+
|
|
10
|
+
// The commit the ref points at; an annotated tag's is the commit it
|
|
11
|
+
// peels to.
|
|
12
|
+
readonly sha: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface RemoteRefListing {
|
|
16
|
+
readonly ok: true;
|
|
17
|
+
|
|
18
|
+
// The branch the upstream's HEAD points at, or null when it reports none.
|
|
19
|
+
readonly head: string | null;
|
|
20
|
+
readonly refs: readonly RemoteRef[];
|
|
21
|
+
|
|
22
|
+
// Every listed ref by full name, each annotated tag's peeled `^{}` entry
|
|
23
|
+
// beside it, for resolving one ref the way a clone does.
|
|
24
|
+
readonly byName: ReadonlyMap<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface RemoteRefRefusal {
|
|
28
|
+
readonly ok: false;
|
|
29
|
+
readonly code: 'clone_failed' | 'credential_missing';
|
|
30
|
+
readonly message: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// How long a listing may take: a host that never answers holds a client
|
|
34
|
+
// waiting on it no longer than this.
|
|
35
|
+
const REMOTE_TIMEOUT_MS = 20_000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Lists an upstream's branches and tags and the branch its HEAD points at,
|
|
39
|
+
* through one `git ls-remote` that authenticates exactly as a clone of the
|
|
40
|
+
* same URL does: through the host's git config, or through a private
|
|
41
|
+
* askpass helper for an env credential. git never prompts. A listing git
|
|
42
|
+
* cannot read is refused as `clone_failed` with git's own message, and so
|
|
43
|
+
* is one that takes longer than the time limit, 20 s unless given. git
|
|
44
|
+
* fetches only over `transports`.
|
|
45
|
+
*/
|
|
46
|
+
export async function collectRemoteRefs(
|
|
47
|
+
url: string,
|
|
48
|
+
credential: GitCredential | undefined,
|
|
49
|
+
transports: readonly string[],
|
|
50
|
+
timeoutMs: number = REMOTE_TIMEOUT_MS,
|
|
51
|
+
): Promise<RemoteRefListing | RemoteRefRefusal> {
|
|
52
|
+
const askpass = await createGitAskpass(credential);
|
|
53
|
+
|
|
54
|
+
if (!askpass.ok) {
|
|
55
|
+
return askpass;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let listed: Awaited<ReturnType<typeof runGit>>;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const sshEnv = await buildSSHTimeoutEnv(timeoutMs);
|
|
62
|
+
|
|
63
|
+
listed = await runGit(
|
|
64
|
+
[
|
|
65
|
+
...askpass.args,
|
|
66
|
+
...buildHTTPTimeoutArgs(timeoutMs),
|
|
67
|
+
'ls-remote',
|
|
68
|
+
'--symref',
|
|
69
|
+
'--',
|
|
70
|
+
url,
|
|
71
|
+
'HEAD',
|
|
72
|
+
'refs/heads/*',
|
|
73
|
+
'refs/tags/*',
|
|
74
|
+
],
|
|
75
|
+
{ env: { ...askpass.env, ...sshEnv }, timeoutMs, transports },
|
|
76
|
+
);
|
|
77
|
+
} finally {
|
|
78
|
+
await askpass[Symbol.asyncDispose]();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (listed.timedOut) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
code: 'clone_failed',
|
|
85
|
+
message: `git ls-remote did not answer within ${timeoutMs / 1000} s`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (listed.exitCode !== 0) {
|
|
90
|
+
return { ok: false, code: 'clone_failed', message: listed.stderr.trim() };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return parseListing(listed.stdout);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// An ssh connection that takes longer than the limit is given up by ssh.
|
|
97
|
+
// A host that chose its own ssh command keeps it.
|
|
98
|
+
async function buildSSHTimeoutEnv(timeoutMs: number): Promise<Readonly<Record<string, string>>> {
|
|
99
|
+
if (process.env['GIT_SSH_COMMAND'] !== undefined || process.env['GIT_SSH'] !== undefined) {
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const configured = await runGit(['config', '--get', 'core.sshCommand']);
|
|
104
|
+
|
|
105
|
+
if (configured.exitCode === 0 && configured.stdout.trim() !== '') {
|
|
106
|
+
return {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
|
|
110
|
+
|
|
111
|
+
return { GIT_SSH_COMMAND: `ssh -o ConnectTimeout=${seconds}` };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// An HTTP transfer that moves under a byte a second for the whole limit is
|
|
115
|
+
// given up by git itself.
|
|
116
|
+
function buildHTTPTimeoutArgs(timeoutMs: number): string[] {
|
|
117
|
+
const seconds = String(Math.max(1, Math.ceil(timeoutMs / 1000)));
|
|
118
|
+
|
|
119
|
+
return ['-c', 'http.lowSpeedLimit=1', '-c', `http.lowSpeedTime=${seconds}`];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SYMREF_PREFIX = 'ref: refs/heads/';
|
|
123
|
+
|
|
124
|
+
function parseListing(stdout: string): RemoteRefListing {
|
|
125
|
+
const byName = new Map<string, string>();
|
|
126
|
+
|
|
127
|
+
let head: string | null = null;
|
|
128
|
+
|
|
129
|
+
for (const line of stdout.split('\n')) {
|
|
130
|
+
const [left = '', name = ''] = line.split('\t');
|
|
131
|
+
|
|
132
|
+
if (name === 'HEAD' && left.startsWith(SYMREF_PREFIX)) {
|
|
133
|
+
head = left.slice(SYMREF_PREFIX.length);
|
|
134
|
+
} else if (name.startsWith('refs/heads/') || name.startsWith('refs/tags/')) {
|
|
135
|
+
byName.set(name, left);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const refs: RemoteRef[] = [];
|
|
140
|
+
|
|
141
|
+
for (const [name, sha] of byName) {
|
|
142
|
+
if (name.startsWith('refs/heads/')) {
|
|
143
|
+
refs.push({ name: name.slice('refs/heads/'.length), kind: 'branch', sha });
|
|
144
|
+
} else if (!name.endsWith('^{}')) {
|
|
145
|
+
refs.push({
|
|
146
|
+
name: name.slice('refs/tags/'.length),
|
|
147
|
+
kind: 'tag',
|
|
148
|
+
sha: byName.get(`${name}^{}`) ?? sha,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ok: true, head, refs, byName };
|
|
154
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
|
|
5
|
+
export interface GitCredential {
|
|
6
|
+
readonly kind: 'env';
|
|
7
|
+
readonly name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface GitAskpass {
|
|
11
|
+
readonly ok: true;
|
|
12
|
+
|
|
13
|
+
// What each network git command takes: the variables that point it at
|
|
14
|
+
// the helper, and the arguments that switch the host's credential
|
|
15
|
+
// helpers off.
|
|
16
|
+
readonly env: Readonly<Record<string, string>>;
|
|
17
|
+
readonly args: readonly string[];
|
|
18
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MissingCredential {
|
|
22
|
+
readonly ok: false;
|
|
23
|
+
readonly code: 'credential_missing';
|
|
24
|
+
readonly message: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const ASKPASS_SECRET_VAR = 'ATC_GIT_ASKPASS_SECRET';
|
|
28
|
+
|
|
29
|
+
// Username prompts get a fixed name that GitHub and GitLab both accept
|
|
30
|
+
// alongside a token; password prompts get the token.
|
|
31
|
+
const ASKPASS_SCRIPT = `#!/bin/sh
|
|
32
|
+
case "$1" in
|
|
33
|
+
Username*) printf '%s\\n' x-access-token ;;
|
|
34
|
+
*) printf '%s\\n' "$${ASKPASS_SECRET_VAR}" ;;
|
|
35
|
+
esac
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Selects how network git commands authenticate. Without a credential they
|
|
40
|
+
* authenticate through the host's own git config, and the result adds
|
|
41
|
+
* nothing. An env credential is read from the named variable and handed to
|
|
42
|
+
* git only through a private askpass helper, never through the command line
|
|
43
|
+
* or the URL, with the host's credential helpers switched off so none of
|
|
44
|
+
* them stores the token. Disposing the result deletes the helper.
|
|
45
|
+
*/
|
|
46
|
+
export async function createGitAskpass(
|
|
47
|
+
credential: GitCredential | undefined,
|
|
48
|
+
): Promise<GitAskpass | MissingCredential> {
|
|
49
|
+
if (credential === undefined) {
|
|
50
|
+
return { ok: true, env: {}, args: [], [Symbol.asyncDispose]: async () => {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const secret = process.env[credential.name];
|
|
54
|
+
|
|
55
|
+
if (secret === undefined || secret === '') {
|
|
56
|
+
return {
|
|
57
|
+
ok: false,
|
|
58
|
+
code: 'credential_missing',
|
|
59
|
+
message: 'the credential environment variable is unset or empty',
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const dir = await mkdtemp(join(tmpdir(), 'atc-askpass-'));
|
|
64
|
+
|
|
65
|
+
const helper = join(dir, 'askpass');
|
|
66
|
+
|
|
67
|
+
// A helper that cannot be written leaves no directory behind.
|
|
68
|
+
try {
|
|
69
|
+
await writeFile(helper, ASKPASS_SCRIPT, { mode: 0o700 });
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await rm(dir, { recursive: true, force: true });
|
|
72
|
+
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
env: { GIT_ASKPASS: helper, [ASKPASS_SECRET_VAR]: secret },
|
|
79
|
+
args: ['-c', 'credential.helper='],
|
|
80
|
+
[Symbol.asyncDispose]: async () => {
|
|
81
|
+
await rm(dir, { recursive: true, force: true });
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|