@junoflow/cli 0.6.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +214 -80
  2. package/app-client.js +306 -0
  3. package/eslint.app.config.js +34 -0
  4. package/eslint.flow.config.js +4 -2
  5. package/package.json +9 -4
  6. package/src/api.js +213 -51
  7. package/src/app-dev/build.js +134 -0
  8. package/src/app-dev/harness.js +108 -0
  9. package/src/app-dev/panel.html +336 -0
  10. package/src/app-dev/panel.js +290 -0
  11. package/src/app-dev/server.js +347 -0
  12. package/src/app-dev/stream.js +224 -0
  13. package/src/app-dev/watch.js +47 -0
  14. package/src/app-project.js +133 -0
  15. package/src/authoring-skills.js +2 -1
  16. package/src/commands/app-check.js +75 -0
  17. package/src/commands/app-deploy.js +113 -0
  18. package/src/commands/app-dev.js +141 -0
  19. package/src/commands/app-gen-types.js +37 -0
  20. package/src/commands/app-init.js +46 -0
  21. package/src/commands/app-pull.js +106 -0
  22. package/src/commands/check.js +33 -24
  23. package/src/commands/config.js +26 -7
  24. package/src/commands/deploy.js +59 -13
  25. package/src/commands/flow-check.js +4 -22
  26. package/src/commands/flow-deploy.js +11 -8
  27. package/src/commands/flow-fixture.js +67 -0
  28. package/src/commands/flow-gen-types.js +12 -5
  29. package/src/commands/flow-pull.js +69 -40
  30. package/src/commands/gen-types.js +2 -9
  31. package/src/commands/init.js +2 -9
  32. package/src/commands/pull.js +98 -111
  33. package/src/config.js +20 -5
  34. package/src/errors.js +3 -3
  35. package/src/exit.js +2 -2
  36. package/src/flow-project.js +47 -97
  37. package/src/index.js +99 -23
  38. package/src/project.js +76 -8
  39. package/src/token.js +30 -0
  40. package/src/watch.js +48 -11
  41. package/src/working-copy.js +262 -0
  42. package/tsconfig.page.json +15 -0
  43. package/verbs.json +52 -3
@@ -1,10 +1,9 @@
1
1
  import { mkdir, writeFile, access } from 'node:fs/promises';
2
2
  import { dirname, join, resolve, sep } from 'node:path';
3
3
 
4
- import { clientName, JunoClient } from '../api.js';
4
+ import { connect } from '../api.js';
5
5
  import { printAuthoringIndex } from '../authoring-skills.js';
6
6
  import { UsageError, UserError } from '../errors.js';
7
- import { resolveTarget } from '../target.js';
8
7
 
9
8
  export const INIT_USAGE = 'juno integration init <id> [--name <name>] [--here]';
10
9
 
@@ -29,13 +28,7 @@ export async function init(argv, io) {
29
28
  throw new UserError(`${dir} already exists`);
30
29
  }
31
30
 
32
- const target = await resolveTarget({
33
- url: argv.url,
34
- cwd: io.cwd,
35
- env: io.env,
36
- });
37
- const client =
38
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
31
+ const { target, client } = await connect(argv, io);
39
32
  // The id is validated server-side, against the same rules an install is:
40
33
  // one answer to "is this a valid integration id" rather than two.
41
34
  const files = await client.scaffold(id, { name: argv.name, target });
@@ -1,143 +1,130 @@
1
- import { execFile } from 'node:child_process';
2
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
- import { dirname, join } from 'node:path';
4
- import { promisify } from 'node:util';
5
-
6
- import { clientName, JunoClient } from '../api.js';
1
+ import { connect } from '../api.js';
7
2
  import { UserError } from '../errors.js';
