@junoflow/cli 0.6.0 → 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.
Files changed (43) hide show
  1. package/README.md +214 -80
  2. package/app-client.js +306 -0
  3. package/eslint.app.config.js +34 -0
  4. package/eslint.flow.config.js +4 -2
  5. package/package.json +9 -4
  6. package/src/api.js +213 -51
  7. package/src/app-dev/build.js +134 -0
  8. package/src/app-dev/harness.js +108 -0
  9. package/src/app-dev/panel.html +336 -0
  10. package/src/app-dev/panel.js +290 -0
  11. package/src/app-dev/server.js +347 -0
  12. package/src/app-dev/stream.js +224 -0
  13. package/src/app-dev/watch.js +47 -0
  14. package/src/app-project.js +133 -0
  15. package/src/authoring-skills.js +2 -1
  16. package/src/commands/app-check.js +75 -0
  17. package/src/commands/app-deploy.js +113 -0
  18. package/src/commands/app-dev.js +141 -0
  19. package/src/commands/app-gen-types.js +37 -0
  20. package/src/commands/app-init.js +46 -0
  21. package/src/commands/app-pull.js +106 -0
  22. package/src/commands/check.js +33 -24
  23. package/src/commands/config.js +26 -7
  24. package/src/commands/deploy.js +59 -13
  25. package/src/commands/flow-check.js +4 -22
  26. package/src/commands/flow-deploy.js +11 -8
  27. package/src/commands/flow-fixture.js +67 -0
  28. package/src/commands/flow-gen-types.js +12 -5
  29. package/src/commands/flow-pull.js +69 -40
  30. package/src/commands/gen-types.js +2 -9
  31. package/src/commands/init.js +2 -9
  32. package/src/commands/pull.js +98 -111
  33. package/src/config.js +20 -5
  34. package/src/errors.js +3 -3
  35. package/src/exit.js +2 -2
  36. package/src/flow-project.js +47 -97
  37. package/src/index.js +99 -23
  38. package/src/project.js +76 -8
  39. package/src/token.js +30 -0
  40. package/src/watch.js +48 -11
  41. package/src/working-copy.js +262 -0
  42. package/tsconfig.page.json +15 -0
  43. package/verbs.json +52 -3
package/src/api.js CHANGED
@@ -6,21 +6,22 @@ import {
6
6
  UserError,
7
7
  } from './errors.js';
8
8
  import { resolveTarget } from './target.js';
9
+ import { resolveToken } from './token.js';
9
10
 
10
11
  /**
11
12
  * clientName is the X-Juno-Client value: `cli/claude-code` inside a shell
12
- * Claude Code started (it sets CLAUDECODE=1), `cli` everywhere else. The
13
- * gateway records a write as `operator:<client>`; it is the actor, not a
14
- * security control.
13
+ * Claude Code started (it sets CLAUDECODE=1), `cli` everywhere else. It labels
14
+ * the calling client; the actor a write is recorded under is the request's
15
+ * principal, and the header is not a security control.
15
16
  */
16
17
  export function clientName(env = process.env) {
17
18
  return env.CLAUDECODE === '1' ? 'cli/claude-code' : 'cli';
18
19
  }
19
20
 
20
21
  /**
21
- * connect resolves the instance (target.js) and builds the client every
22
- * command uses, unless one was injected: `args.client` is how the tests hand
23
- * a command a client instead of a URL.
22
+ * connect resolves the instance (target.js) and the API key (token.js) and
23
+ * builds the client every command uses, unless one was injected:
24
+ * `args.client` is how the tests hand a command a client instead of a URL.
24
25
  */
