@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
@@ -1,91 +1,135 @@
1
- import { watch } from 'node:fs';
2
1
  import { join } from 'node:path';
3
2
 
4
- import { JunoClient } from '../api.js';
3
+ import { clientName, JunoClient } from '../api.js';
4
+ import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
5
5
  import { DiagnosticsError } from '../errors.js';
6
- import { readManifestRaw, readProject, writeTypes } from '../project.js';
6
+ import {
7
+ readManifestRaw,
8
+ readProject,
9
+ updateIntegrationPackage,
10
+ writeTypes,
11
+ } from '../project.js';
7
12
  import { resolveTarget } from '../target.js';
8
13
  import { stampTypeFiles } from '../stamp.js';
9
- import { formatDiagnostics } from '../diagnostics.js';
10
- import { pickTypeFiles } from './types.js';
14
+ import { pickTypeFiles } from './gen-types.js';
15
+ import { deployOnChange } from '../watch.js';
11
16
 
12
- /** How long to wait for edits to settle before redeploying. */
13
- const WATCH_DEBOUNCE_MS = 200;
17
+ export const DEPLOY_USAGE =
18
+ 'juno integration deploy [--watch] [--force] [--id <id>] [--json]';
14
19
 
15
20
  /**
16
- * `juno deploy [--watch]` — upload the working copy, verify, activate.
21
+ * `juno integration deploy [--watch]` — upload the working copy, verify, activate.
22
+ *
23
+ * It sends `juno.base_version`, so an install changed elsewhere since this
24
+ * copy was pulled is a 409 (exit 4) rather than a silent overwrite; --force
25
+ * sends none and replaces whatever is active. A successful deploy writes the
26
+ * new version back as the base. --id naming another install sends no base and
27
+ * writes none back: the base belongs to the install the manifest names (see
28
+ * baseFor).
17
29
  *
18
30
  * The response carries the regenerated types, so a deploy keeps the committed
19
31
  * types/ honest without a second round trip that could answer for a different
20
32
  * manifest.
33
+ *
34
+ * --json prints the route's body (or the 422's diagnostics) on stdout instead
35
+ * of the summary line, for CI.
21
36
  */
22
37
  export async function deploy(argv, io) {
23
- const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
24
- const client = argv.client ?? new JunoClient(target);
38
+ const target = await resolveTarget({
39
+ url: argv.url,
40
+ cwd: io.cwd,
41
+ env: io.env,
42
+ });
43
+ const client =
44
+ argv.client ?? new JunoClient(target, { client: clientName(io.env) });
25
45
 
26
46
  if (!argv.watch) {
27
- await deployOnce(client, argv, io, target);
47
+ try {
48
+ await deployOnce(client, argv, io, target);
49
+ } catch (err) {
50
+ if (argv.json && err instanceof DiagnosticsError) {
51
+ return printDiagnosticsJSON(err, io);
52
+ }
53
+ throw err;
54
+ }
28
55
  return 0;
29
56
  }
30
57
  return watchAndDeploy(client, argv, io, target);
31
58
  }
32
59
 
33
60
  async function deployOnce(client, argv, io, target) {
61
+ // Re-read on every deploy, base included, so the watch loop is checked
62
+ // against its own previous deploy rather than refused by it.
34
63
  const project = await readProject(io.cwd);
35
- const result = await client.ingest(project, { name: argv.name });
64
+ const result = await client.ingest(project, {
65
+ id: argv.id,
66
+ baseVersion: argv.force ? undefined : baseFor(project, argv, io),
67
+ });
36
68
  // Stamped against the manifest this deploy was built from, so the watch loop
37
- // leaves a tree that `juno verify-types` accepts.
69
+ // leaves a tree that `juno integration verify-types` accepts.
38
70
  const written = await writeTypes(
39
71
  stampTypeFiles(pickTypeFiles(result.types), await readManifestRaw(io.cwd)),
40
72
  io.cwd
41
73
  );
74
+ if (
75
+ !deploysElsewhere(project, argv) &&
76
+ project.baseVersion !== result.version
77
+ ) {
78
+ const wrote = await updateIntegrationPackage(
79
+ io.cwd,
80
+ (pkg) => {
81
+ pkg.juno.base_version = result.version;
82
+ },
83
+ io
84
+ );
85
+ if (wrote) written.push('package.json');
86
+ }
87
+ if (argv.json) {
88
+ printJSON(result, io);
89
+ return result;
90
+ }
42
91
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
43
92
  io.log(
44
- `deployed ${result.name} v${result.version} to ${target} (${written.join(', ')})`
93
+ `deployed ${result.id} v${result.version} to ${target} (${written.sort().join(', ')})`
45
94
  );
46
95
  return result;
47
96
  }