8
- import { readManifest } from '../project.js';
9
- import { resolveTarget } from '../target.js';
10
-
11
- const execFileAsync = promisify(execFile);
3
+ import {
4
+ readIntegrationBaseVersion,
5
+ readManifest,
6
+ updateIntegrationPackage,
7
+ } from '../project.js';
8
+ import { stampTypeFiles } from '../stamp.js';
9
+ import {
10
+ baseArtifactPaths,
11
+ MANIFEST,
12
+ manifestText,
13
+ pullArtifacts,
14
+ requireCleanTree,
15
+ setDependencies,
16
+ writeChangedFiles,
17
+ } from '../working-copy.js';
18
+ import { pickTypeFiles } from './gen-types.js';
12
19
 
13
20
  export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
14
21
 
15
22
  /**
16
- * `juno integration pull` — write back the manifest and sources Juno holds,
17
- * and package.json's `scripts`.
23
+ * `juno integration pull` — make the working copy's artifact files the
24
+ * install's active version: manifest.json and every file under src/ and
25
+ * test/, exactly as the version stores them, plus types/ regenerated and
26
+ * stamped against the pulled manifest, and package.json's
27
+ * `juno.base_version` (the version pulled, which the next check and deploy
28
+ * send).
18
29
  *
19
30
  * This exists because the zip is a good first touch and a bad second one: once
20
31
  * the authoring agent has edited an integration you deployed, downloading the
21
32
  * project again would clobber your working tree, while pull touches only the
22
- * things Juno owns. The script lines are among them — the server generates
23
- * them (POST /api/integrations/scaffold renders this install's project), so a
24
- * project made by an older CLI picks up the current commands; every other key
25
- * in package.json is the author's and stays. It still refuses a dirty tree
26
- * without --force, because an uncommitted edit is the one thing git cannot get
27
- * back for you.
33
+ * files Juno stores. The scaffold's script lines are not among them: they are
34
+ * project content, and an author's own edits to them are theirs — a fresh
35
+ * `juno integration init` is how to get the current ones. `dependencies` is
36
+ * replaced only when the pulled manifest declares some (an agent-authored
37
+ * integration); otherwise they live in package.json alone, which Juno does not
38
+ * store, and stay. Every other key in package.json, and of `juno`, stays.
39
+ *
40
+ * A local file is deleted only when the base version had it and the pulled
41
+ * one does not; a file neither has is one not deployed yet, and stays. It
42
+ * still refuses a dirty tree without --force, because an uncommitted edit is
43
+ * the one thing git cannot get back for you.
28
44
  */