25
26
  export async function connect(args, io) {
26
27
  const target = await resolveTarget({
@@ -28,9 +29,12 @@ export async function connect(args, io) {
28
29
  cwd: io.cwd,
29
30
  env: io.env,
30
31
  });
31
- const client =
32
- args.client ?? new JunoClient(target, { client: clientName(io.env) });
33
- return { target, client };
32
+ if (args.client) return { target, client: args.client };
33
+ const token = await resolveToken({ token: args.token, env: io.env });
34
+ return {
35
+ target,
36
+ client: new JunoClient(target, { client: clientName(io.env), token }),
37
+ };
34
38
  }
35
39
 
36
40
  /**
@@ -43,17 +47,22 @@ export async function connect(args, io) {
43
47
  export class JunoClient {
44
48
  constructor(
45
49
  target,
46
- { fetch: fetchImpl = globalThis.fetch, client = clientName() } = {}
50
+ { fetch: fetchImpl = globalThis.fetch, client = clientName(), token } = {}
47
51
  ) {
48
52
  this.target = target;
49
53
  this.fetch = fetchImpl;
50
54
  this.client = client;
55
+ // The API key, sent as a bearer on every request. A private field, so
56
+ // nothing that logs or serialises a client can print it.
57
+ this.#token = token;
51
58
  }
52
59
 
60
+ #token;
61
+
53
62
  /**
54
63
  * POST /api/integrations/scaffold — a whole starting project, path →
55
- * content. `target` is what this client reached the instance at; the server
56
- * prefers its own configured public URL and falls back to it.
64
+ * content. `target` is what this client reached the instance at, recorded
65
+ * as juno.target; the server falls back to its public URL without it.
57
66
  */
58
67
  async scaffold(id, { name, description, target } = {}) {
59
68
  const body = await this.#json('POST', '/api/integrations/scaffold', {
@@ -70,16 +79,28 @@ export class JunoClient {
70
79
  return this.#json('POST', '/api/typegen/integration', { manifest });
71
80
  }
72
81
 
73
- /** POST /api/integrations/ingest — deploy, or validate only with dryRun. */
74
- async ingest(project, { dryRun = false, id } = {}) {
75
- const query = dryRun ? '?dry_run=true' : '';
76
- return this.#json('POST', `/api/integrations/ingest${query}`, {
82
+ /**
83
+ * POST /api/integrations/ingest — deploy, or validate only with dryRun.
84
+ * `baseVersion` undefined sends none (a copy that records no base, --force,
85
+ * or --id naming another install). A 409 is a ConflictError: the install's
86
+ * active version is not the copy's base.
87
+ *
88
+ * It does not go through #workingCopyJSON, whose 422-without-diagnostics
89
+ * wording differs from what this route has always printed; only the 409 is
90
+ * shared.
91
+ */
92
+ async ingest(project, { dryRun = false, id, baseVersion } = {}) {
93
+ const path = `/api/integrations/ingest${dryRun ? '?dry_run=true' : ''}`;
94
+ const res = await this.#request('POST', path, {
77
95
  manifest: project.manifest,
78
96
  files: project.files,
79
97
  package_json: project.packageJSON ?? undefined,
80
98
  lockfile: project.lockfile ?? undefined,
81
99
  id: id ?? undefined,
100
+ base_version: baseVersion ?? undefined,
82
101
  });
102
+ if (res.status === 409) throw new ConflictError(problemDetail(res));
103
+ return this.#answer('POST', path, res);
83
104
  }
84
105
 
85
106
  /** GET /api/integrations/{id} — the manifest and sources Juno holds. */
@@ -87,6 +108,59 @@ export class JunoClient {
87
108
  return this.#json('GET', `/api/integrations/${encodeURIComponent(id)}`);
88
109
  }
89
110
 
111
+ /**
112
+ * GET /api/integrations/{id}/versions/{v} — one stored version, keyed as
113
+ * the Go struct spells it (`Manifest`, `SourceFiles`), or null when the
114
+ * instance has no such version.
115
+ */
116
+ async getIntegrationVersion(id, version) {
117
+ return this.#versionOrNull(
118
+ `/api/integrations/${encodeURIComponent(id)}/versions/${version}`
119
+ );
120
+ }
121
+
122
+ /**
123
+ * GET /api/flows/{id}/versions/{v} — one stored version (`manifest_json`,
124
+ * `source_files`), or null when the instance has no such version.
125
+ */
126
+ async getFlowVersion(id, version) {
127
+ return this.#versionOrNull(
128
+ `/api/flows/${encodeURIComponent(id)}/versions/${version}`
129
+ );
130
+ }
131
+
132
+ /**
133
+ * GET /api/flows/{id}/runs/{seq}/fixture — `{fixture, notes}`, run `seq`
134
+ * as a flowHarness scenario. A refusal (a run still going, one that
135
+ * suspended or was cancelled) is a 422 and an unknown flow or run a 404;
136
+ * both are a UserError in the server's own words.
137
+ */
138
+ async runFixture(flowId, seq) {
139
+ const path = `/api/flows/${encodeURIComponent(flowId)}/runs/${seq}/fixture`;
140
+ const res = await this.#request('GET', path);
141
+ if (res.status === 422 || res.status === 404) {
142
+ throw new UserError(problemDetail(res));
143
+ }
144
+ return this.#answer('GET', path, res);
145
+ }
146
+
147
+ /**
148
+ * GET /api/apps/{id}/versions/{v} — one stored version (`manifest`,
149
+ * `files`), or null when the instance has no such version.
150
+ */
151
+ async getAppVersion(id, version) {
152
+ return this.#versionOrNull(
153
+ `/api/apps/${encodeURIComponent(id)}/versions/${version}`
154
+ );
155
+ }
156
+
157
+ /** #versionOrNull is a GET whose 404 is an answer: there is no such version. */
158
+ async #versionOrNull(path) {
159
+ const res = await this.#request('GET', path);
160
+ if (res.status === 404) return null;
161
+ return this.#answer('GET', path, res);
162
+ }
163
+
90
164
  /**
91
165
  * POST /api/flows/scaffold — a flow working copy, `{files: {path: content},
92
166
  * warnings?: [string]}`: the active version's, or a stub for an id with
@@ -94,7 +168,7 @@ export class JunoClient {
94
168
  * the working copy is still rendered, because it is how that gets fixed.
95
169
  */
