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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end auth test.
|
|
3
|
+
*
|
|
4
|
+
* Spins up a tiny HTTP server that mimics the runwork.ai git endpoint's
|
|
5
|
+
* auth contract (HTTP Basic, any username, password = API key) and runs a
|
|
6
|
+
* real `git ls-remote` against it through a credential-helper script. This
|
|
7
|
+
* verifies the entire chain we depend on:
|
|
8
|
+
*
|
|
9
|
+
* git -> credential.<origin>.helper -> our helper script -> our helper's
|
|
10
|
+
* protocol output -> git constructs Basic auth -> server accepts it.
|
|
11
|
+
*
|
|
12
|
+
* Catches the class of bugs we've burned hours on (Pass 1's PATH issues,
|
|
13
|
+
* Pass 2's "logged in but no helper registered") *before* a user hits them.
|
|
14
|
+
*
|
|
15
|
+
* Note: this test exercises the contract our credential helper depends on,
|
|
16
|
+
* not the helper function itself -- credentials.test.ts already covers
|
|
17
|
+
* `handleGitCredentialRequest()` in isolation. Together they prove that
|
|
18
|
+
* (a) the helper produces the right protocol output and (b) git+server
|
|
19
|
+
* accept that output as Basic auth.
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
22
|
+
import { execFile } from 'child_process';
|
|
23
|
+
import { promisify } from 'util';
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync, chmodSync, existsSync } from 'fs';
|
|
25
|
+
import { join } from 'path';
|
|
26
|
+
import { tmpdir } from 'os';
|
|
27
|
+
import { createServer } from 'http';
|
|
28
|
+
import { probeGit } from '../preflight.js';
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
const EXPECTED_API_KEY = 'test-api-key-abcd1234';
|
|
31
|
+
/**
|
|
32
|
+
* Minimal HTTP server that:
|
|
33
|
+
* - Returns 401 + WWW-Authenticate when no Basic auth is supplied
|
|
34
|
+
* - Returns 401 when password (the API key) is wrong; username is ignored
|
|
35
|
+
* - Returns 200 + a valid empty-refs pkt-line response when auth is correct
|
|
36
|
+
*
|
|
37
|
+
* The pkt-line response is the smallest valid output for `info/refs?
|
|
38
|
+
* service=git-upload-pack` -- the service announcement plus a flush packet.
|
|
39
|
+
* That's enough for `git ls-remote` to terminate successfully.
|
|
40
|
+
*/
|
|
41
|
+
function createBasicAuthGitServer(attempts) {
|
|
42
|
+
return new Promise((resolve) => {
|
|
43
|
+
const server = createServer((req, res) => {
|
|
44
|
+
const authHeader = req.headers['authorization'];
|
|
45
|
+
if (!authHeader || !authHeader.startsWith('Basic ')) {
|
|
46
|
+
attempts.push({ hasAuthHeader: false });
|
|
47
|
+
res.writeHead(401, {
|
|
48
|
+
'WWW-Authenticate': 'Basic realm="Test"',
|
|
49
|
+
'Content-Type': 'text/plain',
|
|
50
|
+
});
|
|
51
|
+
res.end('Authentication required');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
|
|
55
|
+
const colonIdx = decoded.indexOf(':');
|
|
56
|
+
const username = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
|
|
57
|
+
const password = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : '';
|
|
58
|
+
attempts.push({ hasAuthHeader: true, username, password });
|
|
59
|
+
if (password !== EXPECTED_API_KEY) {
|
|
60
|
+
res.writeHead(401, {
|
|
61
|
+
'WWW-Authenticate': 'Basic realm="Test"',
|
|
62
|
+
'Content-Type': 'text/plain',
|
|
63
|
+
});
|
|
64
|
+
res.end('Invalid API key');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
// Minimal valid info/refs response for git-upload-pack with no refs.
|
|
68
|
+
// Shape:
|
|
69
|
+
// 001e# service=git-upload-pack\n
|
|
70
|
+
// 0000
|
|
71
|
+
// 0000 (no refs)
|
|
72
|
+
const service = '# service=git-upload-pack\n';
|
|
73
|
+
const pktServiceLength = (service.length + 4).toString(16).padStart(4, '0');
|
|
74
|
+
const flush = '0000';
|
|
75
|
+
const body = `${pktServiceLength}${service}${flush}${flush}`;
|
|
76
|
+
res.writeHead(200, {
|
|
77
|
+
'Content-Type': 'application/x-git-upload-pack-advertisement',
|
|
78
|
+
'Cache-Control': 'no-cache',
|
|
79
|
+
});
|
|
80
|
+
res.end(body);
|
|
81
|
+
});
|
|
82
|
+
server.listen(0, '127.0.0.1', () => resolve(server));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Write a credential helper script into `dir` and return its path. The
|
|
87
|
+
* script ignores stdin (test fixtures don't need to honour git's
|
|
88
|
+
* action/host inputs) and prints the test's API key. Bash on macOS/Linux
|
|
89
|
+
* and Git Bash on Windows both run the resulting `!"<path>"` invocation
|
|
90
|
+
* the same way.
|
|
91
|
+
*/
|
|
92
|
+
function writeHelperScript(dir, apiKey) {
|
|
93
|
+
const scriptPath = join(dir, 'fake-helper.sh');
|
|
94
|
+
const body = [
|
|
95
|
+
'#!/usr/bin/env bash',
|
|
96
|
+
'# Test fixture: emits the git credential helper "get" protocol output',
|
|
97
|
+
'# with a hardcoded API key. The "get" arg is the only one git invokes',
|
|
98
|
+
'# during a fetch; "store"/"erase" are no-ops.',
|
|
99
|
+
'if [ "$1" != "get" ]; then exit 0; fi',
|
|
100
|
+
'# Drain stdin so git\'s pipe doesn\'t stall.',
|
|
101
|
+
'cat > /dev/null',
|
|
102
|
+
'echo "username=runwork"',
|
|
103
|
+
`echo "password=${apiKey}"`,
|
|
104
|
+
'',
|
|
105
|
+
].join('\n');
|
|
106
|
+
writeFileSync(scriptPath, body, { mode: 0o755 });
|
|
107
|
+
// chmod is no-op on Windows but won't error.
|
|
108
|
+
chmodSync(scriptPath, 0o755);
|
|
109
|
+
return scriptPath;
|
|
110
|
+
}
|
|
111
|
+
describe('git credential helper e2e (Basic auth contract)', () => {
|
|
112
|
+
const gitProbe = probeGit();
|
|
113
|
+
const skipReason = !gitProbe.installed ? 'git not installed' : null;
|
|
114
|
+
let tmpDir;
|
|
115
|
+
let homeDir;
|
|
116
|
+
let xdgConfigHome;
|
|
117
|
+
let scriptPath;
|
|
118
|
+
let server;
|
|
119
|
+
let serverUrl;
|
|
120
|
+
let attempts = [];
|
|
121
|
+
beforeAll(async () => {
|
|
122
|
+
if (skipReason)
|
|
123
|
+
return;
|
|
124
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'runwork-helper-e2e-'));
|
|
125
|
+
homeDir = join(tmpDir, 'home');
|
|
126
|
+
xdgConfigHome = join(homeDir, '.config');
|
|
127
|
+
// git -c gc.auto=0 needs ~/.gitconfig to exist when GIT_CONFIG_GLOBAL is
|
|
128
|
+
// pointed at a path that doesn't yet exist; pre-create the dir and
|
|
129
|
+
// empty file.
|
|
130
|
+
const gitConfigGlobal = join(homeDir, '.gitconfig');
|
|
131
|
+
require('fs').mkdirSync(homeDir, { recursive: true });
|
|
132
|
+
writeFileSync(gitConfigGlobal, '');
|
|
133
|
+
scriptPath = writeHelperScript(tmpDir, EXPECTED_API_KEY);
|
|
134
|
+
server = await createBasicAuthGitServer(attempts);
|
|
135
|
+
const addr = server.address();
|
|
136
|
+
serverUrl = `http://127.0.0.1:${addr.port}`;
|
|
137
|
+
// Register the credential helper globally in the isolated git config.
|
|
138
|
+
// We use the unscoped `credential.helper` (not `credential.<url>.helper`)
|
|
139
|
+
// because (a) the test config is fully isolated so we don't risk
|
|
140
|
+
// bleeding into other host auth flows and (b) git's URL matching for
|
|
141
|
+
// scoped helpers has subtleties that aren't what we're testing here --
|
|
142
|
+
// we're testing the helper *contract*, not git's URL matcher.
|
|
143
|
+
const gitBin = gitProbe.path ?? 'git';
|
|
144
|
+
await execFileAsync(gitBin, [
|
|
145
|
+
'config', '--file', gitConfigGlobal,
|
|
146
|
+
'credential.helper', `!"${scriptPath}"`,
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
afterAll(() => {
|
|
150
|
+
if (server)
|
|
151
|
+
server.close();
|
|
152
|
+
if (tmpDir && existsSync(tmpDir))
|
|
153
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
154
|
+
});
|
|
155
|
+
it.skipIf(skipReason)('git ls-remote authenticates via the credential helper end-to-end', async () => {
|
|
156
|
+
const gitBin = gitProbe.path ?? 'git';
|
|
157
|
+
// Use GIT_CONFIG_GLOBAL to isolate from the user's real ~/.gitconfig
|
|
158
|
+
// (which may have an auth helper registered for some other host).
|
|
159
|
+
const env = {
|
|
160
|
+
...process.env,
|
|
161
|
+
GIT_CONFIG_GLOBAL: join(homeDir, '.gitconfig'),
|
|
162
|
+
GIT_CONFIG_SYSTEM: '/dev/null',
|
|
163
|
+
XDG_CONFIG_HOME: xdgConfigHome,
|
|
164
|
+
HOME: homeDir,
|
|
165
|
+
// Force git to never prompt -- if the helper fails, we want the
|
|
166
|
+
// command to exit non-zero, not hang.
|
|
167
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
168
|
+
};
|
|
169
|
+
const path = '/test-workspace/test-app';
|
|
170
|
+
const url = `${serverUrl}${path}`;
|
|
171
|
+
// ls-remote against the fake server. Should exit 0 (server accepted
|
|
172
|
+
// auth and returned a valid -- empty -- ref advertisement).
|
|
173
|
+
const result = await execFileAsync(gitBin, ['ls-remote', url], { env });
|
|
174
|
+
expect(result.stdout).toBe('');
|
|
175
|
+
// No refs in the test response, so stdout is empty.
|
|
176
|
+
// Verify the server actually saw a Basic-auth attempt with our key.
|
|
177
|
+
const successAttempt = attempts.find((a) => a.hasAuthHeader && a.password === EXPECTED_API_KEY);
|
|
178
|
+
expect(successAttempt).toBeDefined();
|
|
179
|
+
expect(successAttempt.username).toBe('runwork');
|
|
180
|
+
}, 20_000);
|
|
181
|
+
it.skipIf(skipReason)('server rejects requests with no auth (sanity: contract is enforced)', async () => {
|
|
182
|
+
// Direct fetch with no Authorization header should 401.
|
|
183
|
+
const response = await fetch(`${serverUrl}/whatever`);
|
|
184
|
+
expect(response.status).toBe(401);
|
|
185
|
+
expect(response.headers.get('www-authenticate')).toContain('Basic');
|
|
186
|
+
});
|
|
187
|
+
it.skipIf(skipReason)('server rejects requests with the wrong API key (sanity: contract is enforced)', async () => {
|
|
188
|
+
const response = await fetch(`${serverUrl}/whatever`, {
|
|
189
|
+
headers: {
|
|
190
|
+
authorization: `Basic ${Buffer.from('runwork:wrong-key').toString('base64')}`,
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
expect(response.status).toBe(401);
|
|
194
|
+
});
|
|
195
|
+
});
|
|
@@ -5,38 +5,51 @@ vi.mock('../../auth/store.js', () => ({
|
|
|
5
5
|
}));
|
|
6
6
|
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
7
7
|
const { getCredentials: mockGetCredentials } = await import('../../auth/store.js');
|
|
8
|
-
const { configureGitCredentials, removeGitCredentials, handleGitCredentialRequest, } = await import('../credentials.js');
|
|
8
|
+
const { configureGitCredentials, removeGitCredentials, handleGitCredentialRequest, buildHelperValue, } = await import('../credentials.js');
|
|
9
9
|
describe('git/credentials', () => {
|
|
10
10
|
beforeEach(() => {
|
|
11
11
|
vi.clearAllMocks();
|
|
12
12
|
});
|
|
13
|
+
describe('buildHelperValue()', () => {
|
|
14
|
+
it('wraps the execPath in quotes and prefixes with !', () => {
|
|
15
|
+
expect(buildHelperValue('/usr/local/bin/runwork')).toBe('!"/usr/local/bin/runwork" git-credential-helper');
|
|
16
|
+
});
|
|
17
|
+
it('normalises Windows backslashes to forward slashes for .gitconfig safety', () => {
|
|
18
|
+
expect(buildHelperValue('C:\\Users\\test\\.runwork\\bin\\runwork.exe')).toBe('!"C:/Users/test/.runwork/bin/runwork.exe" git-credential-helper');
|
|
19
|
+
});
|
|
20
|
+
it('survives spaces in install paths via quoting', () => {
|
|
21
|
+
expect(buildHelperValue('C:\\Program Files\\Runwork\\runwork.exe')).toBe('!"C:/Program Files/Runwork/runwork.exe" git-credential-helper');
|
|
22
|
+
});
|
|
23
|
+
});
|
|
13
24
|
describe('configureGitCredentials()', () => {
|
|
14
|
-
it('
|
|
25
|
+
it('registers the helper using process.execPath, not bare "runwork"', async () => {
|
|
15
26
|
await configureGitCredentials('https://runwork.ai/api/git/ws/app');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
27
|
+
// Don't pin process.execPath itself (varies per machine), but
|
|
28
|
+
// assert the value follows the absolute-path shape so PATH-relative
|
|
29
|
+
// regressions are caught.
|
|
30
|
+
const call = mockExecFileSync.mock.calls.find((c) => c[0] === 'git' && c[1]?.includes('--global'));
|
|
31
|
+
expect(call).toBeDefined();
|
|
32
|
+
const args = call[1];
|
|
33
|
+
expect(args).toContain('config');
|
|
34
|
+
expect(args).toContain('--global');
|
|
35
|
+
expect(args).toContain('credential.https://runwork.ai.helper');
|
|
36
|
+
const helperValue = args[args.length - 1];
|
|
37
|
+
expect(helperValue).toMatch(/^!"[^"]+" git-credential-helper$/);
|
|
38
|
+
expect(helperValue).not.toBe('!runwork git-credential-helper');
|
|
39
|
+
// The path inside the quotes should be process.execPath, normalised.
|
|
40
|
+
expect(helperValue).toContain(process.execPath.replace(/\\/g, '/'));
|
|
22
41
|
});
|
|
23
42
|
it('extracts origin from full URL', async () => {
|
|
24
43
|
await configureGitCredentials('https://custom.runwork.dev/api/git/ws-1/app-1');
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
'credential.https://custom.runwork.dev.helper',
|
|
29
|
-
'!runwork git-credential-helper',
|
|
30
|
-
], { stdio: 'pipe' });
|
|
44
|
+
const call = mockExecFileSync.mock.calls[0];
|
|
45
|
+
const args = call[1];
|
|
46
|
+
expect(args[2]).toBe('credential.https://custom.runwork.dev.helper');
|
|
31
47
|
});
|
|
32
48
|
it('handles URL with port', async () => {
|
|
33
49
|
await configureGitCredentials('https://localhost:8787/api/git/ws/app');
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
'credential.https://localhost:8787.helper',
|
|
38
|
-
'!runwork git-credential-helper',
|
|
39
|
-
], { stdio: 'pipe' });
|
|
50
|
+
const call = mockExecFileSync.mock.calls[0];
|
|
51
|
+
const args = call[1];
|
|
52
|
+
expect(args[2]).toBe('credential.https://localhost:8787.helper');
|
|
40
53
|
});
|
|
41
54
|
});
|
|
42
55
|
describe('removeGitCredentials()', () => {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
// Mocks must be declared before imports of the module under test so vitest
|
|
3
|
+
// hoists them above the static import.
|
|
4
|
+
vi.mock('child_process', async () => {
|
|
5
|
+
const actual = await vi.importActual('child_process');
|
|
6
|
+
return { ...actual, execFileSync: vi.fn() };
|
|
7
|
+
});
|
|
8
|
+
vi.mock('fs', async () => {
|
|
9
|
+
const actual = await vi.importActual('fs');
|
|
10
|
+
return { ...actual, existsSync: vi.fn() };
|
|
11
|
+
});
|
|
12
|
+
vi.mock('os', async () => {
|
|
13
|
+
const actual = await vi.importActual('os');
|
|
14
|
+
return { ...actual, homedir: vi.fn(() => 'C:\\Users\\test') };
|
|
15
|
+
});
|
|
16
|
+
import { execFileSync } from 'child_process';
|
|
17
|
+
import { existsSync } from 'fs';
|
|
18
|
+
import { probeGit, resetGitProbeCache, buildMissingGitMessage, parseGitVersion, } from '../preflight.js';
|
|
19
|
+
const mockedExec = vi.mocked(execFileSync);
|
|
20
|
+
const mockedExists = vi.mocked(existsSync);
|
|
21
|
+
function enoent(file) {
|
|
22
|
+
const err = new Error(`spawn ${file} ENOENT`);
|
|
23
|
+
err.code = 'ENOENT';
|
|
24
|
+
err.errno = -2;
|
|
25
|
+
err.syscall = `spawn ${file}`;
|
|
26
|
+
err.path = file;
|
|
27
|
+
return err;
|
|
28
|
+
}
|
|
29
|
+
describe('probeGit -- Windows fallback resolution', () => {
|
|
30
|
+
let originalPlatform;
|
|
31
|
+
let originalPath;
|
|
32
|
+
let originalProgramFiles;
|
|
33
|
+
let originalProgramFilesX86;
|
|
34
|
+
let originalLocalAppData;
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
resetGitProbeCache();
|
|
37
|
+
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
38
|
+
Object.defineProperty(process, 'platform', { value: 'win32', writable: true, configurable: true });
|
|
39
|
+
originalPath = process.env.PATH;
|
|
40
|
+
originalProgramFiles = process.env.ProgramFiles;
|
|
41
|
+
originalProgramFilesX86 = process.env['ProgramFiles(x86)'];
|
|
42
|
+
originalLocalAppData = process.env.LOCALAPPDATA;
|
|
43
|
+
process.env.PATH = 'C:\\Windows\\system32;C:\\Windows';
|
|
44
|
+
process.env.ProgramFiles = 'C:\\Program Files';
|
|
45
|
+
process.env['ProgramFiles(x86)'] = 'C:\\Program Files (x86)';
|
|
46
|
+
process.env.LOCALAPPDATA = 'C:\\Users\\test\\AppData\\Local';
|
|
47
|
+
mockedExec.mockReset();
|
|
48
|
+
mockedExists.mockReset();
|
|
49
|
+
});
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
if (originalPlatform)
|
|
52
|
+
Object.defineProperty(process, 'platform', originalPlatform);
|
|
53
|
+
if (originalPath !== undefined)
|
|
54
|
+
process.env.PATH = originalPath;
|
|
55
|
+
else
|
|
56
|
+
delete process.env.PATH;
|
|
57
|
+
if (originalProgramFiles !== undefined)
|
|
58
|
+
process.env.ProgramFiles = originalProgramFiles;
|
|
59
|
+
else
|
|
60
|
+
delete process.env.ProgramFiles;
|
|
61
|
+
if (originalProgramFilesX86 !== undefined)
|
|
62
|
+
process.env['ProgramFiles(x86)'] = originalProgramFilesX86;
|
|
63
|
+
else
|
|
64
|
+
delete process.env['ProgramFiles(x86)'];
|
|
65
|
+
if (originalLocalAppData !== undefined)
|
|
66
|
+
process.env.LOCALAPPDATA = originalLocalAppData;
|
|
67
|
+
else
|
|
68
|
+
delete process.env.LOCALAPPDATA;
|
|
69
|
+
delete process.env.Path;
|
|
70
|
+
});
|
|
71
|
+
it('resolves via where.exe when bare git lookup fails', () => {
|
|
72
|
+
const realGitPath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
73
|
+
mockedExec.mockImplementation((file) => {
|
|
74
|
+
if (file === 'git')
|
|
75
|
+
throw enoent('git');
|
|
76
|
+
if (file === 'where.exe')
|
|
77
|
+
return Buffer.from(`${realGitPath}\r\n`);
|
|
78
|
+
if (file === realGitPath)
|
|
79
|
+
return Buffer.from('git version 2.43.0.windows.1');
|
|
80
|
+
throw enoent(file);
|
|
81
|
+
});
|
|
82
|
+
mockedExists.mockImplementation((p) => p === realGitPath);
|
|
83
|
+
const probe = probeGit();
|
|
84
|
+
expect(probe.installed).toBe(true);
|
|
85
|
+
expect(probe.source).toBe('where');
|
|
86
|
+
expect(probe.path).toBe(realGitPath);
|
|
87
|
+
expect(probe.version).toContain('git version 2.43.0');
|
|
88
|
+
});
|
|
89
|
+
it('prepends the resolved git directory to PATH', () => {
|
|
90
|
+
const realGitPath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
91
|
+
mockedExec.mockImplementation((file) => {
|
|
92
|
+
if (file === 'git')
|
|
93
|
+
throw enoent('git');
|
|
94
|
+
if (file === 'where.exe')
|
|
95
|
+
return Buffer.from(`${realGitPath}\r\n`);
|
|
96
|
+
if (file === realGitPath)
|
|
97
|
+
return Buffer.from('git version 2.43.0');
|
|
98
|
+
throw enoent(file);
|
|
99
|
+
});
|
|
100
|
+
mockedExists.mockReturnValue(true);
|
|
101
|
+
probeGit();
|
|
102
|
+
expect(process.env.PATH?.split(';')[0]).toBe('C:\\Program Files\\Git\\cmd');
|
|
103
|
+
});
|
|
104
|
+
it('also mirrors the new value into Path (case variant) when present', () => {
|
|
105
|
+
process.env.Path = process.env.PATH; // simulate Windows where Path is set
|
|
106
|
+
const realGitPath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
107
|
+
mockedExec.mockImplementation((file) => {
|
|
108
|
+
if (file === 'git')
|
|
109
|
+
throw enoent('git');
|
|
110
|
+
if (file === 'where.exe')
|
|
111
|
+
return Buffer.from(`${realGitPath}\r\n`);
|
|
112
|
+
if (file === realGitPath)
|
|
113
|
+
return Buffer.from('git version 2.43.0');
|
|
114
|
+
throw enoent(file);
|
|
115
|
+
});
|
|
116
|
+
mockedExists.mockReturnValue(true);
|
|
117
|
+
probeGit();
|
|
118
|
+
expect(process.env.Path).toBe(process.env.PATH);
|
|
119
|
+
});
|
|
120
|
+
it('prefers .exe match when where.exe lists multiple candidates', () => {
|
|
121
|
+
const exePath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
122
|
+
const cmdShim = 'C:\\Users\\test\\.runwork\\bin\\git';
|
|
123
|
+
mockedExec.mockImplementation((file) => {
|
|
124
|
+
if (file === 'git')
|
|
125
|
+
throw enoent('git');
|
|
126
|
+
if (file === 'where.exe')
|
|
127
|
+
return Buffer.from(`${cmdShim}\r\n${exePath}\r\n`);
|
|
128
|
+
if (file === exePath)
|
|
129
|
+
return Buffer.from('git version 2.43.0');
|
|
130
|
+
throw enoent(file);
|
|
131
|
+
});
|
|
132
|
+
mockedExists.mockImplementation((p) => p === exePath || p === cmdShim);
|
|
133
|
+
const probe = probeGit();
|
|
134
|
+
expect(probe.path).toBe(exePath);
|
|
135
|
+
});
|
|
136
|
+
it('falls back to the registry when where.exe fails', () => {
|
|
137
|
+
const installRoot = 'C:\\Program Files\\Git';
|
|
138
|
+
const gitExe = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
139
|
+
mockedExec.mockImplementation((file, args) => {
|
|
140
|
+
if (file === 'git')
|
|
141
|
+
throw enoent('git');
|
|
142
|
+
if (file === 'where.exe')
|
|
143
|
+
throw enoent('where.exe');
|
|
144
|
+
if (file === 'reg.exe') {
|
|
145
|
+
// Only HKLM has a value; HKCU returns nothing (we throw to mimic).
|
|
146
|
+
const key = args?.[1] ?? '';
|
|
147
|
+
if (key.includes('HKLM')) {
|
|
148
|
+
return Buffer.from('HKEY_LOCAL_MACHINE\\SOFTWARE\\GitForWindows\r\n' +
|
|
149
|
+
` InstallPath REG_SZ ${installRoot}\r\n`);
|
|
150
|
+
}
|
|
151
|
+
throw enoent('reg.exe');
|
|
152
|
+
}
|
|
153
|
+
if (file === gitExe)
|
|
154
|
+
return Buffer.from('git version 2.43.0');
|
|
155
|
+
throw enoent(file);
|
|
156
|
+
});
|
|
157
|
+
mockedExists.mockImplementation((p) => p === gitExe);
|
|
158
|
+
const probe = probeGit();
|
|
159
|
+
expect(probe.installed).toBe(true);
|
|
160
|
+
expect(probe.source).toBe('registry');
|
|
161
|
+
expect(probe.path).toBe(gitExe);
|
|
162
|
+
expect(process.env.PATH?.split(';')[0]).toBe('C:\\Program Files\\Git\\cmd');
|
|
163
|
+
});
|
|
164
|
+
it('honours HKCU\\Software\\GitForWindows when HKLM is empty', () => {
|
|
165
|
+
const installRoot = 'C:\\Users\\test\\AppData\\Local\\Programs\\Git';
|
|
166
|
+
const gitExe = 'C:\\Users\\test\\AppData\\Local\\Programs\\Git\\cmd\\git.exe';
|
|
167
|
+
mockedExec.mockImplementation((file, args) => {
|
|
168
|
+
if (file === 'git')
|
|
169
|
+
throw enoent('git');
|
|
170
|
+
if (file === 'where.exe')
|
|
171
|
+
throw enoent('where.exe');
|
|
172
|
+
if (file === 'reg.exe') {
|
|
173
|
+
const key = args?.[1] ?? '';
|
|
174
|
+
if (key.includes('HKLM'))
|
|
175
|
+
throw enoent('reg.exe');
|
|
176
|
+
if (key.includes('HKCU')) {
|
|
177
|
+
return Buffer.from('HKEY_CURRENT_USER\\Software\\GitForWindows\r\n' +
|
|
178
|
+
` InstallPath REG_SZ ${installRoot}\r\n`);
|
|
179
|
+
}
|
|
180
|
+
throw enoent('reg.exe');
|
|
181
|
+
}
|
|
182
|
+
if (file === gitExe)
|
|
183
|
+
return Buffer.from('git version 2.43.0');
|
|
184
|
+
throw enoent(file);
|
|
185
|
+
});
|
|
186
|
+
mockedExists.mockImplementation((p) => p === gitExe);
|
|
187
|
+
const probe = probeGit();
|
|
188
|
+
expect(probe.installed).toBe(true);
|
|
189
|
+
expect(probe.source).toBe('registry');
|
|
190
|
+
expect(probe.path).toBe(gitExe);
|
|
191
|
+
});
|
|
192
|
+
it('falls back to canonical install path when where.exe and registry both fail', () => {
|
|
193
|
+
const canonical = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
194
|
+
mockedExec.mockImplementation((file) => {
|
|
195
|
+
if (file === 'git')
|
|
196
|
+
throw enoent('git');
|
|
197
|
+
if (file === 'where.exe')
|
|
198
|
+
throw enoent('where.exe');
|
|
199
|
+
if (file === 'reg.exe')
|
|
200
|
+
throw enoent('reg.exe');
|
|
201
|
+
if (file === canonical)
|
|
202
|
+
return Buffer.from('git version 2.43.0');
|
|
203
|
+
throw enoent(file);
|
|
204
|
+
});
|
|
205
|
+
mockedExists.mockImplementation((p) => p === canonical);
|
|
206
|
+
const probe = probeGit();
|
|
207
|
+
expect(probe.installed).toBe(true);
|
|
208
|
+
expect(probe.source).toBe('canonical');
|
|
209
|
+
expect(probe.path).toBe(canonical);
|
|
210
|
+
expect(process.env.PATH?.split(';')[0]).toBe('C:\\Program Files\\Git\\cmd');
|
|
211
|
+
});
|
|
212
|
+
it('finds a portable MinGit dropped under ~/.runwork/tools/mingit', () => {
|
|
213
|
+
const portable = 'C:\\Users\\test\\.runwork\\tools\\mingit\\cmd\\git.exe';
|
|
214
|
+
mockedExec.mockImplementation((file) => {
|
|
215
|
+
if (file === 'git')
|
|
216
|
+
throw enoent('git');
|
|
217
|
+
if (file === 'where.exe')
|
|
218
|
+
throw enoent('where.exe');
|
|
219
|
+
if (file === 'reg.exe')
|
|
220
|
+
throw enoent('reg.exe');
|
|
221
|
+
if (file === portable)
|
|
222
|
+
return Buffer.from('git version 2.45.0.windows.1');
|
|
223
|
+
throw enoent(file);
|
|
224
|
+
});
|
|
225
|
+
mockedExists.mockImplementation((p) => p === portable);
|
|
226
|
+
const probe = probeGit();
|
|
227
|
+
expect(probe.installed).toBe(true);
|
|
228
|
+
expect(probe.source).toBe('canonical');
|
|
229
|
+
expect(probe.path).toBe(portable);
|
|
230
|
+
});
|
|
231
|
+
it('finds a Scoop install at ~/scoop/apps/git/current/cmd/git.exe', () => {
|
|
232
|
+
const scoop = 'C:\\Users\\test\\scoop\\apps\\git\\current\\cmd\\git.exe';
|
|
233
|
+
mockedExec.mockImplementation((file) => {
|
|
234
|
+
if (file === 'git')
|
|
235
|
+
throw enoent('git');
|
|
236
|
+
if (file === 'where.exe')
|
|
237
|
+
throw enoent('where.exe');
|
|
238
|
+
if (file === 'reg.exe')
|
|
239
|
+
throw enoent('reg.exe');
|
|
240
|
+
if (file === scoop)
|
|
241
|
+
return Buffer.from('git version 2.45.0');
|
|
242
|
+
throw enoent(file);
|
|
243
|
+
});
|
|
244
|
+
mockedExists.mockImplementation((p) => p === scoop);
|
|
245
|
+
const probe = probeGit();
|
|
246
|
+
expect(probe.installed).toBe(true);
|
|
247
|
+
expect(probe.path).toBe(scoop);
|
|
248
|
+
});
|
|
249
|
+
it('returns installed=false when nothing resolves and preserves the bare-call error', () => {
|
|
250
|
+
mockedExec.mockImplementation((file) => { throw enoent(file); });
|
|
251
|
+
mockedExists.mockReturnValue(false);
|
|
252
|
+
const probe = probeGit();
|
|
253
|
+
expect(probe.installed).toBe(false);
|
|
254
|
+
expect(probe.error?.code).toBe('ENOENT');
|
|
255
|
+
expect(probe.error?.path).toBe('git');
|
|
256
|
+
// No PATH side-effect when nothing was resolved.
|
|
257
|
+
expect(process.env.PATH?.startsWith('C:\\Windows\\system32')).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
it('caches the probe result across calls and stops re-spawning git', () => {
|
|
260
|
+
const realGitPath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
261
|
+
mockedExec.mockImplementation((file) => {
|
|
262
|
+
if (file === 'git')
|
|
263
|
+
throw enoent('git');
|
|
264
|
+
if (file === 'where.exe')
|
|
265
|
+
return Buffer.from(`${realGitPath}\r\n`);
|
|
266
|
+
if (file === realGitPath)
|
|
267
|
+
return Buffer.from('git version 2.43.0');
|
|
268
|
+
throw enoent(file);
|
|
269
|
+
});
|
|
270
|
+
mockedExists.mockReturnValue(true);
|
|
271
|
+
const first = probeGit();
|
|
272
|
+
const callsAfterFirst = mockedExec.mock.calls.length;
|
|
273
|
+
const second = probeGit();
|
|
274
|
+
expect(second).toBe(first);
|
|
275
|
+
expect(mockedExec.mock.calls.length).toBe(callsAfterFirst);
|
|
276
|
+
});
|
|
277
|
+
it('does not double-prepend PATH on repeat probes after a cache reset', () => {
|
|
278
|
+
const realGitPath = 'C:\\Program Files\\Git\\cmd\\git.exe';
|
|
279
|
+
mockedExec.mockImplementation((file) => {
|
|
280
|
+
if (file === 'git')
|
|
281
|
+
throw enoent('git');
|
|
282
|
+
if (file === 'where.exe')
|
|
283
|
+
return Buffer.from(`${realGitPath}\r\n`);
|
|
284
|
+
if (file === realGitPath)
|
|
285
|
+
return Buffer.from('git version 2.43.0');
|
|
286
|
+
throw enoent(file);
|
|
287
|
+
});
|
|
288
|
+
mockedExists.mockReturnValue(true);
|
|
289
|
+
probeGit();
|
|
290
|
+
const pathAfterFirst = process.env.PATH;
|
|
291
|
+
resetGitProbeCache();
|
|
292
|
+
probeGit();
|
|
293
|
+
expect(process.env.PATH).toBe(pathAfterFirst);
|
|
294
|
+
});
|
|
295
|
+
it('skips canonical paths whose env var is unset', () => {
|
|
296
|
+
delete process.env.ProgramFiles;
|
|
297
|
+
delete process.env['ProgramFiles(x86)'];
|
|
298
|
+
delete process.env.LOCALAPPDATA;
|
|
299
|
+
// Only the hard-coded "C:\\Program Files\\Git\\cmd\\git.exe" default
|
|
300
|
+
// remains. Make existsSync reject it too -- nothing should resolve.
|
|
301
|
+
mockedExec.mockImplementation((file) => { throw enoent(file); });
|
|
302
|
+
mockedExists.mockReturnValue(false);
|
|
303
|
+
const probe = probeGit();
|
|
304
|
+
expect(probe.installed).toBe(false);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
describe('probeGit -- non-Windows hosts skip the fallback', () => {
|
|
308
|
+
let originalPlatform;
|
|
309
|
+
beforeEach(() => {
|
|
310
|
+
resetGitProbeCache();
|
|
311
|
+
originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
|
312
|
+
Object.defineProperty(process, 'platform', { value: 'darwin', writable: true, configurable: true });
|
|
313
|
+
mockedExec.mockReset();
|
|
314
|
+
mockedExists.mockReset();
|
|
315
|
+
});
|
|
316
|
+
afterEach(() => {
|
|
317
|
+
if (originalPlatform)
|
|
318
|
+
Object.defineProperty(process, 'platform', originalPlatform);
|
|
319
|
+
});
|
|
320
|
+
it('does not invoke where.exe on macOS when bare lookup fails', () => {
|
|
321
|
+
mockedExec.mockImplementation((file) => { throw enoent(file); });
|
|
322
|
+
mockedExists.mockReturnValue(true);
|
|
323
|
+
const probe = probeGit();
|
|
324
|
+
expect(probe.installed).toBe(false);
|
|
325
|
+
const calledFiles = mockedExec.mock.calls.map((c) => c[0]);
|
|
326
|
+
expect(calledFiles).toContain('git');
|
|
327
|
+
expect(calledFiles).not.toContain('where.exe');
|
|
328
|
+
});
|
|
329
|
+
it('returns immediately on bare-call success without further probing', () => {
|
|
330
|
+
mockedExec.mockImplementation((file) => {
|
|
331
|
+
if (file === 'git')
|
|
332
|
+
return Buffer.from('git version 2.45.0');
|
|
333
|
+
throw enoent(file);
|
|
334
|
+
});
|
|
335
|
+
mockedExists.mockReturnValue(false);
|
|
336
|
+
const probe = probeGit();
|
|
337
|
+
expect(probe.installed).toBe(true);
|
|
338
|
+
expect(probe.source).toBe('PATH');
|
|
339
|
+
expect(probe.path).toBe('git');
|
|
340
|
+
expect(mockedExists).not.toHaveBeenCalled();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
describe('parseGitVersion', () => {
|
|
344
|
+
it('extracts a semver-ish number from `git --version` output', () => {
|
|
345
|
+
expect(parseGitVersion('git version 2.43.0')).toBe('2.43.0');
|
|
346
|
+
expect(parseGitVersion('git version 2.43.0.windows.1')).toBe('2.43.0');
|
|
347
|
+
expect(parseGitVersion('git version 2.45')).toBe('2.45');
|
|
348
|
+
});
|
|
349
|
+
it('returns null for unparsable input', () => {
|
|
350
|
+
expect(parseGitVersion(undefined)).toBeNull();
|
|
351
|
+
expect(parseGitVersion('')).toBeNull();
|
|
352
|
+
expect(parseGitVersion('not a version')).toBeNull();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
describe('buildMissingGitMessage with a probe error', () => {
|
|
356
|
+
it('appends the underlying error message when one is present', () => {
|
|
357
|
+
const err = new Error('spawn git ENOENT');
|
|
358
|
+
err.code = 'ENOENT';
|
|
359
|
+
const msg = buildMissingGitMessage('clone', { installed: false, error: err });
|
|
360
|
+
expect(msg).toContain('Underlying error: spawn git ENOENT');
|
|
361
|
+
});
|
|
362
|
+
it('omits the underlying-error footer when no probe is supplied', () => {
|
|
363
|
+
const msg = buildMissingGitMessage('clone');
|
|
364
|
+
expect(msg).not.toContain('Underlying error');
|
|
365
|
+
});
|
|
366
|
+
});
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the value we hand to `git config credential.<origin>.helper`.
|
|
3
|
+
*
|
|
4
|
+
* Git invokes credential helpers via its bash shell (Git Bash on Windows,
|
|
5
|
+
* /bin/sh elsewhere). A leading `!` tells git to treat the value as a raw
|
|
6
|
+
* shell command. We embed the absolute path of the *currently running*
|
|
7
|
+
* runwork binary so the helper invocation never depends on PATH at the
|
|
8
|
+
* moment git happens to call it -- which used to be a real failure mode
|
|
9
|
+
* on Windows when runwork.exe sits in `~/.runwork/bin` and that directory
|
|
10
|
+
* isn't picked up by git's bash subshell.
|
|
11
|
+
*
|
|
12
|
+
* The path is wrapped in quotes (handles spaces in install paths) and on
|
|
13
|
+
* Windows we normalise backslashes to forward slashes -- bash on Windows
|
|
14
|
+
* accepts both, but forward slashes avoid escaping issues in the .gitconfig
|
|
15
|
+
* file itself.
|
|
16
|
+
*/
|
|
17
|
+
export declare function buildHelperValue(execPath: string): string;
|
|
1
18
|
/**
|
|
2
19
|
* Configure git to use the runwork credential helper for our remote.
|
|
3
20
|
* Called by `runwork login` and `runwork init`.
|