@junoflow/cli 0.3.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.
Files changed (59) hide show
  1. package/README.md +693 -52
  2. package/app-client.js +306 -0
  3. package/bin/juno.js +7 -0
  4. package/eslint.app.config.js +34 -0
  5. package/eslint.flow.config.js +35 -0
  6. package/package.json +12 -4
  7. package/src/api.js +541 -30
  8. package/src/app-dev/build.js +134 -0
  9. package/src/app-dev/harness.js +108 -0
  10. package/src/app-dev/panel.html +336 -0
  11. package/src/app-dev/panel.js +290 -0
  12. package/src/app-dev/server.js +347 -0
  13. package/src/app-dev/stream.js +224 -0
  14. package/src/app-dev/watch.js +47 -0
  15. package/src/app-project.js +133 -0
  16. package/src/authoring-skills.js +43 -0
  17. package/src/commands/agent-skill-install.js +64 -0
  18. package/src/commands/app-check.js +75 -0
  19. package/src/commands/app-deploy.js +102 -0
  20. package/src/commands/app-dev.js +141 -0
  21. package/src/commands/app-gen-types.js +37 -0
  22. package/src/commands/app-init.js +46 -0
  23. package/src/commands/app-pull.js +106 -0
  24. package/src/commands/ask.js +60 -0
  25. package/src/commands/check.js +71 -27
  26. package/src/commands/config.js +26 -0
  27. package/src/commands/credential-set.js +68 -0
  28. package/src/commands/deploy.js +95 -51
  29. package/src/commands/flow-check.js +75 -0
  30. package/src/commands/flow-deploy.js +110 -0
  31. package/src/commands/flow-fixture.js +67 -0
  32. package/src/commands/flow-gen-types.js +37 -0
  33. package/src/commands/flow-init.js +59 -0
  34. package/src/commands/flow-pull.js +115 -0
  35. package/src/commands/{types.js → gen-types.js} +12 -5
  36. package/src/commands/init.js +28 -16
  37. package/src/commands/pull.js +111 -63
  38. package/src/commands/session-approve.js +73 -0
  39. package/src/commands/skill.js +73 -0
  40. package/src/commands/tool.js +249 -0
  41. package/src/commands/verify-types.js +4 -2
  42. package/src/config.js +34 -0
  43. package/src/diagnostics.js +14 -0
  44. package/src/errors.js +40 -0
  45. package/src/exit.js +22 -0
  46. package/src/flow-project.js +78 -0
  47. package/src/help.js +199 -0
  48. package/src/index.js +567 -75
  49. package/src/installed-skill.js +55 -0
  50. package/src/project.js +76 -8
  51. package/src/prompt.js +196 -0
  52. package/src/stamp.js +10 -9
  53. package/src/suggest.js +67 -0
  54. package/src/target.js +22 -7
  55. package/src/verbs.js +34 -0
  56. package/src/watch.js +87 -0
  57. package/src/working-copy.js +262 -0
  58. package/tsconfig.page.json +15 -0
  59. package/verbs.json +276 -0
package/src/api.js CHANGED
@@ -1,4 +1,37 @@
1
- import { DiagnosticsError, UserError } from './errors.js';
1
+ import {
2
+ ConflictError,
3
+ DiagnosticsError,
4
+ UnreachableError,
5
+ UsageError,
6
+ UserError,
7
+ } from './errors.js';
8
+ import { resolveTarget } from './target.js';
9
+
10
+ /**
11
+ * 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.
15
+ */
16
+ export function clientName(env = process.env) {
17
+ return env.CLAUDECODE === '1' ? 'cli/claude-code' : 'cli';
18
+ }
19
+
20
+ /**
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.
24
+ */
25
+ export async function connect(args, io) {
26
+ const target = await resolveTarget({
27
+ url: args.url,
28
+ cwd: io.cwd,
29
+ env: io.env,
30
+ });
31
+ const client =
32
+ args.client ?? new JunoClient(target, { client: clientName(io.env) });
33
+ return { target, client };
34
+ }
2
35
 
3
36
  /**
4
37
  * Client for the handful of endpoints this CLI knows about. It knows nothing
@@ -8,19 +41,24 @@ import { DiagnosticsError, UserError } from './errors.js';
8
41
  * disagree.
9
42
  */
