@junoflow/cli 0.6.0 → 0.9.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/README.md +189 -73
- package/app-client.js +306 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +4 -2
- package/package.json +9 -4
- package/src/api.js +182 -39
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +2 -1
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/check.js +31 -15
- package/src/commands/deploy.js +57 -4
- package/src/commands/flow-check.js +4 -22
- package/src/commands/flow-deploy.js +11 -8
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +12 -5
- package/src/commands/flow-pull.js +69 -40
- package/src/commands/pull.js +96 -102
- package/src/errors.js +3 -3
- package/src/exit.js +2 -2
- package/src/flow-project.js +47 -97
- package/src/index.js +62 -1
- package/src/project.js +76 -8
- package/src/watch.js +48 -11
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +51 -2
package/src/api.js
CHANGED
|
@@ -52,8 +52,8 @@ export class JunoClient {
|
|
|
52
52
|
|
|
53
53
|
/**
|
|
54
54
|
* POST /api/integrations/scaffold — a whole starting project, path →
|
|
55
|
-
* content. `target` is what this client reached the instance at
|
|
56
|
-
*
|
|
55
|
+
* content. `target` is what this client reached the instance at, recorded
|
|
56
|
+
* as juno.target; the server falls back to its public URL without it.
|
|
57
57
|
*/
|
|
58
58
|
async scaffold(id, { name, description, target } = {}) {
|
|
59
59
|
const body = await this.#json('POST', '/api/integrations/scaffold', {
|
|
@@ -70,16 +70,28 @@ export class JunoClient {
|
|
|
70
70
|
return this.#json('POST', '/api/typegen/integration', { manifest });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
/**
|
|
74
|
+
* POST /api/integrations/ingest — deploy, or validate only with dryRun.
|
|
75
|
+
* `baseVersion` undefined sends none (a copy that records no base, --force,
|
|
76
|
+
* or --id naming another install). A 409 is a ConflictError: the install's
|
|
77
|
+
* active version is not the copy's base.
|
|
78
|
+
*
|
|
79
|
+
* It does not go through #workingCopyJSON, whose 422-without-diagnostics
|
|
80
|
+
* wording differs from what this route has always printed; only the 409 is
|
|
81
|
+
* shared.
|
|
82
|
+
*/
|
|
83
|
+
async ingest(project, { dryRun = false, id, baseVersion } = {}) {
|
|
84
|
+
const path = `/api/integrations/ingest${dryRun ? '?dry_run=true' : ''}`;
|
|
85
|
+
const res = await this.#request('POST', path, {
|
|
77
86
|
manifest: project.manifest,
|
|
78
87
|
files: project.files,
|
|
79
88
|
package_json: project.packageJSON ?? undefined,
|
|
80
89
|
lockfile: project.lockfile ?? undefined,
|
|
81
90
|
id: id ?? undefined,
|
|
91
|
+
base_version: baseVersion ?? undefined,
|
|
82
92
|
});
|
|
93
|
+
if (res.status === 409) throw new ConflictError(problemDetail(res));
|
|
94
|
+
return this.#answer('POST', path, res);
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
/** GET /api/integrations/{id} — the manifest and sources Juno holds. */
|
|
@@ -87,6 +99,59 @@ export class JunoClient {
|
|
|
87
99
|
return this.#json('GET', `/api/integrations/${encodeURIComponent(id)}`);
|
|
88
100
|
}
|
|
89
101
|
|
|
102
|
+
/**
|
|
103
|
+
* GET /api/integrations/{id}/versions/{v} — one stored version, keyed as
|
|
104
|
+
* the Go struct spells it (`Manifest`, `SourceFiles`), or null when the
|
|
105
|
+
* instance has no such version.
|
|
106
|
+
*/
|
|
107
|
+
async getIntegrationVersion(id, version) {
|
|
108
|
+
return this.#versionOrNull(
|
|
109
|
+
`/api/integrations/${encodeURIComponent(id)}/versions/${version}`
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* GET /api/flows/{id}/versions/{v} — one stored version (`manifest_json`,
|
|
115
|
+
* `source_files`), or null when the instance has no such version.
|
|
116
|
+
*/
|
|
117
|
+
async getFlowVersion(id, version) {
|
|
118
|
+
return this.#versionOrNull(
|
|
119
|
+
`/api/flows/${encodeURIComponent(id)}/versions/${version}`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* GET /api/flows/{id}/runs/{seq}/fixture — `{fixture, notes}`, run `seq`
|
|
125
|
+
* as a flowHarness scenario. A refusal (a run still going, one that
|
|
126
|
+
* suspended or was cancelled) is a 422 and an unknown flow or run a 404;
|
|
127
|
+
* both are a UserError in the server's own words.
|
|
128
|
+
*/
|
|
129
|
+
async runFixture(flowId, seq) {
|
|
130
|
+
const path = `/api/flows/${encodeURIComponent(flowId)}/runs/${seq}/fixture`;
|
|
131
|
+
const res = await this.#request('GET', path);
|
|
132
|
+
if (res.status === 422 || res.status === 404) {
|
|
133
|
+
throw new UserError(problemDetail(res));
|
|
134
|
+
}
|
|
135
|
+
return this.#answer('GET', path, res);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* GET /api/apps/{id}/versions/{v} — one stored version (`manifest`,
|
|
140
|
+
* `files`), or null when the instance has no such version.
|
|
141
|
+
*/
|
|
142
|
+
async getAppVersion(id, version) {
|
|
143
|
+
return this.#versionOrNull(
|
|
144
|
+
`/api/apps/${encodeURIComponent(id)}/versions/${version}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** #versionOrNull is a GET whose 404 is an answer: there is no such version. */
|
|
149
|
+
async #versionOrNull(path) {
|
|
150
|
+
const res = await this.#request('GET', path);
|
|
151
|
+
if (res.status === 404) return null;
|
|
152
|
+
return this.#answer('GET', path, res);
|
|
153
|
+
}
|
|
154
|
+
|
|
90
155
|
/**
|
|
91
156
|
* POST /api/flows/scaffold — a flow working copy, `{files: {path: content},
|
|
92
157
|
* warnings?: [string]}`: the active version's, or a stub for an id with
|
|
@@ -94,7 +159,7 @@ export class JunoClient {
|
|
|
94
159
|
* the working copy is still rendered, because it is how that gets fixed.
|
|
95
160
|
*/
|
|
96
161
|
async scaffoldFlow(id, { target } = {}) {
|
|
97
|
-
const body = await this.#
|
|
162
|
+
const body = await this.#workingCopyJSON('/api/flows/scaffold', {
|
|
98
163
|
id,
|
|
99
164
|
target: target ?? undefined,
|
|
100
165
|
});
|
|
@@ -102,41 +167,85 @@ export class JunoClient {
|
|
|
102
167
|
}
|
|
103
168
|
|
|
104
169
|
/**
|
|
105
|
-
* POST /api/typegen/flow — `{manifest, types: {"flow-env.d.ts",
|
|
106
|
-
* "flow-api.d.ts"}}` for a manifest, from the live catalog.
|
|
170
|
+
* POST /api/typegen/flow — `{manifest, types: {"types/flow-env.d.ts",
|
|
171
|
+
* "types/flow-api.d.ts"}}` for a manifest, from the live catalog.
|
|
107
172
|
*/
|
|
108
173
|
async typegenFlow(manifest) {
|
|
109
|
-
return this.#
|
|
174
|
+
return this.#workingCopyJSON('/api/typegen/flow', { manifest });
|
|
110
175
|
}
|
|
111
176
|
|
|
112
177
|
/**
|
|
113
178
|
* POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
|
|
114
|
-
* commit and activate it. `
|
|
179
|
+
* commit and activate it. `files` is the working copy's src/ tree, path →
|
|
180
|
+
* content. `baseVersion` undefined sends none (a new flow,
|
|
115
181
|
* or --force). A 409 is a ConflictError; a 422 with diagnostics is a
|
|
116
182
|
* DiagnosticsError.
|
|
117
183
|
*/
|
|
118
|
-
async ingestFlow({ flowId, manifest,
|
|
184
|
+
async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
|
|
119
185
|
const query = dryRun ? '?dry_run=true' : '';
|
|
120
|
-
return this.#
|
|
186
|
+
return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
|
|
121
187
|
flow_id: flowId,
|
|
122
188
|
manifest,
|
|
123
|
-
|
|
189
|
+
files,
|
|
190
|
+
base_version: baseVersion ?? undefined,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* POST /api/apps/scaffold — an app working copy, `{files: {path:
|
|
196
|
+
* content}}`: the active version's, or a stub for an id with none.
|
|
197
|
+
*/
|
|
198
|
+
async scaffoldApp(id, { target } = {}) {
|
|
199
|
+
const body = await this.#workingCopyJSON('/api/apps/scaffold', {
|
|
200
|
+
id,
|
|
201
|
+
target: target ?? undefined,
|
|
202
|
+
});
|
|
203
|
+
return body?.files ?? {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* POST /api/typegen/app — `{manifest, types: {"types/app-api.d.ts", …}}`
|
|
208
|
+
* for an edited manifest, from the live catalog.
|
|
209
|
+
*/
|
|
210
|
+
async typegenApp(manifest) {
|
|
211
|
+
return this.#workingCopyJSON('/api/typegen/app', { manifest });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* POST /api/apps/ingest — verify an app working copy and store it as a
|
|
216
|
+
* version: activated, or with `activate: false` left as the draft
|
|
217
|
+
* `~draft/` serves. A 422 with diagnostics is a DiagnosticsError.
|
|
218
|
+
* `baseVersion` undefined sends none (a copy that records no base, or
|
|
219
|
+
* --force). When activating, a 409 is a ConflictError: the app's active
|
|
220
|
+
* version is not the copy's base. A draft is never refused; the answer's
|
|
221
|
+
* `stale_base` carries the sentence instead.
|
|
222
|
+
*/
|
|
223
|
+
async ingestApp(
|
|
224
|
+
{ manifest, files, lockfile },
|
|
225
|
+
{ activate = true, baseVersion } = {}
|
|
226
|
+
) {
|
|
227
|
+
return this.#workingCopyJSON('/api/apps/ingest', {
|
|
228
|
+
manifest,
|
|
229
|
+
files,
|
|
230
|
+
lockfile: lockfile ?? undefined,
|
|
231
|
+
activate,
|
|
124
232
|
base_version: baseVersion ?? undefined,
|
|
125
233
|
});
|
|
126
234
|
}
|
|
127
235
|
|
|
128
236
|
/**
|
|
129
|
-
* #
|
|
130
|
-
* more failures in their own words: a 409 (the working copy is
|
|
131
|
-
* 422 with no diagnostics (
|
|
237
|
+
* #workingCopyJSON is #json for the flow and app working-copy routes, which
|
|
238
|
+
* answer two more failures in their own words: a 409 (the working copy is
|
|
239
|
+
* stale) and a 422 with no diagnostics (an id that is not kebab-case, an
|
|
240
|
+
* archive path outside src/, page/ and public/).
|
|
132
241
|
*/
|
|
133
|
-
async #
|
|
242
|
+
async #workingCopyJSON(path, body) {
|
|
134
243
|
const res = await this.#request('POST', path, body);
|
|
135
244
|
if (res.status === 409) throw new ConflictError(problemDetail(res));
|
|
136
245
|
if (res.status === 422 && !res.parsed?.diagnostics?.length) {
|
|
137
246
|
throw new UserError(validationDetail(res));
|
|
138
247
|
}
|
|
139
|
-
if (res.ok) return res
|
|
248
|
+
if (res.ok) return jsonBody('POST', path, res);
|
|
140
249
|
if (res.parsed?.diagnostics?.length) {
|
|
141
250
|
throw new DiagnosticsError(
|
|
142
251
|
res.parsed.detail || 'verification failed',
|
|
@@ -167,12 +276,7 @@ export class JunoClient {
|
|
|
167
276
|
);
|
|
168
277
|
}
|
|
169
278
|
if (!res.ok) throw failure('POST', path, res);
|
|
170
|
-
|
|
171
|
-
throw new UserError(
|
|
172
|
-
`POST ${path} answered something that is not JSON: ${res.text.trim()}`
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
return { result: res.parsed, text: res.text };
|
|
279
|
+
return { result: jsonBody('POST', path, res), text: res.text };
|
|
176
280
|
}
|
|
177
281
|
|
|
178
282
|
/**
|
|
@@ -214,8 +318,8 @@ export class JunoClient {
|
|
|
214
318
|
const path = `/api/skills/${encodeURIComponent(name)}`;
|
|
215
319
|
const res = await this.#request('GET', path);
|
|
216
320
|
if (res.status === 404) return null;
|
|
217
|
-
if (!res.ok
|
|
218
|
-
return res
|
|
321
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
322
|
+
return jsonBody('GET', path, res);
|
|
219
323
|
}
|
|
220
324
|
|
|
221
325
|
/**
|
|
@@ -228,7 +332,8 @@ export class JunoClient {
|
|
|
228
332
|
const path = `/api/secrets/${encodeURIComponent(id)}`;
|
|
229
333
|
const res = await this.#request('GET', path);
|
|
230
334
|
if (res.status === 404) return null;
|
|
231
|
-
if (!res.ok
|
|
335
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
336
|
+
jsonBody('GET', path, res);
|
|
232
337
|
return {
|
|
233
338
|
name: res.parsed.name ?? '',
|
|
234
339
|
description: res.parsed.description ?? '',
|
|
@@ -268,8 +373,12 @@ export class JunoClient {
|
|
|
268
373
|
}
|
|
269
374
|
|
|
270
375
|
async #json(method, path, body) {
|
|
271
|
-
|
|
272
|
-
|
|
376
|
+
return this.#answer(method, path, await this.#request(method, path, body));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** #answer is #json's reading of an answer already received. */
|
|
380
|
+
#answer(method, path, res) {
|
|
381
|
+
if (res.ok) return jsonBody(method, path, res);
|
|
273
382
|
|
|
274
383
|
// A 422 is the interesting failure: it carries the compiler's own
|
|
275
384
|
// findings, and reprinting them beats summarising them.
|
|
@@ -311,12 +420,11 @@ export class JunoClient {
|
|
|
311
420
|
if (answer.status === 400) throw new UsageError(problemDetail(answer));
|
|
312
421
|
if (answer.status === 404) throw new UserError(problemDetail(answer));
|
|
313
422
|
if (!answer.ok) throw failure('POST', path, answer);
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
return { status: answer.status, event: answer.parsed, text: answer.text };
|
|
423
|
+
return {
|
|
424
|
+
status: answer.status,
|
|
425
|
+
event: jsonBody('POST', path, answer),
|
|
426
|
+
text: answer.text,
|
|
427
|
+
};
|
|
320
428
|
}
|
|
321
429
|
|
|
322
430
|
/**
|
|
@@ -327,8 +435,8 @@ export class JunoClient {
|
|
|
327
435
|
const path = `/api/agent/${encodeURIComponent(id)}`;
|
|
328
436
|
const res = await this.#request('GET', path);
|
|
329
437
|
if (res.status === 404) return null;
|
|
330
|
-
if (!res.ok
|
|
331
|
-
return res
|
|
438
|
+
if (!res.ok) throw failure('GET', path, res);
|
|
439
|
+
return jsonBody('GET', path, res);
|
|
332
440
|
}
|
|
333
441
|
|
|
334
442
|
/**
|
|
@@ -343,6 +451,11 @@ export class JunoClient {
|
|
|
343
451
|
/**
|
|
344
452
|
* #send is the one place a request leaves this CLI, returning the fetch
|
|
345
453
|
* Response unread so a stream can be read as it arrives.
|
|
454
|
+
*
|
|
455
|
+
* A redirect is not followed. Juno's API never redirects, so a 3xx comes
|
|
456
|
+
* from something in front of it, typically an auth proxy sending the
|
|
457
|
+
* request to its sign-in page. Following it would hand the caller that
|
|
458
|
+
* page's HTML as a 200.
|
|
346
459
|
*/
|
|
347
460
|
async #send(method, path, body) {
|
|
348
461
|
const url = `${this.target}${path}`;
|
|
@@ -350,17 +463,31 @@ export class JunoClient {
|
|
|
350
463
|
// one client, one header.
|
|
351
464
|
const headers = { 'X-Juno-Client': this.client };
|
|
352
465
|
if (body) headers['content-type'] = 'application/json';
|
|
466
|
+
let res;
|
|
353
467
|
try {
|
|
354
|
-
|
|
468
|
+
res = await this.fetch(url, {
|
|
355
469
|
method,
|
|
356
470
|
headers,
|
|
357
471
|
body: body ? JSON.stringify(body) : undefined,
|
|
472
|
+
redirect: 'manual',
|
|
358
473
|
});
|
|
359
474
|
} catch (err) {
|
|
360
475
|
throw new UnreachableError(
|
|
361
476
|
`cannot reach Juno at ${this.target}: ${err.message}`
|
|
362
477
|
);
|
|
363
478
|
}
|
|
479
|
+
if (res.status >= 300 && res.status < 400) {
|
|
480
|
+
const location = res.headers?.get?.('location');
|
|
481
|
+
throw new UserError(
|
|
482
|
+
`${method} ${path} was redirected (${res.status})` +
|
|
483
|
+
(location ? ` to ${location}` : '') +
|
|
484
|
+
`: ${this.target} looks like it is behind an auth proxy or is ` +
|
|
485
|
+
'not the Juno API. Point the CLI at an address that reaches Juno ' +
|
|
486
|
+
'directly with --url or $JUNO_URL (inside a working copy, ' +
|
|
487
|
+
"package.json's juno.target is used otherwise)"
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return res;
|
|
364
491
|
}
|
|
365
492
|
|
|
366
493
|
async #read(res) {
|
|
@@ -429,6 +556,22 @@ async function* ndjson(body, target) {
|
|
|
429
556
|
yield* lines(true);
|
|
430
557
|
}
|
|
431
558
|
|
|
559
|
+
/**
|
|
560
|
+
* jsonBody is a 2xx answer's parsed body, which every route this client calls
|
|
561
|
+
* answers as a JSON object. Anything else came from something other than
|
|
562
|
+
* Juno, such as a proxy's HTML page or a wrong URL. It is an error, never an
|
|
563
|
+
* empty success, because a caller reading fields off null either crashes or
|
|
564
|
+
* quietly does nothing.
|
|
565
|
+
*/
|
|
566
|
+
function jsonBody(method, path, res) {
|
|
567
|
+
if (res.parsed === null || typeof res.parsed !== 'object') {
|
|
568
|
+
throw new UserError(
|
|
569
|
+
`${method} ${path} answered something that is not JSON: ${res.text.trim()}`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
return res.parsed;
|
|
573
|
+
}
|
|
574
|
+
|
|
432
575
|
/** The server's own words in an error answer: Huma's `detail`, else the body. */
|
|
433
576
|
function problemDetail(res) {
|
|
434
577
|
return (
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { pathToFileURL } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { UserError } from '../errors.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* loadFromWorkingCopy imports `name` as the working copy's own code would:
|
|
11
|
+
* resolved from its package.json, so out of its node_modules. The CLI has no
|
|
12
|
+
* runtime dependencies (`.claude/rules/cli.md`, "A thin client"), and the dev
|
|
13
|
+
* server must run the esbuild and testkit the project's own build and tests
|
|
14
|
+
* run, not a second copy of either.
|
|
15
|
+
*/
|
|
16
|
+
export async function loadFromWorkingCopy(cwd, name) {
|
|
17
|
+
let resolved;
|
|
18
|
+
try {
|
|
19
|
+
resolved = createRequire(join(cwd, 'package.json')).resolve(name);
|
|
20
|
+
} catch {
|
|
21
|
+
throw new UserError(
|
|
22
|
+
`${name} is not installed in this working copy — run npm install`
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return import(pathToFileURL(resolved).href);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* createBuilder wraps the working copy's esbuild with the two builds the dev
|
|
30
|
+
* server makes: a page/ module for the browser, as ingest bundles it
|
|
31
|
+
* (internal/verify/page.go's bundlePage), and src/app.ts for Node, imported
|
|
32
|
+
* in process for the harness.
|
|
33
|
+
*
|
|
34
|
+
* A page module is built on every request, so a save needs no watcher to show
|
|
35
|
+
* up. The handlers are built once and kept: `invalidate()` marks them for a
|
|
36
|
+
* rebuild on the next `handlers()`, and a rebuild that fails keeps the last
|
|
37
|
+
* good module serving, with the failure in `error` — a syntax error mid-edit
|
|
38
|
+
* must not take the page's working calls down with it. `generation` counts
|
|
39
|
+
* the handler builds that succeeded, so a caller holding something built from
|
|
40
|
+
* one module can tell when it is out of date.
|
|
41
|
+
*/
|
|
42
|
+
export async function createBuilder(cwd) {
|
|
43
|
+
const mod = await loadFromWorkingCopy(cwd, 'esbuild');
|
|
44
|
+
const esbuild = typeof mod.build === 'function' ? mod : mod.default;
|
|
45
|
+
const tmp = await mkdtemp(join(tmpdir(), 'juno-app-dev-'));
|
|
46
|
+
|
|
47
|
+
let generation = 0;
|
|
48
|
+
let good = null; // the last module that built and imported
|
|
49
|
+
let dirty = true;
|
|
50
|
+
let error = null; // the last rebuild's failure, null after a success
|
|
51
|
+
|
|
52
|
+
const builder = {
|
|
53
|
+
/** The page/ module at abs, bundled for the browser, as JavaScript text. */
|
|
54
|
+
async page(abs) {
|
|
55
|
+
const out = await esbuild.build({
|
|
56
|
+
entryPoints: [abs],
|
|
57
|
+
absWorkingDir: cwd,
|
|
58
|
+
bundle: true,
|
|
59
|
+
format: 'esm',
|
|
60
|
+
platform: 'browser',
|
|
61
|
+
target: 'es2020',
|
|
62
|
+
write: false,
|
|
63
|
+
logLevel: 'silent',
|
|
64
|
+
});
|
|
65
|
+
return out.outputFiles[0].text;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The handlers module's default export, rebuilt first when invalidated.
|
|
70
|
+
* Throws only when nothing has ever built.
|
|
71
|
+
*/
|
|
72
|
+
async handlers() {
|
|
73
|
+
if (dirty) {
|
|
74
|
+
dirty = false;
|
|
75
|
+
try {
|
|
76
|
+
good = await buildHandlers();
|
|
77
|
+
error = null;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// Retried on the next call too, so a fix is picked up even before
|
|
80
|
+
// anything invalidates.
|
|
81
|
+
error = err;
|
|
82
|
+
dirty = true;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!good) throw error;
|
|
86
|
+
return good;
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
/** Marks src/ as changed: the next handlers() rebuilds. */
|
|
90
|
+
invalidate() {
|
|
91
|
+
dirty = true;
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
/** The last handlers rebuild's failure, or null. */
|
|
95
|
+
get error() {
|
|
96
|
+
return error;
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
/** How many handler builds have succeeded. */
|
|
100
|
+
get generation() {
|
|
101
|
+
return generation;
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/** Removes the temp directory the handler builds are written to. */
|
|
105
|
+
close() {
|
|
106
|
+
return rm(tmp, { recursive: true, force: true });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* One Node build of src/app.ts, written under tmp and imported with a
|
|
112
|
+
* cache-busting query: Node caches a module by URL, and the same URL would
|
|
113
|
+
* answer the first build forever.
|
|
114
|
+
*/
|
|
115
|
+
async function buildHandlers() {
|
|
116
|
+
const out = await esbuild.build({
|
|
117
|
+
entryPoints: [join(cwd, 'src', 'app.ts')],
|
|
118
|
+
absWorkingDir: cwd,
|
|
119
|
+
bundle: true,
|
|
120
|
+
format: 'esm',
|
|
121
|
+
platform: 'node',
|
|
122
|
+
target: 'node20',
|
|
123
|
+
write: false,
|
|
124
|
+
logLevel: 'silent',
|
|
125
|
+
});
|
|
126
|
+
const file = join(tmp, 'app.mjs');
|
|
127
|
+
await writeFile(file, out.outputFiles[0].text, 'utf8');
|
|
128
|
+
generation += 1;
|
|
129
|
+
const mod = await import(`${pathToFileURL(file).href}?v=${generation}`);
|
|
130
|
+
return mod.default;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return builder;
|
|
134
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { UserError } from '../errors.js';
|
|
5
|
+
import { loadFromWorkingCopy } from './build.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* createDevHarness runs the app's handlers the way `npm test` does: through
|
|
9
|
+
* the working copy's own @junoflow/testkit appHarness, so the dev server is
|
|
10
|
+
* exactly as strict as the project's tests — an unanswered target is a
|
|
11
|
+
* refusal naming `binding.method`, not a silent `undefined`.
|
|
12
|
+
*
|
|
13
|
+
* The world comes from a scenario file, the shape `h.scenario` takes
|
|
14
|
+
* (plan-local-dev-6 decision 7). With none, the world is empty, and every
|
|
15
|
+
* call that asks it something fails visibly.
|
|
16
|
+
*
|
|
17
|
+
* One harness lives until the scenario or the manifest changes; then a fresh
|
|
18
|
+
* one is built and the scenario re-applied to it. A handlers rebuild keeps
|
|
19
|
+
* the harness: its `app` reads the builder's latest module on every call.
|
|
20
|
+
*/
|
|
21
|
+
export async function createDevHarness({ cwd, manifest, builder }) {
|
|
22
|
+
const { appHarness } = await loadFromWorkingCopy(cwd, '@junoflow/testkit');
|
|
23
|
+
if (typeof appHarness !== 'function') {
|
|
24
|
+
throw new UserError(
|
|
25
|
+
'@junoflow/testkit in this working copy has no appHarness — run npm install @junoflow/testkit@latest'
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let latest = { handlers: {} };
|
|
30
|
+
const app = {
|
|
31
|
+
get handlers() {
|
|
32
|
+
return latest.handlers;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const dev = {
|
|
37
|
+
manifest,
|
|
38
|
+
/** The scenario in use, `{path, value}` (path relative to cwd), or null. */
|
|
39
|
+
scenario: null,
|
|
40
|
+
/** The testkit harness calls go through; replaced by a reset. */
|
|
41
|
+
h: null,
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads and applies the scenario at path (relative to cwd), or the empty
|
|
45
|
+
* world for null. A file that is not a scenario fails here, naming it,
|
|
46
|
+
* and the scenario in use stays.
|
|
47
|
+
*/
|
|
48
|
+
async useScenario(path) {
|
|
49
|
+
if (path == null) {
|
|
50
|
+
dev.scenario = null;
|
|
51
|
+
} else {
|
|
52
|
+
const abs = isAbsolute(path) ? path : resolve(cwd, path);
|
|
53
|
+
const rel = relative(cwd, abs);
|
|
54
|
+
const value = await readScenario(abs, rel);
|
|
55
|
+
// Applied to a throwaway harness first, so a bad key is refused with
|
|
56
|
+
// the testkit's message before anything is replaced.
|
|
57
|
+
try {
|
|
58
|
+
appHarness({ manifest: dev.manifest, app }).scenario(value);
|
|
59
|
+
} catch (err) {
|
|
60
|
+
throw new UserError(`${rel}: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
dev.scenario = { path: rel, value };
|
|
63
|
+
}
|
|
64
|
+
reset();
|
|
65
|
+
return dev;
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
/** Replaces the manifest (manifest.json was saved) and resets. */
|
|
69
|
+
setManifest(m) {
|
|
70
|
+
dev.manifest = m;
|
|
71
|
+
reset();
|
|
72
|
+
return dev;
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Calls handler name with input, after picking up the latest handlers
|
|
77
|
+
* build. Resolves to the result after the JSON round trip; throws what
|
|
78
|
+
* the handler threw, the harness's refusal, or the build's failure.
|
|
79
|
+
*/
|
|
80
|
+
async call(name, input) {
|
|
81
|
+
// A module with no default export reaches the harness's own refusal.
|
|
82
|
+
latest = (await builder.handlers()) ?? {};
|
|
83
|
+
return dev.h.call(name, input);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function reset() {
|
|
88
|
+
dev.h = appHarness({ manifest: dev.manifest, app });
|
|
89
|
+
if (dev.scenario) dev.h.scenario(dev.scenario.value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
reset();
|
|
93
|
+
return dev;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readScenario(abs, rel) {
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = await readFile(abs, 'utf8');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new UserError(`cannot read scenario ${rel}: ${err.message}`);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
return JSON.parse(text);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
throw new UserError(`scenario ${rel} is not valid JSON: ${err.message}`);
|
|
107
|
+
}
|
|
108
|
+
}
|