runwork 0.10.0 → 0.10.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.
@@ -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('calls git config with correct args', async () => {
25
+ it('registers the helper using process.execPath, not bare "runwork"', async () => {
15
26
  await configureGitCredentials('https://runwork.ai/api/git/ws/app');
16
- expect(mockExecFileSync).toHaveBeenCalledWith('git', [
17
- 'config',
18
- '--global',
19
- 'credential.https://runwork.ai.helper',
20
- '!runwork git-credential-helper',
21
- ], { stdio: 'pipe' });
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
- expect(mockExecFileSync).toHaveBeenCalledWith('git', [
26
- 'config',
27
- '--global',
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
- expect(mockExecFileSync).toHaveBeenCalledWith('git', [
35
- 'config',
36
- '--global',
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`.
@@ -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
- '!runwork git-credential-helper',
34
+ helperValue,
14
35
  ], { stdio: 'pipe' });
15
36
  }
16
37
  catch (err) {
@@ -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. We use `execFileSync`
10
- * (not `which`/`where.exe`) so the check follows the exact PATH lookup any
11
- * subsequent git invocation will use -- that way we never report "found"
12
- * when the real call would fail with ENOENT, and vice versa.
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.