10
43
  export class JunoClient {
11
- constructor(target, { fetch: fetchImpl = globalThis.fetch } = {}) {
44
+ constructor(
45
+ target,
46
+ { fetch: fetchImpl = globalThis.fetch, client = clientName() } = {}
47
+ ) {
12
48
  this.target = target;
13
49
  this.fetch = fetchImpl;
50
+ this.client = client;
14
51
  }
15
52
 
16
53
  /**
17
54
  * POST /api/integrations/scaffold — a whole starting project, path →
18
- * content. `target` is what this client reached the instance at; the server
19
- * 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.
20
57
  */
21
- async scaffold(name, { description, target } = {}) {
58
+ async scaffold(id, { name, description, target } = {}) {
22
59
  const body = await this.#json('POST', '/api/integrations/scaffold', {
23
- name,
60
+ id,
61
+ name: name ?? undefined,
24
62
  description: description ?? undefined,
25
63
  target: target ?? undefined,
26
64
  });
@@ -32,58 +70,531 @@ export class JunoClient {
32
70
  return this.#json('POST', '/api/typegen/integration', { manifest });
33
71
  }
34
72
 
35
- /** POST /api/integrations/ingest — deploy, or validate only with dryRun. */
36
- async ingest(project, { dryRun = false, name } = {}) {
37
- const query = dryRun ? '?dry_run=true' : '';
38
- 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, {
39
86
  manifest: project.manifest,
40
87
  files: project.files,
41
88
  package_json: project.packageJSON ?? undefined,
42
89
  lockfile: project.lockfile ?? undefined,
43
- name: name ?? undefined,
90
+ id: id ?? undefined,
91
+ base_version: baseVersion ?? undefined,
92
+ });
93
+ if (res.status === 409) throw new ConflictError(problemDetail(res));
94
+ return this.#answer('POST', path, res);
95
+ }
96
+
97
+ /** GET /api/integrations/{id} — the manifest and sources Juno holds. */
98
+ async getIntegration(id) {
99
+ return this.#json('GET', `/api/integrations/${encodeURIComponent(id)}`);
100
+ }
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
+
155
+ /**
156
+ * POST /api/flows/scaffold — a flow working copy, `{files: {path: content},
157
+ * warnings?: [string]}`: the active version's, or a stub for an id with
158
+ * none. The warnings are a stored manifest the live catalog now refuses;
159
+ * the working copy is still rendered, because it is how that gets fixed.
160
+ */
161
+ async scaffoldFlow(id, { target } = {}) {
162
+ const body = await this.#workingCopyJSON('/api/flows/scaffold', {
163
+ id,
164
+ target: target ?? undefined,
44
165
  });
166
+ return { files: body?.files ?? {}, warnings: body?.warnings ?? [] };
45
167
  }
46
168
 