96
170
  async scaffoldFlow(id, { target } = {}) {
97
- const body = await this.#flowJSON('/api/flows/scaffold', {
171
+ const body = await this.#workingCopyJSON('/api/flows/scaffold', {
98
172
  id,
99
173
  target: target ?? undefined,
100
174
  });
@@ -102,41 +176,85 @@ export class JunoClient {
102
176
  }
103
177
 
104
178
  /**
105
- * POST /api/typegen/flow — `{manifest, types: {"flow-env.d.ts",
106
- * "flow-api.d.ts"}}` for a manifest, from the live catalog.
179
+ * POST /api/typegen/flow — `{manifest, types: {"types/flow-env.d.ts",
180
+ * "types/flow-api.d.ts"}}` for a manifest, from the live catalog.
107
181
  */
108
182
  async typegenFlow(manifest) {
109
- return this.#flowJSON('/api/typegen/flow', { manifest });
183
+ return this.#workingCopyJSON('/api/typegen/flow', { manifest });
110
184
  }
111
185
 
112
186
  /**
113
187
  * POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
114
- * commit and activate it. `baseVersion` undefined sends none (a new flow,
188
+ * commit and activate it. `files` is the working copy's src/ tree, path →
189
+ * content. `baseVersion` undefined sends none (a new flow,
115
190
  * or --force). A 409 is a ConflictError; a 422 with diagnostics is a
116
191
  * DiagnosticsError.
117
192
  */
118
- async ingestFlow({ flowId, manifest, flowTS, baseVersion }, { dryRun } = {}) {
193
+ async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
119
194
  const query = dryRun ? '?dry_run=true' : '';
120
- return this.#flowJSON(`/api/flows/ingest${query}`, {
195
+ return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
121
196
  flow_id: flowId,
122
197
  manifest,
123
- flow_ts: flowTS,
198
+ files,
199
+ base_version: baseVersion ?? undefined,
200
+ });
201
+ }
202
+
203
+ /**
204
+ * POST /api/apps/scaffold — an app working copy, `{files: {path:
205
+ * content}}`: the active version's, or a stub for an id with none.
206
+ */
207
+ async scaffoldApp(id, { target } = {}) {
208
+ const body = await this.#workingCopyJSON('/api/apps/scaffold', {
209
+ id,
210
+ target: target ?? undefined,
211
+ });
212
+ return body?.files ?? {};
213
+ }
214
+
215
+ /**
216
+ * POST /api/typegen/app — `{manifest, types: {"types/app-api.d.ts", …}}`
217
+ * for an edited manifest, from the live catalog.
218
+ */
219
+ async typegenApp(manifest) {
220
+ return this.#workingCopyJSON('/api/typegen/app', { manifest });
221
+ }
222
+
223
+ /**
224
+ * POST /api/apps/ingest — verify an app working copy and store it as a
225
+ * version: activated, or with `activate: false` left as the draft
226
+ * `~draft/` serves. A 422 with diagnostics is a DiagnosticsError.
227
+ * `baseVersion` undefined sends none (a copy that records no base, or
228
+ * --force). When activating, a 409 is a ConflictError: the app's active
229
+ * version is not the copy's base. A draft is never refused; the answer's
230
+ * `stale_base` carries the sentence instead.
231
+ */
232
+ async ingestApp(
233
+ { manifest, files, lockfile },
234
+ { activate = true, baseVersion } = {}
235
+ ) {
236
+ return this.#workingCopyJSON('/api/apps/ingest', {
237
+ manifest,
238
+ files,
239
+ lockfile: lockfile ?? undefined,
240
+ activate,
124
241
  base_version: baseVersion ?? undefined,
125
242
  });
