@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/index.js CHANGED
@@ -4,6 +4,12 @@ import {
4
4
  AGENT_SKILL_INSTALL_USAGE,
5
5
  agentSkillInstall,
6
6
  } from './commands/agent-skill-install.js';
7
+ import { APP_CHECK_USAGE, appCheck } from './commands/app-check.js';
8
+ import { APP_DEPLOY_USAGE, appDeploy } from './commands/app-deploy.js';
9
+ import { APP_DEV_USAGE, appDev } from './commands/app-dev.js';
10
+ import { APP_GEN_TYPES_USAGE, appGenTypes } from './commands/app-gen-types.js';
11
+ import { APP_INIT_USAGE, appInit } from './commands/app-init.js';
12
+ import { APP_PULL_USAGE, appPull } from './commands/app-pull.js';
7
13
  import { ASK_USAGE, ask } from './commands/ask.js';
8
14
  import { CHECK_USAGE, check } from './commands/check.js';
9
15
  import { CONFIG_SET_USAGE, configSet } from './commands/config.js';
@@ -18,6 +24,7 @@ import {
18
24
  FLOW_GEN_TYPES_USAGE,
19
25
  flowGenTypes,
20
26
  } from './commands/flow-gen-types.js';
27
+ import { FLOW_FIXTURE_USAGE, flowFixture } from './commands/flow-fixture.js';
21
28
  import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
22
29
  import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
23
30
  import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
