runwork 0.14.0 → 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 (83) hide show
  1. package/dist/__tests__/install-scripts.test.js +33 -0
  2. package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
  3. package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
  4. package/dist/agents/__tests__/detection.test.js +3 -0
  5. package/dist/agents/detection.js +3 -0
  6. package/dist/agents/intro-skill.js +5 -3
  7. package/dist/agents/registry-data.d.ts +59 -2
  8. package/dist/agents/registry-data.js +176 -0
  9. package/dist/commands/__tests__/clone-args.test.js +8 -1
  10. package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
  11. package/dist/commands/__tests__/clone-source-result.test.js +45 -0
  12. package/dist/commands/clone.d.ts +33 -0
  13. package/dist/commands/clone.js +105 -10
  14. package/dist/commands/deploy.js +128 -11
  15. package/dist/commands/dev.js +72 -32
  16. package/dist/commands/doctor.js +64 -3
  17. package/dist/commands/info.d.ts +3 -0
  18. package/dist/commands/info.js +11 -0
  19. package/dist/commands/sync.js +14 -1
  20. package/dist/commands/validate.d.ts +2 -0
  21. package/dist/commands/validate.js +89 -0
  22. package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
  23. package/dist/deploy/__tests__/deploy-state.test.js +80 -0
  24. package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
  25. package/dist/deploy/__tests__/deploy-status.test.js +41 -0
  26. package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
  27. package/dist/deploy/__tests__/detach-args.test.js +30 -0
  28. package/dist/deploy/deploy-state.d.ts +36 -0
  29. package/dist/deploy/deploy-state.js +63 -0
  30. package/dist/deploy/deploy-status.d.ts +21 -0
  31. package/dist/deploy/deploy-status.js +36 -0
  32. package/dist/deploy/detach.d.ts +32 -0
  33. package/dist/deploy/detach.js +71 -0
  34. package/dist/dev/__tests__/session.test.js +39 -0
  35. package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
  36. package/dist/dev/__tests__/startup-sync.test.js +32 -0
  37. package/dist/dev/session.d.ts +9 -0
  38. package/dist/dev/session.js +4 -1
  39. package/dist/dev/startup-sync.d.ts +23 -0
  40. package/dist/dev/startup-sync.js +14 -0
  41. package/dist/generated/version.d.ts +1 -1
  42. package/dist/generated/version.js +1 -1
  43. package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
  44. package/dist/git/__tests__/classify-sync-error.test.js +57 -0
  45. package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
  46. package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
  47. package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
  48. package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
  49. package/dist/git/__tests__/repo-config.test.d.ts +1 -0
  50. package/dist/git/__tests__/repo-config.test.js +26 -0
  51. package/dist/git/classify-sync-error.d.ts +21 -0
  52. package/dist/git/classify-sync-error.js +111 -0
  53. package/dist/git/credentials.d.ts +43 -0
  54. package/dist/git/credentials.js +67 -0
  55. package/dist/git/remote.d.ts +37 -0
  56. package/dist/git/remote.js +75 -0
  57. package/dist/git/repo-config.d.ts +8 -0
  58. package/dist/git/repo-config.js +29 -0
  59. package/dist/health/__tests__/checks.test.js +2 -2
  60. package/dist/health/__tests__/fix.test.d.ts +1 -0
  61. package/dist/health/__tests__/fix.test.js +72 -0
  62. package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
  63. package/dist/health/__tests__/runner-filter.test.js +61 -0
  64. package/dist/health/checks.d.ts +7 -0
  65. package/dist/health/checks.js +50 -36
  66. package/dist/health/fix.d.ts +22 -0
  67. package/dist/health/fix.js +62 -0
  68. package/dist/health/runner.d.ts +7 -1
  69. package/dist/health/runner.js +40 -27
  70. package/dist/index.js +2 -0
  71. package/dist/tools/types.d.ts +5 -0
  72. package/dist/utils/__tests__/help-json.test.d.ts +1 -0
  73. package/dist/utils/__tests__/help-json.test.js +39 -0
  74. package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
  75. package/dist/utils/help-json.d.ts +14 -0
  76. package/dist/utils/help-json.js +4 -1
  77. package/dist/validate/__tests__/validate.test.d.ts +1 -0
  78. package/dist/validate/__tests__/validate.test.js +42 -0
  79. package/dist/validate/freshness.d.ts +28 -0
  80. package/dist/validate/freshness.js +48 -0
  81. package/dist/validate/preview.d.ts +21 -0
  82. package/dist/validate/preview.js +39 -0
  83. package/package.json +1 -1
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The sentinel `syncWithRemote` returns when a stash pop conflicts after an
3
+ * otherwise-successful fetch/rebase. Routed through the classifier so the
4
+ * diagnosis stays in one place.
5
+ */
6
+ const STASH_CONFLICT = 'stash-conflict';
7
+ /** Map raw git stderr (or the stash-conflict sentinel) to a coarse reason. */
8
+ export function classifySyncError(raw) {
9
+ if (!raw)
10
+ return 'unknown';
11
+ const s = raw.toLowerCase();
12
+ if (raw === STASH_CONFLICT)
13
+ return 'conflict';
14
+ // Missing/!misnamed remote. A plain `git clone` names the remote `origin`,
15
+ // so `git fetch runwork` fails with this before auth ever matters.
16
+ if (s.includes('does not appear to be a git repository') ||
17
+ s.includes("'runwork' does not appear") ||
18
+ s.includes('no such remote') ||
19
+ (s.includes('remote') && s.includes('not found'))) {
20
+ return 'no-remote';
21
+ }
22
+ // Authentication / authorization.
23
+ if (s.includes('authentication failed') ||
24
+ s.includes('could not read username') ||
25
+ s.includes('could not read password') ||
26
+ s.includes('terminal prompts disabled') ||
27
+ s.includes('permission denied') ||
28
+ s.includes('error: 403') ||
29
+ s.includes('error: 401') ||
30
+ s.includes('http basic: access denied') ||
31
+ s.includes('invalid username or password')) {
32
+ return 'auth';
33
+ }
34
+ // Network / connectivity.
35
+ if (s.includes('could not resolve host') ||
36
+ s.includes('failed to connect') ||
37
+ s.includes("couldn't connect to server") ||
38
+ s.includes('connection timed out') ||
39
+ s.includes('operation timed out') ||
40
+ s.includes('network is unreachable') ||
41
+ s.includes('temporary failure in name resolution')) {
42
+ return 'network';
43
+ }
44
+ // Merge/rebase conflicts.
45
+ if (s.includes('conflict') ||
46
+ s.includes('needs merge') ||
47
+ s.includes('automatic merge failed') ||
48
+ s.includes('patch failed')) {
49
+ return 'conflict';
50
+ }
51
+ return 'unknown';
52
+ }
53
+ /** Build a full, user-facing diagnosis from the raw git stderr. */
54
+ export function diagnoseSyncError(raw) {
55
+ const reason = classifySyncError(raw);
56
+ switch (reason) {
57
+ case 'no-remote':
58
+ return {
59
+ reason,
60
+ message: "git remote 'runwork' is missing or misconfigured",
61
+ diagnosis: "The sync targets a remote named 'runwork', but it doesn't exist in this repository (a plain `git clone` names the remote 'origin').",
62
+ suggestions: [
63
+ 'Run `runwork doctor --fix` to add/repair the runwork remote',
64
+ 'Or add it manually: `git remote add runwork <app git URL>`',
65
+ ],
66
+ };
67
+ case 'auth':
68
+ return {
69
+ reason,
70
+ message: 'git could not authenticate to the runwork remote',
71
+ diagnosis: 'The runwork git credential helper is not registered (or the stored credentials were rejected), so git could not authenticate.',
72
+ suggestions: [
73
+ 'Run `runwork doctor --fix` to register the git credential helper',
74
+ 'If it persists, re-authenticate with `runwork login`',
75
+ ],
76
+ };
77
+ case 'network':
78
+ return {
79
+ reason,
80
+ message: 'network error while syncing with the runwork remote',
81
+ diagnosis: 'git could not reach the runwork host. This is usually a connectivity or DNS issue.',
82
+ suggestions: [
83
+ 'Check your network connection',
84
+ 'Confirm outbound access to runwork.ai, then run `runwork dev` again',
85
+ ],
86
+ };
87
+ case 'conflict':
88
+ return {
89
+ reason,
90
+ message: raw === STASH_CONFLICT
91
+ ? 'merge conflicts after restoring local changes'
92
+ : 'merge conflicts while syncing with the remote',
93
+ diagnosis: 'Local changes could not be automatically merged with the remote state; the conflict needs manual resolution.',
94
+ suggestions: [
95
+ 'Resolve the conflicts in the affected files (`git status` lists them)',
96
+ 'Run `git add` on resolved files, then `git commit`',
97
+ 'Run `runwork dev` again after resolving',
98
+ ],
99
+ };
100
+ default:
101
+ return {
102
+ reason,
103
+ message: raw || 'file sync failed',
104
+ diagnosis: 'Could not sync files to the sandbox.',
105
+ suggestions: [
106
+ 'Run `runwork doctor` to diagnose environment issues',
107
+ 'Run `runwork dev` again to start a fresh session',
108
+ ],
109
+ };
110
+ }
111
+ }
@@ -20,6 +20,49 @@ export declare function buildHelperValue(execPath: string): string;
20
20
  * Called by `runwork login` and `runwork init`.