126
243
  }
127
244
 
128
245
  /**
129
- * #flowJSON is #json for the flow working-copy routes, which answer two
130
- * more failures in their own words: a 409 (the working copy is stale) and a
131
- * 422 with no diagnostics (a flow id that is not kebab-case).
246
+ * #workingCopyJSON is #json for the flow and app working-copy routes, which
247
+ * answer two more failures in their own words: a 409 (the working copy is
248
+ * stale) and a 422 with no diagnostics (an id that is not kebab-case, an
249
+ * archive path outside src/, page/ and public/).
132
250
  */
133
- async #flowJSON(path, body) {
251
+ async #workingCopyJSON(path, body) {
134
252
  const res = await this.#request('POST', path, body);
135
253
  if (res.status === 409) throw new ConflictError(problemDetail(res));
136
254
  if (res.status === 422 && !res.parsed?.diagnostics?.length) {
137
255
  throw new UserError(validationDetail(res));
138
256
  }
139
- if (res.ok) return res.parsed;
257
+ if (res.ok) return jsonBody('POST', path, res);
140
258
  if (res.parsed?.diagnostics?.length) {
141
259
  throw new DiagnosticsError(
142
260
  res.parsed.detail || 'verification failed',
@@ -167,12 +285,7 @@ export class JunoClient {
167
285
  );
168
286
  }
169
287
  if (!res.ok) throw failure('POST', path, res);
170
- if (res.parsed === null) {
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 };
288
+ return { result: jsonBody('POST', path, res), text: res.text };
176
289
  }
177
290
 
178
291
  /**
@@ -214,8 +327,8 @@ export class JunoClient {
214
327
  const path = `/api/skills/${encodeURIComponent(name)}`;
215
328
  const res = await this.#request('GET', path);
216
329
  if (res.status === 404) return null;
217
- if (!res.ok || res.parsed === null) throw failure('GET', path, res);
218
- return res.parsed;
330
+ if (!res.ok) throw failure('GET', path, res);
331
+ return jsonBody('GET', path, res);
219
332
  }
220
333
 
221
334
  /**
@@ -228,7 +341,8 @@ export class JunoClient {
228
341
  const path = `/api/secrets/${encodeURIComponent(id)}`;
229
342
  const res = await this.#request('GET', path);
230
343
  if (res.status === 404) return null;
231
- if (!res.ok || res.parsed === null) throw failure('GET', path, res);
344
+ if (!res.ok) throw failure('GET', path, res);
345
+ jsonBody('GET', path, res);
232
346
  return {
233
347
  name: res.parsed.name ?? '',
234
348
  description: res.parsed.description ?? '',
@@ -268,8 +382,12 @@ export class JunoClient {
268
382
  }
269
383
 
270
384
  async #json(method, path, body) {
271
- const res = await this.#request(method, path, body);
272
- if (res.ok) return res.parsed;
385
+ return this.#answer(method, path, await this.#request(method, path, body));
386
+ }
387
+
388
+ /** #answer is #json's reading of an answer already received. */
389
+ #answer(method, path, res) {
390
+ if (res.ok) return jsonBody(method, path, res);
273
391
 
274
392
  // A 422 is the interesting failure: it carries the compiler's own
275
393
  // findings, and reprinting them beats summarising them.
@@ -311,12 +429,11 @@ export class JunoClient {
311
429
  if (answer.status === 400) throw new UsageError(problemDetail(answer));
312
430
  if (answer.status === 404) throw new UserError(problemDetail(answer));
313
431
  if (!answer.ok) throw failure('POST', path, answer);
314
- if (answer.parsed === null || typeof answer.parsed !== 'object') {
315
- throw new UserError(
316
- `POST ${path} answered something that is not JSON: ${answer.text.trim()}`
317
- );
318
- }
319
- return { status: answer.status, event: answer.parsed, text: answer.text };
432
+ return {
433
+ status: answer.status,
434
+ event: jsonBody('POST', path, answer),
435
+ text: answer.text,
436
+ };
320
437
  }
321
438
 
