runwork 0.10.1 → 0.10.3
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/commands/__tests__/clone-args.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-args.test.js +44 -0
- package/dist/commands/clone.d.ts +14 -0
- package/dist/commands/clone.js +20 -2
- package/dist/commands/dev.d.ts +3 -0
- package/dist/commands/dev.js +621 -8
- package/dist/commands/info.d.ts +31 -0
- package/dist/commands/info.js +37 -0
- package/dist/dev/__tests__/attach.test.d.ts +1 -0
- package/dist/dev/__tests__/attach.test.js +296 -0
- package/dist/dev/__tests__/detach.test.d.ts +1 -0
- package/dist/dev/__tests__/detach.test.js +328 -0
- package/dist/dev/__tests__/preview-url-poller.test.d.ts +1 -0
- package/dist/dev/__tests__/preview-url-poller.test.js +149 -0
- package/dist/dev/__tests__/session.test.d.ts +1 -0
- package/dist/dev/__tests__/session.test.js +347 -0
- package/dist/dev/__tests__/stop.test.d.ts +1 -0
- package/dist/dev/__tests__/stop.test.js +172 -0
- package/dist/dev/attach.d.ts +120 -0
- package/dist/dev/attach.js +269 -0
- package/dist/dev/detach.d.ts +164 -0
- package/dist/dev/detach.js +247 -0
- package/dist/dev/preview-url-poller.d.ts +35 -0
- package/dist/dev/preview-url-poller.js +50 -0
- package/dist/dev/session.d.ts +158 -0
- package/dist/dev/session.js +252 -0
- package/dist/dev/stop.d.ts +52 -0
- package/dist/dev/stop.js +101 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credential-helper-e2e.test.d.ts +21 -0
- package/dist/git/__tests__/credential-helper-e2e.test.js +195 -0
- package/dist/git/__tests__/credentials.test.js +33 -20
- package/dist/git/__tests__/preflight-resolution.test.d.ts +1 -0
- package/dist/git/__tests__/preflight-resolution.test.js +366 -0
- package/dist/git/credentials.d.ts +17 -0
- package/dist/git/credentials.js +22 -1
- package/dist/git/preflight.d.ts +34 -5
- package/dist/git/preflight.js +237 -11
- package/dist/health/__tests__/checks.test.js +134 -0
- package/dist/health/checks.d.ts +13 -0
- package/dist/health/checks.js +130 -14
- package/dist/health/runner.js +5 -1
- package/dist/ui/__tests__/keyboard.test.js +4 -0
- package/dist/ui/keyboard.d.ts +1 -1
- package/dist/ui/keyboard.js +4 -0
- package/package.json +1 -1
package/dist/git/credentials.js
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
2
|
import { getCredentials } from '../auth/store.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build the value we hand to `git config credential.<origin>.helper`.
|
|
5
|
+
*
|
|
6
|
+
* Git invokes credential helpers via its bash shell (Git Bash on Windows,
|
|
7
|
+
* /bin/sh elsewhere). A leading `!` tells git to treat the value as a raw
|
|
8
|
+
* shell command. We embed the absolute path of the *currently running*
|
|
9
|
+
* runwork binary so the helper invocation never depends on PATH at the
|
|
10
|
+
* moment git happens to call it -- which used to be a real failure mode
|
|
11
|
+
* on Windows when runwork.exe sits in `~/.runwork/bin` and that directory
|
|
12
|
+
* isn't picked up by git's bash subshell.
|
|
13
|
+
*
|
|
14
|
+
* The path is wrapped in quotes (handles spaces in install paths) and on
|
|
15
|
+
* Windows we normalise backslashes to forward slashes -- bash on Windows
|
|
16
|
+
* accepts both, but forward slashes avoid escaping issues in the .gitconfig
|
|
17
|
+
* file itself.
|
|
18
|
+
*/
|
|
19
|
+
export function buildHelperValue(execPath) {
|
|
20
|
+
const normalised = execPath.replace(/\\/g, '/');
|
|
21
|
+
return `!"${normalised}" git-credential-helper`;
|
|
22
|
+
}
|
|
3
23
|
/**
|
|
4
24
|
* Configure git to use the runwork credential helper for our remote.
|
|
5
25
|
* Called by `runwork login` and `runwork init`.
|
|
6
26
|
*/
|
|
7
27
|
export async function configureGitCredentials(remoteUrl) {
|
|
8
28
|
const origin = new URL(remoteUrl).origin;
|
|
29
|
+
const helperValue = buildHelperValue(process.execPath);
|
|
9
30
|
try {
|
|
10
31
|
execFileSync('git', [
|
|
11
32
|
'config', '--global',
|
|
12
33
|
`credential.${origin}.helper`,
|
|
13
|
-
|
|
34
|
+
helperValue,
|
|
14
35
|
], { stdio: 'pipe' });
|
|
15
36
|
}
|
|
16
37
|
catch (err) {
|
package/dist/git/preflight.d.ts
CHANGED
|
@@ -1,25 +1,54 @@
|
|
|
1
|
+
export type GitSource = 'PATH' | 'where' | 'registry' | 'canonical';
|
|
1
2
|
export interface GitProbe {
|
|
2
3
|
installed: boolean;
|
|
3
4
|
/** Trimmed `git --version` output when detected; undefined otherwise. */
|
|
4
5
|
version?: string;
|
|
6
|
+
/** Resolved binary path; 'git' when bare PATH lookup worked, absolute when discovered via fallback. */
|
|
7
|
+
path?: string;
|
|
8
|
+
/** How we resolved the binary -- useful for `runwork doctor --verbose` diagnostics. */
|
|
9
|
+
source?: GitSource;
|
|
5
10
|
/** Underlying error for diagnostics (most often ENOENT on a missing git binary). */
|
|
6
11
|
error?: NodeJS.ErrnoException;
|
|
7
12
|
}
|
|
8
13
|
/**
|
|
9
|
-
* Probe whether `git` is callable from this process.
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
14
|
+
* Probe whether `git` is callable from this process.
|
|
15
|
+
*
|
|
16
|
+
* Strategy:
|
|
17
|
+
* 1. Try a bare `git --version` -- the common path on macOS, Linux, and
|
|
18
|
+
* most Windows installs.
|
|
19
|
+
* 2. On Windows, fall back to `where.exe git` (uses the same PATH +
|
|
20
|
+
* PATHEXT rules cmd.exe applies, which are more permissive than
|
|
21
|
+
* Bun's spawn lookup in standalone-compiled binaries).
|
|
22
|
+
* 3. On Windows, finally probe canonical install paths under
|
|
23
|
+
* %ProgramFiles%, %ProgramFiles(x86)%, %LOCALAPPDATA%\Programs.
|
|
24
|
+
*
|
|
25
|
+
* When step 2 or 3 succeeds, we prepend the resolved directory to
|
|
26
|
+
* process.env.PATH so the ~50 other call sites that do
|
|
27
|
+
* `execFileSync('git', ...)` automatically benefit, without rewriting
|
|
28
|
+
* each one. Result is cached for the lifetime of the process.
|
|
13
29
|
*/
|
|
14
30
|
export declare function probeGit(): GitProbe;
|
|
31
|
+
/**
|
|
32
|
+
* Reset the cached probe result. Exposed for tests and for `runwork doctor`
|
|
33
|
+
* when it wants to re-check after a guided install.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resetGitProbeCache(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Extract a short numeric version (e.g. "2.43.0") from a `git --version`
|
|
38
|
+
* output line. Returns null if no number could be parsed.
|
|
39
|
+
*/
|
|
40
|
+
export declare function parseGitVersion(versionLine: string | undefined): string | null;
|
|
15
41
|
/**
|
|
16
42
|
* Build a beginner-friendly message explaining how to recover from a missing
|
|
17
43
|
* git binary. Includes a Windows-specific hint because the most common
|
|
18
44
|
* scenario there is "winget install Git.Git just succeeded but this shell's
|
|
19
45
|
* PATH was cached at launch" -- restarting the shell fixes it without a
|
|
20
46
|
* second install attempt.
|
|
47
|
+
*
|
|
48
|
+
* If a probe error is supplied, the underlying message is appended so users
|
|
49
|
+
* can see *why* detection failed (ENOENT vs EACCES vs something else).
|
|
21
50
|
*/
|
|
22
|
-
export declare function buildMissingGitMessage(commandName: string): string;
|
|
51
|
+
export declare function buildMissingGitMessage(commandName: string, probe?: GitProbe): string;
|
|
23
52
|
/**
|
|
24
53
|
* Convenience wrapper for command entry points: probe git, and if it's
|
|
25
54
|
* missing, print the beginner-friendly message and exit with code 1.
|
package/dist/git/preflight.js
CHANGED
|
@@ -1,18 +1,235 @@
|
|
|
1
1
|
import { execFileSync } from 'child_process';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { win32 as winPath } from 'path';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
const VERSION_REGEX = /(\d+\.\d+(?:\.\d+)?)/;
|
|
6
|
+
function tryRun(bin) {
|
|
7
|
+
try {
|
|
8
|
+
const out = execFileSync(bin, ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
9
|
+
return { ok: true, version: out.toString('utf-8').trim() };
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
return { ok: false, error: err };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function whereGit() {
|
|
16
|
+
if (process.platform !== 'win32')
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
// No `encoding: 'utf-8'` on purpose: keep the return as a Buffer to
|
|
20
|
+
// match tryRun()'s shape. We toString() ourselves so test mocks can
|
|
21
|
+
// return Buffers uniformly.
|
|
22
|
+
const buf = execFileSync('where.exe', ['git'], {
|
|
23
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
24
|
+
windowsHide: true,
|
|
25
|
+
});
|
|
26
|
+
const out = buf.toString('utf-8');
|
|
27
|
+
// `where.exe git` may print multiple matches (cmd shim, exe shim).
|
|
28
|
+
// Prefer git.exe -- the bare shim is a .cmd that's harder to invoke
|
|
29
|
+
// reliably from spawn() and that mismatch is exactly what we're trying
|
|
30
|
+
// to work around.
|
|
31
|
+
const lines = out.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
32
|
+
const exe = lines.find(line => /\.exe$/i.test(line));
|
|
33
|
+
if (exe && existsSync(exe))
|
|
34
|
+
return exe;
|
|
35
|
+
const fallback = lines.find(line => existsSync(line));
|
|
36
|
+
return fallback ?? null;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
2
42
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
43
|
+
* Query Git for Windows' registry keys for an InstallPath.
|
|
44
|
+
*
|
|
45
|
+
* The official Git for Windows installer always writes
|
|
46
|
+
* `HKLM\SOFTWARE\GitForWindows\InstallPath` (system installs) or
|
|
47
|
+
* `HKCU\Software\GitForWindows\InstallPath` (per-user installs). When that
|
|
48
|
+
* value points at e.g. `C:\Program Files\Git`, the binary lives at
|
|
49
|
+
* `<value>\cmd\git.exe`. Portable / non-installer Gits (MinGit, Scoop,
|
|
50
|
+
* GitHub Desktop's bundled git) won't appear here -- they're covered by
|
|
51
|
+
* canonicalGitCandidates().
|
|
52
|
+
*/
|
|
53
|
+
function registryGit() {
|
|
54
|
+
if (process.platform !== 'win32')
|
|
55
|
+
return null;
|
|
56
|
+
const queries = [
|
|
57
|
+
['HKLM\\SOFTWARE\\GitForWindows', '/v', 'InstallPath'],
|
|
58
|
+
['HKCU\\Software\\GitForWindows', '/v', 'InstallPath'],
|
|
59
|
+
];
|
|
60
|
+
for (const args of queries) {
|
|
61
|
+
try {
|
|
62
|
+
const buf = execFileSync('reg.exe', ['query', ...args], {
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
windowsHide: true,
|
|
65
|
+
});
|
|
66
|
+
const out = buf.toString('utf-8');
|
|
67
|
+
// Output shape:
|
|
68
|
+
// HKEY_LOCAL_MACHINE\SOFTWARE\GitForWindows
|
|
69
|
+
// InstallPath REG_SZ C:\Program Files\Git
|
|
70
|
+
const match = out.match(/InstallPath\s+REG_SZ\s+(.+)/i);
|
|
71
|
+
if (!match)
|
|
72
|
+
continue;
|
|
73
|
+
const installRoot = match[1].trim();
|
|
74
|
+
const gitExe = winPath.join(installRoot, 'cmd', 'git.exe');
|
|
75
|
+
if (existsSync(gitExe))
|
|
76
|
+
return gitExe;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// reg.exe missing or key not present -- next query.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
function canonicalGitCandidates() {
|
|
85
|
+
if (process.platform !== 'win32')
|
|
86
|
+
return [];
|
|
87
|
+
const paths = [];
|
|
88
|
+
const push = (...parts) => {
|
|
89
|
+
if (parts.every((part) => typeof part === 'string' && part.length > 0)) {
|
|
90
|
+
// Use winPath.join (not the bare `join` from 'path') because the path
|
|
91
|
+
// module's separator is locked at module-load time. On macOS test
|
|
92
|
+
// hosts running these tests with process.platform spoofed to 'win32',
|
|
93
|
+
// bare join would mix '\' and '/' separators and miss real Windows
|
|
94
|
+
// file system paths.
|
|
95
|
+
paths.push(winPath.join(...parts));
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const home = homedir();
|
|
99
|
+
// Standard installer locations. `Git\cmd\git.exe` is the public entry
|
|
100
|
+
// point (a small shim that sets up PATH internally then runs the real
|
|
101
|
+
// git from `Git\mingw64\bin`). Bun's spawn copes with .exe just fine,
|
|
102
|
+
// so the shim works as long as we call it by absolute path.
|
|
103
|
+
push(process.env.ProgramFiles, 'Git', 'cmd', 'git.exe');
|
|
104
|
+
push(process.env['ProgramFiles(x86)'], 'Git', 'cmd', 'git.exe');
|
|
105
|
+
push(process.env.LOCALAPPDATA, 'Programs', 'Git', 'cmd', 'git.exe');
|
|
106
|
+
// Portable / per-user installs that don't register in HKLM:
|
|
107
|
+
// - ~/.runwork/tools/mingit, ~/.runwork/tools/git -- our own namespace.
|
|
108
|
+
// Other tools (e.g. Codex CLI) have been observed dropping a portable
|
|
109
|
+
// MinGit there; if anything lives in our directory we should respect
|
|
110
|
+
// it, even when we didn't put it there ourselves.
|
|
111
|
+
// - Scoop -- ~/scoop/apps/git/current/cmd/git.exe.
|
|
112
|
+
// - GitHub Desktop's bundled git lives under a versioned path that's
|
|
113
|
+
// awkward to enumerate without a glob; skipped intentionally.
|
|
114
|
+
if (home) {
|
|
115
|
+
push(home, '.runwork', 'tools', 'mingit', 'cmd', 'git.exe');
|
|
116
|
+
push(home, '.runwork', 'tools', 'git', 'cmd', 'git.exe');
|
|
117
|
+
push(home, '.runwork', 'tools', 'PortableGit', 'cmd', 'git.exe');
|
|
118
|
+
push(home, 'scoop', 'apps', 'git', 'current', 'cmd', 'git.exe');
|
|
119
|
+
}
|
|
120
|
+
// Hard-coded defaults in case the env vars are missing (e.g. bizarre
|
|
121
|
+
// sandbox where %ProgramFiles% isn't propagated).
|
|
122
|
+
paths.push('C:\\Program Files\\Git\\cmd\\git.exe');
|
|
123
|
+
paths.push('C:\\Program Files (x86)\\Git\\cmd\\git.exe');
|
|
124
|
+
// De-dup while preserving order.
|
|
125
|
+
return [...new Set(paths)];
|
|
126
|
+
}
|
|
127
|
+
function prependToPath(dir) {
|
|
128
|
+
// Hard-coded ';' instead of `path.delimiter`: this helper is only called
|
|
129
|
+
// from the Windows-only fallback branch of probeGit(), but the path
|
|
130
|
+
// module's `delimiter` is determined by the *real* host OS at module
|
|
131
|
+
// load time -- so unit tests that flip `process.platform` to 'win32' on
|
|
132
|
+
// a macOS host would otherwise see ':' and fail to detect a duplicate
|
|
133
|
+
// entry. PATH on Windows always uses ';'.
|
|
134
|
+
const PATH_DELIM = ';';
|
|
135
|
+
const current = process.env.PATH ?? '';
|
|
136
|
+
const parts = current.split(PATH_DELIM);
|
|
137
|
+
if (parts.includes(dir))
|
|
138
|
+
return;
|
|
139
|
+
process.env.PATH = `${dir}${PATH_DELIM}${current}`;
|
|
140
|
+
// Windows historically reads `Path` (any case). Node normalizes via
|
|
141
|
+
// process.env, but the Bun-on-Windows path lookup we're working around
|
|
142
|
+
// here has surprised us before, so make the casing redundant.
|
|
143
|
+
if (process.env.Path !== undefined) {
|
|
144
|
+
process.env.Path = process.env.PATH;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
let cachedProbe;
|
|
148
|
+
/**
|
|
149
|
+
* Probe whether `git` is callable from this process.
|
|
150
|
+
*
|
|
151
|
+
* Strategy:
|
|
152
|
+
* 1. Try a bare `git --version` -- the common path on macOS, Linux, and
|
|
153
|
+
* most Windows installs.
|
|
154
|
+
* 2. On Windows, fall back to `where.exe git` (uses the same PATH +
|
|
155
|
+
* PATHEXT rules cmd.exe applies, which are more permissive than
|
|
156
|
+
* Bun's spawn lookup in standalone-compiled binaries).
|
|
157
|
+
* 3. On Windows, finally probe canonical install paths under
|
|
158
|
+
* %ProgramFiles%, %ProgramFiles(x86)%, %LOCALAPPDATA%\Programs.
|
|
159
|
+
*
|
|
160
|
+
* When step 2 or 3 succeeds, we prepend the resolved directory to
|
|
161
|
+
* process.env.PATH so the ~50 other call sites that do
|
|
162
|
+
* `execFileSync('git', ...)` automatically benefit, without rewriting
|
|
163
|
+
* each one. Result is cached for the lifetime of the process.
|
|
7
164
|
*/
|
|
8
165
|
export function probeGit() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
166
|
+
if (cachedProbe)
|
|
167
|
+
return cachedProbe;
|
|
168
|
+
// 1. Bare lookup.
|
|
169
|
+
const bare = tryRun('git');
|
|
170
|
+
if (bare.ok) {
|
|
171
|
+
cachedProbe = { installed: true, version: bare.version, path: 'git', source: 'PATH' };
|
|
172
|
+
return cachedProbe;
|
|
12
173
|
}
|
|
13
|
-
|
|
14
|
-
|
|
174
|
+
if (process.platform === 'win32') {
|
|
175
|
+
// 2. where.exe -- uses the same PATH+PATHEXT rules cmd.exe applies.
|
|
176
|
+
const whereResult = whereGit();
|
|
177
|
+
if (whereResult) {
|
|
178
|
+
const verify = tryRun(whereResult);
|
|
179
|
+
if (verify.ok) {
|
|
180
|
+
prependToPath(winPath.dirname(whereResult));
|
|
181
|
+
cachedProbe = { installed: true, version: verify.version, path: whereResult, source: 'where' };
|
|
182
|
+
return cachedProbe;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// 3. Registry -- the official Git for Windows installer always writes
|
|
186
|
+
// HKLM\SOFTWARE\GitForWindows\InstallPath (or the HKCU equivalent
|
|
187
|
+
// for per-user installs), so this catches "git is installed but
|
|
188
|
+
// something has stripped both PATH and any chance of where.exe
|
|
189
|
+
// finding it" cases (e.g. sandboxed agent shells).
|
|
190
|
+
const registryResult = registryGit();
|
|
191
|
+
if (registryResult) {
|
|
192
|
+
const verify = tryRun(registryResult);
|
|
193
|
+
if (verify.ok) {
|
|
194
|
+
prependToPath(winPath.dirname(registryResult));
|
|
195
|
+
cachedProbe = { installed: true, version: verify.version, path: registryResult, source: 'registry' };
|
|
196
|
+
return cachedProbe;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// 4. Canonical paths -- standard installer dirs plus our own
|
|
200
|
+
// ~/.runwork/tools/{mingit,git,PortableGit} namespace and Scoop's
|
|
201
|
+
// install location, for portable / non-installer Gits the registry
|
|
202
|
+
// won't know about.
|
|
203
|
+
for (const candidate of canonicalGitCandidates()) {
|
|
204
|
+
if (!existsSync(candidate))
|
|
205
|
+
continue;
|
|
206
|
+
const verify = tryRun(candidate);
|
|
207
|
+
if (verify.ok) {
|
|
208
|
+
prependToPath(winPath.dirname(candidate));
|
|
209
|
+
cachedProbe = { installed: true, version: verify.version, path: candidate, source: 'canonical' };
|
|
210
|
+
return cachedProbe;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
15
213
|
}
|
|
214
|
+
cachedProbe = { installed: false, error: bare.error };
|
|
215
|
+
return cachedProbe;
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Reset the cached probe result. Exposed for tests and for `runwork doctor`
|
|
219
|
+
* when it wants to re-check after a guided install.
|
|
220
|
+
*/
|
|
221
|
+
export function resetGitProbeCache() {
|
|
222
|
+
cachedProbe = undefined;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Extract a short numeric version (e.g. "2.43.0") from a `git --version`
|
|
226
|
+
* output line. Returns null if no number could be parsed.
|
|
227
|
+
*/
|
|
228
|
+
export function parseGitVersion(versionLine) {
|
|
229
|
+
if (!versionLine)
|
|
230
|
+
return null;
|
|
231
|
+
const match = versionLine.match(VERSION_REGEX);
|
|
232
|
+
return match ? match[1] : null;
|
|
16
233
|
}
|
|
17
234
|
/**
|
|
18
235
|
* Build a beginner-friendly message explaining how to recover from a missing
|
|
@@ -20,8 +237,11 @@ export function probeGit() {
|
|
|
20
237
|
* scenario there is "winget install Git.Git just succeeded but this shell's
|
|
21
238
|
* PATH was cached at launch" -- restarting the shell fixes it without a
|
|
22
239
|
* second install attempt.
|
|
240
|
+
*
|
|
241
|
+
* If a probe error is supplied, the underlying message is appended so users
|
|
242
|
+
* can see *why* detection failed (ENOENT vs EACCES vs something else).
|
|
23
243
|
*/
|
|
24
|
-
export function buildMissingGitMessage(commandName) {
|
|
244
|
+
export function buildMissingGitMessage(commandName, probe) {
|
|
25
245
|
const lines = [
|
|
26
246
|
`Git is required to run \`runwork ${commandName}\`, but it was not found on PATH.`,
|
|
27
247
|
'',
|
|
@@ -34,6 +254,12 @@ export function buildMissingGitMessage(commandName) {
|
|
|
34
254
|
lines.push('', 'If you just installed Git, this shell\'s PATH was cached when it opened', 'and does not yet include Git. Close this PowerShell / Command Prompt', 'window, open a fresh one, and re-run the command.');
|
|
35
255
|
}
|
|
36
256
|
lines.push('', 'Verify the install with: git --version');
|
|
257
|
+
if (probe?.error?.message) {
|
|
258
|
+
// Surface the underlying error for diagnostics so users (and we, in bug
|
|
259
|
+
// reports) can see whether this was ENOENT, EACCES, a sandbox PATH
|
|
260
|
+
// strip, etc. -- not just "not found".
|
|
261
|
+
lines.push('', `Underlying error: ${probe.error.message}`);
|
|
262
|
+
}
|
|
37
263
|
return lines.join('\n');
|
|
38
264
|
}
|
|
39
265
|
/**
|
|
@@ -45,6 +271,6 @@ export function requireGit(commandName) {
|
|
|
45
271
|
const probe = probeGit();
|
|
46
272
|
if (probe.installed)
|
|
47
273
|
return;
|
|
48
|
-
console.error(buildMissingGitMessage(commandName));
|
|
274
|
+
console.error(buildMissingGitMessage(commandName, probe));
|
|
49
275
|
process.exit(1);
|
|
50
276
|
}
|
|
@@ -9,6 +9,7 @@ vi.mock('child_process', async (importOriginal) => {
|
|
|
9
9
|
});
|
|
10
10
|
import { existsSync } from 'fs';
|
|
11
11
|
import { execFileSync } from 'child_process';
|
|
12
|
+
import { resetGitProbeCache } from '../../git/preflight.js';
|
|
12
13
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
13
14
|
const mockedExecFileSync = vi.mocked(execFileSync);
|
|
14
15
|
beforeEach(() => {
|
|
@@ -17,6 +18,10 @@ beforeEach(() => {
|
|
|
17
18
|
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
18
19
|
vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
19
20
|
mockedExistsSync.mockReturnValue(true);
|
|
21
|
+
// checkSystemDeps now goes through probeGit() which caches the resolved
|
|
22
|
+
// git binary for the lifetime of the process. Reset between tests so each
|
|
23
|
+
// mock setup actually drives a fresh probe.
|
|
24
|
+
resetGitProbeCache();
|
|
20
25
|
});
|
|
21
26
|
afterEach(() => {
|
|
22
27
|
vi.restoreAllMocks();
|
|
@@ -64,6 +69,135 @@ describe('checkSystemDeps', () => {
|
|
|
64
69
|
expect(result.fix).toBeDefined();
|
|
65
70
|
});
|
|
66
71
|
});
|
|
72
|
+
// ── 4a. Git credential helper ────────────────────────────────────────
|
|
73
|
+
describe('checkGitCredentialHelper', () => {
|
|
74
|
+
function gitConfigOutput(line) {
|
|
75
|
+
return Buffer.from(`${line}\n`);
|
|
76
|
+
}
|
|
77
|
+
function gitConfigEmpty() {
|
|
78
|
+
// git config --get-regexp returns 1 with no output when nothing matches
|
|
79
|
+
const err = new Error('git config exited with code 1');
|
|
80
|
+
err.status = 1;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
it('skips when not logged in', async () => {
|
|
84
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
85
|
+
const ctx = makeCtx({ credentials: null });
|
|
86
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
87
|
+
expect(result.status).toBe('skip');
|
|
88
|
+
expect(result.message).toContain('not logged in');
|
|
89
|
+
});
|
|
90
|
+
it('skips when git is not installed', async () => {
|
|
91
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
92
|
+
// First call (probeGit -> git --version) throws ENOENT-shaped error.
|
|
93
|
+
mockedExecFileSync.mockImplementation((file) => {
|
|
94
|
+
if (file === 'git') {
|
|
95
|
+
const err = new Error('spawn git ENOENT');
|
|
96
|
+
err.code = 'ENOENT';
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
throw new Error('unreachable');
|
|
100
|
+
});
|
|
101
|
+
const ctx = makeCtx();
|
|
102
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
103
|
+
expect(result.status).toBe('skip');
|
|
104
|
+
expect(result.message).toContain('git not installed');
|
|
105
|
+
});
|
|
106
|
+
it('passes when helper is registered for the credentials origin', async () => {
|
|
107
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
108
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
109
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
110
|
+
return Buffer.from('git version 2.43.0');
|
|
111
|
+
if (file === 'git' && args?.includes('--get-regexp')) {
|
|
112
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !runwork git-credential-helper');
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
115
|
+
}));
|
|
116
|
+
const ctx = makeCtx();
|
|
117
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
118
|
+
expect(result.status).toBe('pass');
|
|
119
|
+
expect(result.message).toContain('https://runwork.ai');
|
|
120
|
+
});
|
|
121
|
+
it('fails when no entry exists in git config', async () => {
|
|
122
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
123
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
124
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
125
|
+
return Buffer.from('git version 2.43.0');
|
|
126
|
+
if (file === 'git' && args?.includes('--get-regexp'))
|
|
127
|
+
gitConfigEmpty();
|
|
128
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
129
|
+
}));
|
|
130
|
+
const ctx = makeCtx();
|
|
131
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
132
|
+
expect(result.status).toBe('fail');
|
|
133
|
+
expect(result.fix).toBe('runwork login');
|
|
134
|
+
});
|
|
135
|
+
it('fails when entries exist but none match the credentials origin', async () => {
|
|
136
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
137
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
138
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
139
|
+
return Buffer.from('git version 2.43.0');
|
|
140
|
+
if (file === 'git' && args?.includes('--get-regexp')) {
|
|
141
|
+
// Stale entry from a previous staging URL.
|
|
142
|
+
return gitConfigOutput('credential.https://runwork-staging.example.com.helper !runwork git-credential-helper');
|
|
143
|
+
}
|
|
144
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
145
|
+
}));
|
|
146
|
+
const ctx = makeCtx({
|
|
147
|
+
credentials: { apiKey: 'k', email: 'u@e.com', baseUrl: 'https://runwork.ai' },
|
|
148
|
+
});
|
|
149
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
150
|
+
expect(result.status).toBe('fail');
|
|
151
|
+
expect(result.message).toContain('1 other runwork helper');
|
|
152
|
+
});
|
|
153
|
+
it('fails when the registered absolute path no longer exists', async () => {
|
|
154
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
155
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
156
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
157
|
+
return Buffer.from('git version 2.43.0');
|
|
158
|
+
if (file === 'git' && args?.includes('--get-regexp')) {
|
|
159
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !"/old/path/runwork" git-credential-helper');
|
|
160
|
+
}
|
|
161
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
162
|
+
}));
|
|
163
|
+
mockedExistsSync.mockImplementation((p) => p !== '/old/path/runwork');
|
|
164
|
+
const ctx = makeCtx();
|
|
165
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
166
|
+
expect(result.status).toBe('fail');
|
|
167
|
+
expect(result.message).toContain('binary not found');
|
|
168
|
+
expect(result.fix).toContain('runwork login');
|
|
169
|
+
});
|
|
170
|
+
it('passes when the registered absolute path still exists', async () => {
|
|
171
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
172
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
173
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
174
|
+
return Buffer.from('git version 2.43.0');
|
|
175
|
+
if (file === 'git' && args?.includes('--get-regexp')) {
|
|
176
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !"/usr/local/bin/runwork" git-credential-helper');
|
|
177
|
+
}
|
|
178
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
179
|
+
}));
|
|
180
|
+
mockedExistsSync.mockReturnValue(true);
|
|
181
|
+
const ctx = makeCtx();
|
|
182
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
183
|
+
expect(result.status).toBe('pass');
|
|
184
|
+
});
|
|
185
|
+
it('passes a Windows-style absolute path that exists', async () => {
|
|
186
|
+
const { checkGitCredentialHelper } = await import('../checks.js');
|
|
187
|
+
mockedExecFileSync.mockImplementation(((file, args) => {
|
|
188
|
+
if (file === 'git' && args?.[0] === '--version')
|
|
189
|
+
return Buffer.from('git version 2.43.0');
|
|
190
|
+
if (file === 'git' && args?.includes('--get-regexp')) {
|
|
191
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !"C:/Users/test/.runwork/bin/runwork.exe" git-credential-helper');
|
|
192
|
+
}
|
|
193
|
+
throw new Error(`unexpected call: ${file} ${args?.join(' ')}`);
|
|
194
|
+
}));
|
|
195
|
+
mockedExistsSync.mockReturnValue(true);
|
|
196
|
+
const ctx = makeCtx();
|
|
197
|
+
const result = await checkGitCredentialHelper(ctx);
|
|
198
|
+
expect(result.status).toBe('pass');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
67
201
|
// ── 3 & 4. Auth + Network ───────────────────────────────────────────
|
|
68
202
|
describe('checkAuthAndNetwork', () => {
|
|
69
203
|
it('fails auth when no credentials', async () => {
|
package/dist/health/checks.d.ts
CHANGED
|
@@ -16,6 +16,19 @@ export declare function checkAuthAndNetwork(ctx: DoctorContext): Promise<{
|
|
|
16
16
|
auth: CheckResult;
|
|
17
17
|
network: CheckResult;
|
|
18
18
|
}>;
|
|
19
|
+
/**
|
|
20
|
+
* Verify that git's credential helper for the runwork origin is registered
|
|
21
|
+
* and that the binary it points at exists on disk. Catches the very common
|
|
22
|
+
* "logged in with an older CLI before configureGitCredentials shipped"
|
|
23
|
+
* state, where the user has credentials but git falls back to the system
|
|
24
|
+
* credential manager (Git Credential Manager popup on Windows, etc.) when
|
|
25
|
+
* trying to clone or push -- producing confusing UX with no obvious fix.
|
|
26
|
+
*
|
|
27
|
+
* The check is scoped to the *user's* logged-in baseUrl when available, so
|
|
28
|
+
* it doesn't false-fail on staging deployments that point at a different
|
|
29
|
+
* origin.
|
|
30
|
+
*/
|
|
31
|
+
export declare function checkGitCredentialHelper(ctx: DoctorContext): Promise<CheckResult>;
|
|
19
32
|
export declare function checkProjectConfig(ctx: DoctorContext): Promise<CheckResult>;
|
|
20
33
|
export declare function checkAppExists(ctx: DoctorContext): Promise<CheckResult>;
|
|
21
34
|
export declare function checkGitRemote(ctx: DoctorContext): Promise<CheckResult>;
|