runwork 0.9.4 → 0.10.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/dist/agents/__tests__/intro-skill.test.js +6 -2
- package/dist/agents/codex.js +9 -3
- package/dist/api/__tests__/client.test.js +10 -2
- package/dist/api/client.js +5 -4
- package/dist/auth/__tests__/login-flow.test.js +57 -81
- package/dist/auth/__tests__/store.test.js +35 -6
- package/dist/commands/__tests__/upgrade.test.js +25 -42
- package/dist/commands/clone.d.ts +2 -2
- package/dist/commands/clone.js +39 -7
- package/dist/commands/dev.js +9 -1
- package/dist/commands/endpoints.js +2 -1
- package/dist/commands/files.js +3 -2
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +34 -5
- package/dist/commands/upgrade.js +4 -3
- package/dist/commands/welcome.js +2 -2
- package/dist/devtools/registry-data.d.ts +7 -0
- package/dist/devtools/registry-data.js +1 -0
- package/dist/generated/version.d.ts +1 -1
- package/dist/generated/version.js +1 -1
- package/dist/git/__tests__/credentials.test.js +4 -4
- package/dist/git/__tests__/identity.test.d.ts +1 -0
- package/dist/git/__tests__/identity.test.js +146 -0
- package/dist/git/__tests__/preflight.test.d.ts +1 -0
- package/dist/git/__tests__/preflight.test.js +36 -0
- package/dist/git/auto-commit.js +8 -2
- package/dist/git/credentials.js +1 -1
- package/dist/git/identity.d.ts +44 -0
- package/dist/git/identity.js +133 -0
- package/dist/git/preflight.d.ts +28 -0
- package/dist/git/preflight.js +50 -0
- package/dist/git/sync.js +3 -1
- package/dist/health/__tests__/cli-distribution-checks.test.js +25 -28
- package/dist/health/checks.js +3 -2
- package/dist/index.js +32 -1
- package/dist/utils/__tests__/format-error.test.d.ts +1 -0
- package/dist/utils/__tests__/format-error.test.js +43 -0
- package/dist/utils/__tests__/http.test.d.ts +1 -0
- package/dist/utils/__tests__/http.test.js +381 -0
- package/dist/utils/agent-guidance.js +10 -3
- package/dist/utils/format-error.d.ts +10 -0
- package/dist/utils/format-error.js +38 -0
- package/dist/utils/http.d.ts +46 -0
- package/dist/utils/http.js +421 -0
- package/package.json +3 -2
|
@@ -145,9 +145,13 @@ describe('generateInstructionHint', () => {
|
|
|
145
145
|
expect(hint).toContain('runwork');
|
|
146
146
|
expect(hint).toContain('skill');
|
|
147
147
|
});
|
|
148
|
-
it('includes routing directive', () => {
|
|
148
|
+
it('includes the default-to-Runwork routing directive', () => {
|
|
149
|
+
// The earlier hint format used a "Routing:" header; the current format
|
|
150
|
+
// expresses the same idea as a "Default to Runwork" rule plus an explicit
|
|
151
|
+
// routing table. Either of those two anchors is sufficient guidance.
|
|
149
152
|
const hint = generateInstructionHint(baseCtx);
|
|
150
|
-
expect(hint).toContain('
|
|
153
|
+
expect(hint).toContain('Default to Runwork');
|
|
154
|
+
expect(hint).toContain('| User asks for | Use Runwork | Don\'t |');
|
|
151
155
|
});
|
|
152
156
|
it('includes inventory line', () => {
|
|
153
157
|
const hint = generateInstructionHint({ ...baseCtx, appCount: 2, skillCount: 3 });
|
package/dist/agents/codex.js
CHANGED
|
@@ -336,12 +336,18 @@ export class CodexAdapter {
|
|
|
336
336
|
const instructions = payload.instructions;
|
|
337
337
|
if (typeof instructions !== 'string')
|
|
338
338
|
continue;
|
|
339
|
-
// Parse "- name: description (file: path/SKILL.md)" entries
|
|
340
|
-
//
|
|
341
|
-
|
|
339
|
+
// Parse "- name: description (file: path/SKILL.md)" entries from the
|
|
340
|
+
// "### Available skills" section. Skip Codex's built-in system skills
|
|
341
|
+
// (those installed under `.system/`, e.g. skill-creator,
|
|
342
|
+
// skill-installer) since they are bundled with the agent and would
|
|
343
|
+
// skew adoption metrics meant to track *user-installed* skills.
|
|
344
|
+
const skillRegex = /^- ([^:]+):\s+.+\(file:\s+(.+\/SKILL\.md)\)/gm;
|
|
342
345
|
let match;
|
|
343
346
|
while ((match = skillRegex.exec(instructions)) !== null) {
|
|
344
347
|
const skillName = match[1].trim();
|
|
348
|
+
const skillPath = match[2];
|
|
349
|
+
if (skillPath.includes('/.system/'))
|
|
350
|
+
continue;
|
|
345
351
|
const existing = skillCounts.get(skillName);
|
|
346
352
|
if (existing) {
|
|
347
353
|
existing.count++;
|
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
|
|
2
|
+
// HTTP goes through our `httpFetch` wrapper (single seam for diagnostics
|
|
3
|
+
// and the optional `curl` transport). Tests mock that wrapper directly so
|
|
4
|
+
// they exercise the same code path the production CLI uses.
|
|
3
5
|
const mockFetch = vi.fn();
|
|
4
|
-
|
|
6
|
+
vi.mock('../../utils/http.js', () => ({
|
|
7
|
+
httpFetch: (...args) => mockFetch(...args),
|
|
8
|
+
}));
|
|
9
|
+
const { ApiClient } = await import('../client.js');
|
|
5
10
|
function createJsonResponse(data, status = 200) {
|
|
6
11
|
return {
|
|
7
12
|
ok: status >= 200 && status < 300,
|
|
8
13
|
status,
|
|
14
|
+
statusText: '',
|
|
15
|
+
url: '',
|
|
16
|
+
headers: new Headers(),
|
|
9
17
|
json: () => Promise.resolve(data),
|
|
10
18
|
text: () => Promise.resolve(JSON.stringify(data)),
|
|
11
19
|
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
|
package/dist/api/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { httpFetch } from '../utils/http.js';
|
|
1
2
|
const DEFAULT_BASE_URL = 'https://runwork.ai';
|
|
2
3
|
export class ApiClient {
|
|
3
4
|
baseUrl;
|
|
@@ -10,12 +11,12 @@ export class ApiClient {
|
|
|
10
11
|
const url = `${this.baseUrl}${path}`;
|
|
11
12
|
const headers = {
|
|
12
13
|
'Content-Type': 'application/json',
|
|
13
|
-
...Object.fromEntries(Object.entries(options.headers || {})),
|
|
14
|
+
...Object.fromEntries(Object.entries(options.headers || {}).filter(([, v]) => v !== undefined)),
|
|
14
15
|
};
|
|
15
16
|
if (this.apiKey) {
|
|
16
17
|
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
17
18
|
}
|
|
18
|
-
const response = await
|
|
19
|
+
const response = await httpFetch(url, { ...options, headers });
|
|
19
20
|
if (!response.ok) {
|
|
20
21
|
const body = await response.text();
|
|
21
22
|
throw new Error(`API error ${response.status}: ${body}`);
|
|
@@ -121,7 +122,7 @@ export class ApiClient {
|
|
|
121
122
|
if (this.apiKey) {
|
|
122
123
|
headers['Authorization'] = `Bearer ${this.apiKey}`;
|
|
123
124
|
}
|
|
124
|
-
const response = await
|
|
125
|
+
const response = await httpFetch(url, { headers });
|
|
125
126
|
if (!response.ok) {
|
|
126
127
|
throw new Error(`Failed to download skeleton: ${response.status}`);
|
|
127
128
|
}
|
|
@@ -368,7 +369,7 @@ export class ApiClient {
|
|
|
368
369
|
if (opts?.body) {
|
|
369
370
|
headers['Content-Type'] = 'application/json';
|
|
370
371
|
}
|
|
371
|
-
const response = await
|
|
372
|
+
const response = await httpFetch(url, {
|
|
372
373
|
method,
|
|
373
374
|
headers,
|
|
374
375
|
body: opts?.body ? JSON.stringify(opts.body) : undefined,
|
|
@@ -6,7 +6,26 @@ vi.mock('../store.js', () => ({
|
|
|
6
6
|
vi.mock('../../git/credentials.js', () => ({
|
|
7
7
|
configureGitCredentials: vi.fn().mockResolvedValue(undefined),
|
|
8
8
|
}));
|
|
9
|
+
// HTTP goes through our `httpFetch` wrapper (single seam for diagnostics
|
|
10
|
+
// and the optional `curl` transport). Tests mock that wrapper directly so
|
|
11
|
+
// they exercise the same code path the production CLI uses.
|
|
12
|
+
const mockFetch = vi.fn();
|
|
13
|
+
vi.mock('../../utils/http.js', () => ({
|
|
14
|
+
httpFetch: (...args) => mockFetch(...args),
|
|
15
|
+
}));
|
|
9
16
|
import { saveCredentials } from '../store.js';
|
|
17
|
+
function jsonResponse(data, status = 200) {
|
|
18
|
+
return {
|
|
19
|
+
ok: status >= 200 && status < 300,
|
|
20
|
+
status,
|
|
21
|
+
statusText: '',
|
|
22
|
+
url: '',
|
|
23
|
+
headers: new Headers(),
|
|
24
|
+
json: () => Promise.resolve(data),
|
|
25
|
+
text: () => Promise.resolve(JSON.stringify(data)),
|
|
26
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
10
29
|
beforeEach(() => {
|
|
11
30
|
vi.clearAllMocks();
|
|
12
31
|
vi.spyOn(console, 'log').mockImplementation(() => { });
|
|
@@ -14,100 +33,58 @@ beforeEach(() => {
|
|
|
14
33
|
});
|
|
15
34
|
describe('performLoginNoOpen', () => {
|
|
16
35
|
it('with printOnly prints URL and returns null without polling', async () => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
});
|
|
26
|
-
globalThis.fetch = mockFetch;
|
|
27
|
-
try {
|
|
28
|
-
const { performLoginNoOpen } = await import('../login-flow.js');
|
|
29
|
-
const result = await performLoginNoOpen('https://runwork.ai', true);
|
|
30
|
-
expect(console.log).toHaveBeenCalledWith('https://runwork.ai/cli-auth?session=sess-123');
|
|
31
|
-
expect(result).toBeNull();
|
|
32
|
-
expect(saveCredentials).not.toHaveBeenCalled();
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
globalThis.fetch = originalFetch;
|
|
36
|
-
}
|
|
36
|
+
mockFetch.mockResolvedValue(jsonResponse({
|
|
37
|
+
data: { sessionId: 'sess-123', loginUrl: 'https://runwork.ai/cli-auth?session=sess-123' },
|
|
38
|
+
}));
|
|
39
|
+
const { performLoginNoOpen } = await import('../login-flow.js');
|
|
40
|
+
const result = await performLoginNoOpen('https://runwork.ai', true);
|
|
41
|
+
expect(console.log).toHaveBeenCalledWith('https://runwork.ai/cli-auth?session=sess-123');
|
|
42
|
+
expect(result).toBeNull();
|
|
43
|
+
expect(saveCredentials).not.toHaveBeenCalled();
|
|
37
44
|
});
|
|
38
45
|
});
|
|
39
46
|
describe('performLoginWithApiKey', () => {
|
|
40
47
|
it('validates key by listing workspaces and saves credentials', async () => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const result = await performLoginWithApiKey('rw_test123');
|
|
53
|
-
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
|
|
54
|
-
apiKey: 'rw_test123',
|
|
55
|
-
defaultWorkspaceId: 'ws-1',
|
|
56
|
-
defaultWorkspaceName: 'My Workspace',
|
|
57
|
-
}));
|
|
58
|
-
expect(result.apiKey).toBe('rw_test123');
|
|
59
|
-
}
|
|
60
|
-
finally {
|
|
61
|
-
globalThis.fetch = originalFetch;
|
|
62
|
-
}
|
|
48
|
+
mockFetch.mockResolvedValue(jsonResponse({
|
|
49
|
+
data: [{ id: 'ws-1', name: 'My Workspace' }],
|
|
50
|
+
}));
|
|
51
|
+
const { performLoginWithApiKey } = await import('../login-flow.js');
|
|
52
|
+
const result = await performLoginWithApiKey('rw_test123');
|
|
53
|
+
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
|
|
54
|
+
apiKey: 'rw_test123',
|
|
55
|
+
defaultWorkspaceId: 'ws-1',
|
|
56
|
+
defaultWorkspaceName: 'My Workspace',
|
|
57
|
+
}));
|
|
58
|
+
expect(result.apiKey).toBe('rw_test123');
|
|
63
59
|
});
|
|
64
60
|
it('saves credentials with custom base URL', async () => {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
});
|
|
71
|
-
globalThis.fetch = mockFetch;
|
|
72
|
-
try {
|
|
73
|
-
const { performLoginWithApiKey } = await import('../login-flow.js');
|
|
74
|
-
const result = await performLoginWithApiKey('rw_key', 'https://custom.runwork.dev');
|
|
75
|
-
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://custom.runwork.dev'), expect.anything());
|
|
76
|
-
expect(result.baseUrl).toBe('https://custom.runwork.dev');
|
|
77
|
-
}
|
|
78
|
-
finally {
|
|
79
|
-
globalThis.fetch = originalFetch;
|
|
80
|
-
}
|
|
61
|
+
mockFetch.mockResolvedValue(jsonResponse({ data: [{ id: 'ws-1', name: 'WS' }] }));
|
|
62
|
+
const { performLoginWithApiKey } = await import('../login-flow.js');
|
|
63
|
+
const result = await performLoginWithApiKey('rw_key', 'https://custom.runwork.dev');
|
|
64
|
+
expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('https://custom.runwork.dev'), expect.anything());
|
|
65
|
+
expect(result.baseUrl).toBe('https://custom.runwork.dev');
|
|
81
66
|
});
|
|
82
67
|
it('handles no workspaces (sets no default)', async () => {
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const result = await performLoginWithApiKey('rw_key');
|
|
93
|
-
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
|
|
94
|
-
apiKey: 'rw_key',
|
|
95
|
-
defaultWorkspaceId: undefined,
|
|
96
|
-
defaultWorkspaceName: undefined,
|
|
97
|
-
}));
|
|
98
|
-
expect(result.apiKey).toBe('rw_key');
|
|
99
|
-
}
|
|
100
|
-
finally {
|
|
101
|
-
globalThis.fetch = originalFetch;
|
|
102
|
-
}
|
|
68
|
+
mockFetch.mockResolvedValue(jsonResponse({ data: [] }));
|
|
69
|
+
const { performLoginWithApiKey } = await import('../login-flow.js');
|
|
70
|
+
const result = await performLoginWithApiKey('rw_key');
|
|
71
|
+
expect(saveCredentials).toHaveBeenCalledWith(expect.objectContaining({
|
|
72
|
+
apiKey: 'rw_key',
|
|
73
|
+
defaultWorkspaceId: undefined,
|
|
74
|
+
defaultWorkspaceName: undefined,
|
|
75
|
+
}));
|
|
76
|
+
expect(result.apiKey).toBe('rw_key');
|
|
103
77
|
});
|
|
104
78
|
it('exits on invalid API key', async () => {
|
|
105
|
-
|
|
106
|
-
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
79
|
+
mockFetch.mockResolvedValue({
|
|
107
80
|
ok: false,
|
|
108
81
|
status: 401,
|
|
82
|
+
statusText: 'Unauthorized',
|
|
83
|
+
url: '',
|
|
84
|
+
headers: new Headers(),
|
|
109
85
|
json: () => Promise.resolve({ error: 'Unauthorized' }),
|
|
110
86
|
text: () => Promise.resolve('Unauthorized'),
|
|
87
|
+
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
|
|
111
88
|
});
|
|
112
89
|
const mockExit = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
113
90
|
throw new Error(`process.exit(${code})`);
|
|
@@ -118,7 +95,6 @@ describe('performLoginWithApiKey', () => {
|
|
|
118
95
|
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Invalid API key'));
|
|
119
96
|
}
|
|
120
97
|
finally {
|
|
121
|
-
globalThis.fetch = originalFetch;
|
|
122
98
|
mockExit.mockRestore();
|
|
123
99
|
}
|
|
124
100
|
});
|
|
@@ -87,17 +87,46 @@ describe('auth/store', () => {
|
|
|
87
87
|
const result = requireAuth();
|
|
88
88
|
expect(result).toEqual(validCredentials);
|
|
89
89
|
});
|
|
90
|
-
it('exits process with code 1 when
|
|
90
|
+
it('exits process with code 1 with TTY guidance when invoked from a terminal', () => {
|
|
91
91
|
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
92
92
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
93
93
|
throw new Error('process.exit called');
|
|
94
94
|
}));
|
|
95
95
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
96
|
+
const previousTTY = process.stdin.isTTY;
|
|
97
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
|
98
|
+
try {
|
|
99
|
+
expect(() => requireAuth()).toThrow('process.exit called');
|
|
100
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
101
|
+
expect(errorSpy).toHaveBeenCalledWith('Not logged in. Run `runwork login` first.');
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: previousTTY, configurable: true });
|
|
105
|
+
exitSpy.mockRestore();
|
|
106
|
+
errorSpy.mockRestore();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
it('exits process with code 1 with headless guidance when invoked without a TTY', () => {
|
|
110
|
+
vi.mocked(mockFs.existsSync).mockReturnValue(false);
|
|
111
|
+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
112
|
+
throw new Error('process.exit called');
|
|
113
|
+
}));
|
|
114
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
115
|
+
const previousTTY = process.stdin.isTTY;
|
|
116
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true });
|
|
117
|
+
try {
|
|
118
|
+
expect(() => requireAuth()).toThrow('process.exit called');
|
|
119
|
+
expect(exitSpy).toHaveBeenCalledWith(1);
|
|
120
|
+
const message = errorSpy.mock.calls[0]?.[0];
|
|
121
|
+
expect(message).toContain('Not logged in.');
|
|
122
|
+
expect(message).toContain('RUNWORK_API_KEY');
|
|
123
|
+
expect(message).toContain('runwork login --api-key');
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
Object.defineProperty(process.stdin, 'isTTY', { value: previousTTY, configurable: true });
|
|
127
|
+
exitSpy.mockRestore();
|
|
128
|
+
errorSpy.mockRestore();
|
|
129
|
+
}
|
|
101
130
|
});
|
|
102
131
|
});
|
|
103
132
|
});
|
|
@@ -2,12 +2,27 @@
|
|
|
2
2
|
* Tests for `runwork upgrade`.
|
|
3
3
|
*
|
|
4
4
|
* The module under test has top-level side effects (it builds a Commander
|
|
5
|
-
* command and captures
|
|
5
|
+
* command and captures `httpFetch` references), so we exercise the exported
|
|
6
6
|
* __internal helpers against a local HTTP server that serves a fake
|
|
7
|
-
* /cli/latest.json.
|
|
7
|
+
* /cli/latest.json. We intercept `httpFetch` and rewrite the LATEST_JSON_URL
|
|
8
|
+
* to point at the test server so the actual node:https-backed wrapper is
|
|
9
|
+
* exercised end to end.
|
|
8
10
|
*/
|
|
9
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
10
12
|
import { createServer } from 'node:http';
|
|
13
|
+
let currentServerUrl = null;
|
|
14
|
+
vi.mock('../../utils/http.js', async () => {
|
|
15
|
+
const actual = await vi.importActual('../../utils/http.js');
|
|
16
|
+
return {
|
|
17
|
+
...actual,
|
|
18
|
+
httpFetch: (url, init) => {
|
|
19
|
+
if (currentServerUrl && url.endsWith('/cli/latest.json')) {
|
|
20
|
+
return actual.httpFetch(`${currentServerUrl}/cli/latest.json`, init);
|
|
21
|
+
}
|
|
22
|
+
return actual.httpFetch(url, init);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
});
|
|
11
26
|
import { __internal } from '../upgrade.js';
|
|
12
27
|
function startJsonServer(body, status = 200) {
|
|
13
28
|
return new Promise((resolvePromise) => {
|
|
@@ -41,17 +56,15 @@ function startJsonServer(body, status = 200) {
|
|
|
41
56
|
}
|
|
42
57
|
describe('__internal.fetchLatestVersion', () => {
|
|
43
58
|
let originalBase;
|
|
44
|
-
let originalFetch;
|
|
45
59
|
let serverStop = null;
|
|
46
60
|
beforeEach(() => {
|
|
47
61
|
originalBase = process.env.RUNWORK_DOWNLOAD_BASE_URL;
|
|
48
|
-
originalFetch = globalThis.fetch;
|
|
49
62
|
});
|
|
50
63
|
afterEach(async () => {
|
|
51
64
|
if (serverStop)
|
|
52
65
|
await serverStop();
|
|
53
66
|
serverStop = null;
|
|
54
|
-
|
|
67
|
+
currentServerUrl = null;
|
|
55
68
|
if (originalBase === undefined)
|
|
56
69
|
delete process.env.RUNWORK_DOWNLOAD_BASE_URL;
|
|
57
70
|
else
|
|
@@ -65,64 +78,34 @@ describe('__internal.fetchLatestVersion', () => {
|
|
|
65
78
|
artifacts: {},
|
|
66
79
|
});
|
|
67
80
|
serverStop = server.stop;
|
|
68
|
-
|
|
69
|
-
const realFetch = globalThis.fetch;
|
|
70
|
-
globalThis.fetch = ((input, init) => {
|
|
71
|
-
const url = typeof input === 'string' ? input : input.toString();
|
|
72
|
-
if (url === __internal.LATEST_JSON_URL) {
|
|
73
|
-
return realFetch(`${server.url}/cli/latest.json`, init);
|
|
74
|
-
}
|
|
75
|
-
return realFetch(input, init);
|
|
76
|
-
});
|
|
81
|
+
currentServerUrl = server.url;
|
|
77
82
|
const version = await __internal.fetchLatestVersion();
|
|
78
83
|
expect(version).toBe('1.2.3');
|
|
79
84
|
});
|
|
80
85
|
it('strips a leading v from the version', async () => {
|
|
81
86
|
const server = await startJsonServer({ version: 'v9.9.9' });
|
|
82
87
|
serverStop = server.stop;
|
|
83
|
-
|
|
84
|
-
globalThis.fetch = ((input, init) => {
|
|
85
|
-
const url = typeof input === 'string' ? input : input.toString();
|
|
86
|
-
if (url === __internal.LATEST_JSON_URL) {
|
|
87
|
-
return realFetch(`${server.url}/cli/latest.json`, init);
|
|
88
|
-
}
|
|
89
|
-
return realFetch(input, init);
|
|
90
|
-
});
|
|
88
|
+
currentServerUrl = server.url;
|
|
91
89
|
const version = await __internal.fetchLatestVersion();
|
|
92
90
|
expect(version).toBe('9.9.9');
|
|
93
91
|
});
|
|
94
92
|
it('returns null on HTTP error', async () => {
|
|
95
93
|
const server = await startJsonServer(null, 500);
|
|
96
94
|
serverStop = server.stop;
|
|
97
|
-
|
|
98
|
-
globalThis.fetch = ((input, init) => {
|
|
99
|
-
const url = typeof input === 'string' ? input : input.toString();
|
|
100
|
-
if (url === __internal.LATEST_JSON_URL) {
|
|
101
|
-
return realFetch(`${server.url}/cli/latest.json`, init);
|
|
102
|
-
}
|
|
103
|
-
return realFetch(input, init);
|
|
104
|
-
});
|
|
95
|
+
currentServerUrl = server.url;
|
|
105
96
|
const version = await __internal.fetchLatestVersion();
|
|
106
97
|
expect(version).toBeNull();
|
|
107
98
|
});
|
|
108
99
|
it('returns null when fetch throws', async () => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
});
|
|
100
|
+
// Point at a port that is virtually guaranteed to refuse connections.
|
|
101
|
+
currentServerUrl = 'http://127.0.0.1:1';
|
|
112
102
|
const version = await __internal.fetchLatestVersion();
|
|
113
103
|
expect(version).toBeNull();
|
|
114
104
|
});
|
|
115
105
|
it('returns null when the manifest has no version field', async () => {
|
|
116
106
|
const server = await startJsonServer({ name: 'runwork' });
|
|
117
107
|
serverStop = server.stop;
|
|
118
|
-
|
|
119
|
-
globalThis.fetch = ((input, init) => {
|
|
120
|
-
const url = typeof input === 'string' ? input : input.toString();
|
|
121
|
-
if (url === __internal.LATEST_JSON_URL) {
|
|
122
|
-
return realFetch(`${server.url}/cli/latest.json`, init);
|
|
123
|
-
}
|
|
124
|
-
return realFetch(input, init);
|
|
125
|
-
});
|
|
108
|
+
currentServerUrl = server.url;
|
|
126
109
|
const version = await __internal.fetchLatestVersion();
|
|
127
110
|
expect(version).toBeNull();
|
|
128
111
|
});
|
package/dist/commands/clone.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { ApiClient } from '../api/client.js';
|
|
3
|
-
import type { AppInfo } from '../types.js';
|
|
3
|
+
import type { Credentials, AppInfo } from '../types.js';
|
|
4
4
|
export interface CloneResult {
|
|
5
5
|
appId: string;
|
|
6
6
|
appName: string;
|
|
@@ -9,5 +9,5 @@ export interface CloneResult {
|
|
|
9
9
|
workspaceId: string;
|
|
10
10
|
workspaceName: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function execClone(client: ApiClient, app: AppInfo, directory?: string): Promise<CloneResult>;
|
|
12
|
+
export declare function execClone(client: ApiClient, app: AppInfo, directory?: string, creds?: Credentials | null): Promise<CloneResult>;
|
|
13
13
|
export declare const cloneCommand: Command;
|
package/dist/commands/clone.js
CHANGED
|
@@ -12,7 +12,10 @@ import { removeNestedGitDirs } from '../utils/fs.js';
|
|
|
12
12
|
import { runAgentWizard } from '../ui/banner.js';
|
|
13
13
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
14
14
|
import { buildCloneGuide, buildErrorResponse } from '../utils/agent-guidance.js';
|
|
15
|
-
|
|
15
|
+
import { ensureGitIdentity } from '../git/identity.js';
|
|
16
|
+
import { requireGit } from '../git/preflight.js';
|
|
17
|
+
import { formatError } from '../utils/format-error.js';
|
|
18
|
+
export async function execClone(client, app, directory, creds) {
|
|
16
19
|
const slug = app.slug || app.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
17
20
|
const dir = directory || slug;
|
|
18
21
|
const remoteUrl = client.getGitRemoteUrl(app.workspaceId, app.id);
|
|
@@ -24,8 +27,13 @@ export async function execClone(client, app, directory) {
|
|
|
24
27
|
extractZip(zipData, dir);
|
|
25
28
|
removeNestedGitDirs(dir);
|
|
26
29
|
}
|
|
27
|
-
catch {
|
|
28
|
-
|
|
30
|
+
catch (err) {
|
|
31
|
+
// Surface the real error so we never silently end up with an empty
|
|
32
|
+
// clone directory. The clone is best-effort here -- git fetch may
|
|
33
|
+
// still recover the project below -- but the user should know what
|
|
34
|
+
// went wrong on the way in.
|
|
35
|
+
console.warn(`Failed to download template: ${formatError(err)}`);
|
|
36
|
+
console.warn('Continuing with the git overlay only.');
|
|
29
37
|
}
|
|
30
38
|
// Generate template manifest BEFORE git overlay (captures pristine template checksums)
|
|
31
39
|
const manifest = await generateManifest(dir);
|
|
@@ -33,8 +41,27 @@ export async function execClone(client, app, directory) {
|
|
|
33
41
|
// Step 2: Git clone into the directory (overlays changed files on top of template)
|
|
34
42
|
// Init git, add remote, fetch, and checkout to overlay on existing template files
|
|
35
43
|
if (!existsSync(join(dir, '.git'))) {
|
|
36
|
-
|
|
44
|
+
try {
|
|
45
|
+
execFileSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
// `stdio: 'pipe'` discards git's stderr, so we have to print the
|
|
49
|
+
// error ourselves -- otherwise this fails silently and the user
|
|
50
|
+
// is left with an empty directory and no clue what happened.
|
|
51
|
+
console.error(`git init failed in ${dir}: ${formatError(err)}`);
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
// Force the initial branch to `main` regardless of the user's
|
|
55
|
+
// `init.defaultBranch` config (older Git installs default to
|
|
56
|
+
// `master`). symbolic-ref works before any commits exist.
|
|
57
|
+
try {
|
|
58
|
+
execFileSync('git', ['symbolic-ref', 'HEAD', 'refs/heads/main'], { cwd: dir, stdio: 'pipe' });
|
|
59
|
+
}
|
|
60
|
+
catch { /* best-effort: push uses HEAD:main so this is just cleanliness */ }
|
|
37
61
|
}
|
|
62
|
+
// Seed a local git identity so subsequent commits do not fail on machines
|
|
63
|
+
// (commonly fresh Windows installs) without `git config --global user.email`.
|
|
64
|
+
ensureGitIdentity(dir, creds);
|
|
38
65
|
try {
|
|
39
66
|
execFileSync('git', ['remote', 'set-url', 'runwork', remoteUrl], { cwd: dir, stdio: 'pipe' });
|
|
40
67
|
}
|
|
@@ -48,8 +75,12 @@ export async function execClone(client, app, directory) {
|
|
|
48
75
|
// Checkout git-tracked files (overwrites template files where they differ)
|
|
49
76
|
execFileSync('git', ['checkout', '.'], { cwd: dir, stdio: 'pipe' });
|
|
50
77
|
}
|
|
51
|
-
catch {
|
|
52
|
-
|
|
78
|
+
catch (err) {
|
|
79
|
+
// Distinguish "remote is empty" (expected for brand-new apps) from
|
|
80
|
+
// real failures. We print both the generic next-step hint and the
|
|
81
|
+
// underlying error so authentication / network failures aren't lost.
|
|
82
|
+
console.warn('Could not pull from runwork remote. Starting with template only.');
|
|
83
|
+
console.warn(` ${formatError(err)}`);
|
|
53
84
|
}
|
|
54
85
|
// Write .runwork.json config
|
|
55
86
|
const config = {
|
|
@@ -84,6 +115,7 @@ export const cloneCommand = new Command('clone')
|
|
|
84
115
|
.argument('[directory]', 'Target directory')
|
|
85
116
|
.option('--app <name-or-id>', 'App name or ID (skips interactive selection)')
|
|
86
117
|
.action(async (appId, directory, options) => {
|
|
118
|
+
requireGit('clone');
|
|
87
119
|
const creds = requireAuth();
|
|
88
120
|
const client = new ApiClient(creds);
|
|
89
121
|
const useJson = shouldOutputJson(undefined);
|
|
@@ -108,7 +140,7 @@ export const cloneCommand = new Command('clone')
|
|
|
108
140
|
if (!useJson) {
|
|
109
141
|
console.log(`Cloning "${app.name}"...`);
|
|
110
142
|
}
|
|
111
|
-
const cloneResult = await execClone(client, app, directory);
|
|
143
|
+
const cloneResult = await execClone(client, app, directory, creds);
|
|
112
144
|
if (useJson) {
|
|
113
145
|
const response = {
|
|
114
146
|
success: true,
|
package/dist/commands/dev.js
CHANGED
|
@@ -6,6 +6,8 @@ import { requireAuth } from '../auth/store.js';
|
|
|
6
6
|
import { ApiClient } from '../api/client.js';
|
|
7
7
|
import { watchAndAutoCommit, stopAutoCommit } from '../git/auto-commit.js';
|
|
8
8
|
import { syncWithRemote } from '../git/sync.js';
|
|
9
|
+
import { ensureGitIdentity } from '../git/identity.js';
|
|
10
|
+
import { requireGit } from '../git/preflight.js';
|
|
9
11
|
import { startLogTailer } from '../logs/tailer.js';
|
|
10
12
|
import { populateTypes } from '../types-manager.js';
|
|
11
13
|
import { loadManifest, generateManifest, saveManifest, detectUserEdits } from '../template/manifest.js';
|
|
@@ -85,7 +87,8 @@ function commitAndPushRestoredFiles(cwd, files) {
|
|
|
85
87
|
return false;
|
|
86
88
|
}
|
|
87
89
|
try {
|
|
88
|
-
|
|
90
|
+
// `HEAD:main` so the push works regardless of local branch name.
|
|
91
|
+
execFileSync('git', ['push', 'runwork', 'HEAD:main'], { cwd, stdio: 'pipe' });
|
|
89
92
|
return true;
|
|
90
93
|
}
|
|
91
94
|
catch {
|
|
@@ -96,11 +99,16 @@ function commitAndPushRestoredFiles(cwd, files) {
|
|
|
96
99
|
}
|
|
97
100
|
export async function execDev(options) {
|
|
98
101
|
const useJson = options?.json ?? false;
|
|
102
|
+
requireGit('dev');
|
|
99
103
|
const config = readConfig();
|
|
100
104
|
const creds = requireAuth();
|
|
101
105
|
const client = new ApiClient(creds);
|
|
102
106
|
const cwd = process.cwd();
|
|
103
107
|
const ts = () => new Date().toISOString();
|
|
108
|
+
// Seed a local git identity so the very first commit cannot fail on a
|
|
109
|
+
// fresh machine where `git config --global user.email/.name` has never
|
|
110
|
+
// been set (extremely common on Windows after a clean install).
|
|
111
|
+
ensureGitIdentity(cwd, creds);
|
|
104
112
|
// Detect user edits made outside of `runwork dev`
|
|
105
113
|
const oldManifest = await loadManifest(cwd);
|
|
106
114
|
if (oldManifest) {
|
|
@@ -3,6 +3,7 @@ import { requireAuth } from '../auth/store.js';
|
|
|
3
3
|
import { ApiClient } from '../api/client.js';
|
|
4
4
|
import { resolveWorkspace } from '../workspace/resolve.js';
|
|
5
5
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
6
|
+
import { httpFetch } from '../utils/http.js';
|
|
6
7
|
function truncate(text, max) {
|
|
7
8
|
if (!text)
|
|
8
9
|
return '';
|
|
@@ -137,7 +138,7 @@ const callCommand = new Command('call')
|
|
|
137
138
|
else {
|
|
138
139
|
console.log(`\n${targetMethod} ${url}\n`);
|
|
139
140
|
}
|
|
140
|
-
const response = await
|
|
141
|
+
const response = await httpFetch(url, {
|
|
141
142
|
method: targetMethod,
|
|
142
143
|
headers,
|
|
143
144
|
body,
|
package/dist/commands/files.js
CHANGED
|
@@ -6,6 +6,7 @@ import { ApiClient } from '../api/client.js';
|
|
|
6
6
|
import { resolveWorkspace } from '../workspace/resolve.js';
|
|
7
7
|
import { shouldOutputJson, jsonOut } from '../utils/output.js';
|
|
8
8
|
import { promptConfirm } from '../utils/prompt.js';
|
|
9
|
+
import { httpFetch } from '../utils/http.js';
|
|
9
10
|
function formatSize(bytes) {
|
|
10
11
|
if (bytes === undefined)
|
|
11
12
|
return '';
|
|
@@ -106,7 +107,7 @@ const downloadCommand = new Command('download')
|
|
|
106
107
|
const outputPath = output || basename(key);
|
|
107
108
|
try {
|
|
108
109
|
const { url } = await client.getPresignedUrl(workspaceId, bucket, { action: 'read', key });
|
|
109
|
-
const response = await
|
|
110
|
+
const response = await httpFetch(url);
|
|
110
111
|
if (!response.ok) {
|
|
111
112
|
throw new Error(`Download failed: ${response.status} ${response.statusText}`);
|
|
112
113
|
}
|
|
@@ -137,7 +138,7 @@ const uploadCommand = new Command('upload')
|
|
|
137
138
|
try {
|
|
138
139
|
const fileBuffer = readFileSync(localPath);
|
|
139
140
|
const { url } = await client.getPresignedUrl(workspaceId, bucket, { action: 'write', key: objectKey });
|
|
140
|
-
const response = await
|
|
141
|
+
const response = await httpFetch(url, { method: 'PUT', body: fileBuffer });
|
|
141
142
|
if (!response.ok) {
|
|
142
143
|
throw new Error(`Upload failed: ${response.status} ${response.statusText}`);
|
|
143
144
|
}
|
package/dist/commands/init.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { ApiClient } from '../api/client.js';
|
|
3
|
-
import type { WorkspaceInfo } from '../types.js';
|
|
3
|
+
import type { Credentials, WorkspaceInfo } from '../types.js';
|
|
4
4
|
export interface InitResult {
|
|
5
5
|
appId: string;
|
|
6
6
|
appName: string;
|
|
@@ -15,7 +15,7 @@ export interface ExecInitOptions {
|
|
|
15
15
|
}
|
|
16
16
|
/** Default parent directory for new apps when --here is not passed. */
|
|
17
17
|
export declare const DEFAULT_APPS_DIR: string;
|
|
18
|
-
export declare function execInit(client: ApiClient, appName: string, workspace: WorkspaceInfo, options?: ExecInitOptions): Promise<InitResult>;
|
|
18
|
+
export declare function execInit(client: ApiClient, appName: string, workspace: WorkspaceInfo, options?: ExecInitOptions, creds?: Credentials | null): Promise<InitResult>;
|
|
19
19
|
/** Full create flow: prompt for name/workspace, init, and run agent wizard */
|
|
20
20
|
export declare function runCreateFlow(name?: string, workspaceFlag?: string, options?: ExecInitOptions): Promise<void>;
|
|
21
21
|
export declare const initCommand: Command;
|