21
21
  */
22
22
  export declare function configureGitCredentials(remoteUrl: string): Promise<void>;
23
+ /**
24
+ * Result of looking up the git credential helper registered for a given
25
+ * origin in the user's global git config. Shared by `runwork doctor`
26
+ * (reporting) and the lazy-ensure path (clone/dev/deploy) so the detection
27
+ * logic never drifts between the two.
28
+ */
29
+ export type CredentialHelperLookup = {
30
+ status: 'none';
31
+ } | {
32
+ status: 'unscoped';
33
+ otherCount: number;
34
+ } | {
35
+ status: 'registered';
36
+ value: string;
37
+ };
38
+ /**
39
+ * Read the git credential helper (if any) registered for `origin` in the
40
+ * global git config. Mirrors the scoped lookup `runwork doctor` performs: an
41
+ * entry only counts when its scope matches the user's baseUrl origin, so a
42
+ * stale staging origin from a previous login doesn't mask a missing entry.
43
+ */
44
+ export declare function lookupCredentialHelper(origin: string): CredentialHelperLookup;
45
+ /**
46
+ * Inspect the binary a credential-helper value points at. Only absolute-path
47
+ * helper values (`!"/abs/path" git-credential-helper`) can be validated on
48
+ * disk; PATH-relative entries are trusted to resolve at git-invocation time
49
+ * (reported `ok` with a null path). Shared so the doctor check and the
50
+ * lazy-ensure path agree on what "stale" means.
51
+ */
52
+ export declare function helperBinaryStatus(value: string): {
53
+ ok: boolean;
54
+ path: string | null;
55
+ };
56
+ /**
57
+ * Idempotently make sure the runwork git credential helper is registered for
58
+ * the given baseUrl's origin -- registering it when absent, or repairing it
59
+ * when it points at a binary that no longer exists (e.g. after the CLI moved
60
+ * on upgrade). This is the bridge that lets headless auth paths -- the
61
+ * `get_cli_setup` MCP tool or a hand-written `~/.runwork/.credentials` --
62
+ * work with git without a separate `runwork login`. Safe (and cheap) to call
63
+ * before every git operation in clone/dev/deploy.
64
+ */
65
+ export declare function ensureGitCredentialHelper(baseUrl: string): Promise<void>;
23
66
  /**
24
67
  * Remove the git credential helper configuration for our remote.
25
68
  */
