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,57 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { classifySyncError, diagnoseSyncError } from '../classify-sync-error.js';
|
|
3
|
+
describe('git/classify-sync-error', () => {
|
|
4
|
+
describe('classifySyncError()', () => {
|
|
5
|
+
it('detects a missing/misnamed runwork remote', () => {
|
|
6
|
+
expect(classifySyncError("fatal: 'runwork' does not appear to be a git repository")).toBe('no-remote');
|
|
7
|
+
expect(classifySyncError('fatal: No such remote: runwork')).toBe('no-remote');
|
|
8
|
+
});
|
|
9
|
+
it('detects authentication failures', () => {
|
|
10
|
+
expect(classifySyncError('fatal: Authentication failed for https://runwork.ai/...')).toBe('auth');
|
|
11
|
+
expect(classifySyncError('fatal: could not read Username for https://runwork.ai: terminal prompts disabled')).toBe('auth');
|
|
12
|
+
expect(classifySyncError("fatal: unable to access '...': The requested URL returned error: 403")).toBe('auth');
|
|
13
|
+
});
|
|
14
|
+
it('detects network failures', () => {
|
|
15
|
+
expect(classifySyncError("fatal: unable to access '...': Could not resolve host: runwork.ai")).toBe('network');
|
|
16
|
+
expect(classifySyncError('fatal: unable to access: Failed to connect to runwork.ai port 443: Connection timed out')).toBe('network');
|
|
17
|
+
});
|
|
18
|
+
it('detects merge/rebase conflicts and the stash-conflict sentinel', () => {
|
|
19
|
+
expect(classifySyncError('CONFLICT (content): Merge conflict in src/App.tsx')).toBe('conflict');
|
|
20
|
+
expect(classifySyncError('error: Automatic merge failed')).toBe('conflict');
|
|
21
|
+
expect(classifySyncError('stash-conflict')).toBe('conflict');
|
|
22
|
+
});
|
|
23
|
+
it('falls back to unknown for unrecognized errors and empty input', () => {
|
|
24
|
+
expect(classifySyncError('some unexpected git failure')).toBe('unknown');
|
|
25
|
+
expect(classifySyncError(undefined)).toBe('unknown');
|
|
26
|
+
expect(classifySyncError('')).toBe('unknown');
|
|
27
|
+
});
|
|
28
|
+
it('is case-insensitive', () => {
|
|
29
|
+
expect(classifySyncError('AUTHENTICATION FAILED')).toBe('auth');
|
|
30
|
+
expect(classifySyncError('could not resolve HOST')).toBe('network');
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
describe('diagnoseSyncError()', () => {
|
|
34
|
+
it('points a no-remote failure at doctor --fix, not network/session', () => {
|
|
35
|
+
const d = diagnoseSyncError("fatal: 'runwork' does not appear to be a git repository");
|
|
36
|
+
expect(d.reason).toBe('no-remote');
|
|
37
|
+
expect(d.suggestions.join(' ')).toContain('runwork doctor --fix');
|
|
38
|
+
// Must NOT regress to the old misleading "session expired / network" advice.
|
|
39
|
+
expect(d.diagnosis.toLowerCase()).not.toContain('session expired');
|
|
40
|
+
});
|
|
41
|
+
it('points an auth failure at credential-helper remediation', () => {
|
|
42
|
+
const d = diagnoseSyncError('fatal: Authentication failed');
|
|
43
|
+
expect(d.reason).toBe('auth');
|
|
44
|
+
expect(d.suggestions.join(' ')).toContain('runwork doctor --fix');
|
|
45
|
+
});
|
|
46
|
+
it('gives stash-conflict a restore-specific message', () => {
|
|
47
|
+
const d = diagnoseSyncError('stash-conflict');
|
|
48
|
+
expect(d.reason).toBe('conflict');
|
|
49
|
+
expect(d.message).toContain('restoring local changes');
|
|
50
|
+
});
|
|
51
|
+
it('preserves the raw error in the unknown-case message', () => {
|
|
52
|
+
const d = diagnoseSyncError('weird git explosion');
|
|
53
|
+
expect(d.reason).toBe('unknown');
|
|
54
|
+
expect(d.message).toBe('weird git explosion');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
vi.mock('fs', () => ({
|
|
4
|
+
existsSync: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
vi.mock('../../auth/store.js', () => ({
|
|
7
|
+
getCredentials: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
10
|
+
const { existsSync: mockExistsSync } = await import('fs');
|
|
11
|
+
const { lookupCredentialHelper, helperBinaryStatus, ensureGitCredentialHelper, } = await import('../credentials.js');
|
|
12
|
+
/** Build a `git config --get-regexp` stdout block from full config lines. */
|
|
13
|
+
function gitConfigOutput(...lines) {
|
|
14
|
+
return Buffer.from(lines.join('\n') + '\n');
|
|
15
|
+
}
|
|
16
|
+
/** True when a recorded execFileSync call wrote a credential helper config. */
|
|
17
|
+
function isHelperWrite(call) {
|
|
18
|
+
const args = call[1];
|
|
19
|
+
return call[0] === 'git'
|
|
20
|
+
&& Array.isArray(args)
|
|
21
|
+
&& args.includes('--global')
|
|
22
|
+
&& args.some(a => /^credential\..*\.helper$/.test(a))
|
|
23
|
+
&& !args.includes('--get-regexp')
|
|
24
|
+
&& !args.includes('--unset');
|
|
25
|
+
}
|
|
26
|
+
describe('git/credentials lazy-ensure', () => {
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
describe('lookupCredentialHelper()', () => {
|
|
31
|
+
it('returns "none" when git has no matching entries (get-regexp exits non-zero)', () => {
|
|
32
|
+
mockExecFileSync.mockImplementation(() => {
|
|
33
|
+
throw new Error('exit 1'); // git config --get-regexp: no match
|
|
34
|
+
});
|
|
35
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({ status: 'none' });
|
|
36
|
+
});
|
|
37
|
+
it('returns "unscoped" when entries exist but none match the origin', () => {
|
|
38
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork-staging.example.com.helper !runwork git-credential-helper'));
|
|
39
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({
|
|
40
|
+
status: 'unscoped',
|
|
41
|
+
otherCount: 1,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('returns "registered" with the helper value when a scoped entry exists', () => {
|
|
45
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork.ai.helper !"/usr/local/bin/runwork" git-credential-helper'));
|
|
46
|
+
expect(lookupCredentialHelper('https://runwork.ai')).toEqual({
|
|
47
|
+
status: 'registered',
|
|
48
|
+
value: '!"/usr/local/bin/runwork" git-credential-helper',
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
it('matches the correct origin among multiple entries', () => {
|
|
52
|
+
mockExecFileSync.mockReturnValue(gitConfigOutput('credential.https://runwork-staging.example.com.helper !runwork git-credential-helper', 'credential.https://runwork.ai.helper !"/usr/local/bin/runwork" git-credential-helper'));
|
|
53
|
+
const result = lookupCredentialHelper('https://runwork.ai');
|
|
54
|
+
expect(result.status).toBe('registered');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
describe('helperBinaryStatus()', () => {
|
|
58
|
+
it('trusts PATH-relative helper values (no on-disk check)', () => {
|
|
59
|
+
expect(helperBinaryStatus('!runwork git-credential-helper')).toEqual({ ok: true, path: null });
|
|
60
|
+
expect(mockExistsSync).not.toHaveBeenCalled();
|
|
61
|
+
});
|
|
62
|
+
it('reports ok when an absolute-path binary exists', () => {
|
|
63
|
+
vi.mocked(mockExistsSync).mockReturnValue(true);
|
|
64
|
+
expect(helperBinaryStatus('!"/usr/local/bin/runwork" git-credential-helper')).toEqual({
|
|
65
|
+
ok: true,
|
|
66
|
+
path: '/usr/local/bin/runwork',
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
it('reports not-ok with the path when an absolute-path binary is missing', () => {
|
|
70
|
+
vi.mocked(mockExistsSync).mockReturnValue(false);
|
|
71
|
+
expect(helperBinaryStatus('!"/old/path/runwork" git-credential-helper')).toEqual({
|
|
72
|
+
ok: false,
|
|
73
|
+
path: '/old/path/runwork',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it('handles Windows absolute paths', () => {
|
|
77
|
+
vi.mocked(mockExistsSync).mockReturnValue(true);
|
|
78
|
+
const result = helperBinaryStatus('!"C:/Users/test/.runwork/bin/runwork.exe" git-credential-helper');
|
|
79
|
+
expect(result.ok).toBe(true);
|
|
80
|
+
expect(result.path).toBe('C:/Users/test/.runwork/bin/runwork.exe');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('ensureGitCredentialHelper()', () => {
|
|
84
|
+
it('is a no-op when a valid scoped helper is already registered', async () => {
|
|
85
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
86
|
+
if (args.includes('--get-regexp')) {
|
|
87
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !runwork git-credential-helper');
|
|
88
|
+
}
|
|
89
|
+
return Buffer.from('');
|
|
90
|
+
});
|
|
91
|
+
await ensureGitCredentialHelper('https://runwork.ai/api/git/ws/app');
|
|
92
|
+
// No registration write should have happened.
|
|
93
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
it('registers the helper when none is present (the get_cli_setup path)', async () => {
|
|
96
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
97
|
+
if (args.includes('--get-regexp')) {
|
|
98
|
+
throw new Error('exit 1'); // nothing registered yet
|
|
99
|
+
}
|
|
100
|
+
return Buffer.from('');
|
|
101
|
+
});
|
|
102
|
+
await ensureGitCredentialHelper('https://runwork.ai/api/git/ws/app');
|
|
103
|
+
const write = mockExecFileSync.mock.calls.find(isHelperWrite);
|
|
104
|
+
expect(write).toBeDefined();
|
|
105
|
+
expect(write[1]).toContain('credential.https://runwork.ai.helper');
|
|
106
|
+
});
|
|
107
|
+
it('registers when entries exist but none are scoped to this origin', async () => {
|
|
108
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
109
|
+
if (args.includes('--get-regexp')) {
|
|
110
|
+
return gitConfigOutput('credential.https://other.example.com.helper !runwork git-credential-helper');
|
|
111
|
+
}
|
|
112
|
+
return Buffer.from('');
|
|
113
|
+
});
|
|
114
|
+
await ensureGitCredentialHelper('https://runwork.ai');
|
|
115
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
it('repairs a stale helper that points at a missing binary', async () => {
|
|
118
|
+
vi.mocked(mockExistsSync).mockReturnValue(false);
|
|
119
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
120
|
+
if (args.includes('--get-regexp')) {
|
|
121
|
+
return gitConfigOutput('credential.https://runwork.ai.helper !"/old/path/runwork" git-credential-helper');
|
|
122
|
+
}
|
|
123
|
+
return Buffer.from('');
|
|
124
|
+
});
|
|
125
|
+
await ensureGitCredentialHelper('https://runwork.ai');
|
|
126
|
+
expect(mockExecFileSync.mock.calls.some(isHelperWrite)).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
it('is a no-op for an unparseable baseUrl', async () => {
|
|
129
|
+
await ensureGitCredentialHelper('not a url');
|
|
130
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
4
|
+
const { normalizeRemoteUrl, listRemotes, ensureRunworkRemote, } = await import('../remote.js');
|
|
5
|
+
/** Build `git remote -v` stdout from name->url pairs (fetch + push lines). */
|
|
6
|
+
function remoteVerbose(pairs) {
|
|
7
|
+
const lines = [];
|
|
8
|
+
for (const [name, url] of Object.entries(pairs)) {
|
|
9
|
+
lines.push(`${name}\t${url} (fetch)`);
|
|
10
|
+
lines.push(`${name}\t${url} (push)`);
|
|
11
|
+
}
|
|
12
|
+
return Buffer.from(lines.join('\n') + '\n');
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Drive ensureRunworkRemote/listRemotes against a fixed `git remote -v`
|
|
16
|
+
* result, recording any set-url/add mutations.
|
|
17
|
+
*/
|
|
18
|
+
function withRemotes(pairs) {
|
|
19
|
+
const setUrl = [];
|
|
20
|
+
const add = [];
|
|
21
|
+
mockExecFileSync.mockImplementation((_file, args) => {
|
|
22
|
+
if (args[0] === 'remote' && args[1] === '-v') {
|
|
23
|
+
return remoteVerbose(pairs);
|
|
24
|
+
}
|
|
25
|
+
if (args[0] === 'remote' && args[1] === 'set-url') {
|
|
26
|
+
setUrl.push(args);
|
|
27
|
+
return Buffer.from('');
|
|
28
|
+
}
|
|
29
|
+
if (args[0] === 'remote' && args[1] === 'add') {
|
|
30
|
+
add.push(args);
|
|
31
|
+
return Buffer.from('');
|
|
32
|
+
}
|
|
33
|
+
return Buffer.from('');
|
|
34
|
+
});
|
|
35
|
+
return { setUrl, add };
|
|
36
|
+
}
|
|
37
|
+
const CANONICAL = 'https://runwork.ai/api/git/ws/app';
|
|
38
|
+
describe('git/remote', () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
vi.clearAllMocks();
|
|
41
|
+
});
|
|
42
|
+
describe('normalizeRemoteUrl()', () => {
|
|
43
|
+
it('strips a trailing .git', () => {
|
|
44
|
+
expect(normalizeRemoteUrl('https://runwork.ai/api/git/ws/app.git')).toBe(CANONICAL);
|
|
45
|
+
});
|
|
46
|
+
it('strips trailing slashes and lower-cases the host (path preserved)', () => {
|
|
47
|
+
expect(normalizeRemoteUrl('https://RunWork.ai/api/git/ws/app/')).toBe(CANONICAL);
|
|
48
|
+
});
|
|
49
|
+
it('drops userinfo so embedded creds do not break equality', () => {
|
|
50
|
+
expect(normalizeRemoteUrl('https://x-access-token:KEY@runwork.ai/api/git/ws/app')).toBe(CANONICAL);
|
|
51
|
+
});
|
|
52
|
+
it('falls back to a lower-cased trim for scp-style remotes', () => {
|
|
53
|
+
expect(normalizeRemoteUrl('git@GitHub.com:Foo/Bar.git')).toBe('git@github.com:foo/bar');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('listRemotes()', () => {
|
|
57
|
+
it('parses name -> fetch URL, ignoring push duplicates', () => {
|
|
58
|
+
withRemotes({ origin: 'https://example.com/a', runwork: CANONICAL });
|
|
59
|
+
expect(listRemotes('/repo')).toEqual({
|
|
60
|
+
origin: 'https://example.com/a',
|
|
61
|
+
runwork: CANONICAL,
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
it('returns {} when git fails (not a repo)', () => {
|
|
65
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('not a git repo'); });
|
|
66
|
+
expect(listRemotes('/repo')).toEqual({});
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe('ensureRunworkRemote()', () => {
|
|
70
|
+
it('is a no-op when runwork already points at the canonical URL', () => {
|
|
71
|
+
const { setUrl, add } = withRemotes({ runwork: CANONICAL });
|
|
72
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'noop' });
|
|
73
|
+
expect(setUrl).toHaveLength(0);
|
|
74
|
+
expect(add).toHaveLength(0);
|
|
75
|
+
});
|
|
76
|
+
it('treats a .git-suffixed existing runwork remote as a match (no rewrite)', () => {
|
|
77
|
+
const { setUrl, add } = withRemotes({ runwork: `${CANONICAL}.git` });
|
|
78
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'noop' });
|
|
79
|
+
expect(setUrl).toHaveLength(0);
|
|
80
|
+
expect(add).toHaveLength(0);
|
|
81
|
+
});
|
|
82
|
+
it('repoints an existing runwork remote that points elsewhere', () => {
|
|
83
|
+
const { setUrl, add } = withRemotes({ runwork: 'https://runwork.ai/api/git/OLD/app' });
|
|
84
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'updated' });
|
|
85
|
+
expect(setUrl).toHaveLength(1);
|
|
86
|
+
expect(setUrl[0]).toEqual(['remote', 'set-url', 'runwork', CANONICAL]);
|
|
87
|
+
expect(add).toHaveLength(0);
|
|
88
|
+
});
|
|
89
|
+
it('adds the runwork remote when missing (clean repo)', () => {
|
|
90
|
+
const { setUrl, add } = withRemotes({});
|
|
91
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'added' });
|
|
92
|
+
expect(add).toHaveLength(1);
|
|
93
|
+
expect(add[0]).toEqual(['remote', 'add', 'runwork', CANONICAL]);
|
|
94
|
+
expect(setUrl).toHaveLength(0);
|
|
95
|
+
});
|
|
96
|
+
it('recognizes a matching origin (manual git clone) via reusedFrom and still adds the runwork alias', () => {
|
|
97
|
+
const { setUrl, add } = withRemotes({ origin: CANONICAL });
|
|
98
|
+
expect(ensureRunworkRemote('/repo', CANONICAL)).toEqual({ action: 'added', reusedFrom: 'origin' });
|
|
99
|
+
expect(add).toHaveLength(1);
|
|
100
|
+
expect(add[0]).toEqual(['remote', 'add', 'runwork', CANONICAL]);
|
|
101
|
+
expect(setUrl).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
it('does not set reusedFrom when no existing remote matches', () => {
|
|
104
|
+
withRemotes({ origin: 'https://github.com/someone/fork' });
|
|
105
|
+
const result = ensureRunworkRemote('/repo', CANONICAL);
|
|
106
|
+
expect(result).toEqual({ action: 'added' });
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
vi.mock('child_process');
|
|
3
|
+
const mockExecFileSync = (await import('child_process')).execFileSync;
|
|
4
|
+
const { hardenRepoForRestrictedFs } = await import('../repo-config.js');
|
|
5
|
+
describe('git/repo-config hardenRepoForRestrictedFs', () => {
|
|
6
|
+
beforeEach(() => vi.clearAllMocks());
|
|
7
|
+
it('disables gc.auto and maintenance.auto in the repo (local scope)', () => {
|
|
8
|
+
mockExecFileSync.mockReturnValue(Buffer.from(''));
|
|
9
|
+
hardenRepoForRestrictedFs('/repo');
|
|
10
|
+
const configCalls = mockExecFileSync.mock.calls
|
|
11
|
+
.filter((c) => c[0] === 'git' && c[1][0] === 'config')
|
|
12
|
+
.map((c) => c[1].slice(1));
|
|
13
|
+
expect(configCalls).toContainEqual(['gc.auto', '0']);
|
|
14
|
+
expect(configCalls).toContainEqual(['maintenance.auto', 'false']);
|
|
15
|
+
// Repo scope, not --global.
|
|
16
|
+
expect(configCalls.every((args) => !args.includes('--global'))).toBe(true);
|
|
17
|
+
// Run against the given cwd.
|
|
18
|
+
for (const call of mockExecFileSync.mock.calls) {
|
|
19
|
+
expect(call[2].cwd).toBe('/repo');
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
it('never throws when git config fails (best-effort)', () => {
|
|
23
|
+
mockExecFileSync.mockImplementation(() => { throw new Error('git unavailable'); });
|
|
24
|
+
expect(() => hardenRepoForRestrictedFs('/repo')).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classify a git sync failure from the raw stderr that `syncWithRemote`
|
|
3
|
+
* captures, so `runwork dev`/`deploy` can show an accurate cause and fix
|
|
4
|
+
* instead of the old catch-all "the sandbox session expired or the network
|
|
5
|
+
* connection was interrupted" (which pointed users away from the real
|
|
6
|
+
* problem, e.g. a missing `runwork` remote -- see friction log #3).
|
|
7
|
+
*/
|
|
8
|
+
export type SyncErrorReason = 'no-remote' | 'auth' | 'network' | 'conflict' | 'unknown';
|
|
9
|
+
export interface SyncErrorDiagnosis {
|
|
10
|
+
reason: SyncErrorReason;
|
|
11
|
+
/** One-line summary suitable for an `error.message` field. */
|
|
12
|
+
message: string;
|
|
13
|
+
/** Plain explanation of the likely cause. */
|
|
14
|
+
diagnosis: string;
|
|
15
|
+
/** Actionable next steps, most-likely-fix first. */
|
|
16
|
+
suggestions: string[];
|
|
17
|
+
}
|
|
18
|
+
/** Map raw git stderr (or the stash-conflict sentinel) to a coarse reason. */
|
|
19
|
+
export declare function classifySyncError(raw: string | undefined): SyncErrorReason;
|
|
20
|
+
/** Build a full, user-facing diagnosis from the raw git stderr. */
|
|
21
|
+
export declare function diagnoseSyncError(raw: string | undefined): SyncErrorDiagnosis;
|
|
@@ -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
|
*/
|
package/dist/git/credentials.js
CHANGED
|
@@ -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;
|