runwork 0.13.4 → 0.15.0

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.
Files changed (95) hide show
  1. package/bundled-types/core-endpoints.d.ts +16 -4
  2. package/dist/__tests__/install-scripts.test.js +33 -0
  3. package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
  4. package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
  5. package/dist/agents/__tests__/detection.test.js +3 -0
  6. package/dist/agents/claude-desktop-plugin-tree.js +23 -8
  7. package/dist/agents/claude-desktop.js +6 -3
  8. package/dist/agents/codex.js +15 -7
  9. package/dist/agents/detection.js +3 -0
  10. package/dist/agents/intro-skill.js +5 -3
  11. package/dist/agents/registry-data.d.ts +59 -2
  12. package/dist/agents/registry-data.js +176 -0
  13. package/dist/api/client.d.ts +5 -0
  14. package/dist/api/client.js +8 -0
  15. package/dist/commands/__tests__/clone-args.test.js +8 -1
  16. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  17. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  18. package/dist/commands/__tests__/mcp-entries.test.d.ts +1 -0
  19. package/dist/commands/__tests__/mcp-entries.test.js +48 -0
  20. package/dist/commands/clone.d.ts +33 -0
  21. package/dist/commands/clone.js +105 -10
  22. package/dist/commands/deploy.js +128 -11
  23. package/dist/commands/dev.js +72 -32
  24. package/dist/commands/doctor.js +64 -3
  25. package/dist/commands/info.d.ts +3 -0
  26. package/dist/commands/info.js +11 -0
  27. package/dist/commands/mcp-entries.d.ts +13 -0
  28. package/dist/commands/mcp-entries.js +26 -0
  29. package/dist/commands/sync.js +20 -11
  30. package/dist/commands/validate.d.ts +2 -0
  31. package/dist/commands/validate.js +89 -0
  32. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  33. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  34. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  35. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  36. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  37. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  38. package/dist/deploy/deploy-state.d.ts +36 -0
  39. package/dist/deploy/deploy-state.js +63 -0
  40. package/dist/deploy/deploy-status.d.ts +21 -0
  41. package/dist/deploy/deploy-status.js +36 -0
  42. package/dist/deploy/detach.d.ts +32 -0
  43. package/dist/deploy/detach.js +71 -0
  44. package/dist/dev/__tests__/session.test.js +39 -0
  45. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  46. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  47. package/dist/dev/session.d.ts +9 -0
  48. package/dist/dev/session.js +4 -1
  49. package/dist/dev/startup-sync.d.ts +23 -0
  50. package/dist/dev/startup-sync.js +14 -0
  51. package/dist/generated/bundled-types.js +1 -1
  52. package/dist/generated/version.d.ts +1 -1
  53. package/dist/generated/version.js +1 -1
  54. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  55. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  56. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  57. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  58. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  59. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  60. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  61. package/dist/git/__tests__/repo-config.test.js +26 -0
  62. package/dist/git/classify-sync-error.d.ts +21 -0
  63. package/dist/git/classify-sync-error.js +111 -0
  64. package/dist/git/credentials.d.ts +43 -0
  65. package/dist/git/credentials.js +67 -0
  66. package/dist/git/remote.d.ts +37 -0
  67. package/dist/git/remote.js +75 -0
  68. package/dist/git/repo-config.d.ts +8 -0
  69. package/dist/git/repo-config.js +29 -0
  70. package/dist/health/__tests__/checks.test.js +2 -2
  71. package/dist/health/__tests__/fix.test.d.ts +1 -0
  72. package/dist/health/__tests__/fix.test.js +72 -0
  73. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  74. package/dist/health/__tests__/runner-filter.test.js +61 -0
  75. package/dist/health/checks.d.ts +7 -0
  76. package/dist/health/checks.js +50 -36
  77. package/dist/health/fix.d.ts +22 -0
  78. package/dist/health/fix.js +62 -0
  79. package/dist/health/runner.d.ts +7 -1
  80. package/dist/health/runner.js +40 -27
  81. package/dist/index.js +2 -0
  82. package/dist/tools/types.d.ts +5 -0
  83. package/dist/types.d.ts +3 -0
  84. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  85. package/dist/utils/__tests__/help-json.test.js +39 -0
  86. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  87. package/dist/utils/help-json.d.ts +14 -0
  88. package/dist/utils/help-json.js +4 -1
  89. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  90. package/dist/validate/__tests__/validate.test.js +42 -0
  91. package/dist/validate/freshness.d.ts +28 -0
  92. package/dist/validate/freshness.js +48 -0
  93. package/dist/validate/preview.d.ts +21 -0
  94. package/dist/validate/preview.js +39 -0
  95. package/package.json +1 -1