47
- /** GET /api/integrations/{name} — the manifest and sources Juno holds. */
48
- async getIntegration(name) {
49
- return this.#json('GET', `/api/integrations/${encodeURIComponent(name)}`);
169
+ /**
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.
172
+ */
173
+ async typegenFlow(manifest) {
174
+ return this.#workingCopyJSON('/api/typegen/flow', { manifest });
175
+ }
176
+
177
+ /**
178
+ * POST /api/flows/ingest — verify a flow working copy and, unless dryRun,
179
+ * commit and activate it. `files` is the working copy's src/ tree, path →
180
+ * content. `baseVersion` undefined sends none (a new flow,
181
+ * or --force). A 409 is a ConflictError; a 422 with diagnostics is a
182
+ * DiagnosticsError.
183
+ */
184
+ async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
185
+ const query = dryRun ? '?dry_run=true' : '';
186
+ return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
187
+ flow_id: flowId,
188
+ manifest,
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,
232
+ base_version: baseVersion ?? undefined,
233
+ });
234
+ }
235
+
236
+ /**
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/).
241
+ */
242
+ async #workingCopyJSON(path, body) {
243
+ const res = await this.#request('POST', path, body);
244
+ if (res.status === 409) throw new ConflictError(problemDetail(res));
245
+ if (res.status === 422 && !res.parsed?.diagnostics?.length) {
246
+ throw new UserError(validationDetail(res));
247
+ }
248
+ if (res.ok) return jsonBody('POST', path, res);
249
+ if (res.parsed?.diagnostics?.length) {
250
+ throw new DiagnosticsError(
251
+ res.parsed.detail || 'verification failed',
252
+ res.parsed.diagnostics
253
+ );
254
+ }
255
+ throw failure('POST', path, res);
256
+ }
257
+
258
+ /**
259
+ * POST /api/tools/{name} — one tool call. A 200 is the tool's answer and is
260
+ * returned whatever it says, `{"error": …}` included: a domain failure is a
261
+ * result, and the caller decides the exit code. `text` is the body exactly as
262
+ * it arrived, so it can be printed unchanged.
263
+ */
264
+ async callTool(name, input) {
265
+ const path = `/api/tools/${encodeURIComponent(name)}`;
266
+ const res = await this.#request('POST', path, input);
267
+ if (res.status === 422) {
268
+ throw new UsageError(
269
+ `invalid input for ${name}: ${validationDetail(res)}`
270
+ );
271
+ }
272
+ if (res.status === 404) {
273
+ throw new UsageError(
274
+ `${this.target} does not serve the tool "${name}"; ` +
275
+ 'the instance is probably older than this CLI'
276
+ );
277
+ }
278
+ if (!res.ok) throw failure('POST', path, res);
279
+ return { result: jsonBody('POST', path, res), text: res.text };
280
+ }
281
+
282
+ /**
283
+ * GET /api/tools — every served tool with its description, input schema
284
+ * and effect. Help reads it, so the text a person sees is the server's.
285
+ */
286
+ async listTools() {
287
+ const body = await this.#json('GET', '/api/tools');
288
+ return body?.tools ?? [];
289
+ }
290
+
291
+ /**
292
+ * GET /api/skills — `{skills: [{name, description}], agent_skill_version}`,
293
+ * sorted by name. `authoring` (`flow` or `integration`) narrows it to the
294
+ * skills for writing that kind of working copy.
295
+ */
296
+ async listSkills({ authoring } = {}) {
297
+ const query = authoring
298
+ ? `?authoring=${encodeURIComponent(authoring)}`
299
+ : '';
300
+ return this.#json('GET', `/api/skills${query}`);
301
+ }
302
+
303
+ /**
304
+ * GET /api/agent-skill — `{version, skill_md, agents_md, permissions,
305
+ * working_copy_permissions}`: the skill a coding agent installs and the
306
+ * permission rules that go with it, both rendered by the server.
307
+ */
308
+ async getAgentSkill() {
309
+ return this.#json('GET', '/api/agent-skill');
310
+ }
311
+
312
+ /**
313
+ * GET /api/skills/{name} — `{name, description, body}`, the body exactly as
314
+ * the in-app load_skill returns it, or null when the instance serves no
315
+ * skill of that name.
316
+ */
317
+ async getSkill(name) {
318
+ const path = `/api/skills/${encodeURIComponent(name)}`;
319
+ const res = await this.#request('GET', path);
320
+ if (res.status === 404) return null;
321
+ if (!res.ok) throw failure('GET', path, res);
322
+ return jsonBody('GET', path, res);
323
+ }
324
+
325
+ /**
326
+ * GET /api/secrets/{id} — the credential's `{name, description}`, or null
327
+ * when there is none (a built-in id answers 404 too). The route also
328
+ * answers the plaintext value; it is dropped here, so it never leaves this
329
+ * method.
330
+ */
331
+ async getCredentialMeta(id) {
332
+ const path = `/api/secrets/${encodeURIComponent(id)}`;
333
+ const res = await this.#request('GET', path);
334
+ if (res.status === 404) return null;
335
+ if (!res.ok) throw failure('GET', path, res);
336
+ jsonBody('GET', path, res);
337
+ return {
338
+ name: res.parsed.name ?? '',
339
+ description: res.parsed.description ?? '',
340
+ };
341
+ }
342
+
343
+ /** POST /api/secrets — a new credential. */
344
+ async createCredential({ id, name, description, value }) {
345
+ await this.#credentialWrite('POST', '/api/secrets', id, {
346
+ id,
347
+ name,
348
+ description,
349
+ value,
350
+ });
351
+ }
352
+
353
+ /** PUT /api/secrets/{id} — replace an existing credential's value. */
354
+ async updateCredential(id, { name, description, value }) {
355
+ const path = `/api/secrets/${encodeURIComponent(id)}`;
356
+ await this.#credentialWrite('PUT', path, id, { name, description, value });
357
+ }
358
+
359
+ /**
360
+ * #credentialWrite sends a credential write and returns nothing: PUT
361
+ * answers the stored plaintext, which no caller has any use for. A 422 is
362
+ * the server refusing the request (a reserved built-in id, a missing
363
+ * description), printed in its own words as a usage error.
364
+ */
365
+ async #credentialWrite(method, path, id, body) {
366
+ const res = await this.#request(method, path, body);
367
+ if (res.status === 422) {
368
+ throw new UsageError(
369
+ `cannot set credential "${id}": ${validationDetail(res)}`
370
+ );
371
+ }
372
+ if (!res.ok) throw failure(method, path, res);
50
373
  }
