@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
@@ -0,0 +1,55 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, resolve } from 'node:path';
4
+
5
+ /** Where `juno agent-skill install` writes the skill, relative to a root. */
6
+ export const SKILL_DIR = join('.claude', 'skills', 'juno');
7
+
8
+ const VERSION_LINE = /^\s*(?:<!--\s*)?juno-skill-version:\s*"?([^"\s]+)"?/m;
9
+
10
+ /**
11
+ * installedSkill finds the Juno skill a harness would load: SKILL.md under
12
+ * `.claude/skills/juno` in the current directory or the nearest parent that
13
+ * has one, then in the home directory. It answers the first file found and the
14
+ * version it records (null when it records none), or null when there is no
15
+ * file. A file that cannot be read counts as absent: the check it serves is a
16
+ * courtesy, never a failure.
17
+ */
18
+ export async function installedSkill(cwd, env = process.env) {
19
+ const candidates = [];
20
+ let dir = resolve(cwd);
21
+ for (;;) {
22
+ candidates.push(join(dir, SKILL_DIR, 'SKILL.md'));
23
+ const parent = dirname(dir);
24
+ if (parent === dir) break;
25
+ dir = parent;
26
+ }
27
+ candidates.push(join(env.HOME || homedir(), SKILL_DIR, 'SKILL.md'));
28
+
29
+ for (const path of candidates) {
30
+ let text;
31
+ try {
32
+ text = await readFile(path, 'utf8');
33
+ } catch {
34
+ continue;
35
+ }
36
+ return { path, version: VERSION_LINE.exec(text)?.[1] ?? null };
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /**
42
+ * warnIfStale says on stderr when the installed skill records a version other
43
+ * than the one the instance serves (`agent_skill_version` on GET /api/skills).
44
+ * Silent when either side has none.
45
+ */
46
+ export async function warnIfStale(index, target, io) {
47
+ const served = index?.agent_skill_version;
48
+ if (!served) return;
49
+ const installed = await installedSkill(io.cwd, io.env);
50
+ if (!installed?.version || installed.version === served) return;
51
+ io.warn(
52
+ `the installed Juno skill (${installed.path}) is version ${installed.version}; ` +
53
+ `${target} serves ${served} — run \`juno agent-skill install\` to update it`
54
+ );
55
+ }
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/prompt.js ADDED
@@ -0,0 +1,196 @@
1
+ import { UnreachableError, UserError } from './errors.js';
2
+ import {
3
+ EXIT_AWAITING_INPUT,
4
+ EXIT_FAILURE,
5
+ EXIT_OK,
6
+ EXIT_PENDING,
7
+ } from './exit.js';
8
+
9
+ /**
10
+ * The Agent Prompt API client both `juno ask` and `juno session approve` use:
11
+ * the request with its retries, the 409 contract, and the outcome → exit code
12
+ * mapping. Everything here follows docs/…/reference/agent-api.md; the CLI adds
13
+ * no semantics of its own beyond which of the server's answers ends a command.
14
+ */
15
+
16
+ /** Transport retries after a failed fetch, each reusing both ids. */
17
+ const TRANSPORT_RETRIES = 2;
18
+ const TRANSPORT_BACKOFF_MS = [500, 1000];
19
+
20
+ /** Rounds of observe-then-resend a busy session gets before giving up. */
21
+ const BUSY_ROUNDS = 3;
22
+
23
+ /** The default `wait_timeout_seconds`, which stays under a harness's Bash timeout. */
24
+ export const DEFAULT_WAIT_SECONDS = 60;
25
+
26
+ /**
27
+ * converse sends `body` (which always names its `session_id`: the CLI mints
28
+ * one for a new conversation) and returns the turn's final event as
29
+ * `{event, text}`, `text` being the bytes it arrived as.
30
+ *
31
+ * The 409 contract, told apart by the body as the API reference documents
32
+ * it:
33
+ * - "session is busy" / "is not idle" / "is not awaiting input": observe
34
+ * until the turn ends, then send again with the same message_id, at most
35
+ * BUSY_ROUNDS times. With `resendWhenBusy: false` (session approve, which
36
+ * must not approve something other than what it checked) it is exit 1.
37
+ * An observe that ends awaiting input is returned instead of resent: the
38
+ * message was not meant as an answer to a question asked after it was
39
+ * typed, and to an approval any answer but `approve` is a denial.
40
+ * - "session was just created": retry at once, once.
41
+ * - "session has ended", and anything else: exit 1 with the server's words.
42
+ */
43
+ export async function converse(
44
+ client,
45
+ body,
46
+ { stream, io, resendWhenBusy = true }
47
+ ) {
48
+ let retriedJustCreated = false;
49
+ let rounds = 0;
50
+ for (;;) {
51
+ const answer = await exchange(client, body, { stream, io });
52
+ if (!answer.conflict) return answer;
53
+
54
+ const kind = conflictKind(answer.conflict);
55
+ if (kind === 'just-created' && !retriedJustCreated) {
56
+ retriedJustCreated = true;
57
+ continue;
58
+ }
59
+ if (kind !== 'busy' || !resendWhenBusy || rounds >= BUSY_ROUNDS) {
60
+ throw new UserError(answer.conflict);
61
+ }
62
+
63
+ rounds += 1;
64
+ io.warn(`${answer.conflict}`);
65
+ io.warn(`waiting for session ${body.session_id}'s current turn to end…`);
66
+ const observed = await exchange(client, observeBody(body), { stream, io });
67
+ if (observed.conflict) throw new UserError(observed.conflict);
68
+ if (observed.event.type === 'awaiting_input') {
69
+ io.warn(
70
+ 'your message was not sent: the session is now awaiting input, ' +
71
+ 'and it would have been taken as the answer'
72
+ );
73
+ return observed;
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * exchange is one request, retried at most TRANSPORT_RETRIES times when
80
+ * there was no answer at all. A retry is safe because it carries the same
81
+ * session_id and message_id: the server either creates the session under
82
+ * that id or, finding the message already delivered, observes its turn.
83
+ */
84
+ async function exchange(client, body, { stream, io }) {
85
+ for (let attempt = 0; ; attempt += 1) {
86
+ try {
87
+ return await once(client, body, { stream, io });
88
+ } catch (err) {
89
+ if (!(err instanceof UnreachableError) || attempt >= TRANSPORT_RETRIES) {
90
+ throw err;
91
+ }
92
+ io.warn(`${err.message}; retrying`);
93
+ await sleep(TRANSPORT_BACKOFF_MS[attempt]);
94
+ }
95
+ }
96
+ }
97
+
98
+ async function once(client, body, { stream, io }) {
99
+ const answer = await client.prompt(body, { stream });
100
+ if (!answer.events) return answer;
101
+
102
+ // Streaming: progress to stderr, one line an event, until the final one.
103
+ for await (const line of answer.events) {
104
+ const { event } = line;
105
+ if (FINAL.has(event.type)) return line;
106
+ const note = progress(event);
107
+ if (note) io.warn(note);
108
+ }
109
+ throw new UnreachableError('the prompt stream ended before the turn did');
110
+ }
111
+
112
+ /** The event types that end a turn, and with it the stream. */
113
+ const FINAL = new Set([
114
+ 'message',
115
+ 'awaiting_input',
116
+ 'done',
117
+ 'error',
118
+ 'pending',
119
+ ]);
120
+
121
+ function progress(event) {
122
+ switch (event.type) {
123
+ case 'session':
124
+ return `session ${event.session_id}`;
125
+ case 'turn':
126
+ return '· turn finished';
127
+ case 'child':
128
+ return `· delegated to authoring session ${event.child_session_id}`;
129
+ default:
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Observe: the same session and wait, with no message and so no message_id. */
135
+ function observeBody(body) {
136
+ const { message: _m, message_id: _id, ...rest } = body;
137
+ return rest;
138
+ }
139
+
140
+ function conflictKind(text) {
141
+ if (/^session is busy\b/.test(text)) return 'busy';
142
+ if (/\bis not (idle|awaiting input)\b/.test(text)) return 'busy';
143
+ if (/^session was just created\b/.test(text)) return 'just-created';
144
+ if (/^session has ended\b/.test(text)) return 'ended';
145
+ return 'other';
146
+ }
147
+
148
+ /**
149
+ * outcomeExit maps a turn's final event to the exit code: 0 a reply or a
150
+ * completed session, 5 still running, 6 awaiting input, 1 anything that
151
+ * failed.
152
+ */
153
+ export function outcomeExit(event) {
154
+ switch (event.type) {
155
+ case 'message':
156
+ return EXIT_OK;
157
+ case 'done':
158
+ return event.status === 'completed' ? EXIT_OK : EXIT_FAILURE;
159
+ case 'pending':
160
+ return EXIT_PENDING;
161
+ case 'awaiting_input':
162
+ return EXIT_AWAITING_INPUT;
163
+ default:
164
+ return EXIT_FAILURE;
165
+ }
166
+ }
167
+
168
+ /**
169
+ * finish prints the final event on stdout — unchanged off a TTY, indented on
170
+ * one — says on stderr how to go on when the turn has not ended the
171
+ * conversation's business, and returns the exit code.
172
+ */
173
+ export function finish({ event, text }, sessionID, io) {
174
+ io.log(io.isTTY ? JSON.stringify(event, null, 2) : text.trimEnd());
175
+ const next = nextStep(event, sessionID);
176
+ if (next) io.warn(next);
177
+ return outcomeExit(event);
178
+ }
179
+
180
+ function nextStep(event, id) {
181
+ if (event.type === 'pending') {
182
+ return `still running; keep waiting with: juno ask --session ${id}`;
183
+ }
184
+ if (event.type !== 'awaiting_input') return null;
185
+ if (event.pending_input?.kind === 'approval') {
186
+ return (
187
+ `awaiting approval; approve with: juno session approve ${id}\n` +
188
+ `or deny, with a reason the assistant sees: juno ask --session ${id} "<reason>"`
189
+ );
190
+ }
191
+ return `awaiting an answer; reply with: juno ask --session ${id} "<answer>"`;
192
+ }
193
+
194
+ function sleep(ms) {
195
+ return new Promise((resolve) => setTimeout(resolve, ms));
196
+ }
package/src/stamp.js CHANGED
@@ -7,16 +7,17 @@ import { createHash } from 'node:crypto';
7
7
  * and TestManifestStampParity pin the same input to the same output from each
8
8
  * side, which is the only thing that keeps them together.
9
9
  *
10
- * Why it exists: "edit manifest.json, forget `juno types`" is the one mistake
11
- * this workflow reliably produces, and nothing else catches it. tsc compiles
12
- * happily against stale types, eslint is happy, and h.conformance() checks the
13
- * manifest against the *module*, never against types/. The drift surfaces at
14
- * deploy, or later still, as a flow author calling an action with a shape that
15
- * no longer exists.
10
+ * Why it exists: "edit manifest.json, forget `juno integration gen-types`" is
11
+ * the one mistake this workflow reliably produces, and nothing else catches
12
+ * it. tsc compiles happily against stale types, eslint is happy, and
13
+ * h.conformance() checks the manifest against the *module*, never against
14
+ * types/. The drift surfaces at deploy, or later still, as a flow author
15
+ * calling an action with a shape that no longer exists.
16
16
  *
17
17
  * Why a hash rather than asking a Juno: the check has to run where the mistake
18
18
  * is caught — a contributor's CI, which deliberately has no instance to ask.
19
- * `juno types` needs a server; verifying what it wrote must not.
19
+ * `juno integration gen-types` needs a server; verifying what it wrote must
20
+ * not.
20
21
  */
21
22
  const STAMP_PREFIX = '// juno:manifest-sha256 ';
22
23
  const STAMP_LENGTH = 16;
@@ -68,8 +69,8 @@ export function stampEnvDTS(envDTS, manifestJSON) {
68
69
  * Applied client-side rather than server-side because it fingerprints the bytes
69
70
  * on *this* disk: both endpoints are handed a parsed manifest and never see the
70
71
  * file, and the same typegen endpoint also answers the authoring agent, which
71
- * has no file at all. The project generator behind `juno init` does see one, so
72
- * it stamps there — internal/typegen/stamp.go.
72
+ * has no file at all. The project generator behind `juno integration init`
73
+ * does see one, so it stamps there — internal/typegen/stamp.go.
73
74
  */
74
75
  export function stampTypeFiles(types, rawManifest) {
75
76
  const name = ENV_DTS.replace('types/', '');
package/src/suggest.js ADDED
@@ -0,0 +1,67 @@
1
+ /** Suggestions further than this are more likely noise than a typo. */
2
+ const MAX_DISTANCE = 3;
3
+
4
+ /**
5
+ * closest is the candidate nearest to any of `attempts` by edit distance, or
6
+ * null when none is within MAX_DISTANCE. Ties go to the earlier candidate, so
7
+ * the order of verbs.json decides.
8
+ */
9
+ export function closest(attempts, candidates) {
10
+ let best = null;
11
+ let bestDistance = MAX_DISTANCE + 1;
12
+ for (const candidate of candidates) {
13
+ for (const attempt of attempts) {
14
+ const d = levenshtein(attempt, candidate);
15
+ if (d < bestDistance) {
16
+ best = candidate;
17
+ bestDistance = d;
18
+ }
19
+ }
20
+ }
21
+ return best;
22
+ }
23
+
24
+ /** levenshtein is the plain edit distance: insert, delete, substitute. */
25
+ export function levenshtein(a, b) {
26
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
27
+ for (let i = 1; i <= a.length; i++) {
28
+ const cur = [i];
29
+ for (let j = 1; j <= b.length; j++) {
30
+ cur[j] = Math.min(
31
+ prev[j] + 1,
32
+ cur[j - 1] + 1,
33
+ prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
34
+ );
35
+ }
36
+ prev = cur;
37
+ }
38
+ return prev[b.length];
39
+ }
40
+
41
+ /**
42
+ * closestByVerb is for a bare word that is not a command: the grouped
43
+ * commands whose verb is nearest to it, all of them when several groups share
44
+ * that verb, with the distance. A bare `deploy` is the verb of both
45
+ * `flow deploy` and `integration deploy`, and only the person knows which
46
+ * working copy they are in, so both are offered rather than whichever
47
+ * verbs.json lists first. A verb one of whose `-` words is the attempt counts
48
+ * as distance 1, so a bare `types` still finds `gen-types`.
49
+ */
50
+ export function closestByVerb(attempt, commands) {
51
+ let best = [];
52
+ let bestDistance = MAX_DISTANCE + 1;
53
+ for (const command of commands) {
54
+ const space = command.indexOf(' ');
55
+ if (space < 0) continue;
56
+ const verb = command.slice(space + 1);
57
+ let d = levenshtein(attempt, verb);
58
+ if (d > 1 && verb.split('-').includes(attempt)) d = 1;
59
+ if (d < bestDistance) {
60
+ best = [command];
61
+ bestDistance = d;
62
+ } else if (d === bestDistance) {
63
+ best.push(command);
64
+ }
65
+ }
66
+ return { commands: best, distance: bestDistance };
67
+ }
package/src/target.js CHANGED
@@ -1,24 +1,34 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
 
4
+ import { readUserConfig } from './config.js';
4
5
  import { UserError } from './errors.js';
5
6
 
6
7
  /**
7
8
  * Resolve which Juno instance to talk to, in precedence order: an explicit
8
9
  * --url, then $JUNO_URL, then `juno.target` in package.json — which is what an
9
- * ejected project arrives with, so a fresh clone deploys with no setup.
10
+ * ejected project arrives with, so a fresh clone deploys with no setup — then
11
+ * the user config file `juno config set url` writes, for use outside a
12
+ * project.
10
13
  *
11
- * A missing target names all three: "no target configured" leaves the reader
12
- * guessing which of the three they were supposed to have used.
14
+ * A missing target names all four: "no target configured" leaves the reader
15
+ * guessing which of them they were supposed to have used.
13
16
  */
14
- export async function resolveTarget({ url, cwd = process.cwd() } = {}) {
17
+ export async function resolveTarget({
18
+ url,
19
+ cwd = process.cwd(),
20
+ env = process.env,
21
+ } = {}) {
15
22
  const candidate =
16
- url ?? process.env.JUNO_URL ?? (await targetFromPackageJSON(cwd));
23
+ url ??
24
+ env.JUNO_URL ??
25
+ (await targetFromPackageJSON(cwd)) ??
26
+ (await targetFromUserConfig(env));
17
27
  if (!candidate) {
18
28
  throw new UserError(
19
- 'no Juno instance to deploy to. Pass --url <url>, set $JUNO_URL, or add\n' +
29
+ 'no Juno instance configured. Pass --url <url>, set $JUNO_URL, add\n' +
20
30
  ' "juno": { "target": "http://juno.local:8080" }\n' +
21
- 'to package.json.'
31
+ 'to package.json, or run `juno config set url <url>`.'
22
32
  );
23
33
  }
24
34
  return normalizeTarget(candidate);
@@ -33,6 +43,11 @@ async function targetFromPackageJSON(cwd) {
33
43
  }
34
44
  }
35
45
 
46
+ async function targetFromUserConfig(env) {
47
+ const { url } = await readUserConfig(env);
48
+ return typeof url === 'string' && url.trim() ? url : null;
49
+ }
50
+
36
51
  /** Accepts a bare host the way a browser address bar does. */
37
52
  export function normalizeTarget(target) {
38
53
  let t = target.trim().replace(/\/+$/, '');
package/src/verbs.js ADDED
@@ -0,0 +1,34 @@
1
+ import { createRequire } from 'node:module';
2
+
3
+ // createRequire rather than an import attribute: `with { type: 'json' }` needs
4
+ // Node 20.10+, and engines says >=20.
5
+ const { commands: ROWS } = createRequire(import.meta.url)('../verbs.json');
6
+
7
+ /**
8
+ * verbs.json is the command table: every `juno <group> <verb>` the CLI
9
+ * answers, and the one place a command name is decided. The Go tests in
10
+ * internal/agent/verbs_test.go hold it to the tool gateway; this module only
11
+ * indexes it.
12
+ */
13
+ export const rows = ROWS;
14
+
15
+ const byCommand = new Map(ROWS.map((row) => [row.command, row]));
16
+
17
+ /** The row for a command string ("state get"), or undefined. */
18
+ export function rowFor(command) {
19
+ return byCommand.get(command);
20
+ }
21
+
22
+ /**
23
+ * A verb spelled with `_` is an alias for the `-` spelling, so an agent that
24
+ * types the tool's own word order (`juno state get_history`) still lands.
25
+ */
26
+ export function normalizeVerb(verb) {
27
+ return verb.replaceAll('_', '-');
28
+ }
29
+
30
+ /** The group of a two-word command, or null for a top-level one. */
31
+ export function groupOf(row) {
32
+ const space = row.command.indexOf(' ');
33
+ return space < 0 ? null : row.command.slice(0, space);
34
+ }
package/src/watch.js ADDED
@@ -0,0 +1,87 @@
1
+ import { watch } from 'node:fs';
2
+
3
+ import { DiagnosticsError } from './errors.js';
4
+ import { formatDiagnostics } from './diagnostics.js';
5
+
6
+ /** How long to wait for edits to settle before redeploying. */
7
+ export const WATCH_DEBOUNCE_MS = 200;
8
+
9
+ /**
10
+ * deployOnChange is `deploy --watch` for both kinds of working copy: run
11
+ * `once`, then again whenever one of `paths` changes (`{path, recursive?}`),
12
+ * debounced so an editor's save-and-rename is one deploy. A failed deploy is
13
+ * printed and the loop keeps watching — the next save is the retry. It
14
+ * resolves only on SIGINT: watch mode ends when the author ends it.
15
+ */
16
+ export async function deployOnChange({ io, target, paths, once }) {
17
+ io.log(`watching ${io.cwd} — deploying to ${target}`);
18
+ const iteration = async () => {
19
+ try {
20
+ await once();
21
+ } catch (err) {
22
+ if (err instanceof DiagnosticsError) {
23
+ io.error(formatDiagnostics(err.diagnostics));
24
+ } else {
25
+ io.error(err.message);
26
+ }
27
+ }
28
+ };
29
+ await iteration();
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
+ ) {
56
+ let timer = null;
57
+ const schedule = () => {
58
+ if (timer) clearTimeout(timer);
59
+ timer = setTimeout(() => {
60
+ timer = null;
61
+ onChange();
62
+ }, debounceMs);
63
+ };
64
+
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;
84
+ for (const w of watchers) w.close();
85
+ },
86
+ };
87
+ }