runwork 0.10.1 → 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.
@@ -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
- * Probe whether `git` is callable from this process. We use `execFileSync`
4
- * (not `which`/`where.exe`) so the check follows the exact PATH lookup any
5
- * subsequent git invocation will use -- that way we never report "found"
6
- * when the real call would fail with ENOENT, and vice versa.
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
- try {
10
- const out = execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'pipe'] });
11
- return { installed: true, version: out.toString('utf-8').trim() };
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
- catch (err) {
14
- return { installed: false, error: err };
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 () => {
@@ -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>;
@@ -7,6 +7,7 @@ import { getCredentials } from '../auth/store.js';
7
7
  import { ApiClient } from '../api/client.js';
8
8
  import { detectAgents, getAdapterBySlug } from '../agents/detect.js';
9
9
  import { httpFetch } from '../utils/http.js';
10
+ import { probeGit, parseGitVersion } from '../git/preflight.js';
10
11
  // First-party distribution endpoints. Defaults match the plan, can be
11
12
  // overridden via RUNWORK_DOWNLOAD_BASE_URL for staging or debugging.
12
13
  const BASE_URL = process.env.RUNWORK_DOWNLOAD_BASE_URL || 'https://runwork.ai';
@@ -50,26 +51,39 @@ export function buildContext() {
50
51
  }
51
52
  // ── 1. System dependencies ───────────────────────────────────────────
52
53
  export async function checkSystemDeps() {
53
- try {
54
- const output = execFileSync('git', ['--version'], { stdio: 'pipe' }).toString().trim();
55
- const match = output.match(/(\d+\.\d+(?:\.\d+)?)/);
56
- const version = match ? match[1] : 'unknown';
57
- if (match) {
58
- const [major, minor] = version.split('.').map(Number);
59
- if (major < 2 || (major === 2 && minor < 20)) {
60
- return { name: 'system-deps', status: 'warn', message: `git ${version} (old -- consider upgrading)` };
61
- }
62
- }
63
- return { name: 'system-deps', status: 'pass', message: `git ${version}` };
64
- }
65
- catch {
54
+ // Delegate to probeGit() so doctor agrees with the preflight gate used by
55
+ // `runwork clone`/`init`/`dev`. probeGit also has Windows fallback paths
56
+ // (`where.exe`, canonical install dirs) that the previous bare execFileSync
57
+ // missed when Bun's standalone-binary spawn lookup failed despite git
58
+ // being installed.
59
+ const probe = probeGit();
60
+ if (!probe.installed) {
61
+ const detail = probe.error?.message ? ` (${probe.error.message})` : '';
66
62
  return {
67
63
  name: 'system-deps',
68
64
  status: 'fail',
69
- message: 'git not found in PATH',
65
+ message: `git not found in PATH${detail}`,
70
66
  fix: 'Install git: https://git-scm.com/downloads',
71
67
  };
72
68
  }
69
+ const version = parseGitVersion(probe.version) ?? 'unknown';
70
+ // Show how we found it on Windows fallback paths so the user can see
71
+ // whether their PATH is wired up or we needed a rescue.
72
+ const sourceSuffix = probe.source === 'where' ? ` (resolved via where.exe at ${probe.path})`
73
+ : probe.source === 'registry' ? ` (resolved via Git for Windows registry at ${probe.path})`
74
+ : probe.source === 'canonical' ? ` (resolved via canonical install path ${probe.path})`
75
+ : '';
76
+ if (version !== 'unknown') {
77
+ const [major, minor] = version.split('.').map(Number);
78
+ if (major < 2 || (major === 2 && minor < 20)) {
79
+ return {
80
+ name: 'system-deps',
81
+ status: 'warn',
82
+ message: `git ${version} (old -- consider upgrading)${sourceSuffix}`,
83
+ };
84
+ }
85
+ }
86
+ return { name: 'system-deps', status: 'pass', message: `git ${version}${sourceSuffix}` };
73
87
  }
74
88
  // ── 2. CLI version ───────────────────────────────────────────────────
75
89
  export async function checkCliVersion() {
@@ -224,6 +238,108 @@ export async function checkAuthAndNetwork(ctx) {
224
238
  };
225
239
  }
226
240
  }
241
+ // ── 4a. Git credential helper registration ──────────────────────────
242
+ /**
243
+ * Verify that git's credential helper for the runwork origin is registered
244
+ * and that the binary it points at exists on disk. Catches the very common
245
+ * "logged in with an older CLI before configureGitCredentials shipped"
246
+ * state, where the user has credentials but git falls back to the system
247
+ * credential manager (Git Credential Manager popup on Windows, etc.) when
248
+ * trying to clone or push -- producing confusing UX with no obvious fix.
249
+ *
250
+ * The check is scoped to the *user's* logged-in baseUrl when available, so
251
+ * it doesn't false-fail on staging deployments that point at a different
252
+ * origin.
253
+ */
254
+ export async function checkGitCredentialHelper(ctx) {
255
+ if (!ctx.credentials) {
256
+ return {
257
+ name: 'git-credential-helper',
258
+ status: 'skip',
259
+ message: 'skipped (not logged in)',
260
+ };
261
+ }
262
+ // Make sure git itself is callable before we try to read its config --
263
+ // probeGit() prepends the resolved git directory to PATH for us when it
264
+ // resolves via fallback, so this also smooths the way for the
265
+ // execFileSync('git', ...) call below.
266
+ const git = probeGit();
267
+ if (!git.installed) {
268
+ return {
269
+ name: 'git-credential-helper',
270
+ status: 'skip',
271
+ message: 'skipped (git not installed)',
272
+ };
273
+ }
274
+ let origin;
275
+ try {
276
+ origin = new URL(ctx.credentials.baseUrl).origin;
277
+ }
278
+ catch {
279
+ return {
280
+ name: 'git-credential-helper',
281
+ status: 'fail',
282
+ message: `cannot parse baseUrl from credentials: ${ctx.credentials.baseUrl}`,
283
+ fix: 'runwork login',
284
+ };
285
+ }
286
+ // `--get-regexp` returns 1 (and prints nothing) when no entry matches,
287
+ // so a non-zero exit is a normal "not registered" signal -- not an error.
288
+ let helperConfig;
289
+ try {
290
+ const buf = execFileSync('git', ['config', '--global', '--get-regexp', 'credential\\..*runwork.*\\.helper'], { stdio: ['ignore', 'pipe', 'ignore'] });
291
+ helperConfig = buf.toString('utf-8');
292
+ }
293
+ catch {
294
+ return {
295
+ name: 'git-credential-helper',
296
+ status: 'fail',
297
+ message: `no credential helper registered for ${origin}`,
298
+ fix: 'runwork login',
299
+ };
300
+ }
301
+ // Each line is `credential.<scope>.helper <value>`. We want a line whose
302
+ // <scope> matches the user's actual baseUrl origin (not e.g. a stale
303
+ // staging origin from a previous login).
304
+ const lines = helperConfig.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
305
+ const escapedOrigin = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
306
+ const scopedRegex = new RegExp(`^credential\\.${escapedOrigin}\\.helper\\s+(.+)$`);
307
+ const matchedLine = lines.find(line => scopedRegex.test(line));
308
+ if (!matchedLine) {
309
+ return {
310
+ name: 'git-credential-helper',
311
+ status: 'fail',
312
+ message: `no credential helper registered for ${origin} (found ${lines.length} other runwork helper entries)`,
313
+ fix: 'runwork login',
314
+ };
315
+ }
316
+ const value = scopedRegex.exec(matchedLine)?.[1] ?? '';
317
+ // Helper values starting with `!` are shell commands. Common shapes:
318
+ // `!runwork git-credential-helper` (PATH-relative)
319
+ // `!"/abs/path/to/runwork" git-credential-helper` (post-Pass-2 hardening, POSIX)
320
+ // `!"C:/.../runwork.exe" git-credential-helper` (post-Pass-2 hardening, Windows)
321
+ // We only validate "absolute path" entries by existsSync() because that's
322
+ // the unambiguous case. PATH-relative entries we trust git+bash to resolve
323
+ // at invocation time (no good way to simulate that without running it).
324
+ const absoluteHelperRegex = /^!"?(\/|[A-Za-z]:[\\/])/;
325
+ if (absoluteHelperRegex.test(value)) {
326
+ const pathMatch = value.match(/^!"([^"]+)"|^!(\S+)/);
327
+ const helperPath = pathMatch?.[1] ?? pathMatch?.[2] ?? '';
328
+ if (helperPath && !existsSync(helperPath)) {
329
+ return {
330
+ name: 'git-credential-helper',
331
+ status: 'fail',
332
+ message: `helper registered but binary not found at ${helperPath}`,
333
+ fix: 'runwork login (re-registers with the current binary path)',
334
+ };
335
+ }
336
+ }
337
+ return {
338
+ name: 'git-credential-helper',
339
+ status: 'pass',
340
+ message: `registered for ${origin}`,
341
+ };
342
+ }
227
343
  // ── 5. Project config ────────────────────────────────────────────────
228
344
  export async function checkProjectConfig(ctx) {
229
345
  const configPath = join(ctx.cwd, '.runwork.json');
@@ -1,4 +1,4 @@
1
- import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkProjectConfig, checkAppExists, checkGitRemote, checkAgentSetup, } from './checks.js';
1
+ import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkAgentSetup, } from './checks.js';
2
2
  export async function runAllChecks() {
3
3
  const ctx = buildContext();
4
4
  const checks = [];
@@ -14,6 +14,10 @@ export async function runAllChecks() {
14
14
  const { auth, network } = await checkAuthAndNetwork(ctx);
15
15
  checks.push(auth);
16
16
  checks.push(network);
17
+ // 4a. Git credential helper -- catches "logged in but git auth not wired
18
+ // up" state that surfaces on Windows as a Git Credential Manager
19
+ // popup mid-clone with no obvious cause.
20
+ checks.push(await checkGitCredentialHelper(ctx));
17
21
  // 5. Project config
18
22
  checks.push(await checkProjectConfig(ctx));
19
23
  // 6. App exists
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runwork",
3
- "version": "0.10.1",
3
+ "version": "0.10.2",
4
4
  "description": "CLI for Runwork: develop, preview, and deploy Runwork apps from your local machine.",
5
5
  "license": "UNLICENSED",
6
6
  "author": "Runwork <info@runwork.ai> (https://www.runwork.ai)",