@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,262 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ import { safeRelative } from './commands/init.js';
7
+ import { UserError } from './errors.js';
8
+
9
+ const execFileAsync = promisify(execFile);
10
+
11
+ /**
12
+ * What every working copy bound by package.json's `juno` block shares — a
13
+ * flow's (flow-project.js) and an app's (app-project.js): reading the
14
+ * manifest and the binding, writing back what a route answered, and the
15
+ * clean-tree check before a pull. Pulling itself (pullArtifacts) is shared
16
+ * with an integration's working copy too. The layout is the server's
17
+ * (POST /api/{flows,apps}/scaffold); this module knows only the names a
18
+ * command reads or writes back, and which of them are artifact files.
19
+ */
20
+ export const MANIFEST = 'manifest.json';
21
+ export const PACKAGE_JSON = 'package.json';
22
+
23
+ /**
24
+ * page/tsconfig.json is a project file, not part of the page: the scaffold
25
+ * writes it, deploy never sends it (ingest would refuse it), and pull never
26
+ * writes or removes it.
27
+ */
28
+ export const PAGE_TSCONFIG = 'page/tsconfig.json';
29
+
30
+ /**
31
+ * The directories whose files a version stores, per kind, beside
32
+ * manifest.json. Everything else in a working copy is generated (types/) or
33
+ * a project file (tsconfig.json, README.md, package.json …) that no pull
34
+ * writes or deletes.
35
+ */
36
+ const ARTIFACT_DIRS = {
37
+ flow: ['src/', 'test/'],
38
+ integration: ['src/', 'test/'],
39
+ app: ['src/', 'test/', 'page/', 'public/'],
40
+ };
41
+
42
+ /** isArtifactPath reports whether a `kind` version stores the file at path. */
43
+ export function isArtifactPath(kind, path) {
44
+ if (path === MANIFEST) return true;
45
+ if (path === PAGE_TSCONFIG) return false;
46
+ return ARTIFACT_DIRS[kind].some((dir) => path.startsWith(dir));
47
+ }
48
+
49
+ /**
50
+ * pullArtifacts makes a working copy's artifact files the pulled version's.
51
+ * `stored` is that version's artifact files, path → content, as the version
52
+ * stores them and never as a scaffold renders them (a scaffold adds init-only
53
+ * files). Each one whose content differs is written.
54
+ *
55
+ * `base` is the artifact paths of the version the copy was pulled from or last
56
+ * deployed (`juno.base_version`), or null. A file is deleted only when the base
57
+ * had it and the pulled version does not: that is a file deleted in Juno. A
58
+ * file neither version has is one the author has not deployed yet, and stays.
59
+ * With no base, nothing is deleted. Returns `{written, removed}`, each sorted.
60
+ */
61
+ export async function pullArtifacts(cwd, { stored, base, kind }) {
62
+ const written = await writeChangedFiles(cwd, stored);
63
+ const removed = [];
64
+ for (const path of base ?? []) {
65
+ if (Object.hasOwn(stored, path) || !isArtifactPath(kind, path)) continue;
66
+ try {
67
+ await rm(join(cwd, safeRelative(path)));
68
+ } catch (err) {
69
+ if (err.code === 'ENOENT') continue; // already gone locally
70
+ throw err;
71
+ }
72
+ removed.push(path);
73
+ }
74
+ return { written, removed: removed.sort() };
75
+ }
76
+
77
+ /**
78
+ * baseArtifactPaths is pullArtifacts' `base`: null when the copy records no
79
+ * base, the pulled version's own paths when the base is that version, and
80
+ * otherwise what `read(base)` answers — the base version's artifact paths, or
81
+ * null when the instance no longer has it. Pull then deletes nothing, and says
82
+ * so on stderr.
83
+ */
84
+ export async function baseArtifactPaths({
85
+ base,
86
+ version,
87
+ stored,
88
+ read,
89
+ id,
90
+ target,
91
+ io,
92
+ }) {
93
+ if (base === undefined) return null;
94
+ if (base === version) return Object.keys(stored);
95
+ const paths = await read(base);
96
+ if (paths === null) {
97
+ io.warn(
98
+ `${id} v${base}, this copy's base_version, no longer exists on ${target} — no local file was removed`
99
+ );
100
+ }
101
+ return paths;
102
+ }
103
+
104
+ /**
105
+ * setDependencies makes package.json's `dependencies` the manifest's, in name
106
+ * order as the scaffold writes them, or removes the key when the manifest
107
+ * declares none. It is the one place pull keeps the two in step.
108
+ */
109
+ export function setDependencies(pkg, manifest) {
110
+ const deps = manifest?.dependencies;
111
+ if (deps && typeof deps === 'object' && Object.keys(deps).length) {
112
+ pkg.dependencies = Object.fromEntries(
113
+ Object.keys(deps)
114
+ .sort()
115
+ .map((name) => [name, deps[name]])
116
+ );
117
+ } else {
118
+ delete pkg.dependencies;
119
+ }
120
+ }
121
+
122
+ /** readManifestFile is manifest.json, as bytes and parsed. */
123
+ export async function readManifestFile(cwd) {
124
+ const manifestRaw = await readText(cwd, MANIFEST);
125
+ try {
126
+ return { manifest: JSON.parse(manifestRaw), manifestRaw };
127
+ } catch (err) {
128
+ throw new UserError(`${MANIFEST} is not valid JSON: ${err.message}`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * readBoundPackage is package.json, refusing a directory that is not a
134
+ * `kind` working copy (no string `juno.<idKey>`) — every command but `init`
135
+ * acts on the current directory, and running one from the wrong place should
136
+ * say so rather than fail on a missing file.
137
+ */
138
+ export async function readBoundPackage(cwd, { kind, idKey }) {
139
+ let pkg = null;
140
+ try {
141
+ pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
142
+ } catch {
143
+ // Missing or unreadable: reported below as not a working copy.
144
+ }
145
+ if (typeof pkg?.juno?.[idKey] !== 'string' || pkg.juno[idKey] === '') {
146
+ throw new UserError(
147
+ `${cwd} is not ${article(kind)} ${kind} working copy (no juno.${idKey} in package.json) — ` +
148
+ `run \`juno ${kind} init <id>\` to make one`
149
+ );
150
+ }
151
+ return pkg;
152
+ }
153
+
154
+ /** baseVersionOf is `juno.base_version`, or undefined when there is none. */
155
+ export function baseVersionOf(pkg) {
156
+ return Number.isInteger(pkg.juno.base_version)
157
+ ? pkg.juno.base_version
158
+ : undefined;
159
+ }
160
+
161
+ /**
162
+ * writePackageJSON writes package.json with a two-space indent and a trailing
163
+ * newline — the way npm writes it — so, with every key the caller did not
164
+ * touch left in place, the diff is only what changed.
165
+ */
166
+ export async function writePackageJSON(cwd, pkg) {
167
+ await writeFile(
168
+ join(cwd, PACKAGE_JSON),
169
+ `${JSON.stringify(pkg, null, 2)}\n`,
170
+ 'utf8'
171
+ );
172
+ }
173
+
174
+ /**
175
+ * writeChangedFiles writes files by working-copy path, creating directories
176
+ * as needed and skipping a file whose content is already what is on disk, so
177
+ * a watcher does not see a write that changed nothing. Returns the paths it
178
+ * wrote, sorted.
179
+ */
180
+ export async function writeChangedFiles(cwd, files) {
181
+ const written = [];
182
+ for (const [rel, content] of Object.entries(files)) {
183
+ const path = join(cwd, safeRelative(rel));
184
+ let current = null;
185
+ try {
186
+ current = await readFile(path, 'utf8');
187
+ } catch {
188
+ // Not there yet.
189
+ }
190
+ if (current === content) continue;
191
+ await mkdir(dirname(path), { recursive: true });
192
+ await writeFile(path, content, 'utf8');
193
+ written.push(rel);
194
+ }
195
+ return written.sort();
196
+ }
197
+
198
+ /** The canonical manifest a route answered, as manifest.json holds it. */
199
+ export function manifestText(manifest) {
200
+ return `${JSON.stringify(manifest, null, 2)}\n`;
201
+ }
202
+
203
+ /**
204
+ * The .d.ts files of a route's `types`. Filtering by extension rather than
205
+ * naming them keeps a future file working without a CLI release.
206
+ */
207
+ export function typeFiles(types) {
208
+ return Object.fromEntries(
209
+ Object.entries(types ?? {}).filter(([name]) => name.endsWith('.d.ts'))
210
+ );
211
+ }
212
+
213
+ /**
214
+ * requireCleanTree is pull's guard: without --force it refuses a dirty git
215
+ * tree, because an uncommitted edit is the one thing nothing can get back,
216
+ * and outside git it warns that `overwrites` has no undo.
217
+ */
218
+ export async function requireCleanTree(cwd, { force, overwrites, io }) {
219
+ if (force) return;
220
+ const dirty = await dirtyPaths(cwd);
221
+ if (dirty === null) {
222
+ io.warn(
223
+ `not a git repository — pull will overwrite ${overwrites} with no undo`
224
+ );
225
+ } else if (dirty.length > 0) {
226
+ throw new UserError(
227
+ `working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
228
+ 'Commit or stash them, or pass --force to overwrite.'
229
+ );
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Returns the paths git reports as changed, [] for a clean tree, or null when
235
+ * this is not a git repository at all — three states the caller treats
236
+ * differently, so they must not collapse into a boolean.
237
+ */
238
+ export async function dirtyPaths(cwd) {
239
+ try {
240
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
241
+ cwd,
242
+ });
243
+ return stdout
244
+ .split('\n')
245
+ .map((line) => line.trim())
246
+ .filter(Boolean);
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ export async function readText(cwd, name) {
253
+ try {
254
+ return await readFile(join(cwd, name), 'utf8');
255
+ } catch {
256
+ throw new UserError(`no ${name} in ${cwd}`);
257
+ }
258
+ }
259
+
260
+ function article(word) {
261
+ return /^[aeiou]/.test(word) ? 'an' : 'a';
262
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "//": "The compiler options Juno type-checks an app's page/ with server-side: tsconfig.base.json for the browser, with the DOM's types and no @types package. internal/verify's tsconfig_preset_test.go asserts this file and verify.PageCompilerOptions() are identical. Extend it from page/tsconfig.json; do not fork it.",
3
+ "compilerOptions": {
4
+ "target": "ES2022",
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "noEmit": true,
9
+ "esModuleInterop": true,
10
+ "resolveJsonModule": true,
11
+ "skipLibCheck": true,
12
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
13
+ "types": []
14
+ }
15
+ }
package/verbs.json ADDED
@@ -0,0 +1,276 @@
1
+ {
2
+ "commands": [
3
+ {
4
+ "command": "discover",
5
+ "tool": "discover",
6
+ "id_field": "query",
7
+ "flags": {
8
+ "kind": { "field": "kinds", "type": "string", "multiple": true }
9
+ },
10
+ "naming_exception": "searches every kind, so it has no group"
11
+ },
12
+ {
13
+ "command": "related",
14
+ "tool": "get_related",
15
+ "positional": "kind:id",
16
+ "flags": { "degree": { "field": "degree", "type": "integer" } },
17
+ "naming_exception": "takes any kind, so it has no group"
18
+ },
19
+ { "command": "state get", "tool": "get_state", "id_field": "id" },
20
+ {
21
+ "command": "state get-history",
22
+ "tool": "get_state_history",
23
+ "id_field": "id"
24
+ },
25
+ {
26
+ "command": "state get-layers",
27
+ "tool": "get_state_layers",
28
+ "id_field": "id"
29
+ },
30
+ { "command": "state evaluate-level", "tool": "evaluate_level" },
31
+ {
32
+ "command": "state preview-layers",
33
+ "tool": "edit_state_layers",
34
+ "id_field": "id",
35
+ "fixed_input": { "dry_run": true },
36
+ "effect": "read",
37
+ "naming_exception": "fixes dry_run: true on edit_state_layers, so a permission rule can allow the preview and not the edit"
38
+ },
39
+ { "command": "state create", "tool": "create_state", "id_field": "id" },
40
+ {
41
+ "command": "state edit-layers",
42
+ "tool": "edit_state_layers",
43
+ "id_field": "id",
44
+ "fixed_input": { "dry_run": false }
45
+ },
46
+ {
47
+ "command": "state set-value",
48
+ "tool": "set_state_value",
49
+ "id_field": "id"
50
+ },
51
+ { "command": "state claim", "tool": "claim_state", "id_field": "id" },
52
+ { "command": "state release", "tool": "release_state", "id_field": "id" },
53
+ { "command": "flow get", "tool": "get_flow", "id_field": "id" },
54
+ {
55
+ "command": "flow set-enabled",
56
+ "tool": "set_flow_enabled",
57
+ "id_field": "flow_id"
58
+ },
59
+ {
60
+ "command": "flow trigger",
61
+ "tool": "trigger_flow",
62
+ "id_field": "flow_id"
63
+ },
64
+ {
65
+ "command": "flow fire-trigger",
66
+ "tool": "fire_trigger",
67
+ "id_field": "flow_id"
68
+ },
69
+ {
70
+ "command": "flow init",
71
+ "effect": "read",
72
+ "working_copy": true,
73
+ "summary": "Write a working copy of a flow (or a stub for a new id) into ./<id>"
74
+ },
75
+ {
76
+ "command": "flow gen-types",
77
+ "effect": "read",
78
+ "working_copy": true,
79
+ "summary": "Regenerate types/ from manifest.json"
80
+ },
81
+ {
82
+ "command": "flow check",
83
+ "effect": "read",
84
+ "working_copy": true,
85
+ "summary": "Typecheck and lint locally, then verify against Juno without deploying"
86
+ },
87
+ {
88
+ "command": "flow deploy",
89
+ "effect": "change",
90
+ "working_copy": true,
91
+ "summary": "Verify, commit and activate the working copy as the flow's next version"
92
+ },
93
+ {
94
+ "command": "flow pull",
95
+ "effect": "read",
96
+ "working_copy": true,
97
+ "summary": "Overwrite the working copy with the flow's active version"
98
+ },
99
+ {
100
+ "command": "flow fixture",
101
+ "effect": "read",
102
+ "working_copy": true,
103
+ "summary": "Write a test fixture from a recorded run"
104
+ },
105
+ { "command": "run list", "tool": "list_runs" },
106
+ { "command": "run get", "tool": "get_run", "id_field": "run_id" },
107
+ {
108
+ "command": "integration get",
109
+ "tool": "get_integration",
110
+ "id_field": "id"
111
+ },
112
+ {
113
+ "command": "integration configure",
114
+ "tool": "configure_integration",
115
+ "id_field": "id"
116
+ },
117
+ { "command": "integration install", "tool": "install_integration" },
118
+ {
119
+ "command": "integration run-schedule",
120
+ "tool": "run_integration_schedule",
121
+ "id_field": "integration"
122
+ },
123
+ {
124
+ "command": "integration init",
125
+ "effect": "read",
126
+ "working_copy": true,
127
+ "summary": "Write a working copy of an install (or a starter for a new id) into ./<id>"
128
+ },
129
+ {
130
+ "command": "integration gen-types",
131
+ "effect": "read",
132
+ "working_copy": true,
133
+ "summary": "Regenerate types/ from manifest.json"
134
+ },
135
+ {
136
+ "command": "integration verify-types",
137
+ "effect": "read",
138
+ "working_copy": true,
139
+ "summary": "Check offline that types/ matches manifest.json"
140
+ },
141
+ {
142
+ "command": "integration check",
143
+ "effect": "read",
144
+ "working_copy": true,
145
+ "summary": "Typecheck and lint locally, then verify against Juno without deploying"
146
+ },
147
+ {
148
+ "command": "integration deploy",
149
+ "effect": "change",
150
+ "working_copy": true,
151
+ "summary": "Verify, commit and activate the working copy as the install's next version"
152
+ },
153
+ {
154
+ "command": "integration pull",
155
+ "effect": "read",
156
+ "working_copy": true,
157
+ "summary": "Overwrite manifest.json, src/, test/ and types/ with the active version"
158
+ },
159
+ { "command": "device list", "tool": "list_devices" },
160
+ { "command": "device get", "tool": "get_device", "id_field": "id" },
161
+ { "command": "device preview", "tool": "preview_device" },
162
+ { "command": "device write", "tool": "write_device" },
163
+ { "command": "device suspend", "tool": "suspend_device", "id_field": "id" },
164
+ { "command": "device release", "tool": "release_device", "id_field": "id" },
165
+ { "command": "control write", "tool": "write_control" },
166
+ { "command": "control use", "tool": "use_control", "id_field": "id" },
167
+ {
168
+ "command": "control release",
169
+ "tool": "release_control",
170
+ "id_field": "id"
171
+ },
172
+ { "command": "app get", "tool": "get_app", "id_field": "id" },
173
+ {
174
+ "command": "app list-invocations",
175
+ "tool": "list_app_invocations",
176
+ "id_field": "id",
177
+ "flags": { "limit": { "field": "limit", "type": "integer" } }
178
+ },
179
+ {
180
+ "command": "app init",
181
+ "effect": "read",
182
+ "working_copy": true,
183
+ "summary": "Write a working copy of an app (or a stub for a new id) into ./<id>"
184
+ },
185
+ {
186
+ "command": "app gen-types",
187
+ "effect": "read",
188
+ "working_copy": true,
189
+ "summary": "Regenerate types/ from manifest.json"
190
+ },
191
+ {
192
+ "command": "app check",
193
+ "effect": "change",
194
+ "working_copy": true,
195
+ "summary": "Typecheck and lint locally, then store the working copy as a draft and print its preview URL"
196
+ },
197
+ {
198
+ "command": "app deploy",
199
+ "effect": "change",
200
+ "working_copy": true,
201
+ "summary": "Verify, commit and activate the working copy as the app's next version"
202
+ },
203
+ {
204
+ "command": "app pull",
205
+ "effect": "read",
206
+ "working_copy": true,
207
+ "summary": "Overwrite the working copy with the app's active version"
208
+ },
209
+ {
210
+ "command": "app dev",
211
+ "effect": "local",
212
+ "working_copy": true,
213
+ "summary": "Serve this app locally on mocked data"
214
+ },
215
+ { "command": "finding get", "tool": "get_finding", "id_field": "slug" },
216
+ { "command": "webhook list", "tool": "list_webhooks" },
217
+ {
218
+ "command": "webhook get-deliveries",
219
+ "tool": "get_webhook_deliveries",
220
+ "id_field": "id"
221
+ },
222
+ { "command": "webhook write", "tool": "write_webhook", "id_field": "id" },
223
+ { "command": "webhook fire", "tool": "fire_webhook", "id_field": "id" },
224
+ { "command": "credential list", "tool": "list_credentials" },
225
+ {
226
+ "command": "ha get-entity",
227
+ "tool": "get_entity",
228
+ "id_field": "entity_id"
229
+ },
230
+ {
231
+ "command": "ha get-entity-history",
232
+ "tool": "get_entity_history",
233
+ "id_field": "entity_id"
234
+ },
235
+ { "command": "ha list-services", "tool": "list_services" },
236
+ { "command": "ha call-service", "tool": "call_service" },
237
+ {
238
+ "command": "script run",
239
+ "tool": "run_script",
240
+ "positional": "file",
241
+ "flags": {
242
+ "cred": { "field": "credentials", "type": "string", "multiple": true }
243
+ }
244
+ },
245
+ {
246
+ "command": "skill",
247
+ "effect": "read",
248
+ "summary": "Print the skills Juno's assistant can load, or one skill's body"
249
+ },
250
+ {
251
+ "command": "ask",
252
+ "effect": "act",
253
+ "summary": "Prompt Juno's in-app assistant"
254
+ },
255
+ {
256
+ "command": "session approve",
257
+ "effect": "act",
258
+ "summary": "Approve the tool call an assistant session is waiting on"
259
+ },
260
+ {
261
+ "command": "credential set",
262
+ "effect": "act",
263
+ "summary": "Set a credential's secret, typed at a terminal with echo off"
264
+ },
265
+ {
266
+ "command": "config set",
267
+ "effect": "local",
268
+ "summary": "Remember the instance URL in the user config file"
269
+ },
270
+ {
271
+ "command": "agent-skill install",
272
+ "effect": "local",
273
+ "summary": "Write Juno's skill for a coding agent and print its permission block"
274
+ }
275
+ ]
276
+ }