runwork 0.9.4 → 0.10.1
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__/info-merge.test.d.ts +1 -0
- package/dist/commands/__tests__/info-merge.test.js +55 -0
- 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/info.d.ts +141 -0
- package/dist/commands/info.js +29 -7
- 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
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { gzipSync, deflateSync, brotliCompressSync } from 'node:zlib';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { httpFetch, __setTransportForTests } from '../http.js';
|
|
6
|
+
let server;
|
|
7
|
+
let port;
|
|
8
|
+
const calls = [];
|
|
9
|
+
function startServer() {
|
|
10
|
+
return new Promise((resolve) => {
|
|
11
|
+
server = createServer(async (req, res) => {
|
|
12
|
+
const chunks = [];
|
|
13
|
+
for await (const chunk of req)
|
|
14
|
+
chunks.push(chunk);
|
|
15
|
+
const body = Buffer.concat(chunks).toString('utf-8');
|
|
16
|
+
calls.push({ url: req.url || '', method: req.method || '', headers: req.headers, body });
|
|
17
|
+
const url = req.url || '/';
|
|
18
|
+
// Simple endpoints exercised by the test cases below.
|
|
19
|
+
if (url === '/json') {
|
|
20
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
21
|
+
res.end(JSON.stringify({ hello: 'world' }));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (url === '/text') {
|
|
25
|
+
res.writeHead(200, { 'content-type': 'text/plain' });
|
|
26
|
+
res.end('plain body');
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (url === '/binary') {
|
|
30
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
31
|
+
res.end(Buffer.from([1, 2, 3, 4, 5]));
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (url === '/echo-headers') {
|
|
35
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
36
|
+
res.end(JSON.stringify({ headers: req.headers, method: req.method }));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (url === '/echo-body') {
|
|
40
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
41
|
+
res.end(JSON.stringify({ body, contentType: req.headers['content-type'] }));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (url === '/status/404') {
|
|
45
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
46
|
+
res.end('not found');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (url === '/status/500') {
|
|
50
|
+
res.writeHead(500, { 'content-type': 'text/plain' });
|
|
51
|
+
res.end('boom');
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (url === '/redirect/302') {
|
|
55
|
+
res.writeHead(302, { location: '/json' });
|
|
56
|
+
res.end();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (url === '/redirect/303-post') {
|
|
60
|
+
res.writeHead(303, { location: '/echo-headers' });
|
|
61
|
+
res.end();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (url === '/redirect/307') {
|
|
65
|
+
res.writeHead(307, { location: '/echo-body' });
|
|
66
|
+
res.end();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (url === '/redirect/loop') {
|
|
70
|
+
res.writeHead(302, { location: '/redirect/loop' });
|
|
71
|
+
res.end();
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (url === '/gzip') {
|
|
75
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'gzip' });
|
|
76
|
+
res.end(gzipSync(Buffer.from('compressed-gzip-body')));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (url === '/deflate') {
|
|
80
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'deflate' });
|
|
81
|
+
res.end(deflateSync(Buffer.from('compressed-deflate-body')));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (url === '/brotli') {
|
|
85
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'content-encoding': 'br' });
|
|
86
|
+
res.end(brotliCompressSync(Buffer.from('compressed-brotli-body')));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (url === '/head') {
|
|
90
|
+
res.writeHead(200, { 'content-type': 'text/plain', 'x-marker': 'present' });
|
|
91
|
+
res.end();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end();
|
|
96
|
+
});
|
|
97
|
+
server.listen(0, '127.0.0.1', () => {
|
|
98
|
+
const addr = server.address();
|
|
99
|
+
if (typeof addr === 'object' && addr && 'port' in addr) {
|
|
100
|
+
port = addr.port;
|
|
101
|
+
}
|
|
102
|
+
resolve();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
beforeAll(async () => { await startServer(); });
|
|
107
|
+
afterAll(() => new Promise((resolve) => server.close(() => resolve())));
|
|
108
|
+
function url(path) { return `http://127.0.0.1:${port}${path}`; }
|
|
109
|
+
describe('httpFetch — basic Response surface', () => {
|
|
110
|
+
it('parses JSON responses', async () => {
|
|
111
|
+
const res = await httpFetch(url('/json'));
|
|
112
|
+
expect(res.ok).toBe(true);
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
expect(res.headers.get('content-type')).toContain('application/json');
|
|
115
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
116
|
+
});
|
|
117
|
+
it('reads text responses', async () => {
|
|
118
|
+
const res = await httpFetch(url('/text'));
|
|
119
|
+
expect(res.ok).toBe(true);
|
|
120
|
+
expect(await res.text()).toBe('plain body');
|
|
121
|
+
});
|
|
122
|
+
it('reads binary responses as ArrayBuffer with the exact byte content', async () => {
|
|
123
|
+
const res = await httpFetch(url('/binary'));
|
|
124
|
+
const ab = await res.arrayBuffer();
|
|
125
|
+
expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
126
|
+
});
|
|
127
|
+
it('returns ok=false for 4xx without throwing', async () => {
|
|
128
|
+
const res = await httpFetch(url('/status/404'));
|
|
129
|
+
expect(res.ok).toBe(false);
|
|
130
|
+
expect(res.status).toBe(404);
|
|
131
|
+
expect(await res.text()).toBe('not found');
|
|
132
|
+
});
|
|
133
|
+
it('returns ok=false for 5xx without throwing', async () => {
|
|
134
|
+
const res = await httpFetch(url('/status/500'));
|
|
135
|
+
expect(res.ok).toBe(false);
|
|
136
|
+
expect(res.status).toBe(500);
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
describe('httpFetch — request shape', () => {
|
|
140
|
+
it('passes through plain object headers, lowercased per HTTP convention', async () => {
|
|
141
|
+
const res = await httpFetch(url('/echo-headers'), {
|
|
142
|
+
headers: { 'X-Custom-Foo': 'bar', Authorization: 'Bearer abc' },
|
|
143
|
+
});
|
|
144
|
+
const data = await res.json();
|
|
145
|
+
expect(data.method).toBe('GET');
|
|
146
|
+
expect(data.headers['x-custom-foo']).toBe('bar');
|
|
147
|
+
expect(data.headers['authorization']).toBe('Bearer abc');
|
|
148
|
+
});
|
|
149
|
+
it('passes through Headers instance', async () => {
|
|
150
|
+
const headers = new Headers();
|
|
151
|
+
headers.set('X-One', 'a');
|
|
152
|
+
headers.set('X-Two', 'b');
|
|
153
|
+
const res = await httpFetch(url('/echo-headers'), { headers });
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
expect(data.headers['x-one']).toBe('a');
|
|
156
|
+
expect(data.headers['x-two']).toBe('b');
|
|
157
|
+
});
|
|
158
|
+
it('uppercases lowercase methods', async () => {
|
|
159
|
+
const res = await httpFetch(url('/echo-headers'), { method: 'post', body: '' });
|
|
160
|
+
const data = await res.json();
|
|
161
|
+
expect(data.method).toBe('POST');
|
|
162
|
+
});
|
|
163
|
+
it('sends string body and content-length', async () => {
|
|
164
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
headers: { 'content-type': 'application/json' },
|
|
167
|
+
body: JSON.stringify({ a: 1 }),
|
|
168
|
+
});
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
expect(data.body).toBe('{"a":1}');
|
|
171
|
+
expect(data.contentType).toBe('application/json');
|
|
172
|
+
});
|
|
173
|
+
it('sends Buffer body', async () => {
|
|
174
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
175
|
+
method: 'PUT',
|
|
176
|
+
body: Buffer.from('raw-buffer'),
|
|
177
|
+
});
|
|
178
|
+
const data = await res.json();
|
|
179
|
+
expect(data.body).toBe('raw-buffer');
|
|
180
|
+
});
|
|
181
|
+
it('sends Uint8Array body with offset', async () => {
|
|
182
|
+
const backing = new Uint8Array([0, 0, 65, 66, 67, 0]);
|
|
183
|
+
const view = backing.subarray(2, 5); // "ABC"
|
|
184
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: view });
|
|
185
|
+
const data = await res.json();
|
|
186
|
+
expect(data.body).toBe('ABC');
|
|
187
|
+
});
|
|
188
|
+
it('sends ArrayBuffer body', async () => {
|
|
189
|
+
const ab = new TextEncoder().encode('via-arraybuffer').buffer;
|
|
190
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: ab });
|
|
191
|
+
const data = await res.json();
|
|
192
|
+
expect(data.body).toBe('via-arraybuffer');
|
|
193
|
+
});
|
|
194
|
+
it('drops body on bodyless GET/HEAD methods (matches fetch behavior)', async () => {
|
|
195
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
196
|
+
method: 'GET',
|
|
197
|
+
// GET requests with bodies are non-conformant; fetch silently drops the body.
|
|
198
|
+
body: 'should-not-be-sent',
|
|
199
|
+
});
|
|
200
|
+
const data = await res.json();
|
|
201
|
+
expect(data.body).toBe('');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
describe('httpFetch — redirects', () => {
|
|
205
|
+
it('follows a 302 to the final URL', async () => {
|
|
206
|
+
const res = await httpFetch(url('/redirect/302'));
|
|
207
|
+
expect(res.status).toBe(200);
|
|
208
|
+
expect(res.url.endsWith('/json')).toBe(true);
|
|
209
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
210
|
+
});
|
|
211
|
+
it('downgrades POST to GET on 303 (matches fetch semantics)', async () => {
|
|
212
|
+
const res = await httpFetch(url('/redirect/303-post'), {
|
|
213
|
+
method: 'POST',
|
|
214
|
+
body: 'ignored-after-303',
|
|
215
|
+
});
|
|
216
|
+
const data = await res.json();
|
|
217
|
+
expect(data.method).toBe('GET');
|
|
218
|
+
});
|
|
219
|
+
it('preserves method and body on 307', async () => {
|
|
220
|
+
const res = await httpFetch(url('/redirect/307'), {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
body: 'preserved-on-307',
|
|
223
|
+
});
|
|
224
|
+
const data = await res.json();
|
|
225
|
+
expect(data.body).toBe('preserved-on-307');
|
|
226
|
+
});
|
|
227
|
+
it('rejects after exceeding the redirect limit', async () => {
|
|
228
|
+
// The endpoint redirects to itself; after MAX_REDIRECTS we stop following
|
|
229
|
+
// and return the redirect response itself rather than crash.
|
|
230
|
+
const res = await httpFetch(url('/redirect/loop'));
|
|
231
|
+
expect(res.status).toBe(302);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
describe('httpFetch — content decoding', () => {
|
|
235
|
+
it('advertises gzip/deflate/br by default', async () => {
|
|
236
|
+
await httpFetch(url('/echo-headers'));
|
|
237
|
+
const last = calls[calls.length - 1];
|
|
238
|
+
expect(last.headers['accept-encoding']).toBe('gzip, deflate, br');
|
|
239
|
+
});
|
|
240
|
+
it('decompresses gzip responses transparently', async () => {
|
|
241
|
+
const res = await httpFetch(url('/gzip'));
|
|
242
|
+
expect(await res.text()).toBe('compressed-gzip-body');
|
|
243
|
+
});
|
|
244
|
+
it('decompresses brotli responses transparently', async () => {
|
|
245
|
+
const res = await httpFetch(url('/brotli'));
|
|
246
|
+
expect(await res.text()).toBe('compressed-brotli-body');
|
|
247
|
+
});
|
|
248
|
+
it('decompresses deflate responses transparently', async () => {
|
|
249
|
+
const res = await httpFetch(url('/deflate'));
|
|
250
|
+
expect(await res.text()).toBe('compressed-deflate-body');
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
describe('httpFetch — HEAD requests', () => {
|
|
254
|
+
it('returns headers and an empty body', async () => {
|
|
255
|
+
const res = await httpFetch(url('/head'), { method: 'HEAD' });
|
|
256
|
+
expect(res.status).toBe(200);
|
|
257
|
+
expect(res.headers.get('x-marker')).toBe('present');
|
|
258
|
+
expect(await res.text()).toBe('');
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
describe('httpFetch — errors', () => {
|
|
262
|
+
it('rejects on connection failure', async () => {
|
|
263
|
+
// 127.0.0.1:1 is virtually guaranteed to refuse connections.
|
|
264
|
+
await expect(httpFetch('http://127.0.0.1:1/')).rejects.toThrow();
|
|
265
|
+
});
|
|
266
|
+
it('rejects on unsupported protocols', async () => {
|
|
267
|
+
await expect(httpFetch('ftp://example.com/')).rejects.toThrow(/Unsupported protocol/);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
271
|
+
// curl-transport tests
|
|
272
|
+
//
|
|
273
|
+
// `curl` is the opt-in escape hatch (`RUNWORK_HTTP_TRANSPORT=curl`). It
|
|
274
|
+
// shells out to the system `curl` so HTTP runs in a subprocess, immune to
|
|
275
|
+
// any future runtime regression. These tests force the curl path via the
|
|
276
|
+
// test-only override so the break-glass path stays exercised in CI even
|
|
277
|
+
// though the default transport is `node:https`. Skipped when curl is
|
|
278
|
+
// missing on the host.
|
|
279
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
280
|
+
const curlAvailable = (() => {
|
|
281
|
+
try {
|
|
282
|
+
const r = spawnSync('curl', ['--version'], { encoding: 'utf-8' });
|
|
283
|
+
return r.status === 0;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
})();
|
|
289
|
+
describe.skipIf(!curlAvailable)('httpFetch — curl transport (opt-in via env var)', () => {
|
|
290
|
+
beforeAll(() => { __setTransportForTests('curl'); });
|
|
291
|
+
afterAll(() => { __setTransportForTests('auto'); });
|
|
292
|
+
it('parses JSON responses through curl', async () => {
|
|
293
|
+
const res = await httpFetch(url('/json'));
|
|
294
|
+
expect(res.ok).toBe(true);
|
|
295
|
+
expect(res.status).toBe(200);
|
|
296
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
297
|
+
});
|
|
298
|
+
it('reads binary responses with exact byte content', async () => {
|
|
299
|
+
const res = await httpFetch(url('/binary'));
|
|
300
|
+
const ab = await res.arrayBuffer();
|
|
301
|
+
expect(new Uint8Array(ab)).toEqual(new Uint8Array([1, 2, 3, 4, 5]));
|
|
302
|
+
});
|
|
303
|
+
it('returns ok=false for 4xx without throwing', async () => {
|
|
304
|
+
const res = await httpFetch(url('/status/404'));
|
|
305
|
+
expect(res.ok).toBe(false);
|
|
306
|
+
expect(res.status).toBe(404);
|
|
307
|
+
expect(await res.text()).toBe('not found');
|
|
308
|
+
});
|
|
309
|
+
it('follows 302 redirects to the final URL', async () => {
|
|
310
|
+
const res = await httpFetch(url('/redirect/302'));
|
|
311
|
+
expect(res.status).toBe(200);
|
|
312
|
+
expect(res.url.endsWith('/json')).toBe(true);
|
|
313
|
+
expect(await res.json()).toEqual({ hello: 'world' });
|
|
314
|
+
});
|
|
315
|
+
it('downgrades POST to GET on 303 (matches fetch semantics)', async () => {
|
|
316
|
+
const res = await httpFetch(url('/redirect/303-post'), {
|
|
317
|
+
method: 'POST',
|
|
318
|
+
body: 'ignored-after-303',
|
|
319
|
+
});
|
|
320
|
+
const data = await res.json();
|
|
321
|
+
expect(data.method).toBe('GET');
|
|
322
|
+
});
|
|
323
|
+
it('preserves method and body on 307', async () => {
|
|
324
|
+
const res = await httpFetch(url('/redirect/307'), {
|
|
325
|
+
method: 'POST',
|
|
326
|
+
body: 'preserved-on-307',
|
|
327
|
+
});
|
|
328
|
+
const data = await res.json();
|
|
329
|
+
expect(data.body).toBe('preserved-on-307');
|
|
330
|
+
});
|
|
331
|
+
it('passes plain object headers through', async () => {
|
|
332
|
+
const res = await httpFetch(url('/echo-headers'), {
|
|
333
|
+
headers: { 'X-Custom': 'curl-roundtrip', Authorization: 'Bearer abc' },
|
|
334
|
+
});
|
|
335
|
+
const data = await res.json();
|
|
336
|
+
expect(data.headers['x-custom']).toBe('curl-roundtrip');
|
|
337
|
+
expect(data.headers['authorization']).toBe('Bearer abc');
|
|
338
|
+
});
|
|
339
|
+
it('sends string body and content-type', async () => {
|
|
340
|
+
const res = await httpFetch(url('/echo-body'), {
|
|
341
|
+
method: 'POST',
|
|
342
|
+
headers: { 'content-type': 'application/json' },
|
|
343
|
+
body: JSON.stringify({ a: 1 }),
|
|
344
|
+
});
|
|
345
|
+
const data = await res.json();
|
|
346
|
+
expect(data.body).toBe('{"a":1}');
|
|
347
|
+
expect(data.contentType).toBe('application/json');
|
|
348
|
+
});
|
|
349
|
+
it('sends binary Uint8Array body with offset', async () => {
|
|
350
|
+
const backing = new Uint8Array([0, 0, 65, 66, 67, 0]);
|
|
351
|
+
const view = backing.subarray(2, 5); // "ABC"
|
|
352
|
+
const res = await httpFetch(url('/echo-body'), { method: 'POST', body: view });
|
|
353
|
+
const data = await res.json();
|
|
354
|
+
expect(data.body).toBe('ABC');
|
|
355
|
+
});
|
|
356
|
+
it('returns headers and an empty body for HEAD', async () => {
|
|
357
|
+
const res = await httpFetch(url('/head'), { method: 'HEAD' });
|
|
358
|
+
expect(res.status).toBe(200);
|
|
359
|
+
expect(res.headers.get('x-marker')).toBe('present');
|
|
360
|
+
expect(await res.text()).toBe('');
|
|
361
|
+
});
|
|
362
|
+
it('warns and returns raw bytes on unexpected gzip', async () => {
|
|
363
|
+
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
364
|
+
try {
|
|
365
|
+
const res = await httpFetch(url('/gzip'));
|
|
366
|
+
expect(res.ok).toBe(true);
|
|
367
|
+
const ab = await res.arrayBuffer();
|
|
368
|
+
expect(ab.byteLength).toBeGreaterThan(0);
|
|
369
|
+
expect(stderr).toHaveBeenCalledWith(expect.stringMatching(/Content-Encoding: gzip/));
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
stderr.mockRestore();
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
it('rejects on connection failure', async () => {
|
|
376
|
+
await expect(httpFetch('http://127.0.0.1:1/')).rejects.toThrow();
|
|
377
|
+
});
|
|
378
|
+
it('rejects on unsupported protocols', async () => {
|
|
379
|
+
await expect(httpFetch('ftp://example.com/')).rejects.toThrow(/Unsupported protocol/);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -20,13 +20,19 @@ export const APP_STRUCTURE = {
|
|
|
20
20
|
};
|
|
21
21
|
const COMMON_TIPS = [
|
|
22
22
|
'Read CLAUDE.md in the app directory first -- it has complete framework documentation with code examples.',
|
|
23
|
-
'You do NOT need git commands. runwork dev handles file syncing automatically.',
|
|
23
|
+
'You do NOT need to run git commands manually. runwork dev handles file syncing automatically. (git itself must be installed on the system -- see dependencies.)',
|
|
24
24
|
'Do NOT install external AI SDKs (openai, @anthropic-ai/sdk). Use @runworkai/framework/ai instead.',
|
|
25
25
|
'Every workflow MUST have a trigger (API endpoint, scheduled job, or UI button). Workflows without triggers are dead code.',
|
|
26
26
|
'Every conversational agent MUST have a frontend page to access it.',
|
|
27
27
|
'After adding entities, workflows, or agents, update blueprint.json with the new metadata.',
|
|
28
28
|
'Never guess integration IDs. Run: runwork integrations search <query>',
|
|
29
29
|
];
|
|
30
|
+
/**
|
|
31
|
+
* Required system dependencies for the Runwork CLI to function. The AI agent
|
|
32
|
+
* reading these guides should verify each is installed and install missing
|
|
33
|
+
* ones for the user using whatever method is appropriate for their platform.
|
|
34
|
+
*/
|
|
35
|
+
const SYSTEM_DEPENDENCIES_NOTE = 'System dependency: git must be installed on this machine. The CLI uses git internally to sync files between local and the workspace remote. Verify with: git --version. If missing, the AI agent reading this guide should install git for the user without prompting -- pick the method that fits the platform: macOS uses "brew install git" (or "xcode-select --install" if Homebrew is unavailable); Debian/Ubuntu uses "sudo apt-get update && sudo apt-get install -y git"; Fedora/RHEL uses "sudo dnf install -y git"; Arch uses "sudo pacman -S --noconfirm git"; Windows uses "winget install --id Git.Git -e" or "choco install git -y". After installing, re-verify with git --version before continuing.';
|
|
30
36
|
export function buildInitGuide(appName, dir) {
|
|
31
37
|
return {
|
|
32
38
|
context: 'Created a Runwork app -- a full-stack Cloudflare Workers application with built-in support for persistent data (entities), background jobs (schedules), multi-step durable processes (workflows), AI agents, public APIs, and third-party integrations. The app runs in a sandbox during development and deploys to Cloudflare Workers for production.',
|
|
@@ -52,7 +58,7 @@ export function buildCloneGuide(appName, dir) {
|
|
|
52
58
|
'Edit files for your needs',
|
|
53
59
|
'runwork deploy # deploy to production when ready',
|
|
54
60
|
],
|
|
55
|
-
tips: COMMON_TIPS,
|
|
61
|
+
tips: [SYSTEM_DEPENDENCIES_NOTE, ...COMMON_TIPS],
|
|
56
62
|
learnMore: 'Read CLAUDE.md in the app directory for complete framework documentation.',
|
|
57
63
|
};
|
|
58
64
|
}
|
|
@@ -65,7 +71,8 @@ export function buildDevSessionGuide() {
|
|
|
65
71
|
'Run runwork deploy in another terminal when ready for production',
|
|
66
72
|
],
|
|
67
73
|
tips: [
|
|
68
|
-
|
|
74
|
+
SYSTEM_DEPENDENCIES_NOTE,
|
|
75
|
+
'You in normal cases do NOT need to run git commands manually. The dev server handles all file syncing (it shells out to the system git binary, which must be installed).',
|
|
69
76
|
'If the preview seems stuck, try runwork dev again to restart the session.',
|
|
70
77
|
],
|
|
71
78
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an unknown thrown value as a single human-readable line. Tailored
|
|
3
|
+
* for `execFileSync` failures (which carry stderr/stdout buffers, an `errno`
|
|
4
|
+
* code, and a `message` like "Command failed: git ...") so the most useful
|
|
5
|
+
* piece of information surfaces first.
|
|
6
|
+
*
|
|
7
|
+
* Used by command entry points that have a `stdio: 'pipe'` git invocation
|
|
8
|
+
* to keep the diagnostic from being silently dropped.
|
|
9
|
+
*/
|
|
10
|
+
export declare function formatError(err: unknown): string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render an unknown thrown value as a single human-readable line. Tailored
|
|
3
|
+
* for `execFileSync` failures (which carry stderr/stdout buffers, an `errno`
|
|
4
|
+
* code, and a `message` like "Command failed: git ...") so the most useful
|
|
5
|
+
* piece of information surfaces first.
|
|
6
|
+
*
|
|
7
|
+
* Used by command entry points that have a `stdio: 'pipe'` git invocation
|
|
8
|
+
* to keep the diagnostic from being silently dropped.
|
|
9
|
+
*/
|
|
10
|
+
export function formatError(err) {
|
|
11
|
+
if (!err)
|
|
12
|
+
return 'unknown error';
|
|
13
|
+
if (err instanceof Error) {
|
|
14
|
+
const obj = err;
|
|
15
|
+
const stderr = bufToStr(obj.stderr);
|
|
16
|
+
const stdout = bufToStr(obj.stdout);
|
|
17
|
+
const detail = stderr || stdout;
|
|
18
|
+
if (detail) {
|
|
19
|
+
// Common case for ENOENT: errno -> "git ENOENT: no such file or directory"
|
|
20
|
+
if (obj.code)
|
|
21
|
+
return `${obj.code}: ${detail}`;
|
|
22
|
+
return detail;
|
|
23
|
+
}
|
|
24
|
+
if (obj.code)
|
|
25
|
+
return `${obj.code}: ${obj.message}`;
|
|
26
|
+
return obj.message || String(err);
|
|
27
|
+
}
|
|
28
|
+
return String(err);
|
|
29
|
+
}
|
|
30
|
+
function bufToStr(value) {
|
|
31
|
+
if (!value)
|
|
32
|
+
return '';
|
|
33
|
+
if (typeof value === 'string')
|
|
34
|
+
return value.trim();
|
|
35
|
+
if (Buffer.isBuffer(value))
|
|
36
|
+
return value.toString('utf-8').trim();
|
|
37
|
+
return '';
|
|
38
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `fetch`-like HTTP client built on `node:http` / `node:https`.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists rather than calling `fetch` directly:
|
|
5
|
+
* 1) Single seam for diagnostics, retries, transport swaps, etc., without
|
|
6
|
+
* touching every call site.
|
|
7
|
+
* 2) Opt-in `curl` transport (set `RUNWORK_HTTP_TRANSPORT=curl`) as a
|
|
8
|
+
* break-glass: it shells out to the system `curl` binary so HTTP runs
|
|
9
|
+
* in a subprocess, immune to any runtime regression in the Bun-compiled
|
|
10
|
+
* binary. We've kept it after needing it once on an older Bun version.
|
|
11
|
+
*
|
|
12
|
+
* Default transport is `node:https` on every platform. Surface mirrors
|
|
13
|
+
* enough of the standard `Response` API for call sites to swap from
|
|
14
|
+
* `fetch(...)` to `httpFetch(...)` with no other change. Out of scope:
|
|
15
|
+
* streaming bodies, AbortController, ReadableStream request bodies, the
|
|
16
|
+
* `cache` option. This is a workaround layer, not a fetch polyfill.
|
|
17
|
+
*/
|
|
18
|
+
export interface HttpRequestInit {
|
|
19
|
+
method?: string;
|
|
20
|
+
headers?: Record<string, string | undefined> | Headers;
|
|
21
|
+
body?: string | Uint8Array | ArrayBuffer | Buffer | null;
|
|
22
|
+
/** Accepted for compatibility with `fetch(..., { cache: 'no-store' })`; ignored. */
|
|
23
|
+
cache?: string;
|
|
24
|
+
/** Accepted for compatibility; ignored (no AbortController support yet). */
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
}
|
|
27
|
+
export interface HttpResponse {
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly statusText: string;
|
|
31
|
+
readonly headers: Headers;
|
|
32
|
+
readonly url: string;
|
|
33
|
+
text(): Promise<string>;
|
|
34
|
+
json<T = unknown>(): Promise<T>;
|
|
35
|
+
arrayBuffer(): Promise<ArrayBuffer>;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Available HTTP transports. `node` is the default everywhere. `curl` is an
|
|
39
|
+
* opt-in escape hatch (set the `RUNWORK_HTTP_TRANSPORT=curl` env var) that
|
|
40
|
+
* shells out to `curl.exe` / `curl` for each request, sidestepping the
|
|
41
|
+
* runtime's native HTTP stack entirely. We keep it around as break-glass
|
|
42
|
+
* for any future Bun runtime regression on Windows; see file header.
|
|
43
|
+
*/
|
|
44
|
+
export type HttpTransport = 'auto' | 'node' | 'curl';
|
|
45
|
+
export declare function __setTransportForTests(t: HttpTransport): void;
|
|
46
|
+
export declare function httpFetch(url: string, init?: HttpRequestInit): Promise<HttpResponse>;
|