@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,224 @@
|
|
|
1
|
+
/** How often an open stream sends a `: ping` comment, as the instance does. */
|
|
2
|
+
export const PING_MS = 20_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How a control stands with nothing said about it: the instance's answer for
|
|
6
|
+
* a button and for an idle control (appserve.ControlValue, `null`s sent).
|
|
7
|
+
*/
|
|
8
|
+
const IDLE = Object.freeze({ value: null, active: false, expires_at: null });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* createLive is the dev server's `_juno/stream` (plan-local-dev-6 decision
|
|
12
|
+
* 6), in the instance's wire shape (internal/appserve, `.claude/rules/
|
|
13
|
+
* apps.md` "Live values"):
|
|
14
|
+
*
|
|
15
|
+
* | Event | Data | When |
|
|
16
|
+
* | ---------- | -------------------------------- | ---------------------------- |
|
|
17
|
+
* | `snapshot` | `{"values": {states, controls}}` | on connect, and on `reset()` |
|
|
18
|
+
* | `update` | `{"values": {states, controls}}` | `set()`, one per moved map |
|
|
19
|
+
* | `pressed` | `{"binding", "at"}` | `press()` |
|
|
20
|
+
* | (comment) | `: ping` | every `pingMs` |
|
|
21
|
+
*
|
|
22
|
+
* Values are keyed by binding, as the instance keys them: every declared
|
|
23
|
+
* state binding under `states` (the scenario's value, else null) and every
|
|
24
|
+
* control binding under `controls` (idle). The panel's overrides sit on top.
|
|
25
|
+
* A read or read_write binding's override is also seeded into the harness,
|
|
26
|
+
* so a handler reads what the page is shown; a slot or signal binding holds
|
|
27
|
+
* no value a handler can read, so its override reaches the page only.
|
|
28
|
+
*
|
|
29
|
+
* `stale` is never sent: the dev server serves one version, whatever `?v=`
|
|
30
|
+
* the client names. A handler's claim or set moves no value here, because
|
|
31
|
+
* the layer stack is the instance's, as it is in the harness.
|
|
32
|
+
*
|
|
33
|
+
* dev is the harness (createDevHarness). Telling the panel the values moved
|
|
34
|
+
* is the caller's (createPanel).
|
|
35
|
+
*/
|
|
36
|
+
export function createLive({ dev, pingMs = PING_MS }) {
|
|
37
|
+
const streams = new Set();
|
|
38
|
+
// The panel's overrides, by binding. Cleared by a scenario switch.
|
|
39
|
+
let overrides = { states: {}, controls: {} };
|
|
40
|
+
|
|
41
|
+
const live = {
|
|
42
|
+
/** The values a snapshot would carry now. */
|
|
43
|
+
values() {
|
|
44
|
+
const states = {};
|
|
45
|
+
for (const name of Object.keys(dev.manifest.states ?? {})) {
|
|
46
|
+
states[name] = Object.hasOwn(overrides.states, name)
|
|
47
|
+
? overrides.states[name]
|
|
48
|
+
: (dev.scenario?.value?.states?.[name] ?? null);
|
|
49
|
+
}
|
|
50
|
+
const controls = {};
|
|
51
|
+
for (const name of Object.keys(dev.manifest.controls ?? {})) {
|
|
52
|
+
controls[name] = overrides.controls[name] ?? { ...IDLE };
|
|
53
|
+
}
|
|
54
|
+
return { states, controls };
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Moves values, `{states?, controls?}` by binding, and sends one
|
|
59
|
+
* `update` per map that moved, the other map `{}` — as the instance
|
|
60
|
+
* sends a settle generation and a control change separately. A binding
|
|
61
|
+
* the manifest does not declare, or a control value that is not an
|
|
62
|
+
* object, throws before anything moves.
|
|
63
|
+
*/
|
|
64
|
+
set({ states = {}, controls = {} } = {}) {
|
|
65
|
+
const declared = {
|
|
66
|
+
states: dev.manifest.states ?? {},
|
|
67
|
+
controls: dev.manifest.controls ?? {},
|
|
68
|
+
};
|
|
69
|
+
for (const [section, map] of Object.entries({ states, controls })) {
|
|
70
|
+
if (map === null || typeof map !== 'object' || Array.isArray(map)) {
|
|
71
|
+
throw new Error(`"${section}" maps bindings to values`);
|
|
72
|
+
}
|
|
73
|
+
for (const name of Object.keys(map)) {
|
|
74
|
+
if (!Object.hasOwn(declared[section], name)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`"${name}" is not declared in manifest.json ${section} (have: ${Object.keys(declared[section]).sort().join(', ') || '(none)'})`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const movedControls = {};
|
|
82
|
+
for (const [name, v] of Object.entries(controls)) {
|
|
83
|
+
movedControls[name] = controlValue(name, v);
|
|
84
|
+
}
|
|
85
|
+
const seeded = {};
|
|
86
|
+
for (const [name, v] of Object.entries(states)) {
|
|
87
|
+
if (holdsValue(declared.states[name])) seeded[name] = v;
|
|
88
|
+
}
|
|
89
|
+
dev.h.states(seeded);
|
|
90
|
+
|
|
91
|
+
Object.assign(overrides.states, states);
|
|
92
|
+
Object.assign(overrides.controls, movedControls);
|
|
93
|
+
if (Object.keys(states).length) {
|
|
94
|
+
broadcast('update', { values: { states, controls: {} } });
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(movedControls).length) {
|
|
97
|
+
broadcast('update', {
|
|
98
|
+
values: { states: {}, controls: movedControls },
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
/** Sends `pressed` for a declared control binding, at now. */
|
|
104
|
+
press(binding) {
|
|
105
|
+
const controls = dev.manifest.controls ?? {};
|
|
106
|
+
if (typeof binding !== 'string' || !Object.hasOwn(controls, binding)) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`"${binding}" is not declared in manifest.json controls (have: ${Object.keys(controls).sort().join(', ') || '(none)'})`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
const data = { binding, at: new Date().toISOString() };
|
|
112
|
+
broadcast('pressed', data);
|
|
113
|
+
return data;
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* After the harness was rebuilt (a scenario switch, a manifest save):
|
|
118
|
+
* drops the overrides with `clear`, or keeps those whose binding is still
|
|
119
|
+
* declared and seeds them into the fresh harness, then sends every open
|
|
120
|
+
* stream a new snapshot.
|
|
121
|
+
*/
|
|
122
|
+
reset({ clear }) {
|
|
123
|
+
if (clear) {
|
|
124
|
+
overrides = { states: {}, controls: {} };
|
|
125
|
+
} else {
|
|
126
|
+
const states = dev.manifest.states ?? {};
|
|
127
|
+
const controls = dev.manifest.controls ?? {};
|
|
128
|
+
overrides = {
|
|
129
|
+
states: pick(overrides.states, states),
|
|
130
|
+
controls: pick(overrides.controls, controls),
|
|
131
|
+
};
|
|
132
|
+
const seeded = {};
|
|
133
|
+
for (const [name, v] of Object.entries(overrides.states)) {
|
|
134
|
+
if (holdsValue(states[name])) seeded[name] = v;
|
|
135
|
+
}
|
|
136
|
+
dev.h.states(seeded);
|
|
137
|
+
}
|
|
138
|
+
broadcast('snapshot', { values: live.values() });
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
/** The `_juno/stream` route. */
|
|
142
|
+
serve(req, res) {
|
|
143
|
+
if (req.method !== 'GET') {
|
|
144
|
+
res.writeHead(405, {
|
|
145
|
+
Allow: 'GET',
|
|
146
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
147
|
+
});
|
|
148
|
+
return res.end('method not allowed');
|
|
149
|
+
}
|
|
150
|
+
const stream = openStream(res, pingMs, () => streams.delete(stream));
|
|
151
|
+
streams.add(stream);
|
|
152
|
+
stream.send('snapshot', { values: live.values() });
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/** Ends every open stream. */
|
|
156
|
+
close() {
|
|
157
|
+
for (const s of streams) s.end();
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
function broadcast(event, data) {
|
|
162
|
+
for (const s of streams) s.send(event, data);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return live;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* openStream starts a server-sent event response with the instance's
|
|
170
|
+
* headers, and a `: ping` comment every pingMs so an idle connection is kept.
|
|
171
|
+
* The timer stops when the response closes, by either side. Returns
|
|
172
|
+
* `{send(event, data), end()}`.
|
|
173
|
+
*/
|
|
174
|
+
export function openStream(res, pingMs, onClose = () => {}) {
|
|
175
|
+
res.writeHead(200, {
|
|
176
|
+
'Content-Type': 'text/event-stream',
|
|
177
|
+
'Cache-Control': 'no-cache',
|
|
178
|
+
'X-Accel-Buffering': 'no',
|
|
179
|
+
});
|
|
180
|
+
res.flushHeaders();
|
|
181
|
+
const ping = setInterval(() => res.write(': ping\n\n'), pingMs);
|
|
182
|
+
res.on('close', () => {
|
|
183
|
+
clearInterval(ping);
|
|
184
|
+
onClose();
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
// One line of JSON, which never contains a raw newline.
|
|
188
|
+
send(event, data) {
|
|
189
|
+
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
190
|
+
},
|
|
191
|
+
end() {
|
|
192
|
+
clearInterval(ping);
|
|
193
|
+
res.end();
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A read or read_write binding: what h.states seeds and a handler reads. */
|
|
199
|
+
function holdsValue(binding) {
|
|
200
|
+
return !binding?.slot && !binding?.signal;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** A control's value as the instance sends it: the three fields, nulls kept. */
|
|
204
|
+
function controlValue(name, v) {
|
|
205
|
+
if (v === null || typeof v !== 'object' || Array.isArray(v)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`controls "${name}" — expected {value, active, expires_at}, got ${JSON.stringify(v)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (v.expires_at != null && typeof v.expires_at !== 'string') {
|
|
211
|
+
throw new Error(`controls "${name}" — expires_at must be a string or null`);
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
value: v.value ?? null,
|
|
215
|
+
active: v.active === true,
|
|
216
|
+
expires_at: v.expires_at ?? null,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function pick(map, declared) {
|
|
221
|
+
return Object.fromEntries(
|
|
222
|
+
Object.entries(map).filter(([name]) => Object.hasOwn(declared, name))
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { watchPaths } from '../watch.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* watchApp is `juno app dev`'s rebuild on save (plan-local-dev-6 decision 9):
|
|
7
|
+
* one debounced watch per part of the working copy, so a save reaches the
|
|
8
|
+
* one handler that knows what to rebuild.
|
|
9
|
+
*
|
|
10
|
+
* | Handler | Watches |
|
|
11
|
+
* | ---------- | ---------------------------------------- |
|
|
12
|
+
* | `manifest` | `manifest.json` |
|
|
13
|
+
* | `src` | `src/`, recursively |
|
|
14
|
+
* | `page` | `page/` and `public/`, recursively |
|
|
15
|
+
* | `fixtures` | `test/fixtures/`, recursively |
|
|
16
|
+
*
|
|
17
|
+
* manifest.json is watched through its directory, filtered by name, because
|
|
18
|
+
* a watch on the file itself is lost the first time an editor saves by
|
|
19
|
+
* renaming over it. A directory absent at start is not watched. Returns
|
|
20
|
+
* `{close()}`.
|
|
21
|
+
*/
|
|
22
|
+
export function watchApp(cwd, on, { debounceMs } = {}) {
|
|
23
|
+
const groups = {
|
|
24
|
+
manifest: [
|
|
25
|
+
{
|
|
26
|
+
path: cwd,
|
|
27
|
+
filter: (name) => name === null || name === 'manifest.json',
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
src: [{ path: join(cwd, 'src'), recursive: true }],
|
|
31
|
+
page: [
|
|
32
|
+
{ path: join(cwd, 'page'), recursive: true },
|
|
33
|
+
{ path: join(cwd, 'public'), recursive: true },
|
|
34
|
+
],
|
|
35
|
+
fixtures: [{ path: join(cwd, 'test', 'fixtures'), recursive: true }],
|
|
36
|
+
};
|
|
37
|
+
const watchers = [];
|
|
38
|
+
for (const [group, paths] of Object.entries(groups)) {
|
|
39
|
+
if (typeof on[group] !== 'function') continue;
|
|
40
|
+
watchers.push(watchPaths(paths, on[group], { debounceMs }));
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
close() {
|
|
44
|
+
for (const w of watchers) w.close();
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, posix, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { UserError } from './errors.js';
|
|
5
|
+
import { readSourceFiles, readTestFiles } from './project.js';
|
|
6
|
+
import {
|
|
7
|
+
baseVersionOf,
|
|
8
|
+
PAGE_TSCONFIG,
|
|
9
|
+
readBoundPackage,
|
|
10
|
+
readManifestFile,
|
|
11
|
+
writePackageJSON,
|
|
12
|
+
} from './working-copy.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An app working copy is the archive's own layout — src/ compiled, page/
|
|
16
|
+
* built at ingest, public/ served as is, test/ stored and never compiled —
|
|
17
|
+
* plus manifest.json, types/ (generated), page/tsconfig.json and
|
|
18
|
+
* package.json, whose `juno` block binds the directory to one app. The
|
|
19
|
+
* layout is the server's (POST /api/apps/scaffold); this module only knows
|
|
20
|
+
* what a command reads or writes back, and what it shares with a flow working
|
|
21
|
+
* copy lives in working-copy.js.
|
|
22
|
+
*/
|
|
23
|
+
export const SRC_DIR = 'src';
|
|
24
|
+
export const PAGE_DIR = 'page';
|
|
25
|
+
export const PUBLIC_DIR = 'public';
|
|
26
|
+
export { PAGE_TSCONFIG };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* readAppProject reads what ingest takes: the manifest (parsed, so a broken
|
|
30
|
+
* one fails here with its parse error rather than as a server 422), src/,
|
|
31
|
+
* test/, page/ and public/ as one archive, the lockfile, and the binding in
|
|
32
|
+
* package.json.
|
|
33
|
+
*
|
|
34
|
+
* Ingest keys a version on the manifest's id, so a manifest whose id is not
|
|
35
|
+
* `juno.app_id` would make a second app rather than a version of this one.
|
|
36
|
+
* That is refused here, before anything is sent, as the flow ingest route
|
|
37
|
+
* refuses a manifest naming another flow.
|
|
38
|
+
*/
|
|
39
|
+
export async function readAppProject(cwd) {
|
|
40
|
+
const pkg = await readAppPackage(cwd);
|
|
41
|
+
const { manifest, manifestRaw } = await readManifestFile(cwd);
|
|
42
|
+
const appId = pkg.juno.app_id;
|
|
43
|
+
if (manifest?.id !== appId) {
|
|
44
|
+
throw new UserError(
|
|
45
|
+
`manifest.json's id is ${JSON.stringify(manifest?.id)}, but this working copy is app "${appId}" ` +
|
|
46
|
+
'(juno.app_id in package.json) — deploying it would create a second app rather than a version of this one. ' +
|
|
47
|
+
`Set the id back to "${appId}", or run \`juno app init <new-id>\` for a new app.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
manifest,
|
|
52
|
+
manifestRaw,
|
|
53
|
+
files: {
|
|
54
|
+
...(await readSourceFiles(cwd)),
|
|
55
|
+
...(await readTestFiles(cwd)),
|
|
56
|
+
...(await readPageFiles(cwd)),
|
|
57
|
+
...(await readPublicFiles(cwd)),
|
|
58
|
+
},
|
|
59
|
+
lockfile: await readOptional(join(cwd, 'package-lock.json')),
|
|
60
|
+
packageJSON: pkg,
|
|
61
|
+
appId,
|
|
62
|
+
baseVersion: baseVersionOf(pkg),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** readAppPackage is package.json, refusing a directory that is not an app working copy. */
|
|
67
|
+
export function readAppPackage(cwd) {
|
|
68
|
+
return readBoundPackage(cwd, { kind: 'app', idKey: 'app_id' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* updateAppPackage rewrites an app working copy's package.json through
|
|
73
|
+
* `mutate`, keeping every key it does not touch (and their order).
|
|
74
|
+
*/
|
|
75
|
+
export async function updateAppPackage(cwd, mutate) {
|
|
76
|
+
const pkg = await readAppPackage(cwd);
|
|
77
|
+
mutate(pkg);
|
|
78
|
+
await writePackageJSON(cwd, pkg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* readPublicFiles walks public/ and returns repo-relative POSIX paths →
|
|
83
|
+
* content: every file, since all of public/ is served, except dotfiles
|
|
84
|
+
* (.DS_Store, an editor's swap file), which are the machine's and not the
|
|
85
|
+
* page's. The server enforces the caps and the reserved names.
|
|
86
|
+
*/
|
|
87
|
+
export function readPublicFiles(cwd) {
|
|
88
|
+
return readTree(cwd, PUBLIC_DIR);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* readPageFiles is readPublicFiles for page/, less page/tsconfig.json. What
|
|
93
|
+
* may be in page/ (the entry document and .ts files) is the server's to
|
|
94
|
+
* enforce, so a stray file is sent and refused by name rather than dropped.
|
|
95
|
+
*/
|
|
96
|
+
export async function readPageFiles(cwd) {
|
|
97
|
+
const files = await readTree(cwd, PAGE_DIR);
|
|
98
|
+
delete files[PAGE_TSCONFIG];
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** readTree reads every file under dir but dotfiles, by repo-relative POSIX path. */
|
|
103
|
+
async function readTree(cwd, dir) {
|
|
104
|
+
const files = {};
|
|
105
|
+
async function walk(dir) {
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
return; // no such directory at all; ingest names a missing entry document
|
|
111
|
+
}
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.name.startsWith('.')) continue;
|
|
114
|
+
const full = join(dir, entry.name);
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
await walk(full);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const rel = relative(cwd, full).split(sep).join(posix.sep);
|
|
120
|
+
files[rel] = await readFile(full, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await walk(join(cwd, dir));
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readOptional(path) {
|
|
128
|
+
try {
|
|
129
|
+
return await readFile(path, 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { warnIfStale } from './installed-skill.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The kinds of working copy with an authoring skill index, and the heading
|
|
5
|
+
* each is printed under. The index itself is the server's
|
|
6
|
+
* (GET /api/skills?authoring=<kind>): the same name and description lines the
|
|
7
|
+
* in-app app, flow and integration authors are given, so it cannot drift from the
|
|
8
|
+
* skills.
|
|
9
|
+
*/
|
|
10
|
+
export const AUTHORING_HEADINGS = {
|
|
11
|
+
app: 'Skills for writing an app',
|
|
12
|
+
flow: 'Skills for writing a flow',
|
|
13
|
+
integration: 'Skills for writing an integration',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** The heading line, saying how a listed skill is loaded. */
|
|
17
|
+
export function authoringHeading(kind) {
|
|
18
|
+
return `${AUTHORING_HEADINGS[kind]} (load one with \`juno skill <name>\`):`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* printAuthoringIndex prints a kind's authoring skills after `init` has
|
|
23
|
+
* written the working copy, as ` <name> — <description>`. Fetching it is a
|
|
24
|
+
* courtesy: the working copy is already on disk, so a failure is a note on
|
|
25
|
+
* stderr, never a failed init. It also says when an installed agent skill is
|
|
26
|
+
* stale, since the index carries the version the instance serves.
|
|
27
|
+
*/
|
|
28
|
+
export async function printAuthoringIndex(kind, client, target, io) {
|
|
29
|
+
let index;
|
|
30
|
+
try {
|
|
31
|
+
index = await client.listSkills({ authoring: kind });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
io.warn(`(skill index unavailable: ${err.message})`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const skills = index?.skills ?? [];
|
|
37
|
+
if (skills.length > 0) {
|
|
38
|
+
io.log('');
|
|
39
|
+
io.log(authoringHeading(kind));
|
|
40
|
+
for (const s of skills) io.log(` ${s.name} — ${s.description}`);
|
|
41
|
+
}
|
|
42
|
+
await warnIfStale(index, target, io);
|
|
43
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { connect } from '../api.js';
|
|
5
|
+
import { UsageError } from '../errors.js';
|
|
6
|
+
import { SKILL_DIR } from '../installed-skill.js';
|
|
7
|
+
|
|
8
|
+
export const AGENT_SKILL_INSTALL_USAGE =
|
|
9
|
+
'juno agent-skill install [--dir <path>] [--allow-change] [--working-copy] [--agents-md] [--json]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno agent-skill install` writes the skill GET /api/agent-skill renders to
|
|
13
|
+
* `<dir>/SKILL.md` (default `.claude/skills/juno` under the current
|
|
14
|
+
* directory) and prints a Claude Code permission block on stdout: every read
|
|
15
|
+
* command, plus every change command with --allow-change, plus the working
|
|
16
|
+
* copy's npm scripts of the same tiers with --working-copy. No act command is
|
|
17
|
+
* ever in it — the server leaves them out, and the CLI adds nothing.
|
|
18
|
+
*
|
|
19
|
+
* `--agents-md` prints the same text as an AGENTS.md section instead and
|
|
20
|
+
* writes nothing; `--json` prints the route's body.
|
|
21
|
+
*/
|
|
22
|
+
export async function agentSkillInstall(args, io) {
|
|
23
|
+
if (args._.length > 0) {
|
|
24
|
+
throw new UsageError(
|
|
25
|
+
`juno agent-skill install takes no argument; usage: ${AGENT_SKILL_INSTALL_USAGE}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const { client } = await connect(args, io);
|
|
29
|
+
const skill = await client.getAgentSkill();
|
|
30
|
+
|
|
31
|
+
if (args.json) return printJSON(skill, io);
|
|
32
|
+
if (args['agents-md']) {
|
|
33
|
+
io.write(skill.agents_md);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const dir = resolve(io.cwd, args.dir ?? SKILL_DIR);
|
|
38
|
+
const path = join(dir, 'SKILL.md');
|
|
39
|
+
await mkdir(dir, { recursive: true });
|
|
40
|
+
await writeFile(path, skill.skill_md, 'utf8');
|
|
41
|
+
|
|
42
|
+
const tiers = (t) => [
|
|
43
|
+
...(t?.read ?? []),
|
|
44
|
+
...(args['allow-change'] ? (t?.change ?? []) : []),
|
|
45
|
+
];
|
|
46
|
+
const allow = tiers(skill.permissions);
|
|
47
|
+
if (args['working-copy'])
|
|
48
|
+
allow.push(...tiers(skill.working_copy_permissions));
|
|
49
|
+
|
|
50
|
+
io.warn(`wrote ${path} (version ${skill.version})`);
|
|
51
|
+
io.warn(
|
|
52
|
+
args['working-copy']
|
|
53
|
+
? "Add these rules to this working copy's .claude/settings.json — not to user-wide settings, " +
|
|
54
|
+
"where they would also allow any other project's npm scripts:"
|
|
55
|
+
: 'Add these rules to .claude/settings.json (or ~/.claude/settings.json) to run them without a prompt:'
|
|
56
|
+
);
|
|
57
|
+
return printJSON({ permissions: { allow } }, io);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Compact off a TTY, indented on one, as every JSON the CLI prints. */
|
|
61
|
+
function printJSON(value, io) {
|
|
62
|
+
io.log(io.isTTY ? JSON.stringify(value, null, 2) : JSON.stringify(value));
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { readAppProject } from '../app-project.js';
|
|
3
|
+
import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
|
|
4
|
+
import { DiagnosticsError } from '../errors.js';
|
|
5
|
+
import { EXIT_CONFLICT } from '../exit.js';
|
|
6
|
+
import { runLocalChecks } from './check.js';
|
|
7
|
+
import { warnOnDependencyDrift } from './flow-check.js';
|
|
8
|
+
|
|
9
|
+
export const APP_CHECK_USAGE = 'juno app check [--skip-local] [--json]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno app check` — local tsc and eslint, then an ingest with
|
|
13
|
+
* `activate: false`: the verification deploy runs, stored as a draft and not
|
|
14
|
+
* activated, so the page can be opened at its `~draft/` URL before anyone
|
|
15
|
+
* else sees it. Unlike a flow's check this stores something — an app's draft
|
|
16
|
+
* is how it is previewed — which is why its effect is `change`. The draft's
|
|
17
|
+
* handlers run for real: a preview is live.
|
|
18
|
+
*
|
|
19
|
+
* It sends `juno.base_version`. The server never refuses a preview, so a stale
|
|
20
|
+
* copy still stores its draft; the answer's `stale_base` is the sentence a
|
|
21
|
+
* deploy would be refused with, printed after the preview, and the exit is 4
|
|
22
|
+
* — the code every kind's check gives a stale copy.
|
|
23
|
+
*
|
|
24
|
+
* --json prints the route's body (or the 422's diagnostics) on stdout and
|
|
25
|
+
* nothing else; the local tools' own output goes to stderr.
|
|
26
|
+
*/
|
|
27
|
+
export async function appCheck(argv, io) {
|
|
28
|
+
const project = await readAppProject(io.cwd);
|
|
29
|
+
const failed = !argv['skip-local'] && (await runLocalChecks(argv, io));
|
|
30
|
+
warnOnDependencyDrift(project, io);
|
|
31
|
+
|
|
32
|
+
const { target, client } = await connect(argv, io);
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = await client.ingestApp(project, {
|
|
36
|
+
activate: false,
|
|
37
|
+
baseVersion: project.baseVersion,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (argv.json && err instanceof DiagnosticsError) {
|
|
41
|
+
return printDiagnosticsJSON(err, io);
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (argv.json) {
|
|
47
|
+
printJSON(result, io);
|
|
48
|
+
} else {
|
|
49
|
+
for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
|
|
50
|
+
io.log(
|
|
51
|
+
`${result.id}: verified and stored as draft v${result.version} on ${target}`
|
|
52
|
+
);
|
|
53
|
+
printPreview(result, io);
|
|
54
|
+
if (result.stale_base) io.error(result.stale_base);
|
|
55
|
+
}
|
|
56
|
+
if (result.stale_base) return EXIT_CONFLICT;
|
|
57
|
+
return failed ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The draft's preview URL is the app's URL with `~draft/` on the end. Juno
|
|
62
|
+
* reports no URL while the apps_url site setting is unset, and the CLI does
|
|
63
|
+
* not guess one: the apps listener's address is not the hostname a proxy
|
|
64
|
+
* puts in front of it.
|
|
65
|
+
*/
|
|
66
|
+
function printPreview(result, io) {
|
|
67
|
+
if (result.url) {
|
|
68
|
+
io.log(`preview: ${result.url}~draft/`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
io.warn(
|
|
72
|
+
'no preview URL: apps_url is not set on this instance (Settings → Site). ' +
|
|
73
|
+
`Once it is, the draft is served at <apps_url>/${result.id}/~draft/`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import {
|
|
5
|
+
PAGE_DIR,
|
|
6
|
+
PUBLIC_DIR,
|
|
7
|
+
readAppProject,
|
|
8
|
+
SRC_DIR,
|
|
9
|
+
updateAppPackage,
|
|
10
|
+
} from '../app-project.js';
|
|
11
|
+
import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
|
|
12
|
+
import { DiagnosticsError } from '../errors.js';
|
|
13
|
+
import { deployOnChange } from '../watch.js';
|
|
14
|
+
import {
|
|
15
|
+
MANIFEST,
|
|
16
|
+
manifestText,
|
|
17
|
+
typeFiles,
|
|
18
|
+
writeChangedFiles,
|
|
19
|
+
} from '../working-copy.js';
|
|
20
|
+
|
|
21
|
+
export const APP_DEPLOY_USAGE = 'juno app deploy [--watch] [--force] [--json]';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `juno app deploy` — upload manifest.json, src/, page/ and public/ to
|
|
25
|
+
* POST /api/apps/ingest, which verifies them through the same path the
|
|
26
|
+
* in-app assistant's submit_code uses, stores a version and activates it.
|
|
27
|
+
*
|
|
28
|
+
* It sends `juno.base_version`, so an app changed elsewhere since this copy
|
|
29
|
+
* was pulled is a 409 (exit 4) rather than a silent overwrite; --force sends
|
|
30
|
+
* none and replaces whatever is active.
|
|
31
|
+
*
|
|
32
|
+
* On success it writes back what the server now holds: the canonical
|
|
33
|
+
* manifest, the three files under types/, and the new juno.base_version.
|
|
34
|
+
* --watch redeploys on every save to src/, page/, public/ or manifest.json,
|
|
35
|
+
* re-reading the base each time, so the loop is checked against its own
|
|
36
|
+
* previous deploy rather than refused by it.
|
|
37
|
+
*/
|
|
38
|
+
export async function appDeploy(argv, io) {
|
|
39
|
+
const { target, client } = await connect(argv, io);
|
|
40
|
+
if (!argv.watch) {
|
|
41
|
+
const project = await readAppProject(io.cwd);
|
|
42
|
+
try {
|
|
43
|
+
await deployProject(client, project, argv, io, target);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (argv.json && err instanceof DiagnosticsError) {
|
|
46
|
+
return printDiagnosticsJSON(err, io);
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A deploy writes manifest.json back, which the watcher sees. Remembering
|
|
54
|
+
// what was last deployed makes that echo — and any save that changed
|
|
55
|
+
// nothing — a no-op instead of another version.
|
|
56
|
+
let last = null;
|
|
57
|
+
return deployOnChange({
|
|
58
|
+
io,
|
|
59
|
+
target,
|
|
60
|
+
paths: [
|
|
61
|
+
{ path: join(io.cwd, SRC_DIR), recursive: true },
|
|
62
|
+
{ path: join(io.cwd, PAGE_DIR), recursive: true },
|
|
63
|
+
{ path: join(io.cwd, PUBLIC_DIR), recursive: true },
|
|
64
|
+
{ path: join(io.cwd, MANIFEST) },
|
|
65
|
+
],
|
|
66
|
+
once: async () => {
|
|
67
|
+
const project = await readAppProject(io.cwd);
|
|
68
|
+
const key = JSON.stringify([project.manifestRaw, project.files]);
|
|
69
|
+
if (key === last) return;
|
|
70
|
+
const after = await deployProject(client, project, argv, io, target);
|
|
71
|
+
last = JSON.stringify([after.manifestRaw, project.files]);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function deployProject(client, project, argv, io, target) {
|
|
77
|
+
const result = await client.ingestApp(project, {
|
|
78
|
+
baseVersion: argv.force ? undefined : project.baseVersion,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const manifestRaw = manifestText(result.manifest);
|
|
82
|
+
const written = await writeChangedFiles(io.cwd, {
|
|
83
|
+
[MANIFEST]: manifestRaw,
|
|
84
|
+
...typeFiles(result.types),
|
|
85
|
+
});
|
|
86
|
+
if (project.baseVersion !== result.version) {
|
|
87
|
+
await updateAppPackage(io.cwd, (pkg) => {
|
|
88
|
+
pkg.juno.base_version = result.version;
|
|
89
|
+
});
|
|
90
|
+
written.push('package.json');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (argv.json) {
|
|
94
|
+
printJSON(result, io);
|
|
95
|
+
} else {
|
|
96
|
+
for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
|
|
97
|
+
const files = written.length ? ` (${written.sort().join(', ')})` : '';
|
|
98
|
+
io.log(`deployed ${result.id} v${result.version} to ${target}${files}`);
|
|
99
|
+
if (result.url) io.log(`open: ${result.url}`);
|
|
100
|
+
}
|
|
101
|
+
return { manifestRaw };
|
|
102
|
+
}
|