322
439
  /**
@@ -327,8 +444,8 @@ export class JunoClient {
327
444
  const path = `/api/agent/${encodeURIComponent(id)}`;
328
445
  const res = await this.#request('GET', path);
329
446
  if (res.status === 404) return null;
330
- if (!res.ok || res.parsed === null) throw failure('GET', path, res);
331
- return res.parsed;
447
+ if (!res.ok) throw failure('GET', path, res);
448
+ return jsonBody('GET', path, res);
332
449
  }
333
450
 
334
451
  /**
@@ -343,24 +460,44 @@ export class JunoClient {
343
460
  /**
344
461
  * #send is the one place a request leaves this CLI, returning the fetch
345
462
  * Response unread so a stream can be read as it arrives.
463
+ *
464
+ * A redirect is not followed. Juno's API never redirects, so a 3xx comes
465
+ * from something in front of it, typically an auth proxy sending the
466
+ * request to its sign-in page. Following it would hand the caller that
467
+ * page's HTML as a 200.
346
468
  */
347
469
  async #send(method, path, body) {
348
470
  const url = `${this.target}${path}`;
349
471
  // Every request carries the header, the existing commands' included:
350
472
  // one client, one header.
351
473
  const headers = { 'X-Juno-Client': this.client };
474
+ if (this.#token) headers.authorization = `Bearer ${this.#token}`;
352
475
  if (body) headers['content-type'] = 'application/json';
476
+ let res;
353
477
  try {
354
- return await this.fetch(url, {
478
+ res = await this.fetch(url, {
355
479
  method,
356
480
  headers,
357
481
  body: body ? JSON.stringify(body) : undefined,
482
+ redirect: 'manual',
358
483
  });
359
484
  } catch (err) {
360
485
  throw new UnreachableError(
361
486
  `cannot reach Juno at ${this.target}: ${err.message}`
362
487
  );
363
488
  }
489
+ if (res.status >= 300 && res.status < 400) {
490
+ const location = res.headers?.get?.('location');
491
+ throw new UserError(
492
+ `${method} ${path} was redirected (${res.status})` +
493
+ (location ? ` to ${location}` : '') +
494
+ `: ${this.target} looks like it is behind an auth proxy or is ` +
495
+ 'not the Juno API. Point the CLI at an address that reaches Juno ' +
496
+ 'directly with --url or $JUNO_URL (inside a working copy, ' +
497
+ "package.json's juno.target is used otherwise)"
498
+ );
499
+ }
500
+ return res;
364
501
  }
365
502
 
366
503
  async #read(res) {
@@ -429,6 +566,22 @@ async function* ndjson(body, target) {
429
566
  yield* lines(true);
430
567
  }
431
568
 
569
+ /**
570
+ * jsonBody is a 2xx answer's parsed body, which every route this client calls
571
+ * answers as a JSON object. Anything else came from something other than
572
+ * Juno, such as a proxy's HTML page or a wrong URL. It is an error, never an
573
+ * empty success, because a caller reading fields off null either crashes or
574
+ * quietly does nothing.
575
+ */
576
+ function jsonBody(method, path, res) {
577
+ if (res.parsed === null || typeof res.parsed !== 'object') {
578
+ throw new UserError(
579
+ `${method} ${path} answered something that is not JSON: ${res.text.trim()}`
580
+ );
581
+ }
582
+ return res.parsed;
583
+ }
584
+
432
585
  /** The server's own words in an error answer: Huma's `detail`, else the body. */
433
586
  function problemDetail(res) {
434
587
  return (
@@ -436,13 +589,22 @@ function problemDetail(res) {
436
589
  );
437
590
  }
438
591
 
439
- /** A UserError for a non-ok answer, carrying the server's own words. */
592
+ /**
593
+ * A UserError for a non-ok answer, carrying the server's own words. A 401
594
+ * adds how to get a credential; a 403 is the server's detail as is, because
595
+ * it already names what the key may not do.
596
+ */
440
597
  function failure(method, path, res) {
598
+ const message = `${method} ${path} failed (${res.status}): ${problemDetail(res)}`;
441
599
  return new UserError(
442
- `${method} ${path} failed (${res.status}): ${problemDetail(res)}`
600
+ res.status === 401 ? `${message}\n${UNAUTHORIZED_HINT}` : message
443
601
  );
444
602
  }
445
603
 
604
+ const UNAUTHORIZED_HINT =
605
+ 'Create an API key under Settings → API keys and pass it with --token, ' +
606
+ '$JUNO_TOKEN or `juno config set token`.';
607
+
446
608
  /**
447
609
  * The words of a 422: the gateway's own detail names the failing field (`/id`,
448
610
  * `/ops/0/when`, `/` for the root); Huma's request validation puts it in
@@ -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
+ }