29
45
  export async function pull(argv, io) {
30
- const target = await resolveTarget({
31
- url: argv.url,
32
- cwd: io.cwd,
33
- env: io.env,
34
- });
35
- const id = argv.id ?? (await readManifest(io.cwd)).id;
46
+ const { target, client } = await connect(argv, io);
47
+ let manifestId;
48
+ try {
49
+ manifestId = (await readManifest(io.cwd)).id;
50
+ } catch (err) {
51
+ // With --id the manifest is about to be replaced, so a broken one is
52
+ // no reason to refuse.
53
+ if (argv.id === undefined) throw err;
54
+ }
55
+ const id = argv.id ?? manifestId;
36
56
  if (!id) {
37
57
  throw new UserError(
38
58
  'manifest.json declares no id — pass --id <integration>'
39
59
  );
40
60
  }
41
61
 
42
- if (!argv.force) {
43
- const dirty = await dirtyPaths(io.cwd);
44
- if (dirty === null) {
45
- io.warn(
46
- 'not a git repository — pull will overwrite manifest.json, src/ and package.json scripts with no undo'
47
- );
48
- } else if (dirty.length > 0) {
49
- throw new UserError(
50
- `working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
51
- 'Commit or stash them, or pass --force to overwrite.'
52
- );
53
- }
54
- }
62
+ await requireCleanTree(io.cwd, {
63
+ force: argv.force,
64
+ overwrites: `${MANIFEST}, src/, test/ and types/`,
65
+ io,
66
+ });
55
67
 
56
- const client =
57
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
58
68
  const detail = await client.getIntegration(id);
59
69
  if (!detail?.manifest) {
60
70
  throw new UserError(
61
71
  `${id} has no active version on ${target} — nothing to pull`
62
72
  );
63
73
  }
64
- // Fetched before anything is written, so a failure here leaves the working
65
- // copy as it was.
66
- const scripts = scaffoldScripts(
67
- await client.scaffold(id, { target }),
68
- target
74
+ // Everything is fetched before anything is written, so a failure leaves
75
+ // the working copy as it was.
76
+ const manifestRaw = manifestText(detail.manifest);
77
+ const stored = { [MANIFEST]: manifestRaw, ...detail.source_files };
78
+ const types = stampTypeFiles(
79
+ pickTypeFiles(await client.typegen(detail.manifest)),
80
+ manifestRaw
69
81
  );
82
+ // The base belongs to the install the manifest names; pulling another
83
+ // (--id) has no base to diff against, so it deletes nothing.
84
+ const base = await baseArtifactPaths({
85
+ base:
86
+ id === manifestId ? await readIntegrationBaseVersion(io.cwd) : undefined,
87
+ version: detail.version,
88
+ stored,
89
+ read: async (v) => {
90
+ const b = await client.getIntegrationVersion(id, v);
91
+ return b ? [MANIFEST, ...Object.keys(b.SourceFiles ?? {})] : null;
92
+ },
93
+ id,
94
+ target,
95
+ io,
96
+ });
70
97
 
71
- const written = ['manifest.json'];
72
- await writeFile(
73
- join(io.cwd, 'manifest.json'),
74
- `${JSON.stringify(detail.manifest, null, 2)}\n`,
75
- 'utf8'
98
+ const { written, removed } = await pullArtifacts(io.cwd, {
99
+ stored,
100
+ base,
101
+ kind: 'integration',
102
+ });
103
+ written.push(
104
+ ...(await writeChangedFiles(
105
+ io.cwd,
106
+ Object.fromEntries(
107
+ Object.entries(types).map(([name, content]) => [
108
+ `types/${name}`,
109
+ content,
110
+ ])
111
+ )
112
+ ))
76
113
  );
77
- for (const [rel, content] of Object.entries(detail.source_files ?? {})) {
78
- const path = join(io.cwd, rel);
79
- await mkdir(dirname(path), { recursive: true });
80
- await writeFile(path, content, 'utf8');
81
- written.push(rel);
82
- }
83
- if (await replaceScripts(io.cwd, scripts, io)) written.push('package.json');
84
- io.log(
85
- `pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
114
+ const wrote = await updateIntegrationPackage(
115
+ io.cwd,
116
+ (pkg) => {
117
+ if (Object.keys(detail.manifest.dependencies ?? {}).length) {
118
+ setDependencies(pkg, detail.manifest);
119
+ }
120
+ pkg.juno.base_version = detail.version;
121
+ },
122
+ io
86
123
  );
124
+ if (wrote) written.push('package.json');
87
125
  io.log(
88
- 'run `npm run types` to regenerate types/ against the pulled manifest'
126
+ `pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
89
127
  );
128
+ if (removed.length) io.log(`removed ${removed.join(', ')}`);
90
129
  return 0;
91
130
  }
92
-
93
- /** The `scripts` of the package.json a scaffold answered. */
94
- function scaffoldScripts(files, target) {
95
- try {
96
- const scripts = JSON.parse(files['package.json']).scripts;
97
- if (scripts && typeof scripts === 'object') return scripts;
98
- } catch {
99
- // Reported below.
100
- }
101
- throw new UserError(
102
- `${target} answered a project with no readable package.json scripts`
103
- );
104
- }
105
-
106
- /**
107
- * replaceScripts sets package.json's `scripts`, keeping every other key and
108
- * its order, written the way npm writes it. A missing or unreadable
109
- * package.json is left alone with a warning: pull is not the place to invent
110
- * one. Returns whether it wrote.
111
- */
112
- async function replaceScripts(cwd, scripts, io) {
113
- const path = join(cwd, 'package.json');
114
- let pkg;
115
- try {
116
- pkg = JSON.parse(await readFile(path, 'utf8'));
117
- } catch {
118
- io.warn('no readable package.json — its scripts were not updated');
119
- return false;
120
- }
121
- pkg.scripts = scripts;
122
- await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8');
123
- return true;
124
- }
125
-
126
- /**
127
- * Returns the paths git reports as changed, [] for a clean tree, or null when
128
- * this is not a git repository at all — three states the caller treats
129
- * differently, so they must not collapse into a boolean.
130
- */
131
- export async function dirtyPaths(cwd) {
132
- try {
133
- const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
134
- cwd,
135
- });
136
- return stdout
137
- .split('\n')
138
- .map((line) => line.trim())
139
- .filter(Boolean);
140
- } catch {
141
- return null;
142
- }
143
- }
package/src/config.js CHANGED
@@ -1,12 +1,14 @@
1
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { dirname, join } from 'node:path';
4
4
 
5
5
  /**
6
6
  * The user config file: `$XDG_CONFIG_HOME/juno/config.json`, or
7
7
  * `~/.config/juno/config.json` when XDG_CONFIG_HOME is unset. It holds
8
- * `{"url": "…"}`, the last place resolveTarget looks, and is what makes `juno`
9
- * usable outside a project after a global install.
8
+ * `{"url": "…", "token": "juno_…"}`: `url` is the last place resolveTarget
9
+ * looks, which makes `juno` usable outside a project after a global install,
10
+ * and `token` is the last place resolveToken looks. The token is a secret,
11
+ * so the file is mode 0600 (see writeUserConfig).
10
12
  */
11
13
  export function configPath(env = process.env) {
12
14
  const base = env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config');
@@ -25,10 +27,23 @@ export async function readUserConfig(env = process.env) {
25
27
  }
26
28
  }
27
29
 
28
- /** Writes the whole config, creating its directory. */
30
+ /**
31
+ * Writes the whole config, creating its directory. The file is created 0600,
32
+ * and one that already exists is narrowed to 0600 before the new content
33
+ * goes in, so a token never sits in a file other users can read, not even
34
+ * for the length of one write. (`mode` alone only applies on creation.)
35
+ */
29
36
  export async function writeUserConfig(env = process.env, cfg) {
30
37
  const path = configPath(env);
31
38
  await mkdir(dirname(path), { recursive: true });
32
- await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, 'utf8');
39
+ try {
40
+ await chmod(path, 0o600);
41
+ } catch (err) {
42
+ if (err.code !== 'ENOENT') throw err;
43
+ }
44
+ await writeFile(path, `${JSON.stringify(cfg, null, 2)}\n`, {
45
+ encoding: 'utf8',
46
+ mode: 0o600,
47
+ });
33
48
  return path;
34
49
  }
package/src/errors.js CHANGED
@@ -52,9 +52,9 @@ export class UnreachableError extends Error {
52
52
  }
53
53
 
54
54
  /**
55
- * ConflictError is a deploy the server refused because the working copy is
56
- * stale: the flow's active version is not the one `juno.base_version` says it
57
- * was pulled from (a 409). The top-level handler prints the server's message,
55
+ * ConflictError is a deploy or check the server refused because the working
56
+ * copy is stale: the flow's, integration's or app's active version is not the
57
+ * one `juno.base_version` says it was pulled from (a 409). The top-level handler prints the server's message,
58
58
  * which names both versions, and exits 4. The fix is `npm run pull`, or
59
59
  * deploying with --force to replace whatever is active.
60
60
  */
package/src/exit.js CHANGED
@@ -8,8 +8,8 @@
8
8
  * 2 usage: unknown command or flag, bad --input, a gateway 422 or 404,
9
9
  * a bad --jq
10
10
  * 3 the instance is unreachable (the fetch itself failed)
11
- * 4 a deploy conflict: `juno flow deploy` / `check` against a flow whose
12
- * active version is not the working copy's base_version (a 409)
11
+ * 4 a deploy conflict: a working-copy deploy or check (any kind) got a
12
+ * 409 because the active version is not the copy's base_version
13
13
  * 5 `juno ask` / `session approve`: the turn is still running (pending)
14
14
  * 6 `juno ask` / `session approve`: the assistant is awaiting input
15
15
  */
@@ -1,128 +1,78 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { join } from 'node:path';
3
-
4
1
  import { UserError } from './errors.js';
2
+ import { readSourceFiles, readTestFiles } from './project.js';
3
+ import {
4
+ baseVersionOf,
5
+ MANIFEST,
6
+ manifestText,
7
+ PACKAGE_JSON,
8
+ readBoundPackage,
9
+ readManifestFile,
10
+ typeFiles,
11
+ writeChangedFiles,
12
+ writePackageJSON,
13
+ } from './working-copy.js';
5
14
 
6
15
  /**
7
- * A flow working copy is flat (plan-external-agents-flows-as-files.md,
8
- * Decision 9): manifest.json and flow.ts are the author's, the two .d.ts
9
- * files are generated, and package.json's `juno` block binds the directory to
10
- * one flow. The layout is the server's (POST /api/flows/scaffold); this
11
- * module only knows the handful of names a command reads or writes back.
16
+ * A flow working copy is laid out as an integration's is: manifest.json, the
17
+ * sources under src/ (entry src/flow.ts) and the tests under test/ are the
18
+ * author's, types/ is
19
+ * generated, and package.json's `juno` block binds the directory to one flow.
20
+ * The layout is the server's (POST /api/flows/scaffold); this module only
21
+ * knows the handful of names a command reads or writes back, and what it
22
+ * shares with an app working copy lives in working-copy.js.
12
23
  */
13
- export const MANIFEST = 'manifest.json';
14
- export const FLOW_TS = 'flow.ts';
15
- export const PACKAGE_JSON = 'package.json';
24
+ export { MANIFEST, PACKAGE_JSON, manifestText, typeFiles };
25
+ export const SRC_DIR = 'src';
26
+ export const FLOW_ENTRY = 'src/flow.ts';
16
27
 
17
28
  /**
18
29
  * readFlowProject reads what ingest takes: the manifest (parsed, so a broken
19
- * one fails here with its parse error rather than as a server 422), flow.ts,
20
- * and the binding in package.json. `flowId` is `juno.flow_id` — the id is
21
- * identity, so the CLI sends it and the server refuses a manifest naming
22
- * another — and `baseVersion` is `juno.base_version`, absent for a flow that
23
- * has never been deployed from this copy.
30
+ * one fails here with its parse error rather than as a server 422), every
31
+ * .ts and .json file under src/ and test/, and the binding in package.json.
32
+ * Juno stores test/ with the version and never compiles it. A copy
33
+ * with no src/flow.ts is refused before anything is sent. `flowId` is
34
+ * `juno.flow_id` — the id is identity, so the CLI sends it and the server
35
+ * refuses a manifest naming another — and `baseVersion` is
36
+ * `juno.base_version`, absent for a flow that has never been deployed from
37
+ * this copy.
24
38
  */
25
39
  export async function readFlowProject(cwd) {
26
40
  const pkg = await readFlowPackage(cwd);
41
+ const files = await readSourceFiles(cwd);
42
+ if (!Object.hasOwn(files, FLOW_ENTRY)) {
43
+ throw new UserError(
44
+ `no ${FLOW_ENTRY} — a flow working copy's entry is ${FLOW_ENTRY}`
45
+ );
46
+ }
27
47
  return {
28
48
  ...(await readFlowManifest(cwd)),
29
- flowTS: await readText(cwd, FLOW_TS),
49
+ files: { ...files, ...(await readTestFiles(cwd)) },
30
50
  packageJSON: pkg,
31
51
  flowId: pkg.juno.flow_id,
32
- baseVersion: Number.isInteger(pkg.juno.base_version)
33
- ? pkg.juno.base_version
34
- : undefined,
52
+ baseVersion: baseVersionOf(pkg),
35
53
  };
36
54
  }
37
55
 
38
56
  /** readFlowManifest is manifest.json, as bytes and parsed. */
39
- export async function readFlowManifest(cwd) {
40
- const manifestRaw = await readText(cwd, MANIFEST);
41
- try {
42
- return { manifest: JSON.parse(manifestRaw), manifestRaw };
43
- } catch (err) {
44
- throw new UserError(`${MANIFEST} is not valid JSON: ${err.message}`);
45
- }
46
- }
57
+ export const readFlowManifest = readManifestFile;
47
58
 
48
- /**
49
- * readFlowPackage is package.json, refusing a directory that is not a flow
50
- * working copy — every command but `init` acts on the current directory, and
51
- * running one from the wrong place should say so rather than fail on a
52
- * missing flow.ts.
53
- */
54
- export async function readFlowPackage(cwd) {
55
- let pkg = null;
56
- try {
57
- pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
58
- } catch {
59
- // Missing or unreadable: reported below as not a working copy.
60
- }
61
- if (typeof pkg?.juno?.flow_id !== 'string' || pkg.juno.flow_id === '') {
62
- throw new UserError(
63
- `${cwd} is not a flow working copy (no juno.flow_id in package.json) — ` +
64
- 'run `juno flow init <id>` to make one'
65
- );
66
- }
67
- return pkg;
59
+ /** readFlowPackage is package.json, refusing a directory that is not a flow working copy. */
60
+ export function readFlowPackage(cwd) {
61
+ return readBoundPackage(cwd, { kind: 'flow', idKey: 'flow_id' });
68
62
  }
69
63
 
70
64
  /**
71
- * updatePackageJSON rewrites package.json through `mutate`, keeping every key
72
- * it does not touch (and their order), with a two-space indent and a trailing
73
- * newline — the way npm writes it, so the diff is only what changed.
65
+ * updatePackageJSON rewrites a flow working copy's package.json through
66
+ * `mutate`, keeping every key it does not touch (and their order).
74
67
  */
75
68
  export async function updatePackageJSON(cwd, mutate) {
76
69
  const pkg = await readFlowPackage(cwd);
77
70
  mutate(pkg);
78
- await writeFile(
79
- join(cwd, PACKAGE_JSON),
80
- `${JSON.stringify(pkg, null, 2)}\n`,
81
- 'utf8'
82
- );
71
+ await writePackageJSON(cwd, pkg);
83
72
  }
84
73
 
85
74
  /**
86
75
  * writeFlowFiles writes files by working-copy name, skipping one whose
87
- * content is already what is on disk so a watcher does not see a write that
88
- * changed nothing. Returns the names it wrote.
76
+ * content is already what is on disk. Returns the names it wrote.
89
77
  */
90
- export async function writeFlowFiles(cwd, files) {
91
- const written = [];
92
- for (const [name, content] of Object.entries(files)) {
93
- const path = join(cwd, name);
94
- let current = null;
95
- try {
96
- current = await readFile(path, 'utf8');
97
- } catch {
98
- // Not there yet.
99
- }
100
- if (current === content) continue;
101
- await writeFile(path, content, 'utf8');
102
- written.push(name);
103
- }
104
- return written.sort();
105
- }
106
-
107
- /** The canonical manifest a route answered, as manifest.json holds it. */
108
- export function manifestText(manifest) {
109
- return `${JSON.stringify(manifest, null, 2)}\n`;
110
- }
111
-
112
- /**
113
- * The .d.ts files of a route's `types`. Filtering by extension rather than
114
- * naming the two keeps a future third file working without a CLI release.
115
- */
116
- export function typeFiles(types) {
117
- return Object.fromEntries(
118
- Object.entries(types ?? {}).filter(([name]) => name.endsWith('.d.ts'))
119
- );
120
- }
121
-
122
- async function readText(cwd, name) {
123
- try {
124
- return await readFile(join(cwd, name), 'utf8');
125
- } catch {
126
- throw new UserError(`no ${name} in ${cwd}`);
127
- }
128
- }
78
+ export const writeFlowFiles = writeChangedFiles;