@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/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; the server
56
- * prefers its own configured public URL and falls back to it.
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
- /** 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}`, {
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.#flowJSON('/api/flows/scaffold', {
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.#flowJSON('/api/typegen/flow', { manifest });
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. `baseVersion` undefined sends none (a new flow,
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, flowTS, baseVersion }, { dryRun } = {}) {
184
+ async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
119
185
  const query = dryRun ? '?dry_run=true' : '';
120
- return this.#flowJSON(`/api/flows/ingest${query}`, {
186
+ return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
121
187
  flow_id: flowId,
122
188
  manifest,
123
- flow_ts: flowTS,
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
- * #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).
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 #flowJSON(path, body) {
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.parsed;
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
- 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 };
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 || res.parsed === null) throw failure('GET', path, res);
218
- return res.parsed;
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 || res.parsed === null) throw failure('GET', path, res);
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
- const res = await this.#request(method, path, body);
272
- if (res.ok) return res.parsed;
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
- 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 };
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 || res.parsed === null) throw failure('GET', path, res);
331
- return res.parsed;
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
- return await this.fetch(url, {
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
+ }