48
97
 
49
98
  /**
50
- * Watch mode is the loop the whole design is for: edit the manifest, the types
51
- * regenerate, and the editor red-squiggles the method you have not written yet.
52
- * A failed deploy prints and keeps watching — the next save is the retry.
99
+ * baseFor is the base a check or deploy sends: the copy's
100
+ * `juno.base_version`, unless --id names an install other than the
101
+ * manifest's. That install's versions are ones this copy has never seen, so
102
+ * there is nothing to compare against, and the warning says the check is off
103
+ * rather than letting it pass silently. A first deploy to a new id has nothing
104
+ * to be stale against either, so this needs no --force.
53
105
  */
54
- async function watchAndDeploy(client, argv, io, target) {
55
- io.log(`watching ${io.cwd} — deploying to ${target}`);
56
- await runWatchIteration(client, argv, io, target);
57
-
58
- let timer = null;
59
- const schedule = () => {
60
- if (timer) clearTimeout(timer);
61
- timer = setTimeout(() => {
62
- timer = null;
63
- void runWatchIteration(client, argv, io, target);
64
- }, WATCH_DEBOUNCE_MS);
65
- };
106
+ export function baseFor(project, argv, io) {
107
+ if (deploysElsewhere(project, argv)) {
108
+ io.warn(
109
+ `no stale-base check: deploying to ${argv.id}, not ${project.manifest?.id}`
110
+ );
111
+ return undefined;
112
+ }
113
+ return project.baseVersion;
114
+ }
66
115
 
67
- const watchers = [
68
- watch(join(io.cwd, 'src'), { recursive: true }, schedule),
69
- watch(join(io.cwd, 'manifest.json'), schedule),
70
- ];
71
- // Resolves only on SIGINT: watch mode ends when the author ends it.
72
- await new Promise((resolve) => {
73
- process.once('SIGINT', () => {
74
- for (const w of watchers) w.close();
75
- resolve();
76
- });
77
- });
78
- return 0;
116
+ /** Whether --id names an install other than the one the manifest does. */
117
+ function deploysElsewhere(project, argv) {
118
+ return argv.id !== undefined && argv.id !== project.manifest?.id;
79
119
  }
80
120
 
81
- async function runWatchIteration(client, argv, io, target) {
82
- try {
83
- await deployOnce(client, argv, io, target);
84
- } catch (err) {
85
- if (err instanceof DiagnosticsError) {
86
- io.error(formatDiagnostics(err.diagnostics));
87
- } else {
88
- io.error(err.message);
89
- }
90
- }
121
+ /**
122
+ * Watch mode is the loop the whole design is for: edit the manifest, the types
123
+ * regenerate, and the editor red-squiggles the method you have not written yet.
124
+ */
125
+ function watchAndDeploy(client, argv, io, target) {
126
+ return deployOnChange({
127
+ io,
128
+ target,
129
+ paths: [
130
+ { path: join(io.cwd, 'src'), recursive: true },
131
+ { path: join(io.cwd, 'manifest.json') },
132
+ ],
133
+ once: () => deployOnce(client, argv, io, target),
134
+ });
91
135
  }
@@ -0,0 +1,75 @@
1
+ import { connect } from '../api.js';
2
+ import { DiagnosticsError } from '../errors.js';
3
+ import { readFlowProject } from '../flow-project.js';
4
+ import { runLocalChecks } from './check.js';
5
+ import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
6
+ import { printWarnings } from './flow-deploy.js';
7
+
8
+ export const FLOW_CHECK_USAGE = 'juno flow check [--skip-local] [--json]';
9
+
10
+ /**
11
+ * `juno flow check` — local tsc and eslint, then a dry-run ingest: the same
12
+ * verification deploy runs (and the base_version check, so a stale copy is
13
+ * reported here before deploy hits it), with nothing committed. Safe to point
14
+ * at a production instance.
15
+ */
16
+ export async function flowCheck(argv, io) {
17
+ const project = await readFlowProject(io.cwd);
18
+ const failed = !argv['skip-local'] && (await runLocalChecks(argv, io));
19
+ warnOnDependencyDrift(project, io);
20
+
21
+ const { target, client } = await connect(argv, io);
22
+ let result;
23
+ try {
24
+ result = await client.ingestFlow(
25
+ {
26
+ flowId: project.flowId,
27
+ manifest: project.manifest,
28
+ files: project.files,
29
+ baseVersion: project.baseVersion,
30
+ },
31
+ { dryRun: true }
32
+ );
33
+ } catch (err) {
34
+ if (argv.json && err instanceof DiagnosticsError) {
35
+ return printDiagnosticsJSON(err, io);
36
+ }
37
+ throw err;
38
+ }
39
+
40
+ if (argv.json) {
41
+ printJSON(result, io);
42
+ } else {
43
+ printWarnings(result.warnings, io);
44
+ io.log(`${result.flow_id}: manifest and src/ verified against ${target}`);
45
+ }
46
+ return failed ? 1 : 0;
47
+ }
48
+
49
+ /**
50
+ * manifest.dependencies is what verify installs; package.json's dependencies
51
+ * are only there so a local `npm install` gives tsc the same modules. When the
52
+ * two disagree, local and server-side checks are checking different programs,
53
+ * so say which packages differ — and that the manifest is the one to edit.
54
+ */
55
+ export function warnOnDependencyDrift(project, io) {
56
+ const declared = project.manifest?.dependencies ?? {};
57
+ const installed = project.packageJSON?.dependencies ?? {};
58
+ const names = [
59
+ ...new Set([...Object.keys(declared), ...Object.keys(installed)]),
60
+ ].sort();
61
+ const diffs = [];
62
+ for (const name of names) {
63
+ const a = declared[name];
64
+ const b = installed[name];
65
+ if (a === b) continue;
66
+ if (b === undefined) diffs.push(`${name} (only in manifest.json)`);
67
+ else if (a === undefined) diffs.push(`${name} (only in package.json)`);
68
+ else diffs.push(`${name} (manifest.json ${a}, package.json ${b})`);
69
+ }
70
+ if (diffs.length === 0) return;
71
+ io.warn(
72
+ `warning: manifest.json and package.json list different dependencies: ${diffs.join(', ')}. ` +
73
+ "Juno installs manifest.json's; add packages there, then copy them to package.json (or run `npm run pull` after deploying)."
74
+ );
75
+ }
@@ -0,0 +1,110 @@
1
+ import { join } from 'node:path';
2
+
3
+ import { connect } from '../api.js';
4
+ import { DiagnosticsError } from '../errors.js';
5
+ import {
6
+ formatDiagnostic,
7
+ printDiagnosticsJSON,
8
+ printJSON,
9
+ } from '../diagnostics.js';
10
+ import {
11
+ MANIFEST,
12
+ manifestText,
13
+ readFlowProject,
14
+ SRC_DIR,
15
+ typeFiles,
16
+ updatePackageJSON,
17
+ writeFlowFiles,
18
+ } from '../flow-project.js';
19
+ import { deployOnChange } from '../watch.js';
20
+
21
+ export const FLOW_DEPLOY_USAGE =
22
+ 'juno flow deploy [--watch] [--force] [--json]';
23
+
24
+ /**
25
+ * `juno flow deploy` — upload manifest.json and src/ to
26
+ * POST /api/flows/ingest, which verifies and commits them through the same
27
+ * core the in-app assistant's submit_code uses, and activates the version.
28
+ *
29
+ * It sends `juno.base_version`, so a flow edited elsewhere since this copy was
30
+ * pulled is a 409 (exit 4) rather than a silent overwrite; --force sends none
31
+ * and replaces whatever is active. On success it writes back what the server
32
+ * now holds: the canonical manifest, both files under types/, and the new
33
+ * base_version. --watch redeploys on every save to src/ or manifest.json.
34
+ */
35
+ export async function flowDeploy(argv, io) {
36
+ const { target, client } = await connect(argv, io);
37
+ if (!argv.watch) {
38
+ return deployOnce(client, argv, io, target);
39
+ }
40
+
41
+ // A deploy writes manifest.json back, which the watcher sees. Remembering
42
+ // what was last deployed makes that echo — and any save that changed
43
+ // nothing — a no-op instead of another version.
44
+ let last = null;
45
+ return deployOnChange({
46
+ io,
47
+ target,
48
+ paths: [
49
+ { path: join(io.cwd, SRC_DIR), recursive: true },
50
+ { path: join(io.cwd, MANIFEST) },
51
+ ],
52
+ once: async () => {
53
+ const project = await readFlowProject(io.cwd);
54
+ const key = JSON.stringify([project.manifestRaw, project.files]);
55
+ if (key === last) return;
56
+ const after = await deployProject(client, project, argv, io, target);
57
+ last = JSON.stringify([after.manifestRaw, project.files]);
58
+ },
59
+ });
60
+ }
61
+
62
+ async function deployOnce(client, argv, io, target) {
63
+ const project = await readFlowProject(io.cwd);
64
+ try {
65
+ await deployProject(client, project, argv, io, target);
66
+ } catch (err) {
67
+ if (argv.json && err instanceof DiagnosticsError) {
68
+ return printDiagnosticsJSON(err, io);
69
+ }
70
+ throw err;
71
+ }
72
+ return 0;
73
+ }
74
+
75
+ async function deployProject(client, project, argv, io, target) {
76
+ const result = await client.ingestFlow({
77
+ flowId: project.flowId,
78
+ manifest: project.manifest,
79
+ files: project.files,
80
+ baseVersion: argv.force ? undefined : project.baseVersion,
81
+ });
82
+
83
+ const manifestRaw = manifestText(result.manifest);
84
+ const written = await writeFlowFiles(io.cwd, {
85
+ [MANIFEST]: manifestRaw,
86
+ ...typeFiles(result.types),
87
+ });
88
+ if (project.baseVersion !== result.version) {
89
+ await updatePackageJSON(io.cwd, (pkg) => {
90
+ pkg.juno.base_version = result.version;
91
+ });
92
+ written.push('package.json');
93
+ }
94
+
95
+ if (argv.json) {
96
+ printJSON(result, io);
97
+ } else {
98
+ printWarnings(result.warnings, io);
99
+ const files = written.length ? ` (${written.sort().join(', ')})` : '';
100
+ io.log(
101
+ `deployed ${result.flow_id} v${result.version} to ${target}${files}`
102
+ );
103
+ }
104
+ return { manifestRaw };
105
+ }
106
+
107
+ /** A route's warnings, each as a diagnostic line with a `warning: ` prefix. */
108
+ export function printWarnings(warnings, io) {
109
+ for (const w of warnings ?? []) io.warn(`warning: ${formatDiagnostic(w)}`);
110
+ }
@@ -0,0 +1,67 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { connect } from '../api.js';
5
+ import { UsageError, UserError } from '../errors.js';
6
+ import { readFlowPackage } from '../flow-project.js';
7
+ import { baseVersionOf } from '../working-copy.js';
8
+ import { exists } from './init.js';
9
+
10
+ export const FLOW_FIXTURE_USAGE = 'juno flow fixture --run <seq> [--force]';
11
+
12
+ export const FIXTURES_DIR = 'test/fixtures';
13
+
14
+ /**
15
+ * `juno flow fixture --run <seq>` — write run `seq` of this working copy's
16
+ * flow as a flowHarness scenario, `test/fixtures/<flow-id>-<seq>.json`, for a
17
+ * test to replay with `h.scenario(fixture)`.
18
+ *
19
+ * The server builds the fixture (GET /api/flows/{id}/runs/{seq}/fixture) and
20
+ * the CLI writes what it is handed, as `init` does a scaffold. What the run
21
+ * did not record comes back as notes, one per line on stderr: each is
22
+ * something the author checks or fills in before the fixture replays.
23
+ *
24
+ * The run is a flag, not a positional, because a working-copy command takes
25
+ * none except `init`. It writes only a local file, so its effect is `read`.
26
+ */
27
+ export async function flowFixture(argv, io) {
28
+ const seq = parseSeq(argv.run);
29
+ const pkg = await readFlowPackage(io.cwd);
30
+ const flowId = pkg.juno.flow_id;
31
+
32
+ const rel = `${FIXTURES_DIR}/${flowId}-${seq}.json`;
33
+ const path = join(io.cwd, rel);
34
+ // Refused before anything is fetched: an existing fixture may be one the
35
+ // author has edited (a credential filled in, a state corrected).
36
+ if (!argv.force && (await exists(path))) {
37
+ throw new UserError(`${rel} already exists; pass --force to overwrite it`);
38
+ }
39
+
40
+ const { client } = await connect(argv, io);
41
+ const { fixture, notes } = await client.runFixture(flowId, seq);
42
+
43
+ await mkdir(join(io.cwd, FIXTURES_DIR), { recursive: true });
44
+ await writeFile(path, `${JSON.stringify(fixture, null, 2)}\n`, 'utf8');
45
+
46
+ io.log(`wrote ${rel}`);
47
+ const base = baseVersionOf(pkg);
48
+ if (
49
+ base !== undefined &&
50
+ Number.isInteger(fixture?.flow_version) &&
51
+ fixture.flow_version !== base
52
+ ) {
53
+ io.warn(
54
+ `run ${seq} ran v${fixture.flow_version}; this working copy is at v${base} — the fixture may not replay`
55
+ );
56
+ }
57
+ for (const note of notes ?? []) io.warn(`note: ${note}`);
58
+ return 0;
59
+ }
60
+
61
+ /** parseSeq is `--run`'s value as a run number, or a usage error. */
62
+ function parseSeq(value) {
63
+ if (value === undefined || !/^[1-9][0-9]*$/.test(value)) {
64
+ throw new UsageError(`usage: ${FLOW_FIXTURE_USAGE}`);
65
+ }
66
+ return Number(value);
67
+ }
@@ -0,0 +1,37 @@
1
+ import { connect } from '../api.js';
2
+ import { UserError } from '../errors.js';
3
+ import {
4
+ readFlowManifest,
5
+ readFlowPackage,
6
+ typeFiles,
7
+ writeFlowFiles,
8
+ } from '../flow-project.js';
9
+
10
+ export const FLOW_GEN_TYPES_USAGE = 'juno flow gen-types';
11
+
12
+ /**
13
+ * `juno flow gen-types` — regenerate types/flow-env.d.ts and
14
+ * types/flow-api.d.ts from manifest.json against the instance's live catalog.
15
+ * manifest.json itself is left alone: this refreshes what the editor
16
+ * type-checks against, nothing more. Stale types cannot ship regardless,
17
+ * because ingest regenerates them.
18
+ */
19
+ export async function flowGenTypes(argv, io) {
20
+ await readFlowPackage(io.cwd);
21
+ const { manifest } = await readFlowManifest(io.cwd);
22
+ const { client } = await connect(argv, io);
23
+ const generated = await client.typegenFlow(manifest);
24
+ const files = typeFiles(generated.types);
25
+ if (!Object.keys(files).length) {
26
+ throw new UserError(
27
+ `${client.target} answered no .d.ts files for this manifest`
28
+ );
29
+ }
30
+ const written = await writeFlowFiles(io.cwd, files);
31
+ io.log(
32
+ written.length
33
+ ? `wrote ${written.join(', ')}`
34
+ : `${Object.keys(files).sort().join(', ')} already up to date`
35
+ );
36
+ return 0;
37
+ }
@@ -0,0 +1,59 @@
1
+ import { mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+
4
+ import { connect } from '../api.js';
5
+ import { printAuthoringIndex } from '../authoring-skills.js';
6
+ import { UsageError, UserError } from '../errors.js';
7
+ import { exists, safeRelative } from './init.js';
8
+
9
+ export const FLOW_INIT_USAGE = 'juno flow init <id> [--here]';
10
+
11
+ /**
12
+ * `juno flow init <id>` — a working copy of the flow's active version, or a
13
+ * stub for an id with none, written into ./<id> (or the current directory
14
+ * with --here).
15
+ *
16
+ * Every file comes from POST /api/flows/scaffold, the single generator of a
17
+ * flow working copy: the CLI writes what it is handed and holds no copy of
18
+ * the layout, the script lines or the types. After the next steps it prints
19
+ * the flow author's skill index, which is where an external agent starts.
20
+ */
21
+ export async function flowInit(argv, io) {
22
+ const [id, ...extra] = argv._;
23
+ if (!id || extra.length > 0) {
24
+ throw new UsageError(`usage: ${FLOW_INIT_USAGE}`);
25
+ }
26
+
27
+ const dir = resolve(io.cwd, argv.here ? '.' : id);
28
+ if (!argv.here && (await exists(dir))) {
29
+ throw new UserError(`${dir} already exists`);
30
+ }
31
+
32
+ const { target, client } = await connect(argv, io);
33
+ // The id is validated server-side: one answer to "is this a valid flow id".
34
+ const { files, warnings } = await client.scaffoldFlow(id, { target });
35
+
36
+ for (const [rel, content] of Object.entries(files)) {
37
+ const path = join(dir, safeRelative(rel));
38
+ await mkdir(dirname(path), { recursive: true });
39
+ await writeFile(path, content, 'utf8');
40
+ }
41
+ printScaffoldWarnings(warnings, io);
42
+
43
+ io.log(`created ${dir}`);
44
+ io.log('');
45
+ io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
46
+ io.log('npm test');
47
+ io.log('npm run deploy');
48
+ await printAuthoringIndex('flow', client, target, io);
49
+ return 0;
50
+ }
51
+
52
+ /**
53
+ * A stored manifest the live catalog now refuses (a state it binds was
54
+ * deleted, say) still scaffolds, because the working copy is how it gets
55
+ * fixed; the refusal comes along as warnings, and they go to stderr.
56
+ */
57
+ export function printScaffoldWarnings(warnings, io) {
58
+ for (const w of warnings ?? []) io.warn(`warning: ${w}`);
59
+ }
@@ -0,0 +1,115 @@
1
+ import { connect } from '../api.js';
2
+ import { UserError } from '../errors.js';
3
+ import {
4
+ MANIFEST,
5
+ manifestText,
6
+ PACKAGE_JSON,
7
+ readFlowPackage,
8
+ updatePackageJSON,
9
+ writeFlowFiles,
10
+ } from '../flow-project.js';
11
+ import { printScaffoldWarnings } from './flow-init.js';
12
+ import {
13
+ baseArtifactPaths,
14
+ baseVersionOf,
15
+ pullArtifacts,
16
+ requireCleanTree,
17
+ setDependencies,
18
+ } from '../working-copy.js';
19
+
20
+ export const FLOW_PULL_USAGE = 'juno flow pull [--force]';
21
+
22
+ /**
23
+ * `juno flow pull` — make the working copy's artifact files the flow's active
24
+ * version: manifest.json and every file under src/ and test/, exactly as the
25
+ * version stores them (GET /api/flows/{id}/versions/{v}), never as the
26
+ * scaffold renders them. types/ is generated, so it comes from
27
+ * POST /api/flows/scaffold, which also names the active version. In
28
+ * package.json only `dependencies` (the manifest's) and `juno.base_version`
29
+ * are Juno's; `scripts` and every other key are the author's and stay.
30
+ *
31
+ * A local file is deleted only when the base version (`juno.base_version`)
32
+ * had it and the pulled one does not; a file neither has is one not deployed
33
+ * yet, and stays. Project files (tsconfig.json, README.md …) are never
34
+ * written or deleted.
35
+ *
36
+ * It refuses a dirty git tree without --force, as integration pull does: an
37
+ * uncommitted edit is the one thing nothing can get back.
38
+ */
39
+ export async function flowPull(argv, io) {
40
+ const pkg = await readFlowPackage(io.cwd);
41
+ const flowId = pkg.juno.flow_id;
42
+
43
+ await requireCleanTree(io.cwd, {
44
+ force: argv.force,
45
+ overwrites: `${MANIFEST}, src/, test/ and types/`,
46
+ io,
47
+ });
48
+
49
+ const { target, client } = await connect(argv, io);
50
+ const { files, warnings } = await client.scaffoldFlow(flowId, { target });
51
+ let scaffoldPkg;
52
+ try {
53
+ scaffoldPkg = JSON.parse(files[PACKAGE_JSON]);
54
+ } catch {
55
+ throw new UserError(
56
+ `${target} answered a working copy with no readable ${PACKAGE_JSON}`
57
+ );
58
+ }
59
+ const version = scaffoldPkg.juno?.base_version;
60
+ const noActive = () =>
61
+ new UserError(
62
+ `${flowId} has no active version on ${target} — nothing to pull`
63
+ );
64
+ // No base_version is the scaffold's stub for a flow with no active
65
+ // version; writing it over the author's work would lose it for nothing.
66
+ if (!Number.isInteger(version)) throw noActive();
67
+
68
+ // Everything is fetched before anything is written, so a failure leaves
69
+ // the working copy as it was.
70
+ const detail = await client.getFlowVersion(flowId, version);
71
+ if (!detail) throw noActive();
72
+ const manifest = JSON.parse(detail.manifest_json);
73
+ const stored = {
74
+ [MANIFEST]: manifestText(manifest),
75
+ ...detail.source_files,
76
+ };
77
+ const base = await baseArtifactPaths({
78
+ base: baseVersionOf(pkg),
79
+ version,
80
+ stored,
81
+ read: async (v) => {
82
+ const b = await client.getFlowVersion(flowId, v);
83
+ return b ? [MANIFEST, ...Object.keys(b.source_files ?? {})] : null;
84
+ },
85
+ id: flowId,
86
+ target,
87
+ io,
88
+ });
89
+
90
+ const { written, removed } = await pullArtifacts(io.cwd, {
91
+ stored,
92
+ base,
93
+ kind: 'flow',
94
+ });
95
+ written.push(
96
+ ...(await writeFlowFiles(
97
+ io.cwd,
98
+ Object.fromEntries(
99
+ Object.entries(files).filter(([name]) => name.startsWith('types/'))
100
+ )
101
+ ))
102
+ );
103
+ await updatePackageJSON(io.cwd, (p) => {
104
+ setDependencies(p, manifest);
105
+ p.juno.base_version = version;
106
+ });
107
+ written.push(PACKAGE_JSON);
108
+ printScaffoldWarnings(warnings, io);
109
+
110
+ io.log(
111
+ `pulled ${flowId} v${version} from ${target} (${written.sort().join(', ')})`
112
+ );
113
+ if (removed.length) io.log(`removed ${removed.join(', ')}`);
114
+ return 0;
115
+ }
@@ -1,21 +1,28 @@
1
- import { JunoClient } from '../api.js';
1
+ import { clientName, JunoClient } from '../api.js';
2
2
  import { readManifest, readManifestRaw, writeTypes } from '../project.js';
3
3
  import { stampTypeFiles } from '../stamp.js';
4
4
  import { resolveTarget } from '../target.js';
5
5
 
6
+ export const GEN_TYPES_USAGE = 'juno integration gen-types';
7
+
6
8
  /**
7
- * `juno types` — POST manifest.json, write back types/.
9
+ * `juno integration gen-types` — POST manifest.json, write back types/.
8
10
  *
9
11
  * Both halves come from one call so they cannot skew, and they are committed:
10
12
  * a fresh clone compiles offline, and a contributor fixing a bug in someone
11
13
  * else's integration never needs a Juno instance. Only the person editing the
12
14
  * manifest does, which is the right person to require it of.
13
15
  */
14
- export async function types(argv, io) {
15
- const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
16
+ export async function genTypes(argv, io) {
17
+ const target = await resolveTarget({
18
+ url: argv.url,
19
+ cwd: io.cwd,
20
+ env: io.env,
21
+ });
16
22
  const raw = await readManifestRaw(io.cwd);
17
23
  const manifest = await readManifest(io.cwd);
18
- const client = argv.client ?? new JunoClient(target);
24
+ const client =
25
+ argv.client ?? new JunoClient(target, { client: clientName(io.env) });
19
26
 
20
27
  const generated = await client.typegen(manifest);
21
28
  const written = await writeTypes(