51
374
 
52
375
  async #json(method, path, body) {
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);
382
+
383
+ // A 422 is the interesting failure: it carries the compiler's own
384
+ // findings, and reprinting them beats summarising them.
385
+ if (res.parsed?.diagnostics?.length) {
386
+ throw new DiagnosticsError(
387
+ res.parsed.detail || 'verification failed',
388
+ res.parsed.diagnostics
389
+ );
390
+ }
391
+ throw failure(method, path, res);
392
+ }
393
+
394
+ /**
395
+ * POST /api/agent/prompt — one exchange with the in-app assistant. `stream`
396
+ * is written into the body here, so the request and how its answer is read
397
+ * cannot disagree.
398
+ *
399
+ * - `stream: false`: `{status, event, text}` — 200 is a turn that ended,
400
+ * 202 a `pending` one; `text` is the body as it arrived.
401
+ * - `stream: true`: `{status: 200, events}`, an async iterator over the
402
+ * NDJSON lines as `{event, text}`, read as they arrive.
403
+ * - 409: `{status: 409, conflict}`, the server's words. The caller decides
404
+ * what each one means (prompt.js); this method only reports it.
405
+ *
406
+ * A 400 is a malformed `--session` or `--wait` (UsageError); a 404 is
407
+ * observing a session that does not exist (UserError).
408
+ */
409
+ async prompt(body, { stream }) {
410
+ const path = '/api/agent/prompt';
411
+ const res = await this.#send('POST', path, { ...body, stream });
412
+ if (stream && res.status === 200) {
413
+ return { status: 200, events: ndjson(res.body, this.target) };
414
+ }
415
+
416
+ const answer = await this.#read(res);
417
+ if (answer.status === 409) {
418
+ return { status: 409, conflict: problemDetail(answer) };
419
+ }
420
+ if (answer.status === 400) throw new UsageError(problemDetail(answer));
421
+ if (answer.status === 404) throw new UserError(problemDetail(answer));
422
+ if (!answer.ok) throw failure('POST', path, answer);
423
+ return {
424
+ status: answer.status,
425
+ event: jsonBody('POST', path, answer),
426
+ text: answer.text,
427
+ };
428
+ }
429
+
430
+ /**
431
+ * GET /api/agent/{id} — one session's `{status, pending_input, …}`, or null
432
+ * when there is none.
433
+ */
434
+ async getAgentSession(id) {
435
+ const path = `/api/agent/${encodeURIComponent(id)}`;
436
+ const res = await this.#request('GET', path);
437
+ if (res.status === 404) return null;
438
+ if (!res.ok) throw failure('GET', path, res);
439
+ return jsonBody('GET', path, res);
440
+ }
441
+
442
+ /**
443
+ * #request sends a request and reads its whole answer. It throws only when
444
+ * there is no answer at all; every status comes back to the caller, which
445
+ * knows what a 404 or a 422 means for its route.
446
+ */
447
+ async #request(method, path, body) {
448
+ return this.#read(await this.#send(method, path, body));
449
+ }
450
+
451
+ /**
452
+ * #send is the one place a request leaves this CLI, returning the fetch
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.
459
+ */
460
+ async #send(method, path, body) {
53
461
  const url = `${this.target}${path}`;
462
+ // Every request carries the header, the existing commands' included:
463
+ // one client, one header.
464
+ const headers = { 'X-Juno-Client': this.client };
465
+ if (body) headers['content-type'] = 'application/json';
54
466
  let res;
