aicodeman 1.16.0 → 1.16.2
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/dist/ai-checker-base.d.ts +12 -0
- package/dist/ai-checker-base.d.ts.map +1 -1
- package/dist/ai-checker-base.js +4 -2
- package/dist/ai-checker-base.js.map +1 -1
- package/dist/git-clone.d.ts +220 -0
- package/dist/git-clone.d.ts.map +1 -0
- package/dist/git-clone.js +717 -0
- package/dist/git-clone.js.map +1 -0
- package/dist/hooks-config.d.ts +15 -0
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +67 -21
- package/dist/hooks-config.js.map +1 -1
- package/dist/intent-store.d.ts +73 -0
- package/dist/intent-store.d.ts.map +1 -0
- package/dist/intent-store.js +208 -0
- package/dist/intent-store.js.map +1 -0
- package/dist/readmymind-collectors.d.ts +42 -0
- package/dist/readmymind-collectors.d.ts.map +1 -0
- package/dist/readmymind-collectors.js +170 -0
- package/dist/readmymind-collectors.js.map +1 -0
- package/dist/readmymind-context.d.ts +106 -0
- package/dist/readmymind-context.d.ts.map +1 -0
- package/dist/readmymind-context.js +241 -0
- package/dist/readmymind-context.js.map +1 -0
- package/dist/readmymind-predictor.d.ts +60 -0
- package/dist/readmymind-predictor.d.ts.map +1 -0
- package/dist/readmymind-predictor.js +208 -0
- package/dist/readmymind-predictor.js.map +1 -0
- package/dist/session.d.ts +5 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +8 -2
- package/dist/session.js.map +1 -1
- package/dist/transcript-watcher.d.ts +7 -0
- package/dist/transcript-watcher.d.ts.map +1 -1
- package/dist/transcript-watcher.js +23 -0
- package/dist/transcript-watcher.js.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js +1 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/intent.d.ts +30 -0
- package/dist/types/intent.d.ts.map +1 -0
- package/dist/types/intent.js +9 -0
- package/dist/types/intent.js.map +1 -0
- package/dist/web/ports/config-port.d.ts +8 -0
- package/dist/web/ports/config-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/{app.c8e6e44e.js → app.4d006245.js} +1 -1
- package/dist/web/public/{app.c8e6e44e.js.br → app.4d006245.js.br} +0 -0
- package/dist/web/public/{app.c8e6e44e.js.gz → app.4d006245.js.gz} +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/constants.7d0f4047.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/home-sessions.js +335 -0
- package/dist/web/public/home-sessions.js.br +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/i18n.ef50083f.js +1 -0
- package/dist/web/public/i18n.ef50083f.js.br +0 -0
- package/dist/web/public/i18n.ef50083f.js.gz +0 -0
- package/dist/web/public/image-input.ee16ad88.js.gz +0 -0
- package/dist/web/public/index.html +101 -9
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.63794d0b.js.gz +0 -0
- package/dist/web/public/{keyboard-accessory.d6c3ad11.js → keyboard-accessory.1f35b482.js} +24 -5
- package/dist/web/public/keyboard-accessory.1f35b482.js.br +0 -0
- package/dist/web/public/keyboard-accessory.1f35b482.js.gz +0 -0
- package/dist/web/public/mobile-handlers.32fdd57f.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.5d8c896c.css +1 -0
- package/dist/web/public/mobile.5d8c896c.css.br +0 -0
- package/dist/web/public/mobile.5d8c896c.css.gz +0 -0
- package/dist/web/public/notification-manager.5d229063.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/panels-ui.3df9cd95.js.gz +0 -0
- package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
- package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js +135 -0
- package/dist/web/public/readmymind-ui.js.br +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
- package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
- package/dist/web/public/{session-ui.1356a4f4.js → session-ui.f4fed187.js} +2 -2
- package/dist/web/public/session-ui.f4fed187.js.br +0 -0
- package/dist/web/public/session-ui.f4fed187.js.gz +0 -0
- package/dist/web/public/settings-ui.1c15649c.js +67 -0
- package/dist/web/public/settings-ui.1c15649c.js.br +0 -0
- package/dist/web/public/settings-ui.1c15649c.js.gz +0 -0
- package/dist/web/public/styles.0024c22e.css +1 -0
- package/dist/web/public/styles.0024c22e.css.br +0 -0
- package/dist/web/public/styles.0024c22e.css.gz +0 -0
- package/dist/web/public/subagent-windows.4cc8b005.js.gz +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/{terminal-ui.430395be.js → terminal-ui.e7c5d233.js} +1 -1
- package/dist/web/public/terminal-ui.e7c5d233.js.br +0 -0
- package/dist/web/public/terminal-ui.e7c5d233.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/upload.html.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.6cec0fba.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.137ad9f0.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.e5461859.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/routes/case-routes.d.ts +3 -1
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +195 -4
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/readmymind-routes.d.ts +37 -0
- package/dist/web/routes/readmymind-routes.d.ts.map +1 -0
- package/dist/web/routes/readmymind-routes.js +127 -0
- package/dist/web/routes/readmymind-routes.js.map +1 -0
- package/dist/web/schemas.d.ts +38 -0
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +67 -1
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +14 -0
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +46 -3
- package/dist/web/server.js.map +1 -1
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +34 -0
- package/skills/codeman/reference/endpoints.md +4 -0
- package/dist/web/public/i18n.75cf29d4.js +0 -1
- package/dist/web/public/i18n.75cf29d4.js.br +0 -0
- package/dist/web/public/i18n.75cf29d4.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.d6c3ad11.js.br +0 -0
- package/dist/web/public/keyboard-accessory.d6c3ad11.js.gz +0 -0
- package/dist/web/public/mobile.8a2651ba.css +0 -1
- package/dist/web/public/mobile.8a2651ba.css.br +0 -0
- package/dist/web/public/mobile.8a2651ba.css.gz +0 -0
- package/dist/web/public/session-ui.1356a4f4.js.br +0 -0
- package/dist/web/public/session-ui.1356a4f4.js.gz +0 -0
- package/dist/web/public/settings-ui.20f9c723.js +0 -67
- package/dist/web/public/settings-ui.20f9c723.js.br +0 -0
- package/dist/web/public/settings-ui.20f9c723.js.gz +0 -0
- package/dist/web/public/styles.e33fc754.css +0 -1
- package/dist/web/public/styles.e33fc754.css.br +0 -0
- package/dist/web/public/styles.e33fc754.css.gz +0 -0
- package/dist/web/public/terminal-ui.430395be.js.br +0 -0
- package/dist/web/public/terminal-ui.430395be.js.gz +0 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Clone a Git repository into a case (issue #236).
|
|
3
|
+
*
|
|
4
|
+
* Split deliberately into a PURE half (URL parsing, argv/env construction,
|
|
5
|
+
* `ls-remote` output parsing, git-stderr classification) and a thin IO half
|
|
6
|
+
* (`probeGitRemote`, `cloneRepository`). The pure half is where every security
|
|
7
|
+
* decision lives, so it is unit-testable without spawning anything.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the URL is parsed rather than passed through
|
|
10
|
+
*
|
|
11
|
+
* `git clone` accepts far more than "a URL". Two families are dangerous:
|
|
12
|
+
*
|
|
13
|
+
* - **Transport helpers** — `ext::sh -c <cmd>` makes git execute an arbitrary
|
|
14
|
+
* command as the transport. `fd::`, and any other `<name>::<payload>` form,
|
|
15
|
+
* dispatch to a `git-remote-<name>` helper. A clone endpoint that forwards
|
|
16
|
+
* these is remote code execution, so `::` forms are rejected outright.
|
|
17
|
+
* - **Option-shaped operands** — a repository starting with `-` is read by git
|
|
18
|
+
* as a flag (`--upload-pack=...`). We reject leading `-` AND pass `--` before
|
|
19
|
+
* the operands, because either alone is one typo away from being a hole.
|
|
20
|
+
*
|
|
21
|
+
* Everything is spawned with an argv array and NEVER through a shell, so quoting
|
|
22
|
+
* is not part of the threat model here (unlike the ssh path in remote-hosts.ts,
|
|
23
|
+
* which genuinely does build a shell line and must `shellescape`).
|
|
24
|
+
*
|
|
25
|
+
* ## Credentials are deliberately absent
|
|
26
|
+
*
|
|
27
|
+
* Codeman collects no tokens, and a URL carrying `user:password@` is rejected —
|
|
28
|
+
* it would end up in error text, logs and (via the case name suggestion) the UI.
|
|
29
|
+
* `GIT_TERMINAL_PROMPT=0` plus the askpass/BatchMode env below guarantees a
|
|
30
|
+
* private repo fails FAST instead of hanging the open HTTP request on an
|
|
31
|
+
* invisible username prompt. If the host's own git config (a credential helper,
|
|
32
|
+
* an ssh agent, `insteadOf` rules) happens to authenticate, that is the user's
|
|
33
|
+
* existing setup working — Codeman neither supplies nor stores anything.
|
|
34
|
+
*
|
|
35
|
+
* ## Bounded by construction
|
|
36
|
+
*
|
|
37
|
+
* Every git spawn has a timeout, a hard kill escalation, captured-output caps,
|
|
38
|
+
* and shares a small global concurrency pool (same reasoning as
|
|
39
|
+
* `document-conversion-limiter.ts`: N simultaneous clones of large repos is a
|
|
40
|
+
* localhost resource-exhaustion vector). The pool's waiter queue is itself
|
|
41
|
+
* bounded (overflow answers BUSY immediately), and time spent queued counts
|
|
42
|
+
* against the operation's own deadline, so a caller's timeout bounds the whole
|
|
43
|
+
* call rather than starting when a slot happens to free up. Cloning is
|
|
44
|
+
* otherwise unbounded in disk and time, which is exactly why the caller must
|
|
45
|
+
* treat the timeout as normal.
|
|
46
|
+
*
|
|
47
|
+
* @module git-clone
|
|
48
|
+
*/
|
|
49
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
50
|
+
import { randomBytes } from 'node:crypto';
|
|
51
|
+
import { existsSync } from 'node:fs';
|
|
52
|
+
import { rename, rm } from 'node:fs/promises';
|
|
53
|
+
import { basename, dirname, join } from 'node:path';
|
|
54
|
+
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
|
|
55
|
+
// ─── Tunables ────────────────────────────────────────────────────────────────
|
|
56
|
+
/** Read a positive-integer env override, clamped into [min, max]. */
|
|
57
|
+
function envMs(name, fallback, min, max) {
|
|
58
|
+
const raw = Number(process.env[name]);
|
|
59
|
+
if (!Number.isFinite(raw) || raw <= 0)
|
|
60
|
+
return fallback;
|
|
61
|
+
return Math.min(max, Math.max(min, Math.floor(raw)));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Wall-clock budget for one `git clone`. Deliberately generous (a real repo over
|
|
65
|
+
* a slow link legitimately takes minutes) but always finite: the HTTP request is
|
|
66
|
+
* held open for the duration, so an unbounded clone would be an unbounded
|
|
67
|
+
* request. Override with CODEMAN_GIT_CLONE_TIMEOUT_MS.
|
|
68
|
+
*/
|
|
69
|
+
export const GIT_CLONE_TIMEOUT_MS = envMs('CODEMAN_GIT_CLONE_TIMEOUT_MS', 300_000, 10_000, 3_600_000);
|
|
70
|
+
/**
|
|
71
|
+
* Budget for the `ls-remote` preflight. Short on purpose — it exists to answer
|
|
72
|
+
* "can this be cloned without credentials?" while the user is still typing.
|
|
73
|
+
* Override with CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS.
|
|
74
|
+
*/
|
|
75
|
+
export const GIT_LS_REMOTE_TIMEOUT_MS = envMs('CODEMAN_GIT_LS_REMOTE_TIMEOUT_MS', 20_000, 2_000, 120_000);
|
|
76
|
+
/** Concurrent git network operations allowed process-wide. Override with CODEMAN_MAX_GIT_OPERATIONS. */
|
|
77
|
+
const MAX_CONCURRENT_GIT_OPERATIONS = (() => {
|
|
78
|
+
const raw = Number(process.env.CODEMAN_MAX_GIT_OPERATIONS);
|
|
79
|
+
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 2;
|
|
80
|
+
})();
|
|
81
|
+
/**
|
|
82
|
+
* Waiters allowed BEHIND the pool before new work is refused outright with
|
|
83
|
+
* BUSY. Without a bound, every queued request holds its HTTP connection (and
|
|
84
|
+
* its closure) open indefinitely, so a burst of clone requests becomes the
|
|
85
|
+
* memory/socket exhaustion the pool exists to prevent. Override with
|
|
86
|
+
* CODEMAN_MAX_GIT_QUEUE (0 disables queuing entirely).
|
|
87
|
+
*/
|
|
88
|
+
const MAX_QUEUED_GIT_OPERATIONS = (() => {
|
|
89
|
+
const raw = Number(process.env.CODEMAN_MAX_GIT_QUEUE);
|
|
90
|
+
return Number.isFinite(raw) && raw >= 0 ? Math.floor(raw) : 16;
|
|
91
|
+
})();
|
|
92
|
+
/** Longest accepted repository operand. Real URLs are far shorter; this bounds abuse. */
|
|
93
|
+
const MAX_REPOSITORY_LENGTH = 2048;
|
|
94
|
+
/** Longest accepted branch/tag. git's own limit is much higher; 200 covers every real ref. */
|
|
95
|
+
const MAX_REF_LENGTH = 200;
|
|
96
|
+
/** Captured stderr returned to the client, in bytes (the tail is the useful part). */
|
|
97
|
+
const MAX_STDERR_BYTES = 8_192;
|
|
98
|
+
/** Captured `ls-remote` stdout. A busy monorepo can list tens of thousands of refs. */
|
|
99
|
+
const MAX_LS_REMOTE_BYTES = 2_000_000;
|
|
100
|
+
/** Refs of each kind surfaced to the UI picker. */
|
|
101
|
+
const MAX_REFS_RETURNED = 500;
|
|
102
|
+
// ─── Pure: repository URL parsing ────────────────────────────────────────────
|
|
103
|
+
/** Hosts worth naming in the UI. Anything else shows its bare hostname. */
|
|
104
|
+
const PROVIDER_LABELS = {
|
|
105
|
+
'github.com': 'GitHub',
|
|
106
|
+
'www.github.com': 'GitHub',
|
|
107
|
+
'gist.github.com': 'GitHub Gist',
|
|
108
|
+
'gitlab.com': 'GitLab',
|
|
109
|
+
'bitbucket.org': 'Bitbucket',
|
|
110
|
+
'codeberg.org': 'Codeberg',
|
|
111
|
+
'git.sr.ht': 'SourceHut',
|
|
112
|
+
'dev.azure.com': 'Azure DevOps',
|
|
113
|
+
'ssh.dev.azure.com': 'Azure DevOps',
|
|
114
|
+
'huggingface.co': 'Hugging Face',
|
|
115
|
+
};
|
|
116
|
+
/** `scheme://` prefix. */
|
|
117
|
+
const SCHEME_RE = /^([a-zA-Z][a-zA-Z0-9+.-]*):\/\//;
|
|
118
|
+
/** `<helper>::<payload>` — git transport helper dispatch (includes `ext::`). */
|
|
119
|
+
const TRANSPORT_HELPER_RE = /^[a-zA-Z0-9][a-zA-Z0-9+.-]*::/;
|
|
120
|
+
/** scp-like `[user@]host:path`, the form GitHub prints as "SSH". */
|
|
121
|
+
const SCP_LIKE_RE = /^(?:([^@/\s]+)@)?([^:/\s]+):(?!\/)(.+)$/;
|
|
122
|
+
/** `C:\repos\x` / `C:/repos/x` — a Windows path, not an scp-like host. */
|
|
123
|
+
const WINDOWS_PATH_RE = /^[a-zA-Z]:[\\/]/;
|
|
124
|
+
/** Hostname or bracketed IPv6 literal, with an optional `:port`. */
|
|
125
|
+
const HOST_RE = /^(?:\[[0-9a-fA-F:.]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9\-.]*[a-zA-Z0-9])?)(?::\d{1,5})?$/;
|
|
126
|
+
/** Anything git would not accept quietly in a branch/tag name. */
|
|
127
|
+
const SAFE_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/\-+]*$/;
|
|
128
|
+
/**
|
|
129
|
+
* Turn a repository name into a Codeman case name.
|
|
130
|
+
*
|
|
131
|
+
* Case names are `[a-zA-Z0-9_-]+` everywhere else in the app (`SAFE_CASE_NAME`
|
|
132
|
+
* in case-routes.ts, `CreateCaseSchema`), so anything else collapses to `-`.
|
|
133
|
+
* Returns `''` when nothing usable survives, which the UI treats as "the user
|
|
134
|
+
* must type a name" rather than silently inventing one.
|
|
135
|
+
*/
|
|
136
|
+
export function suggestCaseNameFromRepo(repo) {
|
|
137
|
+
const cleaned = repo
|
|
138
|
+
.replace(/\.git$/i, '')
|
|
139
|
+
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
|
140
|
+
.replace(/-{2,}/g, '-')
|
|
141
|
+
.replace(/^[-_]+|[-_]+$/g, '')
|
|
142
|
+
.slice(0, 64)
|
|
143
|
+
.replace(/[-_]+$/g, '');
|
|
144
|
+
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : '';
|
|
145
|
+
}
|
|
146
|
+
function reject(code, message) {
|
|
147
|
+
return { cloneable: false, code, message };
|
|
148
|
+
}
|
|
149
|
+
/** Split `owner/sub/repo(.git)` into its owner prefix and repo name. */
|
|
150
|
+
function splitRepoPath(rawPath) {
|
|
151
|
+
const segments = rawPath.replace(/^\/+/, '').replace(/\/+$/, '').split('/').filter(Boolean);
|
|
152
|
+
const last = segments.pop() ?? '';
|
|
153
|
+
return { owner: segments.join('/'), repo: last.replace(/\.git$/i, '') };
|
|
154
|
+
}
|
|
155
|
+
function accept(parts) {
|
|
156
|
+
if (!parts.repo) {
|
|
157
|
+
return reject('NO_REPOSITORY_NAME', 'That URL has no repository name in it. Expected something like https://github.com/owner/repo.git');
|
|
158
|
+
}
|
|
159
|
+
return {
|
|
160
|
+
cloneable: true,
|
|
161
|
+
...parts,
|
|
162
|
+
provider: PROVIDER_LABELS[parts.host.toLowerCase()] || parts.host || 'local path',
|
|
163
|
+
suggestedName: suggestCaseNameFromRepo(parts.repo),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Decide whether `input` is something Codeman will hand to `git clone`, and pull
|
|
168
|
+
* the pieces the UI needs (provider, owner/repo, suggested case name) out of it.
|
|
169
|
+
*
|
|
170
|
+
* This is the security boundary for the clone endpoint. Read the module header
|
|
171
|
+
* before loosening any branch here — `ext::`-style transports and
|
|
172
|
+
* option-shaped operands are the two that turn a clone into arbitrary code
|
|
173
|
+
* execution.
|
|
174
|
+
*
|
|
175
|
+
* Accepting a URL says nothing about whether the remote EXISTS or is public;
|
|
176
|
+
* only `probeGitRemote` can answer that.
|
|
177
|
+
*/
|
|
178
|
+
export function parseGitRepositoryUrl(input) {
|
|
179
|
+
const raw = (input ?? '').trim();
|
|
180
|
+
if (!raw)
|
|
181
|
+
return reject('EMPTY', 'Enter a repository URL.');
|
|
182
|
+
if (raw.length > MAX_REPOSITORY_LENGTH) {
|
|
183
|
+
return reject('TOO_LONG', `Repository URL is too long (max ${MAX_REPOSITORY_LENGTH} characters).`);
|
|
184
|
+
}
|
|
185
|
+
// eslint-disable-next-line no-control-regex -- deliberate: reject C0/C1 and DEL.
|
|
186
|
+
if (/[\u0000-\u001f\u007f-\u009f]/.test(raw)) {
|
|
187
|
+
return reject('CONTROL_CHARS', 'Repository URL contains control characters.');
|
|
188
|
+
}
|
|
189
|
+
if (raw.startsWith('-')) {
|
|
190
|
+
// git would read this as a flag. `--` before the operands makes this
|
|
191
|
+
// defence redundant; both stay, because either one alone is fragile.
|
|
192
|
+
return reject('OPTION_LIKE', 'Repository URL may not start with "-".');
|
|
193
|
+
}
|
|
194
|
+
if (TRANSPORT_HELPER_RE.test(raw)) {
|
|
195
|
+
return reject('TRANSPORT_HELPER', 'Transport helpers such as "ext::" are refused: they let a URL run commands on this machine.');
|
|
196
|
+
}
|
|
197
|
+
const schemeMatch = SCHEME_RE.exec(raw);
|
|
198
|
+
if (schemeMatch) {
|
|
199
|
+
const scheme = schemeMatch[1].toLowerCase();
|
|
200
|
+
if (scheme === 'file')
|
|
201
|
+
return parseLocalSource(raw.slice('file://'.length), raw);
|
|
202
|
+
if (scheme !== 'https' && scheme !== 'http' && scheme !== 'ssh' && scheme !== 'git') {
|
|
203
|
+
return reject('UNSUPPORTED_TRANSPORT', `Unsupported transport "${scheme}://". Use https://, ssh://, git:// or an SSH address like git@host:owner/repo.git`);
|
|
204
|
+
}
|
|
205
|
+
let url;
|
|
206
|
+
try {
|
|
207
|
+
url = new URL(raw);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return reject('BAD_SYNTAX', 'That does not look like a valid URL.');
|
|
211
|
+
}
|
|
212
|
+
if (url.password) {
|
|
213
|
+
return reject('CREDENTIALS_IN_URL', 'Remove the password from the URL. Codeman never accepts or stores Git credentials.');
|
|
214
|
+
}
|
|
215
|
+
const host = url.host;
|
|
216
|
+
if (!host || !HOST_RE.test(host))
|
|
217
|
+
return reject('BAD_SYNTAX', 'That URL has no usable hostname.');
|
|
218
|
+
// `new URL` tolerates malformed percent-escapes ("%zz" passes through), but
|
|
219
|
+
// decodeURIComponent throws on them: uncaught, that URIError was a 500 for
|
|
220
|
+
// what is simply a malformed URL.
|
|
221
|
+
let pathname;
|
|
222
|
+
try {
|
|
223
|
+
pathname = decodeURIComponent(url.pathname);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return reject('BAD_SYNTAX', 'That URL contains an invalid percent-escape.');
|
|
227
|
+
}
|
|
228
|
+
const { owner, repo } = splitRepoPath(pathname);
|
|
229
|
+
const warnings = [];
|
|
230
|
+
if (scheme === 'http')
|
|
231
|
+
warnings.push('Plain http:// is unencrypted. Prefer https:// when the host offers it.');
|
|
232
|
+
if (scheme === 'git')
|
|
233
|
+
warnings.push('git:// is unauthenticated and unencrypted. Prefer https:// when possible.');
|
|
234
|
+
if (scheme === 'ssh')
|
|
235
|
+
warnings.push(sshWarning(host));
|
|
236
|
+
if (url.username && scheme !== 'ssh') {
|
|
237
|
+
warnings.push('The username in the URL is passed to git as-is; Codeman supplies no password for it.');
|
|
238
|
+
}
|
|
239
|
+
return accept({
|
|
240
|
+
repository: raw,
|
|
241
|
+
transport: scheme,
|
|
242
|
+
host,
|
|
243
|
+
owner,
|
|
244
|
+
repo,
|
|
245
|
+
warnings,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
if (raw.startsWith('/'))
|
|
249
|
+
return parseLocalSource(raw, raw);
|
|
250
|
+
if (WINDOWS_PATH_RE.test(raw))
|
|
251
|
+
return parseLocalSource(raw, raw);
|
|
252
|
+
if (raw.startsWith('~') || raw.startsWith('./') || raw.startsWith('../')) {
|
|
253
|
+
return reject('BAD_SYNTAX', 'Use an absolute path for a local repository (no "~" or relative paths), or a full URL.');
|
|
254
|
+
}
|
|
255
|
+
const scp = SCP_LIKE_RE.exec(raw);
|
|
256
|
+
if (scp) {
|
|
257
|
+
const host = scp[2];
|
|
258
|
+
if (!HOST_RE.test(host))
|
|
259
|
+
return reject('BAD_SYNTAX', 'That does not look like a valid SSH address.');
|
|
260
|
+
if (scp[1]?.includes(':')) {
|
|
261
|
+
return reject('CREDENTIALS_IN_URL', 'Remove the password from the address. Codeman never accepts or stores Git credentials.');
|
|
262
|
+
}
|
|
263
|
+
const { owner, repo } = splitRepoPath(scp[3]);
|
|
264
|
+
return accept({
|
|
265
|
+
repository: raw,
|
|
266
|
+
transport: 'ssh',
|
|
267
|
+
host,
|
|
268
|
+
owner,
|
|
269
|
+
repo,
|
|
270
|
+
warnings: [sshWarning(host)],
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
return reject('BAD_SYNTAX', 'Enter a full repository URL, e.g. https://github.com/owner/repo.git or git@github.com:owner/repo.git');
|
|
274
|
+
}
|
|
275
|
+
function sshWarning(host) {
|
|
276
|
+
return `SSH clones use this machine's existing ssh keys and known_hosts for ${host}. Codeman adds no credentials, so an unconfigured key fails immediately instead of prompting.`;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* A local source (`file://…` or an absolute path). Kept because cloning a repo
|
|
280
|
+
* that already exists on this machine is genuinely useful and involves no
|
|
281
|
+
* network at all. Existence is NOT checked here (this half stays free of IO):
|
|
282
|
+
* git reports a missing path perfectly well, and the preflight surfaces it.
|
|
283
|
+
*
|
|
284
|
+
* The route gates local sources to admins in multi-user mode: a per-user case
|
|
285
|
+
* space is a read boundary, and a local clone would read straight through it
|
|
286
|
+
* (the same reason `/api/cases/link` is admin-only there).
|
|
287
|
+
*/
|
|
288
|
+
function parseLocalSource(path, original) {
|
|
289
|
+
const cleaned = path.replace(/\/+$/, '');
|
|
290
|
+
if (!cleaned || (!cleaned.startsWith('/') && !WINDOWS_PATH_RE.test(cleaned))) {
|
|
291
|
+
return reject('BAD_SYNTAX', 'Local repository paths must be absolute.');
|
|
292
|
+
}
|
|
293
|
+
const { owner, repo } = splitRepoPath(cleaned);
|
|
294
|
+
return accept({
|
|
295
|
+
repository: original,
|
|
296
|
+
transport: 'local',
|
|
297
|
+
host: '',
|
|
298
|
+
owner: owner ? `/${owner}` : '',
|
|
299
|
+
repo,
|
|
300
|
+
warnings: ['Local clone: git copies from this machine, no network involved.'],
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** Is `ref` safe to pass as `--branch <ref>`? Rejects flags, spaces and `..`. */
|
|
304
|
+
export function isSafeGitRef(ref) {
|
|
305
|
+
if (!ref || ref.length > MAX_REF_LENGTH)
|
|
306
|
+
return false;
|
|
307
|
+
if (ref.includes('..') || ref.includes('@{') || ref.endsWith('.lock') || ref.endsWith('/'))
|
|
308
|
+
return false;
|
|
309
|
+
return SAFE_REF_RE.test(ref);
|
|
310
|
+
}
|
|
311
|
+
// ─── Pure: argv + env ────────────────────────────────────────────────────────
|
|
312
|
+
/**
|
|
313
|
+
* argv for the clone. `--` separates flags from operands so neither the
|
|
314
|
+
* repository nor the destination can ever be read as an option.
|
|
315
|
+
*/
|
|
316
|
+
export function buildCloneArgs(opts) {
|
|
317
|
+
const args = ['clone'];
|
|
318
|
+
// `--single-branch` is what makes "just this tag/branch" cheap on a big repo.
|
|
319
|
+
if (opts.ref)
|
|
320
|
+
args.push('--single-branch', '--branch', opts.ref);
|
|
321
|
+
if (opts.shallow)
|
|
322
|
+
args.push('--depth', '1');
|
|
323
|
+
args.push('--', opts.repository, opts.destination);
|
|
324
|
+
return args;
|
|
325
|
+
}
|
|
326
|
+
/** argv for the preflight. `--symref` is what reveals the remote's default branch. */
|
|
327
|
+
export function buildLsRemoteArgs(repository) {
|
|
328
|
+
return ['ls-remote', '--symref', '--', repository];
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Environment that makes git fail instead of blocking on a prompt.
|
|
332
|
+
*
|
|
333
|
+
* Every entry closes one way an interactive git can hang a request that has no
|
|
334
|
+
* terminal attached: the built-in prompt, a GUI/askpass helper, an ssh
|
|
335
|
+
* host-key or passphrase prompt, and Git Credential Manager. `HOME` and `PATH`
|
|
336
|
+
* are inherited on purpose — a user whose own ssh agent or credential helper
|
|
337
|
+
* already works should keep working.
|
|
338
|
+
*/
|
|
339
|
+
export function gitNonInteractiveEnv(base = process.env) {
|
|
340
|
+
return {
|
|
341
|
+
...base,
|
|
342
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
343
|
+
GIT_ASKPASS: '',
|
|
344
|
+
SSH_ASKPASS: '',
|
|
345
|
+
SSH_ASKPASS_REQUIRE: 'never',
|
|
346
|
+
DISPLAY: '',
|
|
347
|
+
GCM_INTERACTIVE: 'never',
|
|
348
|
+
GIT_SSH_COMMAND: base.GIT_SSH_COMMAND || 'ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new -oConnectTimeout=10',
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
// ─── Pure: output handling ───────────────────────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* Make git's stderr safe to show in the browser: strip ANSI/control bytes,
|
|
354
|
+
* redact any `scheme://user:secret@host` that a credential helper echoed back,
|
|
355
|
+
* and keep only the tail (the last lines are the ones that say why it failed).
|
|
356
|
+
*/
|
|
357
|
+
export function sanitizeGitOutput(text, maxBytes = MAX_STDERR_BYTES) {
|
|
358
|
+
const redacted = text
|
|
359
|
+
.replace(/([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^/@\s]*:[^/@\s]*@/g, '$1***:***@')
|
|
360
|
+
// eslint-disable-next-line no-control-regex -- deliberate: strip C0/C1 and DEL.
|
|
361
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, '')
|
|
362
|
+
.trim();
|
|
363
|
+
return redacted.length > maxBytes ? `…${redacted.slice(-maxBytes)}` : redacted;
|
|
364
|
+
}
|
|
365
|
+
/** Parse `git ls-remote --symref` output into a default branch plus ref lists. */
|
|
366
|
+
export function parseLsRemoteOutput(stdout) {
|
|
367
|
+
let defaultBranch;
|
|
368
|
+
const branches = [];
|
|
369
|
+
const tags = [];
|
|
370
|
+
let truncated = false;
|
|
371
|
+
for (const line of stdout.split('\n')) {
|
|
372
|
+
const trimmed = line.trim();
|
|
373
|
+
if (!trimmed)
|
|
374
|
+
continue;
|
|
375
|
+
const symref = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(trimmed);
|
|
376
|
+
if (symref) {
|
|
377
|
+
defaultBranch = symref[1];
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const ref = /^[0-9a-f]{40,64}\s+(\S+)$/.exec(trimmed);
|
|
381
|
+
if (!ref)
|
|
382
|
+
continue;
|
|
383
|
+
const name = ref[1];
|
|
384
|
+
// Peeled tags (`refs/tags/v1^{}`) duplicate their tag; drop them.
|
|
385
|
+
if (name.endsWith('^{}'))
|
|
386
|
+
continue;
|
|
387
|
+
if (name.startsWith('refs/heads/')) {
|
|
388
|
+
if (branches.length < MAX_REFS_RETURNED)
|
|
389
|
+
branches.push(name.slice('refs/heads/'.length));
|
|
390
|
+
else
|
|
391
|
+
truncated = true;
|
|
392
|
+
}
|
|
393
|
+
else if (name.startsWith('refs/tags/')) {
|
|
394
|
+
if (tags.length < MAX_REFS_RETURNED)
|
|
395
|
+
tags.push(name.slice('refs/tags/'.length));
|
|
396
|
+
else
|
|
397
|
+
truncated = true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return { defaultBranch, branches, tags, truncated };
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Turn a git failure into something actionable.
|
|
404
|
+
*
|
|
405
|
+
* The AUTH_REQUIRED wording matters: GitHub answers "Repository not found" for a
|
|
406
|
+
* private repo AND for a typo when unauthenticated, so a bare "not found" would
|
|
407
|
+
* send people hunting for a spelling mistake that isn't there.
|
|
408
|
+
*/
|
|
409
|
+
export function classifyGitFailure(stderr, timedOut, spawnError) {
|
|
410
|
+
const clean = sanitizeGitOutput(stderr);
|
|
411
|
+
const lower = `${clean}\n${spawnError ?? ''}`.toLowerCase();
|
|
412
|
+
if (spawnError && /enoent/i.test(spawnError)) {
|
|
413
|
+
return {
|
|
414
|
+
code: 'GIT_MISSING',
|
|
415
|
+
message: 'git is not installed on this machine (or not on the server\u2019s PATH).',
|
|
416
|
+
stderr: clean,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
if (spawnError && spawnError.startsWith('EBUSY')) {
|
|
420
|
+
return {
|
|
421
|
+
code: 'BUSY',
|
|
422
|
+
message: 'Too many git operations are already running on this server. Try again in a moment.',
|
|
423
|
+
stderr: clean,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
if (timedOut) {
|
|
427
|
+
return {
|
|
428
|
+
code: 'TIMEOUT',
|
|
429
|
+
message: 'Git timed out. Large repositories may need the shallow option, or a longer CODEMAN_GIT_CLONE_TIMEOUT_MS.',
|
|
430
|
+
stderr: clean,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
if (/could not read username|authentication failed|terminal prompts disabled|permission denied \(publickey\)|invalid username or password|access denied/.test(lower)) {
|
|
434
|
+
return {
|
|
435
|
+
code: 'AUTH_REQUIRED',
|
|
436
|
+
message: 'That repository needs authentication. Codeman clones without credentials, so private repositories have to be cloned outside Codeman and added with Link Existing.',
|
|
437
|
+
stderr: clean,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (/remote branch .* not found|could not find remote branch|pathspec .* did not match/.test(lower)) {
|
|
441
|
+
return { code: 'REF_NOT_FOUND', message: 'That branch or tag does not exist on the remote.', stderr: clean };
|
|
442
|
+
}
|
|
443
|
+
if (/repository not found|not found|does not exist|does not appear to be a git repository|no such file or directory/.test(lower)) {
|
|
444
|
+
return {
|
|
445
|
+
code: 'NOT_FOUND',
|
|
446
|
+
message: 'Repository not found. Check the URL, since hosts also answer "not found" for private repositories when no credentials are supplied.',
|
|
447
|
+
stderr: clean,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
if (/could not resolve host|connection refused|connection timed out|network is unreachable|ssl|tls/.test(lower)) {
|
|
451
|
+
return { code: 'HOST_UNREACHABLE', message: 'Could not reach that host from this machine.', stderr: clean };
|
|
452
|
+
}
|
|
453
|
+
if (/already exists and is not an empty directory|destination path .* already exists/.test(lower)) {
|
|
454
|
+
return { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: clean };
|
|
455
|
+
}
|
|
456
|
+
return { code: 'FAILED', message: clean ? `git failed: ${firstLine(clean)}` : 'git failed.', stderr: clean };
|
|
457
|
+
}
|
|
458
|
+
function firstLine(text) {
|
|
459
|
+
const line = text.split('\n').find((l) => l.trim().length > 0) ?? '';
|
|
460
|
+
return line.length > 300 ? `${line.slice(0, 300)}…` : line;
|
|
461
|
+
}
|
|
462
|
+
// ─── IO: bounded git spawns ──────────────────────────────────────────────────
|
|
463
|
+
let activeGitOperations = 0;
|
|
464
|
+
const gitWaiters = [];
|
|
465
|
+
/** Test/diagnostic hook: git operations currently holding a slot. */
|
|
466
|
+
export function getActiveGitOperationCount() {
|
|
467
|
+
return activeGitOperations;
|
|
468
|
+
}
|
|
469
|
+
/** Test/diagnostic hook: git operations currently queued behind the pool. */
|
|
470
|
+
export function getQueuedGitOperationCount() {
|
|
471
|
+
return gitWaiters.length;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Acquire a pool slot, waiting at most `maxWaitMs` in a BOUNDED queue.
|
|
475
|
+
*
|
|
476
|
+
* Both failure modes resolve (never reject): a full queue answers immediately,
|
|
477
|
+
* and a queue wait that exhausts the caller's deadline removes itself before
|
|
478
|
+
* resolving, so an abandoned waiter can never be granted a slot later and leak
|
|
479
|
+
* it.
|
|
480
|
+
*/
|
|
481
|
+
function acquireGitSlot(maxWaitMs) {
|
|
482
|
+
if (activeGitOperations < MAX_CONCURRENT_GIT_OPERATIONS) {
|
|
483
|
+
activeGitOperations++;
|
|
484
|
+
return Promise.resolve('acquired');
|
|
485
|
+
}
|
|
486
|
+
if (gitWaiters.length >= MAX_QUEUED_GIT_OPERATIONS)
|
|
487
|
+
return Promise.resolve('queue-full');
|
|
488
|
+
return new Promise((resolve) => {
|
|
489
|
+
const waiter = {
|
|
490
|
+
grant: () => {
|
|
491
|
+
clearTimeout(timer);
|
|
492
|
+
resolve('acquired');
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
const timer = setTimeout(() => {
|
|
496
|
+
const idx = gitWaiters.indexOf(waiter);
|
|
497
|
+
if (idx !== -1)
|
|
498
|
+
gitWaiters.splice(idx, 1);
|
|
499
|
+
resolve('timed-out');
|
|
500
|
+
}, maxWaitMs);
|
|
501
|
+
gitWaiters.push(waiter);
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
function releaseGitSlot() {
|
|
505
|
+
const next = gitWaiters.shift();
|
|
506
|
+
// Hand the slot straight over so the active count can never exceed the cap.
|
|
507
|
+
if (next)
|
|
508
|
+
next.grant();
|
|
509
|
+
else
|
|
510
|
+
activeGitOperations--;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Run git with a hard wall-clock bound and capped output capture.
|
|
514
|
+
*
|
|
515
|
+
* SIGTERM then SIGKILL, because `git clone` fans out into `git-remote-https` /
|
|
516
|
+
* `git index-pack` children: a single polite signal to the parent can leave the
|
|
517
|
+
* fetch running. `detached: true` puts the whole tree in its own process group
|
|
518
|
+
* so the escalation kills the children too, which is also why the negative-pid
|
|
519
|
+
* signal is used rather than `child.kill()`.
|
|
520
|
+
*/
|
|
521
|
+
async function runGit(args, timeoutMs, maxStdoutBytes) {
|
|
522
|
+
// The queue wait spends the SAME deadline as the operation: `timeoutMs` is a
|
|
523
|
+
// promise about the whole call, not about git's runtime after some unbounded
|
|
524
|
+
// wait. A full queue is refused outright rather than queued.
|
|
525
|
+
const queuedAt = Date.now();
|
|
526
|
+
const slot = await acquireGitSlot(timeoutMs);
|
|
527
|
+
if (slot === 'queue-full') {
|
|
528
|
+
return { stdout: '', stderr: '', code: null, timedOut: false, spawnError: 'EBUSY: git operation queue is full' };
|
|
529
|
+
}
|
|
530
|
+
if (slot === 'timed-out') {
|
|
531
|
+
return { stdout: '', stderr: '', code: null, timedOut: true };
|
|
532
|
+
}
|
|
533
|
+
const remainingMs = Math.max(1, timeoutMs - (Date.now() - queuedAt));
|
|
534
|
+
try {
|
|
535
|
+
return await new Promise((resolve) => {
|
|
536
|
+
let child;
|
|
537
|
+
try {
|
|
538
|
+
child = spawn('git', args, {
|
|
539
|
+
env: gitNonInteractiveEnv(),
|
|
540
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
541
|
+
detached: true,
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
catch (err) {
|
|
545
|
+
resolve({ stdout: '', stderr: '', code: null, timedOut: false, spawnError: String(err) });
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
let stdout = '';
|
|
549
|
+
let stderr = '';
|
|
550
|
+
let stdoutBytes = 0;
|
|
551
|
+
let timedOut = false;
|
|
552
|
+
let settled = false;
|
|
553
|
+
let killTimer;
|
|
554
|
+
const killTree = (signal) => {
|
|
555
|
+
try {
|
|
556
|
+
if (child.pid)
|
|
557
|
+
process.kill(-child.pid, signal);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
try {
|
|
561
|
+
child.kill(signal);
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
/* already gone */
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
const timer = setTimeout(() => {
|
|
569
|
+
timedOut = true;
|
|
570
|
+
killTree('SIGTERM');
|
|
571
|
+
killTimer = setTimeout(() => killTree('SIGKILL'), 3_000);
|
|
572
|
+
}, remainingMs);
|
|
573
|
+
child.stdout?.on('data', (chunk) => {
|
|
574
|
+
stdoutBytes += chunk.length;
|
|
575
|
+
if (stdoutBytes <= maxStdoutBytes)
|
|
576
|
+
stdout += chunk.toString('utf-8');
|
|
577
|
+
});
|
|
578
|
+
child.stderr?.on('data', (chunk) => {
|
|
579
|
+
stderr += chunk.toString('utf-8');
|
|
580
|
+
// Keep a bounded tail rather than the whole (potentially huge) stream.
|
|
581
|
+
if (stderr.length > MAX_STDERR_BYTES * 2)
|
|
582
|
+
stderr = stderr.slice(-MAX_STDERR_BYTES);
|
|
583
|
+
});
|
|
584
|
+
const finish = (result) => {
|
|
585
|
+
if (settled)
|
|
586
|
+
return;
|
|
587
|
+
settled = true;
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
if (killTimer)
|
|
590
|
+
clearTimeout(killTimer);
|
|
591
|
+
resolve(result);
|
|
592
|
+
};
|
|
593
|
+
child.on('error', (err) => finish({ stdout, stderr, code: null, timedOut, spawnError: String(err) }));
|
|
594
|
+
child.on('close', (code) => finish({ stdout, stderr, code, timedOut }));
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
finally {
|
|
598
|
+
releaseGitSlot();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
/** Is a usable `git` on this machine? Memoized: the answer cannot change without a restart. */
|
|
602
|
+
let gitAvailable = null;
|
|
603
|
+
export function isGitAvailable() {
|
|
604
|
+
if (gitAvailable !== null)
|
|
605
|
+
return gitAvailable;
|
|
606
|
+
try {
|
|
607
|
+
execFileSync('git', ['--version'], {
|
|
608
|
+
encoding: 'utf-8',
|
|
609
|
+
timeout: EXEC_TIMEOUT_MS,
|
|
610
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
611
|
+
});
|
|
612
|
+
gitAvailable = true;
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
gitAvailable = false;
|
|
616
|
+
}
|
|
617
|
+
return gitAvailable;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Ask the remote what it has, without cloning: reachability, whether it can be
|
|
621
|
+
* read anonymously, its default branch, and its branch/tag lists (which the UI
|
|
622
|
+
* turns into a ref picker instead of a free-text field).
|
|
623
|
+
*
|
|
624
|
+
* Never throws — an unreachable remote is a normal answer here, not an error.
|
|
625
|
+
*/
|
|
626
|
+
export async function probeGitRemote(repository, timeoutMs = GIT_LS_REMOTE_TIMEOUT_MS) {
|
|
627
|
+
if (!isGitAvailable()) {
|
|
628
|
+
return {
|
|
629
|
+
reachable: false,
|
|
630
|
+
branches: [],
|
|
631
|
+
tags: [],
|
|
632
|
+
failure: classifyGitFailure('', false, 'ENOENT: git not found'),
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
const run = await runGit(buildLsRemoteArgs(repository), timeoutMs, MAX_LS_REMOTE_BYTES);
|
|
636
|
+
if (run.code !== 0 || run.spawnError) {
|
|
637
|
+
return {
|
|
638
|
+
reachable: false,
|
|
639
|
+
branches: [],
|
|
640
|
+
tags: [],
|
|
641
|
+
failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError),
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
const parsed = parseLsRemoteOutput(run.stdout);
|
|
645
|
+
return {
|
|
646
|
+
reachable: true,
|
|
647
|
+
...(parsed.defaultBranch ? { defaultBranch: parsed.defaultBranch } : {}),
|
|
648
|
+
branches: parsed.branches,
|
|
649
|
+
tags: parsed.tags,
|
|
650
|
+
...(parsed.truncated ? { truncated: true } : {}),
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Clone `repository` into `destination`.
|
|
655
|
+
*
|
|
656
|
+
* git clones into an ATTEMPT-OWNED temp sibling (`.<name>.cloning-<random>`,
|
|
657
|
+
* dot-prefixed so an orphan from a crash never shows up as a case), which is
|
|
658
|
+
* atomically renamed into place on success. Two concurrent requests for the
|
|
659
|
+
* same destination used to both pass the existence check, and the loser's
|
|
660
|
+
* failure cleanup then deleted the WINNER's freshly cloned tree; now each
|
|
661
|
+
* attempt only ever creates and removes its own directory, the rename decides
|
|
662
|
+
* the winner, and the loser reports DESTINATION_EXISTS. The upfront existence
|
|
663
|
+
* check stays as the fast path for the common non-racing case.
|
|
664
|
+
*
|
|
665
|
+
* Never throws; every outcome is a `CloneResult`.
|
|
666
|
+
*/
|
|
667
|
+
export async function cloneRepository(opts) {
|
|
668
|
+
if (!isGitAvailable()) {
|
|
669
|
+
return { ok: false, failure: classifyGitFailure('', false, 'ENOENT: git not found') };
|
|
670
|
+
}
|
|
671
|
+
if (opts.ref && !isSafeGitRef(opts.ref)) {
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
failure: { code: 'REF_NOT_FOUND', message: 'Invalid branch or tag name.', stderr: '' },
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
if (existsSync(opts.destination)) {
|
|
678
|
+
return {
|
|
679
|
+
ok: false,
|
|
680
|
+
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
// Sibling of the destination (same filesystem), so the rename is atomic.
|
|
684
|
+
const attemptDir = join(dirname(opts.destination), `.${basename(opts.destination)}.cloning-${randomBytes(6).toString('hex')}`);
|
|
685
|
+
const run = await runGit(buildCloneArgs({ ...opts, destination: attemptDir }), opts.timeoutMs ?? GIT_CLONE_TIMEOUT_MS, MAX_STDERR_BYTES);
|
|
686
|
+
if (run.code === 0 && !run.spawnError) {
|
|
687
|
+
try {
|
|
688
|
+
await rename(attemptDir, opts.destination);
|
|
689
|
+
return { ok: true, stderr: sanitizeGitOutput(run.stderr) };
|
|
690
|
+
}
|
|
691
|
+
catch (err) {
|
|
692
|
+
// Renaming a directory onto an existing non-empty one fails: someone
|
|
693
|
+
// else won the race. Clean up OUR tree only; theirs is never touched.
|
|
694
|
+
await rm(attemptDir, { recursive: true, force: true }).catch(() => { });
|
|
695
|
+
const code = err.code;
|
|
696
|
+
if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'ENOTDIR' || code === 'EPERM') {
|
|
697
|
+
return {
|
|
698
|
+
ok: false,
|
|
699
|
+
failure: { code: 'DESTINATION_EXISTS', message: 'The destination directory already exists.', stderr: '' },
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
failure: {
|
|
705
|
+
code: 'FAILED',
|
|
706
|
+
message: `Could not move the finished clone into place: ${String(err)}`,
|
|
707
|
+
stderr: '',
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
// Remove ONLY this attempt's temp directory (git may have written a partial
|
|
713
|
+
// tree, or nothing at all). The destination is never deleted on failure.
|
|
714
|
+
await rm(attemptDir, { recursive: true, force: true }).catch(() => { });
|
|
715
|
+
return { ok: false, failure: classifyGitFailure(run.stderr, run.timedOut, run.spawnError) };
|
|
716
|
+
}
|
|
717
|
+
//# sourceMappingURL=git-clone.js.map
|