@@ -0,0 +1,75 @@
1
+ import { execFileSync } from '../utils/subprocess.js';
2
+ /**
3
+ * The git remote name runwork uses for all sync/push operations. The sync
4
+ * loop (git/sync.ts) references this remote by name, so clone/dev/deploy must
5
+ * guarantee it exists and points at the app's canonical URL before syncing.
6
+ */
7
+ export const RUNWORK_REMOTE = 'runwork';
8
+ // existing remote, e.g. `origin`, already at this URL)
9
+ /**
10
+ * Normalize a git remote URL for equality comparison. Strips a trailing
11
+ * `.git`, trailing slashes, and userinfo; lower-cases the host (case-
12
+ * insensitive) while preserving the path (case-sensitive). Falls back to a
13
+ * lower-cased trim for scp-style / non-URL remotes.
14
+ */
15
+ export function normalizeRemoteUrl(raw) {
16
+ const trimmed = raw.trim().replace(/\.git$/, '').replace(/\/+$/, '');
17
+ try {
18
+ const u = new URL(trimmed);
19
+ const host = u.host.toLowerCase();
20
+ const path = u.pathname.replace(/\/+$/, '');
21
+ return `${u.protocol}//${host}${path}`;
22
+ }
23
+ catch {
24
+ return trimmed.toLowerCase();
25
+ }
26
+ }
27
+ /** Map of remote name -> fetch URL, parsed from `git remote -v`. */
28
+ export function listRemotes(cwd) {
29
+ const remotes = {};
30
+ let output;
31
+ try {
32
+ output = execFileSync('git', ['remote', '-v'], { cwd, stdio: ['ignore', 'pipe', 'ignore'] }).toString();
33
+ }
34
+ catch {
35
+ return remotes; // not a git repo, or git unavailable
36
+ }
37
+ for (const line of output.split(/\r?\n/)) {
38
+ // Format: `<name>\t<url> (fetch)` / `<name>\t<url> (push)`
39
+ const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/);
40
+ if (match && match[3] === 'fetch') {
41
+ remotes[match[1]] = match[2];
42
+ }
43
+ }
44
+ return remotes;
45
+ }
46
+ /**
47
+ * Ensure a `runwork` remote exists and points at the app's canonical git URL,
48
+ * so the sync loop's `runwork`-named git calls succeed regardless of how the
49
+ * repo was obtained. A plain `git clone <url>` names the remote `origin`,
50
+ * which previously made `runwork dev`/`deploy` fail with
51
+ * `fatal: 'runwork' does not appear to be a git repository`.
52
+ *
53
+ * We don't require the remote to literally be named `runwork`: if another
54
+ * remote (e.g. `origin`) already points at the canonical URL, we recognize it
55
+ * (reported via `reusedFrom`) and still register the `runwork` alias so the
56
+ * hardcoded sync path keeps working. Idempotent: a no-op when `runwork`
57
+ * already points at the canonical URL.
58
+ */
59
+ export function ensureRunworkRemote(cwd, canonicalUrl) {
60
+ const remotes = listRemotes(cwd);
61
+ const target = normalizeRemoteUrl(canonicalUrl);
62
+ const existing = remotes[RUNWORK_REMOTE];
63
+ if (existing && normalizeRemoteUrl(existing) === target) {
64
+ return { action: 'noop' };
65
+ }
66
+ if (existing) {
67
+ execFileSync('git', ['remote', 'set-url', RUNWORK_REMOTE, canonicalUrl], { cwd, stdio: 'pipe' });
68
+ return { action: 'updated' };
69
+ }
70
+ // `runwork` is missing. Note if any other remote already points at the same
71
+ // URL (the manual `git clone` case, where it's typically `origin`).
72
+ const reusedFrom = Object.entries(remotes).find(([, url]) => normalizeRemoteUrl(url) === target)?.[0];
73
+ execFileSync('git', ['remote', 'add', RUNWORK_REMOTE, canonicalUrl], { cwd, stdio: 'pipe' });
74
+ return reusedFrom ? { action: 'added', reusedFrom } : { action: 'added' };
75
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Disable git's background gc/maintenance for the repo at `cwd`. This is set
3
+ * locally (repo scope), is harmless on normal filesystems -- these app repos
4
+ * are small and centrally managed -- and avoids the pack-unlink failures that
5
+ * restricted mounts produce. Applied cross-platform with no FS detection, so
6
+ * it never risks a false positive (notably on Windows). Best-effort.
7
+ */
8
+ export declare function hardenRepoForRestrictedFs(cwd: string): void;
@@ -0,0 +1,29 @@
1
+ import { execFileSync } from '../utils/subprocess.js';
2
+ /**
3
+ * Git config keys we disable on a freshly cloned app repo, with values.
4
+ * Background gc/maintenance rewrite pack files, which on restricted
5
+ * filesystems (some AI-agent "outputs" mounts disallow certain unlinks) fail
6
+ * with `unable to unlink '.git/objects/pack/...': Operation not permitted`,
7
+ * leaving git state unreliable (friction log #9).
8
+ */
9
+ const HARDENING = [
10
+ ['gc.auto', '0'],
11
+ ['maintenance.auto', 'false'],
12
+ ];
13
+ /**
14
+ * Disable git's background gc/maintenance for the repo at `cwd`. This is set
15
+ * locally (repo scope), is harmless on normal filesystems -- these app repos
16
+ * are small and centrally managed -- and avoids the pack-unlink failures that
17
+ * restricted mounts produce. Applied cross-platform with no FS detection, so
18
+ * it never risks a false positive (notably on Windows). Best-effort.
19
+ */
20
+ export function hardenRepoForRestrictedFs(cwd) {
21
+ for (const [key, value] of HARDENING) {
22
+ try {
23
+ execFileSync('git', ['config', key, value], { cwd, stdio: 'pipe' });
24
+ }
25
+ catch {
26
+ // Best-effort: failing to set a hardening config must not fail a clone.
27
+ }
28
+ }
29
+ }
@@ -130,7 +130,7 @@ describe('checkGitCredentialHelper', () => {
130
130
  const ctx = makeCtx();
131
131
  const result = await checkGitCredentialHelper(ctx);
132
132
  expect(result.status).toBe('fail');
133
- expect(result.fix).toBe('runwork login');
133
+ expect(result.fix).toBe('runwork doctor --fix');
134
134
  });
135
135
  it('fails when entries exist but none match the credentials origin', async () => {
136
136
  const { checkGitCredentialHelper } = await import('../checks.js');
@@ -165,7 +165,7 @@ describe('checkGitCredentialHelper', () => {
165
165
  const result = await checkGitCredentialHelper(ctx);
166
166
  expect(result.status).toBe('fail');
167
167
  expect(result.message).toContain('binary not found');
168
- expect(result.fix).toContain('runwork login');
168
+ expect(result.fix).toContain('runwork doctor --fix');
169
169
  });
170
170
  it('passes when the registered absolute path still exists', async () => {
171
171
  const { checkGitCredentialHelper } = await import('../checks.js');
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ vi.mock('fs', () => ({ existsSync: vi.fn() }));
3
+ vi.mock('../../git/credentials.js', () => ({ ensureGitCredentialHelper: vi.fn(async () => { }) }));
4
+ vi.mock('../../git/remote.js', () => ({ ensureRunworkRemote: vi.fn(() => ({ action: 'added' })) }));
5
+ const { existsSync: mockExistsSync } = await import('fs');
6
+ const { ensureGitCredentialHelper } = await import('../../git/credentials.js');
7
+ const { ensureRunworkRemote } = await import('../../git/remote.js');
8
+ const { applyDoctorFixes, FIXABLE_CHECKS } = await import('../fix.js');
9
+ const creds = { apiKey: 'k', email: 'u@e.com', baseUrl: 'https://runwork.ai' };
10
+ const config = { workspaceId: 'ws', appId: 'app', workspaceName: 'WS', appName: 'App' };
11
+ function makeCtx(overrides = {}) {
12
+ return {
13
+ credentials: creds,
14
+ client: { getGitRemoteUrl: (ws, app) => `https://runwork.ai/api/git/${ws}/${app}` },
15
+ config,
16
+ cwd: '/repo',
17
+ ...overrides,
18
+ };
19
+ }
20
+ describe('health/fix applyDoctorFixes', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ vi.mocked(mockExistsSync).mockReturnValue(true); // .git present by default
24
+ });
25
+ it('only advertises the two fixable checks', () => {
26
+ expect(FIXABLE_CHECKS).toEqual(['git-credential-helper', 'git-remote']);
27
+ });
28
+ it('registers the git credential helper when that check is failing', async () => {
29
+ const outcomes = await applyDoctorFixes(makeCtx(), ['git-credential-helper']);
30
+ expect(ensureGitCredentialHelper).toHaveBeenCalledWith('https://runwork.ai');
31
+ expect(outcomes).toEqual([
32
+ { name: 'git-credential-helper', applied: true, message: expect.stringContaining('registered') },
33
+ ]);
34
+ });
35
+ it('cannot fix the credential helper when not logged in', async () => {
36
+ const outcomes = await applyDoctorFixes(makeCtx({ credentials: null }), ['git-credential-helper']);
37
+ expect(ensureGitCredentialHelper).not.toHaveBeenCalled();
38
+ expect(outcomes[0]).toMatchObject({ name: 'git-credential-helper', applied: false });
39
+ expect(outcomes[0].message).toContain('runwork login');
40
+ });
41
+ it('configures the runwork remote with the canonical /api/git URL', async () => {
42
+ const outcomes = await applyDoctorFixes(makeCtx(), ['git-remote']);
43
+ expect(ensureRunworkRemote).toHaveBeenCalledWith('/repo', 'https://runwork.ai/api/git/ws/app');
44
+ expect(outcomes[0]).toMatchObject({ name: 'git-remote', applied: true });
45
+ });
46
+ it('cannot fix the remote outside a project (no config)', async () => {
47
+ const outcomes = await applyDoctorFixes(makeCtx({ config: null }), ['git-remote']);
48
+ expect(ensureRunworkRemote).not.toHaveBeenCalled();
49
+ expect(outcomes[0]).toMatchObject({ name: 'git-remote', applied: false });
50
+ expect(outcomes[0].message).toContain('project');
51
+ });
52
+ it('cannot fix the remote when the directory is not a git repo', async () => {
53
+ vi.mocked(mockExistsSync).mockReturnValue(false);
54
+ const outcomes = await applyDoctorFixes(makeCtx(), ['git-remote']);
55
+ expect(ensureRunworkRemote).not.toHaveBeenCalled();
56
+ expect(outcomes[0]).toMatchObject({ name: 'git-remote', applied: false });
57
+ expect(outcomes[0].message).toContain('git init');
58
+ });
59
+ it('ignores failing checks it cannot remediate', async () => {
60
+ const outcomes = await applyDoctorFixes(makeCtx(), ['auth', 'network', 'cli-version']);
61
+ expect(ensureGitCredentialHelper).not.toHaveBeenCalled();
62
+ expect(ensureRunworkRemote).not.toHaveBeenCalled();
63
+ expect(outcomes).toEqual([]);
64
+ });
65
+ it('applies both fixes when both checks fail', async () => {
66
+ const outcomes = await applyDoctorFixes(makeCtx(), ['git-credential-helper', 'git-remote']);
67
+ expect(ensureGitCredentialHelper).toHaveBeenCalledOnce();
68
+ expect(ensureRunworkRemote).toHaveBeenCalledOnce();
69
+ expect(outcomes.map(o => o.name)).toEqual(['git-credential-helper', 'git-remote']);
70
+ expect(outcomes.every(o => o.applied)).toBe(true);
71
+ });
72
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ // Mock the check implementations so we test ONLY the runner's selection logic
3
+ // (no network, no git, no fs). Each returns a trivial passing result tagged
4
+ // with its own name.
5
+ function pass(name) {
6
+ return { name, status: 'pass', message: '' };
7
+ }
8
+ vi.mock('../checks.js', () => ({
9
+ buildContext: vi.fn(() => ({ credentials: null, client: null, config: null, cwd: '/repo' })),
10
+ checkSystemDeps: vi.fn(async () => pass('system-deps')),
11
+ checkCliVersion: vi.fn(async () => pass('cli-version')),
12
+ checkCliArtifactReachable: vi.fn(async () => pass('cli-artifact')),
13
+ checkCliInstallLocation: vi.fn(async () => pass('cli-install-location')),
14
+ checkAuthAndNetwork: vi.fn(async () => ({ auth: pass('auth'), network: pass('network') })),
15
+ checkGitCredentialHelper: vi.fn(async () => pass('git-credential-helper')),
16
+ checkProjectConfig: vi.fn(async () => pass('project-config')),
17
+ checkAppExists: vi.fn(async () => pass('app-exists')),
18
+ checkGitRemote: vi.fn(async () => pass('git-remote')),
19
+ checkDeployFreshness: vi.fn(async () => pass('deploy-freshness')),
20
+ checkAgentSetup: vi.fn(async () => pass('agent-setup')),
21
+ }));
22
+ const { runAllChecks, ALL_CHECK_NAMES } = await import('../runner.js');
23
+ describe('health/runner check selection', () => {
24
+ it('ALL_CHECK_NAMES lists every check in run order', () => {
25
+ expect(ALL_CHECK_NAMES).toEqual([
26
+ 'system-deps',
27
+ 'cli-version',
28
+ 'cli-artifact',
29
+ 'cli-install-location',
30
+ 'auth',
31
+ 'network',
32
+ 'git-credential-helper',
33
+ 'project-config',
34
+ 'app-exists',
35
+ 'git-remote',
36
+ 'deploy-freshness',
37
+ 'agent-setup',
38
+ ]);
39
+ });
40
+ it('runs every check when no filter is given', async () => {
41
+ const report = await runAllChecks();
42
+ expect(report.checks.map(c => c.name)).toEqual(ALL_CHECK_NAMES);
43
+ });
44
+ it('runs only the requested check when scoped', async () => {
45
+ const report = await runAllChecks({ only: ['git-credential-helper'] });
46
+ expect(report.checks.map(c => c.name)).toEqual(['git-credential-helper']);
47
+ });
48
+ it('includes only the requested name from a multi-result runner (auth without network)', async () => {
49
+ const report = await runAllChecks({ only: ['auth'] });
50
+ expect(report.checks.map(c => c.name)).toEqual(['auth']);
51
+ });
52
+ it('can request both halves of a combined runner', async () => {
53
+ const report = await runAllChecks({ only: ['auth', 'network'] });
54
+ expect(report.checks.map(c => c.name)).toEqual(['auth', 'network']);
55
+ });
56
+ it('returns an empty pass report when the filter matches nothing', async () => {
57
+ const report = await runAllChecks({ only: ['does-not-exist'] });
58
+ expect(report.checks).toEqual([]);
59
+ expect(report.status).toBe('pass');
60
+ });
61
+ });
@@ -32,4 +32,11 @@ export declare function checkGitCredentialHelper(ctx: DoctorContext): Promise<Ch
32
32
  export declare function checkProjectConfig(ctx: DoctorContext): Promise<CheckResult>;
33
33
  export declare function checkAppExists(ctx: DoctorContext): Promise<CheckResult>;
34
34
  export declare function checkGitRemote(ctx: DoctorContext): Promise<CheckResult>;
35
+ /**
36
+ * Compare the current local HEAD against the last deploy recorded from this
37
+ * machine (`.runwork/last-deploy.json`). Surfaces "you have undeployed
38
+ * commits" so a deploy can be verified at a glance (friction log #8). Silent
39
+ * (skip) outside a project or before the first local deploy.
40
+ */
41
+ export declare function checkDeployFreshness(ctx: DoctorContext): Promise<CheckResult>;
35
42
  export declare function checkAgentSetup(): Promise<CheckResult>;
@@ -8,6 +8,8 @@ import { ApiClient } from '../api/client.js';
8
8
  import { detectAgents, getAdapterBySlug } from '../agents/detect.js';
9
9
  import { httpFetch } from '../utils/http.js';
10
10
  import { probeGit, parseGitVersion } from '../git/preflight.js';
11
+ import { lookupCredentialHelper, helperBinaryStatus } from '../git/credentials.js';
12
+ import { getDeploySummary } from '../deploy/deploy-state.js';
11
13
  // First-party distribution endpoints. Defaults match the plan, can be
12
14
  // overridden via RUNWORK_DOWNLOAD_BASE_URL for staging or debugging.
13
15
  const BASE_URL = process.env.RUNWORK_DOWNLOAD_BASE_URL || 'https://runwork.ai';
@@ -283,56 +285,40 @@ export async function checkGitCredentialHelper(ctx) {
283
285
  fix: 'runwork login',
284
286
  };
285
287
  }
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 {
288
+ // The scoped lookup (get-regexp + origin match) is shared with the
289
+ // lazy-ensure path in git/credentials.ts so the two can't drift. We only
290
+ // need to map its result onto doctor's richer status messages here.
291
+ const lookup = lookupCredentialHelper(origin);
292
+ if (lookup.status === 'none') {
294
293
  return {
295
294
  name: 'git-credential-helper',
296
295
  status: 'fail',
297
296
  message: `no credential helper registered for ${origin}`,
298
- fix: 'runwork login',
297
+ fix: 'runwork doctor --fix',
299
298
  };
300
299
  }
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) {
300
+ if (lookup.status === 'unscoped') {
309
301
  return {
310
302
  name: 'git-credential-helper',
311
303
  status: 'fail',
312
- message: `no credential helper registered for ${origin} (found ${lines.length} other runwork helper entries)`,
313
- fix: 'runwork login',
304
+ message: `no credential helper registered for ${origin} (found ${lookup.otherCount} other runwork helper entries)`,
305
+ fix: 'runwork doctor --fix',
314
306
  };
315
307
  }
316
- const value = scopedRegex.exec(matchedLine)?.[1] ?? '';
317
308
  // Helper values starting with `!` are shell commands. Common shapes:
318
309
  // `!runwork git-credential-helper` (PATH-relative)
319
310
  // `!"/abs/path/to/runwork" git-credential-helper` (post-Pass-2 hardening, POSIX)
320
311
  // `!"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
- }
312
+ // helperBinaryStatus() validates only absolute-path entries on disk; it
313
+ // trusts PATH-relative entries to resolve at git-invocation time.
314
+ const binary = helperBinaryStatus(lookup.value);
315
+ if (!binary.ok && binary.path) {
316
+ return {
317
+ name: 'git-credential-helper',
318
+ status: 'fail',
319
+ message: `helper registered but binary not found at ${binary.path}`,
320
+ fix: 'runwork doctor --fix (re-registers with the current binary path)',
321
+ };
336
322
  }
337
323
  return {
338
324
  name: 'git-credential-helper',
@@ -441,9 +427,37 @@ export async function checkGitRemote(ctx) {
441
427
  name: 'git-remote',
442
428
  status: 'fail',
443
429
  message: 'remote "runwork" not configured',
444
- fix: `git remote add runwork ${expectedUrl}`,
430
+ fix: `runwork doctor --fix (or: git remote add runwork ${expectedUrl})`,
431
+ };
432
+ }
433
+ }
434
+ // ── 7a. Deploy freshness ─────────────────────────────────────────────
435
+ /**
436
+ * Compare the current local HEAD against the last deploy recorded from this
437
+ * machine (`.runwork/last-deploy.json`). Surfaces "you have undeployed
438
+ * commits" so a deploy can be verified at a glance (friction log #8). Silent
439
+ * (skip) outside a project or before the first local deploy.
440
+ */
441
+ export async function checkDeployFreshness(ctx) {
442
+ if (!ctx.config) {
443
+ return { name: 'deploy-freshness', status: 'skip', message: 'skipped (no project)' };
444
+ }
445
+ const summary = getDeploySummary(ctx.cwd);
446
+ if (!summary.deployedShortSha) {
447
+ return { name: 'deploy-freshness', status: 'skip', message: 'no deploy recorded from this machine' };
448
+ }
449
+ if (summary.inSync === true) {
450
+ return { name: 'deploy-freshness', status: 'pass', message: `local HEAD matches last deploy (${summary.deployedShortSha})` };
451
+ }
452
+ if (summary.inSync === false) {
453
+ return {
454
+ name: 'deploy-freshness',
455
+ status: 'warn',
456
+ message: `local (${summary.localShortSha}) has commits not in the last deploy (${summary.deployedShortSha})`,
457
+ fix: 'runwork deploy',
445
458
  };
446
459
  }
460
+ return { name: 'deploy-freshness', status: 'skip', message: 'local HEAD unknown' };
447
461
  }
448
462
  // ── 8. Agent setup ───────────────────────────────────────────────────
449
463
  function loadSetupState() {
@@ -0,0 +1,22 @@
1
+ import type { DoctorContext } from './checks.js';
2
+ /** Check names `runwork doctor --fix` knows how to auto-remediate. */
3
+ export declare const FIXABLE_CHECKS: readonly string[];
4
+ export interface FixOutcome {
5
+ /** The check name this outcome corresponds to. */
6
+ name: string;
7
+ /** Whether a remediation was actually applied (false = couldn't, with a reason). */
8
+ applied: boolean;
9
+ /** Human-readable description of what was done, or why it couldn't be. */
10
+ message: string;
11
+ }
12
+ /**
13
+ * Apply auto-remediations for the failing checks doctor knows how to fix.
14
+ * Only acts on `failingNames` that are in FIXABLE_CHECKS; everything else is
15
+ * left for the user (with the `fix` hint each check already carries).
16
+ *
17
+ * - git-credential-helper -> register the runwork git helper for the logged-in
18
+ * origin (the B1 ensure-path), so headless-auth'd sessions can use git.
19
+ * - git-remote -> point the `runwork` remote at the app's canonical URL (the
20
+ * B2 ensure-path), using the correct `/api/git/...` URL from the API client.
21
+ */
22
+ export declare function applyDoctorFixes(ctx: DoctorContext, failingNames: string[]): Promise<FixOutcome[]>;
@@ -0,0 +1,62 @@
1
+ import { existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { ensureGitCredentialHelper } from '../git/credentials.js';
4
+ import { ensureRunworkRemote } from '../git/remote.js';
5
+ /** Check names `runwork doctor --fix` knows how to auto-remediate. */
6
+ export const FIXABLE_CHECKS = ['git-credential-helper', 'git-remote'];
7
+ /**
8
+ * Apply auto-remediations for the failing checks doctor knows how to fix.
9
+ * Only acts on `failingNames` that are in FIXABLE_CHECKS; everything else is
10
+ * left for the user (with the `fix` hint each check already carries).
11
+ *
12
+ * - git-credential-helper -> register the runwork git helper for the logged-in
13
+ * origin (the B1 ensure-path), so headless-auth'd sessions can use git.
14
+ * - git-remote -> point the `runwork` remote at the app's canonical URL (the
15
+ * B2 ensure-path), using the correct `/api/git/...` URL from the API client.
16
+ */
17
+ export async function applyDoctorFixes(ctx, failingNames) {
18
+ const failing = new Set(failingNames);
19
+ const outcomes = [];
20
+ if (failing.has('git-credential-helper')) {
21
+ if (ctx.credentials?.baseUrl) {
22
+ await ensureGitCredentialHelper(ctx.credentials.baseUrl);
23
+ outcomes.push({
24
+ name: 'git-credential-helper',
25
+ applied: true,
26
+ message: 'registered the runwork git credential helper',
27
+ });
28
+ }
29
+ else {
30
+ outcomes.push({
31
+ name: 'git-credential-helper',
32
+ applied: false,
33
+ message: 'not logged in -- run `runwork login` first',
34
+ });
35
+ }
36
+ }
37
+ if (failing.has('git-remote')) {
38
+ if (!ctx.config || !ctx.client) {
39
+ outcomes.push({
40
+ name: 'git-remote',
41
+ applied: false,
42
+ message: 'no project config -- run inside an app directory',
43
+ });
44
+ }
45
+ else if (!existsSync(join(ctx.cwd, '.git'))) {
46
+ outcomes.push({
47
+ name: 'git-remote',
48
+ applied: false,
49
+ message: 'not a git repository -- run `git init` first',
50
+ });
51
+ }
52
+ else {
53
+ ensureRunworkRemote(ctx.cwd, ctx.client.getGitRemoteUrl(ctx.config.workspaceId, ctx.config.appId));
54
+ outcomes.push({
55
+ name: 'git-remote',
56
+ applied: true,
57
+ message: 'configured the `runwork` remote at the canonical URL',
58
+ });
59
+ }
60
+ }
61
+ return outcomes;
62
+ }
@@ -1,2 +1,8 @@
1
1
  import type { DoctorReport } from './types.js';
2
- export declare function runAllChecks(): Promise<DoctorReport>;
2
+ /** Every check name doctor knows about, in run order. Used to validate `--check`. */
3
+ export declare const ALL_CHECK_NAMES: readonly string[];
4
+ export interface RunChecksOptions {
5
+ /** When set, run only the named checks (skips unrelated work like network). */
6
+ only?: string[];
7
+ }
8
+ export declare function runAllChecks(options?: RunChecksOptions): Promise<DoctorReport>;
@@ -1,32 +1,45 @@
1
- import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkAgentSetup, } from './checks.js';
2
- export async function runAllChecks() {
1
+ import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkDeployFreshness, checkAgentSetup, } from './checks.js';
2
+ const CHECK_RUNNERS = [
3
+ { names: ['system-deps'], run: async () => [await checkSystemDeps()] },
4
+ { names: ['cli-version'], run: async () => [await checkCliVersion()] },
5
+ { names: ['cli-artifact'], run: async () => [await checkCliArtifactReachable()] },
6
+ { names: ['cli-install-location'], run: async () => [await checkCliInstallLocation()] },
7
+ // Auth + Network come from one combined call (network reuses the auth probe).
8
+ {
9
+ names: ['auth', 'network'],
10
+ run: async (ctx) => {
11
+ const { auth, network } = await checkAuthAndNetwork(ctx);
12
+ return [auth, network];
13
+ },
14
+ },
15
+ // Catches "logged in but git auth not wired up" -- on Windows this surfaces
16
+ // as a Git Credential Manager popup mid-clone with no obvious cause.
17
+ { names: ['git-credential-helper'], run: async (ctx) => [await checkGitCredentialHelper(ctx)] },
18
+ { names: ['project-config'], run: async (ctx) => [await checkProjectConfig(ctx)] },
19
+ { names: ['app-exists'], run: async (ctx) => [await checkAppExists(ctx)] },
20
+ { names: ['git-remote'], run: async (ctx) => [await checkGitRemote(ctx)] },
21
+ { names: ['deploy-freshness'], run: async (ctx) => [await checkDeployFreshness(ctx)] },
22
+ { names: ['agent-setup'], run: async () => [await checkAgentSetup()] },
23
+ ];
24
+ /** Every check name doctor knows about, in run order. Used to validate `--check`. */
25
+ export const ALL_CHECK_NAMES = CHECK_RUNNERS.flatMap(r => r.names);
26
+ export async function runAllChecks(options) {
3
27
  const ctx = buildContext();
28
+ const only = options?.only && options.only.length ? new Set(options.only) : null;
4
29
  const checks = [];
5
- // 1. System deps
6
- checks.push(await checkSystemDeps());
7
- // 2. CLI version (runwork.ai/cli/latest.json)
8
- checks.push(await checkCliVersion());
9
- // 2a. CLI artifact reachability for this host platform
10
- checks.push(await checkCliArtifactReachable());
11
- // 2b. Canonical install location
12
- checks.push(await checkCliInstallLocation());
13
- // 3 & 4. Auth + Network (combined call)
14
- const { auth, network } = await checkAuthAndNetwork(ctx);
15
- checks.push(auth);
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));
21
- // 5. Project config
22
- checks.push(await checkProjectConfig(ctx));
23
- // 6. App exists
24
- checks.push(await checkAppExists(ctx));
25
- // 7. Git remote
26
- checks.push(await checkGitRemote(ctx));
27
- // 8. Agent setup
28
- checks.push(await checkAgentSetup());
29
- // Overall status: worst of all checks
30
+ for (const runner of CHECK_RUNNERS) {
31
+ // Skip a runner entirely unless at least one of its names was requested.
32
+ if (only && !runner.names.some(n => only.has(n)))
33
+ continue;
34
+ const results = await runner.run(ctx);
35
+ for (const result of results) {
36
+ // For multi-result runners (auth+network), include only the requested
37
+ // names when scoped -- e.g. `--check auth` shouldn't print network.
38
+ if (only && !only.has(result.name))
39
+ continue;
40
+ checks.push(result);
41
+ }
42
+ }
30
43
  const hasFailure = checks.some(c => c.status === 'fail');
31
44
  const hasWarning = checks.some(c => c.status === 'warn');
32
45
  const status = hasFailure ? 'fail' : hasWarning ? 'warn' : 'pass';
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { initCommand } from './commands/init.js';
5
5
  import { cloneCommand } from './commands/clone.js';
6
6
  import { devCommand } from './commands/dev.js';
7
7
  import { deployCommand } from './commands/deploy.js';
8
+ import { validateCommand } from './commands/validate.js';
8
9
  import { logsCommand } from './commands/logs.js';
9
10
  import { upgradeCommand } from './commands/upgrade.js';
10
11
  import { logoutCommand } from './commands/logout.js';
@@ -62,6 +63,7 @@ program.addCommand(initCommand);
62
63
  program.addCommand(cloneCommand);
63
64
  program.addCommand(devCommand);
64
65
  program.addCommand(deployCommand);
66
+ program.addCommand(validateCommand);
65
67
  program.addCommand(logsCommand);
66
68
  program.addCommand(upgradeCommand);
67
69
  program.addCommand(logoutCommand);
@@ -16,6 +16,9 @@ export type PlatformString = string | {
16
16
  * Detection strategy. Supports simple binary/path checks plus richer
17
17
  * Windows-specific methods and a nested `any` combinator for tools that
18
18
  * ship as both GUI app and CLI binary.
19
+ *
20
+ * `always` means "no local probe; treat as available." Used by web-only
21
+ * agents (e.g. ChatGPT Web) that have no local footprint to detect.
19
22
  */
20
23
  export type Detection = {
21
24
  method: 'binary' | 'path';
@@ -26,6 +29,8 @@ export type Detection = {
26
29
  } | {
27
30
  method: 'any';
28
31
  target: Detection[];
32
+ } | {
33
+ method: 'always';
29
34
  };
30
35
  /**
31
36
  * Common shape for anything that can be detected and installed locally.
package/dist/types.d.ts CHANGED
@@ -115,6 +115,7 @@ export interface ExternalSkillRegistration {
115
115
  importedAt: number;
116
116
  importedBy: string;
117
117
  }
118
+ export type McpAuthType = 'none' | 'oauth' | 'header';
118
119
  export interface McpServerConfig {
119
120
  id: string;
120
121
  name: string;
@@ -125,6 +126,8 @@ export interface McpServerConfig {
125
126
  addedBy: string;
126
127
  lastConnectedAt?: number;
127
128
  toolCount?: number;
129
+ authType?: McpAuthType;
130
+ headerNames?: string[];
128
131
  }
129
132
  /**
130
133
  * Per-agent state tracking baked-in default permission rules that the CLI
@@ -0,0 +1 @@
1
+ export {};