55
467
  try {
56
468
  res = await this.fetch(url, {
57
469
  method,
58
- headers: body ? { 'content-type': 'application/json' } : {},
470
+ headers,
59
471
  body: body ? JSON.stringify(body) : undefined,
472
+ redirect: 'manual',
60
473
  });
61
474
  } catch (err) {
62
- throw new UserError(
475
+ throw new UnreachableError(
63
476
  `cannot reach Juno at ${this.target}: ${err.message}`
64
477
  );
65
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;
491
+ }
66
492
 
67
- const text = await res.text();
493
+ async #read(res) {
494
+ let text;
495
+ try {
496
+ text = await res.text();
497
+ } catch (err) {
498
+ throw new UnreachableError(
499
+ `lost the connection to Juno at ${this.target}: ${err.message}`
500
+ );
501
+ }
68
502
  let parsed = null;
69
503
  try {
70
504
  parsed = text ? JSON.parse(text) : null;
71
505
  } catch {
72
- // A non-JSON body from a proxy or a wrong URL; reported below as-is.
506
+ // A non-JSON body from a proxy or a wrong URL; reported as-is.
73
507
  }
508
+ return {
509
+ ok: res.ok,
510
+ status: res.status,
511
+ statusText: res.statusText,
512
+ text,
513
+ parsed,
514
+ };
515
+ }
516
+ }
74
517
 
75
- if (res.ok) return parsed;
76
-
77
- // A 422 is the interesting failure: it carries the compiler's own
78
- // findings, and reprinting them beats summarising them.
79
- if (parsed?.diagnostics?.length) {
80
- throw new DiagnosticsError(
81
- parsed.detail || 'verification failed',
82
- parsed.diagnostics
83
- );
518
+ /**
519
+ * ndjson reads a response body one line at a time, yielding each parsed line
520
+ * with its exact text. A connection that drops mid-stream is an
521
+ * UnreachableError, like one that never connected: either way there is no
522
+ * answer, and a retry that reuses the message id is safe.
523
+ */
524
+ async function* ndjson(body, target) {
525
+ const decoder = new TextDecoder();
526
+ let buffered = '';
527
+ const lines = function* (final) {
528
+ let nl;
529
+ while ((nl = buffered.indexOf('\n')) >= 0 || (final && buffered)) {
530
+ const text = nl >= 0 ? buffered.slice(0, nl) : buffered;
531
+ buffered = nl >= 0 ? buffered.slice(nl + 1) : '';
532
+ if (!text.trim()) continue;
533
+ let event;
534
+ try {
535
+ event = JSON.parse(text);
536
+ } catch {
537
+ throw new UserError(
538
+ `the prompt stream sent a line that is not JSON: ${text}`
539
+ );
540
+ }
541
+ yield { event, text };
84
542
  }
85
- const detail =
86
- parsed?.detail || parsed?.title || text.trim() || res.statusText;
87
- throw new UserError(`${method} ${path} failed (${res.status}): ${detail}`);
543
+ };
544
+ try {
545
+ for await (const chunk of body) {
546
+ buffered += decoder.decode(chunk, { stream: true });
547
+ yield* lines(false);
548
+ }
549
+ } catch (err) {
550
+ if (err instanceof UserError) throw err;
551
+ throw new UnreachableError(
552
+ `lost the connection to Juno at ${target}: ${err.message}`
553
+ );
554
+ }
555
+ buffered += decoder.decode();
556
+ yield* lines(true);
557
+ }
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
+
575
+ /** The server's own words in an error answer: Huma's `detail`, else the body. */
576
+ function problemDetail(res) {
577
+ return (
578
+ res.parsed?.detail || res.parsed?.title || res.text.trim() || res.statusText
579
+ );
580
+ }
581
+
582
+ /** A UserError for a non-ok answer, carrying the server's own words. */
583
+ function failure(method, path, res) {
584
+ return new UserError(
585
+ `${method} ${path} failed (${res.status}): ${problemDetail(res)}`
586
+ );
587
+ }
588
+
589
+ /**
590
+ * The words of a 422: the gateway's own detail names the failing field (`/id`,
591
+ * `/ops/0/when`, `/` for the root); Huma's request validation puts it in
592
+ * `errors[]` instead, so those are appended.
593
+ */
594
+ function validationDetail(res) {
595
+ const parts = [res.parsed?.detail || res.text.trim() || res.statusText];
596
+ for (const e of res.parsed?.errors ?? []) {
597
+ parts.push(e.location ? `${e.location}: ${e.message}` : e.message);
88
598
  }
599
+ return parts.filter(Boolean).join('; ');
89
600
  }