@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.
- package/README.md +693 -52
- package/app-client.js +306 -0
- package/bin/juno.js +7 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +35 -0
- package/package.json +12 -4
- package/src/api.js +541 -30
- 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 +43 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -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/ask.js +60 -0
- package/src/commands/check.js +71 -27
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +95 -51
- package/src/commands/flow-check.js +75 -0
- package/src/commands/flow-deploy.js +110 -0
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +37 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +115 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +111 -63
- package/src/commands/session-approve.js +73 -0
- package/src/commands/skill.js +73 -0
- package/src/commands/tool.js +249 -0
- package/src/commands/verify-types.js +4 -2
- package/src/config.js +34 -0
- package/src/diagnostics.js +14 -0
- package/src/errors.js +40 -0
- package/src/exit.js +22 -0
- package/src/flow-project.js +78 -0
- package/src/help.js +199 -0
- package/src/index.js +567 -75
- package/src/installed-skill.js +55 -0
- package/src/project.js +76 -8
- package/src/prompt.js +196 -0
- package/src/stamp.js +10 -9
- package/src/suggest.js +67 -0
- package/src/target.js +22 -7
- package/src/verbs.js +34 -0
- package/src/watch.js +87 -0
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- 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.
|
|
13
|
-
*
|
|
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
|
|
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
|
|
55
|
-
|
|
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
|
|
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
|
|
11
|
-
* this workflow reliably produces, and nothing else catches
|
|
12
|
-
* happily against stale types, eslint is happy, and
|
|
13
|
-
* manifest against the *module*, never against
|
|
14
|
-
* deploy, or later still, as a flow author
|
|
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
|
|
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`
|
|
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
|
|
12
|
-
* guessing which of
|
|
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({
|
|
17
|
+
export async function resolveTarget({
|
|
18
|
+
url,
|
|
19
|
+
cwd = process.cwd(),
|
|
20
|
+
env = process.env,
|
|
21
|
+
} = {}) {
|
|
15
22
|
const candidate =
|
|
16
|
-
url ??
|
|
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
|
|
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
|
+
}
|