@@ -55,7 +62,11 @@ import {
55
62
  import { closest, closestByVerb, levenshtein } from './suggest.js';
56
63
  import { groupOf, normalizeVerb, rowFor, rows } from './verbs.js';
57
64
 
58
- const URL_OPTION = { url: { type: 'string' } };
65
+ /**
66
+ * The two flags every command that reaches an instance takes, and the two a
67
+ * leading position accepts (`juno --url <u> --token <k> state get x`).
68
+ */
69
+ const URL_OPTION = { url: { type: 'string' }, token: { type: 'string' } };
59
70
 
60
71
  /**
61
72
  * The verbs.json rows that call no tool, by command. A row is dispatchable
@@ -151,6 +162,15 @@ const WORKING_COPY = {
151
162
  options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
152
163
  usage: FLOW_PULL_USAGE,
153
164
  },
165
+ 'flow fixture': {
166
+ run: flowFixture,
167
+ options: {
168
+ ...URL_OPTION,
169
+ run: { type: 'string' },
170
+ force: { type: 'boolean', default: false },
171
+ },
172
+ usage: FLOW_FIXTURE_USAGE,
173
+ },
154
174
  'integration init': {
155
175
  run: init,
156
176
  options: {
@@ -189,6 +209,7 @@ const WORKING_COPY = {
189
209
  ...URL_OPTION,
190
210
  id: { type: 'string' },
191
211
  watch: { type: 'boolean', default: false },
212
+ force: { type: 'boolean', default: false },
192
213
  json: { type: 'boolean', default: false },
193
214
  },
194
215
  usage: DEPLOY_USAGE,
@@ -202,6 +223,50 @@ const WORKING_COPY = {
202
223
  },
203
224
  usage: PULL_USAGE,
204
225
  },
226
+ 'app init': {
227
+ run: appInit,
228
+ options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
229
+ usage: APP_INIT_USAGE,
230
+ positional: true,
231
+ },
232
+ 'app gen-types': {
233
+ run: appGenTypes,
234
+ options: URL_OPTION,
235
+ usage: APP_GEN_TYPES_USAGE,
236
+ },
237
+ 'app check': {
238
+ run: appCheck,
239
+ options: {
240
+ ...URL_OPTION,
241
+ 'skip-local': { type: 'boolean', default: false },
242
+ json: { type: 'boolean', default: false },
243
+ },
244
+ usage: APP_CHECK_USAGE,
245
+ },
246
+ 'app deploy': {
247
+ run: appDeploy,
248
+ options: {
249
+ ...URL_OPTION,
250
+ watch: { type: 'boolean', default: false },
251
+ force: { type: 'boolean', default: false },
252
+ json: { type: 'boolean', default: false },
253
+ },
254
+ usage: APP_DEPLOY_USAGE,
255
+ },
256
+ 'app pull': {
257
+ run: appPull,
258
+ options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
259
+ usage: APP_PULL_USAGE,
260
+ },
261
+ // No --url: the dev server is offline and never reads juno.target.
262
+ 'app dev': {
263
+ run: appDev,
264
+ options: {
265
+ port: { type: 'string' },
266
+ scenario: { type: 'string' },
267
+ },
268
+ usage: APP_DEV_USAGE,
269
+ },
205
270
  };
206
271
 
207
272
  /** The handler of a non-tool row, working-copy or not, or undefined. */
@@ -223,7 +288,7 @@ function dispatchable(command) {
223
288
  return row && DISPATCHABLE.includes(row) ? row : undefined;
224
289
  }
225
290
 
226
- const USAGE = `juno — operate a Juno instance, and develop flows and integrations for it
291
+ const USAGE = `juno — operate a Juno instance, and develop flows, integrations and apps for it
227
292
 
228
293
  Usage: juno <command> [options]
229
294
  juno <group> <verb> [id] [--input <json>|-|@file] [--jq <expr>]
@@ -239,6 +304,8 @@ ${workingCopyListing(DISPATCHABLE, handlerFor)}
239
304
 
240
305
  --url <url> picks the instance; else $JUNO_URL, package.json juno.target,
241
306
  then the user config (juno config set url <url>).
307
+ --token <key> is the API key to send; else $JUNO_TOKEN, then the user config
308
+ (juno config set token <key>). Create one under Settings → API keys.
242
309
  `;
243
310
 
244
311
  /**
@@ -281,11 +348,11 @@ export async function run(argv, io = defaultIO()) {
281
348
  /**
282
349
  * dispatch is two-level: the first word is a top-level row (`discover`) or a
283
350
  * group, and a group's second word is the verb. A
284
- * leading `--url` is accepted before the first word, so
351
+ * leading `--url` or `--token` is accepted before the first word, so
285
352
  * `juno --url <u> state get x` reads the way an alias would write it.
286
353
  */
287
354
  async function dispatch(argv, io) {
288
- const { url, rest } = leadingURL(argv);
355
+ const { leading, rest } = leadingFlags(argv);
289
356
  const [first, ...tail] = rest;
290
357
 
291
358
  if (!first || first === '--help' || first === '-h' || first === 'help') {
@@ -294,7 +361,7 @@ async function dispatch(argv, io) {
294
361
  }
295
362
 
296
363
  const top = dispatchable(first);
297
- if (top) return runRow(top, tail, url, io);
364
+ if (top) return runRow(top, tail, leading, io);
298
365
 
299
366
  if (GROUPS.has(first)) {
300
367
  const [verb, ...args] = tail;
@@ -304,7 +371,7 @@ async function dispatch(argv, io) {
304
371
  return runCommand(
305
372
  { options: URL_OPTION },
306
373
  tail,
307
- url,
374
+ leading,
308
375
  io,
309
376
  (parsed) => showGroupHelp(first, parsed, io, EXIT_OK),
310
377
  (parsed) => showGroupHelp(first, parsed, io, EXIT_USAGE)
@@ -316,7 +383,7 @@ async function dispatch(argv, io) {
316
383
  `${first} ${normalizeVerb(verb)}`,
317
384
  ]);
318
385
  }
319
- return runRow(row, args, url, io);
386
+ return runRow(row, args, leading, io);
320
387
  }
321
388
 
322
389
  const second = tail[0] && !tail[0].startsWith('-') ? tail[0] : null;
@@ -335,7 +402,7 @@ async function showGroupHelp(group, args, io, code) {
335
402
  }
336
403
 
337
404
  /** runRow runs one verbs.json row, a tool's or a non-tool command's. */
338
- function runRow(row, argv, url, io) {
405
+ function runRow(row, argv, leading, io) {
339
406
  if (!row.tool) {
340
407
  const handler = handlerFor(row);
341
408
  const run =
@@ -348,7 +415,7 @@ function runRow(row, argv, url, io) {
348
415
  return runCommand(
349
416
  handler,
350
417
  argv,
351
- url,
418
+ leading,
352
419
  io,
353
420
  () => {
354
421
  io.log(nonToolHelp(row, handler));
@@ -369,7 +436,7 @@ function runRow(row, argv, url, io) {
369
436
  return runCommand(
370
437
  { run: (args) => runTool(row, args, io), options },
371
438
  argv,
372
- url,
439
+ leading,
373
440
  io,
374
441
  async (args) => {
375
442
  io.log(await verbHelp(row, args, io));
@@ -381,10 +448,10 @@ function runRow(row, argv, url, io) {
381
448
  /**
382
449
  * runCommand parses a command's own flags (plus --help) and runs it, or its
383
450
  * help. A `--url` given before the command word fills in when the command's
384
- * own is absent. `run` defaults to the command's own; a group without a verb
451
+ * own is absent, and likewise `--token`. `run` defaults to the command's own; a group without a verb
385
452
  * passes its help instead.
386
453
  */
387
- async function runCommand(command, argv, url, io, help, run = command.run) {
454
+ async function runCommand(command, argv, leading, io, help, run = command.run) {
388
455
  let parsed;
389
456
  try {
390
457
  parsed = parseArgs({
@@ -400,7 +467,9 @@ async function runCommand(command, argv, url, io, help, run = command.run) {
400
467
  }
401
468
 
402
469
  const args = { ...parsed.values, _: parsed.positionals, client: io.client };
403
- if (url !== undefined && args.url === undefined) args.url = url;
470
+ for (const [flag, value] of Object.entries(leading)) {
471
+ if (value !== undefined && args[flag] === undefined) args[flag] = value;
472
+ }
404
473
  if (parsed.values.help) return help(args);
405
474
  return (await run(args, io)) ?? EXIT_OK;
406
475
  }
@@ -422,22 +491,29 @@ function refusePositional(row, args) {
422
491
  );
423
492
  }
424
493
 
425
- /** leadingURL takes `--url <u>` / `--url=<u>` off the front of argv. */
426
- function leadingURL(argv) {
427
- let url;
494
+ /**
495
+ * leadingFlags takes `--url <u>` / `--url=<u>` and `--token <k>` /
496
+ * `--token=<k>` off the front of argv, in either order.
497
+ */
498
+ function leadingFlags(argv) {
499
+ const leading = {};
428
500
  let i = 0;
429
501
  while (i < argv.length) {
430
- if (argv[i] === '--url' && i + 1 < argv.length) {
431
- url = argv[i + 1];
502
+ const flag = ['url', 'token'].find(
503
+ (name) =>
504
+ (argv[i] === `--${name}` && i + 1 < argv.length) ||
505
+ argv[i].startsWith(`--${name}=`)
506
+ );
507
+ if (!flag) break;
508
+ if (argv[i] === `--${flag}`) {
509
+ leading[flag] = argv[i + 1];
432
510
  i += 2;
433
- } else if (argv[i].startsWith('--url=')) {
434
- url = argv[i].slice('--url='.length);
435
- i += 1;
436
511
  } else {
437
- break;
512
+ leading[flag] = argv[i].slice(`--${flag}=`.length);
513
+ i += 1;
438
514
  }
439
515
  }
440
- return { url, rest: argv.slice(i) };
516
+ return { leading, rest: argv.slice(i) };
441
517
  }
442
518
 
443
519
  /**
package/src/project.js CHANGED
@@ -2,15 +2,22 @@ import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, posix, relative, sep } from 'node:path';
3
3
 
4
4
  import { UserError } from './errors.js';
5
+ import { PACKAGE_JSON, writePackageJSON } from './working-copy.js';
5
6
 
6
7
  /** Only these reach the server. Everything else is project furniture. */
7
8
  const SOURCE_EXTENSIONS = ['.ts', '.json'];
8
9
  const SOURCE_DIR = 'src';
10
+ const TEST_DIR = 'test';
9
11
 
10
12
  /**
11
- * Read the four things Juno looks at: manifest.json, the sources under src/,
12
- * package.json's dependencies, and the lockfile. types/ and test/ are the
13
- * author's — they are never uploaded, and never compiled server-side.
13
+ * Read the four things Juno looks at: manifest.json, the sources under src/
14
+ * and test/, package.json's dependencies, and the lockfile. test/ is stored
15
+ * with the version but never compiled, linted or run server-side; types/ is
16
+ * generated and never uploaded.
17
+ *
18
+ * `baseVersion` is package.json's `juno.base_version`, the version this copy
19
+ * was pulled from or last deployed as, or undefined when it records none (a
20
+ * copy made before the check existed, or one with no `juno` block at all).
14
21
  */
15
22
  export async function readProject(cwd = process.cwd()) {
16
23
  const manifest = await readManifest(cwd);
@@ -18,14 +25,61 @@ export async function readProject(cwd = process.cwd()) {
18
25
  if (Object.keys(files).length === 0) {
19
26
  throw new UserError(`no TypeScript sources found under ${SOURCE_DIR}/`);
20
27
  }
28
+ const packageJSON = await readOptional(join(cwd, PACKAGE_JSON));
21
29
  return {
22
30
  manifest,
23
- files,
24
- packageJSON: await readOptional(join(cwd, 'package.json')),
31
+ files: { ...files, ...(await readTestFiles(cwd)) },
32
+ packageJSON,
25
33
  lockfile: await readOptional(join(cwd, 'package-lock.json')),
34
+ baseVersion: baseVersionOfText(packageJSON),
26
35
  };
27
36
  }
28
37
 
38
+ /**
39
+ * readIntegrationBaseVersion is package.json's `juno.base_version`, or
40
+ * undefined: what `pull` diffs against without reading the sources.
41
+ */
42
+ export async function readIntegrationBaseVersion(cwd) {
43
+ return baseVersionOfText(await readOptional(join(cwd, PACKAGE_JSON)));
44
+ }
45
+
46
+ /** `juno.base_version` of package.json's text, or undefined. */
47
+ function baseVersionOfText(text) {
48
+ let pkg;
49
+ try {
50
+ pkg = JSON.parse(text);
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ const base = pkg?.juno?.base_version;
55
+ return Number.isInteger(base) ? base : undefined;
56
+ }
57
+
58
+ /**
59
+ * updateIntegrationPackage rewrites package.json through `mutate`, keeping
60
+ * every key it does not touch (and their order). `juno` is handed over as an
61
+ * object even when the copy has none yet: an integration copy carries one only
62
+ * once it records a target or a base. A missing or unreadable package.json is
63
+ * left alone with a warning — no command here is the place to invent one.
64
+ * Returns whether it wrote.
65
+ */
66
+ export async function updateIntegrationPackage(cwd, mutate, io) {
67
+ let pkg;
68
+ try {
69
+ pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
70
+ } catch {
71
+ pkg = null;
72
+ }
73
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
74
+ io.warn(`no readable ${PACKAGE_JSON} — it was not updated`);
75
+ return false;
76
+ }
77
+ if (pkg.juno === null || typeof pkg.juno !== 'object') pkg.juno = {};
78
+ mutate(pkg);
79
+ await writePackageJSON(cwd, pkg);
80
+ return true;
81
+ }
82
+
29
83
  /**
30
84
  * The manifest as it sits on disk. The stamp in types/integration-env.d.ts
31
85
  * fingerprints these exact bytes, so both writing and verifying it go through
@@ -51,15 +105,29 @@ export async function readManifest(cwd = process.cwd()) {
51
105
  }
52
106
 
53
107
  /** Walks src/ and returns repo-relative POSIX paths → content. */
54
- export async function readSourceFiles(cwd = process.cwd()) {
55
- const root = join(cwd, SOURCE_DIR);
108
+ export function readSourceFiles(cwd = process.cwd()) {
109
+ return readSourceTree(cwd, SOURCE_DIR);
110
+ }
111
+
112
+ /**
113
+ * readTestFiles is readSourceFiles for test/: the same .ts and .json rule,
114
+ * because Juno stores tests beside the sources under the same path rules.
115
+ * Every kind's working copy sends them; none requires them.
116
+ */
117
+ export function readTestFiles(cwd = process.cwd()) {
118
+ return readSourceTree(cwd, TEST_DIR);
119
+ }
120
+
121
+ /** readSourceTree walks dir for .ts and .json files, by repo-relative POSIX path. */
122
+ async function readSourceTree(cwd, dir) {
123
+ const root = join(cwd, dir);
56
124
  const files = {};
57
125
  async function walk(dir) {
58
126
  let entries;
59
127
  try {
60
128
  entries = await readdir(dir, { withFileTypes: true });
61
129
  } catch {
62
- return; // no src/ at all; the caller reports it
130
+ return; // no such directory at all; the caller reports a missing src/
63
131
  }
64
132
  for (const entry of entries) {
65
133
  const full = join(dir, entry.name);
package/src/token.js ADDED
@@ -0,0 +1,30 @@
1
+ import { readUserConfig } from './config.js';
2
+
3
+ /**
4
+ * Resolve the API key to send as `Authorization: Bearer`, in precedence
5
+ * order: an explicit --token, then $JUNO_TOKEN, then `token` in the user
6
+ * config file `juno config set token` writes. It is the order resolveTarget
7
+ * follows for the URL, minus package.json: a working copy's package.json is
8
+ * committed, and a secret never goes in a file that is.
9
+ *
10
+ * Returns undefined when there is none: a request goes out unauthenticated,
11
+ * which is right for an instance behind a proxy that identifies the person,
12
+ * or one started with --no-auth. A blank value counts as unset, so an empty
13
+ * $JUNO_TOKEN does not shadow the config file.
14
+ *
15
+ * The token is a secret. Nothing in this CLI prints it, in an error message
16
+ * included.
17
+ */
18
+ export async function resolveToken({ token, env = process.env } = {}) {
19
+ return (
20
+ clean(token) ??
21
+ clean(env.JUNO_TOKEN) ??
22
+ clean((await readUserConfig(env)).token)
23
+ );
24
+ }
25
+
26
+ function clean(value) {
27
+ if (typeof value !== 'string') return undefined;
28
+ const trimmed = value.trim();
29
+ return trimmed === '' ? undefined : trimmed;
30
+ }
package/src/watch.js CHANGED
@@ -28,23 +28,60 @@ export async function deployOnChange({ io, target, paths, once }) {
28
28
  };
29
29
  await iteration();
30
30
 
31
+ const watcher = watchPaths(paths, () => void iteration());
32
+ await new Promise((resolve) => {
33
+ process.once('SIGINT', () => {
34
+ watcher.close();
35
+ resolve();
36
+ });
37
+ });
38
+ return 0;
39
+ }
40
+
41
+ /**
42
+ * watchPaths calls onChange once edits to `paths` (`{path, recursive?,
43
+ * filter?}`) have settled for `debounceMs`, so an editor's save-and-rename is
44
+ * one call. `filter(filename)`, when given, drops an event its path does not
45
+ * want — the way to watch one file in a directory, since a watch on the file
46
+ * itself loses it the first time an editor saves by renaming over it. A path
47
+ * that does not exist is not watched: an app's public/ may be absent, since
48
+ * nothing but the page is required. Returns `{close()}`, which also drops a
49
+ * pending call.
50
+ */
51
+ export function watchPaths(
52
+ paths,
53
+ onChange,
54
+ { debounceMs = WATCH_DEBOUNCE_MS } = {}
55
+ ) {
31
56
  let timer = null;
32
57
  const schedule = () => {
33
58
  if (timer) clearTimeout(timer);
34
59
  timer = setTimeout(() => {
35
60
  timer = null;
36
- void iteration();
37
- }, WATCH_DEBOUNCE_MS);
61
+ onChange();
62
+ }, debounceMs);
38
63
  };
39
64
 
40
- const watchers = paths.map(({ path, recursive = false }) =>
41
- watch(path, { recursive }, schedule)
42
- );
43
- await new Promise((resolve) => {
44
- process.once('SIGINT', () => {
65
+ const watchers = [];
66
+ for (const { path, recursive = false, filter } of paths) {
67
+ try {
68
+ const w = watch(path, { recursive }, (_event, filename) => {
69
+ if (!filter || filter(filename == null ? null : String(filename))) {
70
+ schedule();
71
+ }
72
+ });
73
+ // A watched directory removed mid-session is not worth a crash.
74
+ w.on('error', () => w.close());
75
+ watchers.push(w);
76
+ } catch (err) {
77
+ if (err.code !== 'ENOENT') throw err;
78
+ }
79
+ }
80
+ return {
81
+ close() {
82
+ if (timer) clearTimeout(timer);
83
+ timer = null;
45
84
  for (const w of watchers) w.close();
46
- resolve();
47
- });
48
- });
49
- return 0;
85
+ },
86
+ };
50
87
  }