@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.
- package/README.md +214 -80
- 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 +213 -51
- 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 +113 -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 +33 -24
- package/src/commands/config.js +26 -7
- package/src/commands/deploy.js +59 -13
- 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/gen-types.js +2 -9
- package/src/commands/init.js +2 -9
- package/src/commands/pull.js +98 -111
- package/src/config.js +20 -5
- package/src/errors.js +3 -3
- package/src/exit.js +2 -2
- package/src/flow-project.js +47 -97
- package/src/index.js +99 -23
- package/src/project.js +76 -8
- package/src/token.js +30 -0
- package/src/watch.js +48 -11
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- 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.
|
|
13
|
-
*
|
|
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
|
|
22
|
-
* command uses, unless one was injected:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
return {
|
|
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
|
|
56
|
-
*
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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.#
|
|
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.#
|
|
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. `
|
|
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,
|
|
193
|
+
async ingestFlow({ flowId, manifest, files, baseVersion }, { dryRun } = {}) {
|
|
119
194
|
const query = dryRun ? '?dry_run=true' : '';
|
|
120
|
-
return this.#
|
|
195
|
+
return this.#workingCopyJSON(`/api/flows/ingest${query}`, {
|
|
121
196
|
flow_id: flowId,
|
|
122
197
|
manifest,
|
|
123
|
-
|
|
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
|
-
* #
|
|
130
|
-
* more failures in their own words: a 409 (the working copy is
|
|
131
|
-
* 422 with no diagnostics (
|
|
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 #
|
|
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
|
|
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
|
-
|
|
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
|
|
218
|
-
return res
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
331
|
-
return res
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
`${
|
|
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
|
+
}
|