@@ -1,3 +1,4 @@
1
+ import { existsSync } from 'fs';
1
2
  import { execFileSync } from '../utils/subprocess.js';
2
3
  import { getCredentials } from '../auth/store.js';
3
4
  /**
@@ -44,6 +45,72 @@ export async function configureGitCredentials(remoteUrl) {
44
45
  throw err;
45
46
  }
46
47
  }
48
+ /**
49
+ * Read the git credential helper (if any) registered for `origin` in the
50
+ * global git config. Mirrors the scoped lookup `runwork doctor` performs: an
51
+ * entry only counts when its scope matches the user's baseUrl origin, so a
52
+ * stale staging origin from a previous login doesn't mask a missing entry.
53
+ */
54
+ export function lookupCredentialHelper(origin) {
55
+ // `--get-regexp` exits 1 (printing nothing) when no entry matches, which
56
+ // surfaces here as a thrown error -- a normal "not registered" signal.
57
+ let helperConfig;
58
+ try {
59
+ const buf = execFileSync('git', ['config', '--global', '--get-regexp', 'credential\\..*runwork.*\\.helper'], { stdio: ['ignore', 'pipe', 'ignore'] });
60
+ helperConfig = buf.toString();
61
+ }
62
+ catch {
63
+ return { status: 'none' };
64
+ }
65
+ const lines = helperConfig.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
66
+ const escapedOrigin = origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
67
+ const scopedRegex = new RegExp(`^credential\\.${escapedOrigin}\\.helper\\s+(.+)$`);
68
+ const matchedLine = lines.find(line => scopedRegex.test(line));
69
+ if (!matchedLine) {
70
+ return { status: 'unscoped', otherCount: lines.length };
71
+ }
72
+ return { status: 'registered', value: scopedRegex.exec(matchedLine)?.[1] ?? '' };
73
+ }
74
+ /**
75
+ * Inspect the binary a credential-helper value points at. Only absolute-path
76
+ * helper values (`!"/abs/path" git-credential-helper`) can be validated on
77
+ * disk; PATH-relative entries are trusted to resolve at git-invocation time
78
+ * (reported `ok` with a null path). Shared so the doctor check and the
79
+ * lazy-ensure path agree on what "stale" means.
80
+ */
81
+ export function helperBinaryStatus(value) {
82
+ const absoluteHelperRegex = /^!"?(\/|[A-Za-z]:[\\/])/;
83
+ if (!absoluteHelperRegex.test(value))
84
+ return { ok: true, path: null };
85
+ const pathMatch = value.match(/^!"([^"]+)"|^!(\S+)/);
86
+ const helperPath = pathMatch?.[1] ?? pathMatch?.[2] ?? '';
87
+ if (!helperPath)
88
+ return { ok: true, path: null };
89
+ return { ok: existsSync(helperPath), path: helperPath };
90
+ }
91
+ /**
92
+ * Idempotently make sure the runwork git credential helper is registered for
93
+ * the given baseUrl's origin -- registering it when absent, or repairing it
94
+ * when it points at a binary that no longer exists (e.g. after the CLI moved
95
+ * on upgrade). This is the bridge that lets headless auth paths -- the
96
+ * `get_cli_setup` MCP tool or a hand-written `~/.runwork/.credentials` --
97
+ * work with git without a separate `runwork login`. Safe (and cheap) to call
98
+ * before every git operation in clone/dev/deploy.
99
+ */
100
+ export async function ensureGitCredentialHelper(baseUrl) {
101
+ let origin;
102
+ try {
103
+ origin = new URL(baseUrl).origin;
104
+ }
105
+ catch {
106
+ return; // Unparseable baseUrl -- nothing actionable.
107
+ }
108
+ const lookup = lookupCredentialHelper(origin);
109
+ if (lookup.status === 'registered' && helperBinaryStatus(lookup.value).ok) {
110
+ return; // Already registered and valid -- no-op.
111
+ }
112
+ await configureGitCredentials(baseUrl);
113
+ }
47
114
  /**
48
115
  * Remove the git credential helper configuration for our remote.
49
116
  */
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The git remote name runwork uses for all sync/push operations. The sync
3
+ * loop (git/sync.ts) references this remote by name, so clone/dev/deploy must
4
+ * guarantee it exists and points at the app's canonical URL before syncing.
5
+ */
6
+ export declare const RUNWORK_REMOTE = "runwork";
7
+ export type EnsureRemoteResult = {
8
+ action: 'noop';
9
+ } | {
10
+ action: 'updated';
11
+ } | {
12
+ action: 'added';
13
+ reusedFrom?: string;
14
+ };
15
+ /**
16
+ * Normalize a git remote URL for equality comparison. Strips a trailing
17
+ * `.git`, trailing slashes, and userinfo; lower-cases the host (case-
18
+ * insensitive) while preserving the path (case-sensitive). Falls back to a
19
+ * lower-cased trim for scp-style / non-URL remotes.
20
+ */
21
+ export declare function normalizeRemoteUrl(raw: string): string;
22
+ /** Map of remote name -> fetch URL, parsed from `git remote -v`. */
23
+ export declare function listRemotes(cwd: string): Record<string, string>;
24
+ /**
25
+ * Ensure a `runwork` remote exists and points at the app's canonical git URL,
26
+ * so the sync loop's `runwork`-named git calls succeed regardless of how the
27
+ * repo was obtained. A plain `git clone <url>` names the remote `origin`,
28
+ * which previously made `runwork dev`/`deploy` fail with
29
+ * `fatal: 'runwork' does not appear to be a git repository`.
30
+ *
31
+ * We don't require the remote to literally be named `runwork`: if another
32
+ * remote (e.g. `origin`) already points at the canonical URL, we recognize it
33
+ * (reported via `reusedFrom`) and still register the `runwork` alias so the
34
+ * hardcoded sync path keeps working. Idempotent: a no-op when `runwork`
35
+ * already points at the canonical URL.
36
+ */
37
+ export declare function ensureRunworkRemote(cwd: string, canonicalUrl: string): EnsureRemoteResult;
@@ -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>;