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.
- package/bundled-types/core-endpoints.d.ts +16 -4
- package/dist/__tests__/install-scripts.test.js +33 -0
- package/dist/agents/__tests__/chatgpt-registry.test.d.ts +1 -0
- package/dist/agents/__tests__/chatgpt-registry.test.js +61 -0
- package/dist/agents/__tests__/detection.test.js +3 -0
- package/dist/agents/claude-desktop-plugin-tree.js +23 -8
- package/dist/agents/claude-desktop.js +6 -3
- package/dist/agents/codex.js +15 -7
- package/dist/agents/detection.js +3 -0
- package/dist/agents/intro-skill.js +5 -3
- package/dist/agents/registry-data.d.ts +59 -2
- package/dist/agents/registry-data.js +176 -0
- package/dist/api/client.d.ts +5 -0
- package/dist/api/client.js +8 -0
- package/dist/commands/__tests__/clone-args.test.js +8 -1
- package/dist/commands/__tests__/clone-source-result.test.d.ts +1 -0
- package/dist/commands/__tests__/clone-source-result.test.js +45 -0
- package/dist/commands/__tests__/mcp-entries.test.d.ts +1 -0
- package/dist/commands/__tests__/mcp-entries.test.js +48 -0
- package/dist/commands/clone.d.ts +33 -0
- package/dist/commands/clone.js +105 -10
- package/dist/commands/deploy.js +128 -11
- package/dist/commands/dev.js +72 -32
- package/dist/commands/doctor.js +64 -3
- package/dist/commands/info.d.ts +3 -0
- package/dist/commands/info.js +11 -0
- package/dist/commands/mcp-entries.d.ts +13 -0
- package/dist/commands/mcp-entries.js +26 -0
- package/dist/commands/sync.js +20 -11
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.js +89 -0
- package/dist/deploy/__tests__/deploy-state.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-state.test.js +80 -0
- package/dist/deploy/__tests__/deploy-status.test.d.ts +1 -0
- package/dist/deploy/__tests__/deploy-status.test.js +41 -0
- package/dist/deploy/__tests__/detach-args.test.d.ts +1 -0
- package/dist/deploy/__tests__/detach-args.test.js +30 -0
- package/dist/deploy/deploy-state.d.ts +36 -0
- package/dist/deploy/deploy-state.js +63 -0
- package/dist/deploy/deploy-status.d.ts +21 -0
- package/dist/deploy/deploy-status.js +36 -0
- package/dist/deploy/detach.d.ts +32 -0
- package/dist/deploy/detach.js +71 -0
- package/dist/dev/__tests__/session.test.js +39 -0
- package/dist/dev/__tests__/startup-sync.test.d.ts +1 -0
- package/dist/dev/__tests__/startup-sync.test.js +32 -0
- package/dist/dev/session.d.ts +9 -0
- package/dist/dev/session.js +4 -1
- package/dist/dev/startup-sync.d.ts +23 -0
- package/dist/dev/startup-sync.js +14 -0
- package/dist/generated/bundled-types.js +1 -1
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/classify-sync-error.test.d.ts +1 -0
- package/dist/git/__tests__/classify-sync-error.test.js +57 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-git-credential-helper.test.js +133 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.d.ts +1 -0
- package/dist/git/__tests__/ensure-runwork-remote.test.js +109 -0
- package/dist/git/__tests__/repo-config.test.d.ts +1 -0
- package/dist/git/__tests__/repo-config.test.js +26 -0
- package/dist/git/classify-sync-error.d.ts +21 -0
- package/dist/git/classify-sync-error.js +111 -0
- package/dist/git/credentials.d.ts +43 -0
- package/dist/git/credentials.js +67 -0
- package/dist/git/remote.d.ts +37 -0
- package/dist/git/remote.js +75 -0
- package/dist/git/repo-config.d.ts +8 -0
- package/dist/git/repo-config.js +29 -0
- package/dist/health/__tests__/checks.test.js +2 -2
- package/dist/health/__tests__/fix.test.d.ts +1 -0
- package/dist/health/__tests__/fix.test.js +72 -0
- package/dist/health/__tests__/runner-filter.test.d.ts +1 -0
- package/dist/health/__tests__/runner-filter.test.js +61 -0
- package/dist/health/checks.d.ts +7 -0
- package/dist/health/checks.js +50 -36
- package/dist/health/fix.d.ts +22 -0
- package/dist/health/fix.js +62 -0
- package/dist/health/runner.d.ts +7 -1
- package/dist/health/runner.js +40 -27
- package/dist/index.js +2 -0
- package/dist/tools/types.d.ts +5 -0
- package/dist/types.d.ts +3 -0
- package/dist/utils/__tests__/help-json.test.d.ts +1 -0
- package/dist/utils/__tests__/help-json.test.js +39 -0
- package/dist/utils/__tests__/ignore-matcher.test.js +18 -1
- package/dist/utils/help-json.d.ts +14 -0
- package/dist/utils/help-json.js +4 -1
- package/dist/validate/__tests__/validate.test.d.ts +1 -0
- package/dist/validate/__tests__/validate.test.js +42 -0
- package/dist/validate/freshness.d.ts +28 -0
- package/dist/validate/freshness.js +48 -0
- package/dist/validate/preview.d.ts +21 -0
- package/dist/validate/preview.js +39 -0
- 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
|
|
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
|
|
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
|
+
});
|
package/dist/health/checks.d.ts
CHANGED
|
@@ -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>;
|
package/dist/health/checks.js
CHANGED
|
@@ -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
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
|
297
|
+
fix: 'runwork doctor --fix',
|
|
299
298
|
};
|
|
300
299
|
}
|
|
301
|
-
|
|
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 ${
|
|
313
|
-
fix: 'runwork
|
|
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
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
+
}
|
package/dist/health/runner.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
1
|
import type { DoctorReport } from './types.js';
|
|
2
|
-
|
|
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>;
|
package/dist/health/runner.js
CHANGED
|
@@ -1,32 +1,45 @@
|
|
|
1
|
-
import { buildContext, checkSystemDeps, checkCliVersion, checkCliArtifactReachable, checkCliInstallLocation, checkAuthAndNetwork, checkGitCredentialHelper, checkProjectConfig, checkAppExists, checkGitRemote, checkAgentSetup, } from './checks.js';
|
|
2
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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);
|
package/dist/tools/types.d.ts
CHANGED
|
@@ -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 {};
|