@junoflow/cli 0.6.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.
@@ -6,6 +6,7 @@ import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
6
6
  import { DiagnosticsError, UserError } from '../errors.js';
7
7
  import { readProject } from '../project.js';
8
8
  import { resolveTarget } from '../target.js';
9
+ import { baseFor } from './deploy.js';
9
10
  import { verifyTypes } from './verify-types.js';
10
11
 
11
12
  export const CHECK_USAGE =
@@ -17,7 +18,8 @@ export const CHECK_USAGE =
17
18
  * tsc and eslint run locally against the committed types; reserved names,
18
19
  * cross-surface collisions and JSON Schema compilation need the server, so
19
20
  * they go through ingest?dry_run=1, which stores nothing and is safe to point
20
- * at a production instance.
21
+ * at a production instance. It sends `juno.base_version` like deploy does, so
22
+ * a stale copy is a 409 (exit 4) here, before deploy hits it.
21
23
  *
22
24
  * --json prints the route's body (or the 422's diagnostics) on stdout and
23
25
  * nothing else; the local tools' own output goes to stderr.
@@ -36,19 +38,7 @@ export async function check(argv, io) {
36
38
  io.error(err.message);
37
39
  failed = true;
38
40
  }
39
- for (const [label, bin, args] of [
40
- ['typecheck', 'tsc', ['--noEmit']],
41
- ['lint', 'eslint', ['.']],
42
- ]) {
43
- const code = await runLocal(bin, args, io, {
44
- quietStdout: argv.json,
45
- });
46
- if (code === SKIPPED) {
47
- io.warn(`${label}: ${bin} is not installed — run npm install`);
48
- continue;
49
- }
50
- if (code !== 0) failed = true;
51
- }
41
+ if (await runLocalChecks(argv, io)) failed = true;
52
42
  }
53
43
 
54
44
  const target = await resolveTarget({
@@ -61,7 +51,11 @@ export async function check(argv, io) {
61
51
  const project = await readProject(io.cwd);
62
52
  let result;
63
53
  try {
64
- result = await client.ingest(project, { dryRun: true, id: argv.id });
54
+ result = await client.ingest(project, {
55
+ dryRun: true,
56
+ id: argv.id,
57
+ baseVersion: baseFor(project, argv, io),
58
+ });
65
59
  } catch (err) {
66
60
  if (argv.json && err instanceof DiagnosticsError) {
67
61
  return printDiagnosticsJSON(err, io);
@@ -81,6 +75,28 @@ export async function check(argv, io) {
81
75
 
82
76
  export const SKIPPED = -1;
83
77
 
78
+ /**
79
+ * runLocalChecks runs tsc and eslint from the working copy's node_modules,
80
+ * the offline half of every kind's `check`, and answers whether either
81
+ * failed. A tool that is not installed is a warning, not a failure: the
82
+ * server-side half still runs.
83
+ */
84
+ export async function runLocalChecks(argv, io) {
85
+ let failed = false;
86
+ for (const [label, bin, args] of [
87
+ ['typecheck', 'tsc', ['--noEmit']],
88
+ ['lint', 'eslint', ['.']],
89
+ ]) {
90
+ const code = await runLocal(bin, args, io, { quietStdout: argv.json });
91
+ if (code === SKIPPED) {
92
+ io.warn(`${label}: ${bin} is not installed — run npm install`);
93
+ continue;
94
+ }
95
+ if (code !== 0) failed = true;
96
+ }
97
+ return failed;
98
+ }
99
+
84
100
  /**
85
101
  * Runs a local devtool from node_modules/.bin. Its output goes straight to the
86
102
  * terminal: tsc and eslint already print file:line:col, and reformatting them
@@ -3,18 +3,30 @@ import { join } from 'node:path';
3
3
  import { clientName, JunoClient } from '../api.js';
4
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
14
  import { pickTypeFiles } from './gen-types.js';
10
15
  import { deployOnChange } from '../watch.js';
11
16
 
12
17
  export const DEPLOY_USAGE =
13
- 'juno integration deploy [--watch] [--id <id>] [--json]';
18
+ 'juno integration deploy [--watch] [--force] [--id <id>] [--json]';
14
19
 
15
20
  /**
16
21
  * `juno integration deploy [--watch]` — upload the working copy, verify, activate.
17
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).
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.
@@ -46,25 +58,66 @@ export async function deploy(argv, io) {
46
58
  }
47
59
 
48
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.
49
63
  const project = await readProject(io.cwd);
50
- const result = await client.ingest(project, { id: argv.id });
64
+ const result = await client.ingest(project, {
65
+ id: argv.id,
66
+ baseVersion: argv.force ? undefined : baseFor(project, argv, io),
67
+ });
51
68
  // Stamped against the manifest this deploy was built from, so the watch loop
52
69
  // leaves a tree that `juno integration verify-types` accepts.
53
70
  const written = await writeTypes(
54
71
  stampTypeFiles(pickTypeFiles(result.types), await readManifestRaw(io.cwd)),
55
72
  io.cwd
56
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
+ }
57
87
  if (argv.json) {
58
88
  printJSON(result, io);
59
89
  return result;
60
90
  }
61
91
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
62
92
  io.log(
63
- `deployed ${result.id} v${result.version} to ${target} (${written.join(', ')})`
93
+ `deployed ${result.id} v${result.version} to ${target} (${written.sort().join(', ')})`
64
94
  );
65
95
  return result;
66
96
  }
67
97
 
98
+ /**
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.
105
+ */
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
+ }
115
+
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;
119
+ }
120
+
68
121
  /**
69
122
  * Watch mode is the loop the whole design is for: edit the manifest, the types
70
123
  * regenerate, and the editor red-squiggles the method you have not written yet.
@@ -1,7 +1,7 @@
1
1
  import { connect } from '../api.js';
2
2
  import { DiagnosticsError } from '../errors.js';
3
3
  import { readFlowProject } from '../flow-project.js';
4
- import { runLocal, SKIPPED } from './check.js';
4
+ import { runLocalChecks } from './check.js';
5
5
  import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
6
6
  import { printWarnings } from './flow-deploy.js';
7
7
 
@@ -15,23 +15,7 @@ export const FLOW_CHECK_USAGE = 'juno flow check [--skip-local] [--json]';
15
15
  */
16
16
  export async function flowCheck(argv, io) {
17
17
  const project = await readFlowProject(io.cwd);
18
- let failed = false;
19
-
20
- if (!argv['skip-local']) {
21
- for (const [label, bin, args] of [
22
- ['typecheck', 'tsc', ['--noEmit']],
23
- ['lint', 'eslint', ['.']],
24
- ]) {
25
- const code = await runLocal(bin, args, io, {
26
- quietStdout: argv.json,
27
- });
28
- if (code === SKIPPED) {
29
- io.warn(`${label}: ${bin} is not installed — run npm install`);
30
- continue;
31
- }
32
- if (code !== 0) failed = true;
33
- }
34
- }
18
+ const failed = !argv['skip-local'] && (await runLocalChecks(argv, io));
35
19
  warnOnDependencyDrift(project, io);
36
20
 
37
21
  const { target, client } = await connect(argv, io);
@@ -41,7 +25,7 @@ export async function flowCheck(argv, io) {
41
25
  {
42
26
  flowId: project.flowId,
43
27
  manifest: project.manifest,
44
- flowTS: project.flowTS,
28
+ files: project.files,
45
29
  baseVersion: project.baseVersion,
46
30
  },
47
31
  { dryRun: true }
@@ -57,9 +41,7 @@ export async function flowCheck(argv, io) {
57
41
  printJSON(result, io);
58
42
  } else {
59
43
  printWarnings(result.warnings, io);
60
- io.log(
61
- `${result.flow_id}: manifest and flow.ts verified against ${target}`
62
- );
44
+ io.log(`${result.flow_id}: manifest and src/ verified against ${target}`);
63
45
  }
64
46
  return failed ? 1 : 0;
65
47
  }
@@ -8,10 +8,10 @@ import {
8
8
  printJSON,
9
9
  } from '../diagnostics.js';
10
10
  import {
11
- FLOW_TS,
12
11
  MANIFEST,
13
12
  manifestText,
14
13
  readFlowProject,
14
+ SRC_DIR,
15
15
  typeFiles,
16
16
  updatePackageJSON,
17
17
  writeFlowFiles,
@@ -22,15 +22,15 @@ export const FLOW_DEPLOY_USAGE =
22
22
  'juno flow deploy [--watch] [--force] [--json]';
23
23
 
24
24
  /**
25
- * `juno flow deploy` — upload manifest.json and flow.ts to
25
+ * `juno flow deploy` — upload manifest.json and src/ to
26
26
  * POST /api/flows/ingest, which verifies and commits them through the same
27
27
  * core the in-app assistant's submit_code uses, and activates the version.
28
28
  *
29
29
  * It sends `juno.base_version`, so a flow edited elsewhere since this copy was
30
30
  * pulled is a 409 (exit 4) rather than a silent overwrite; --force sends none
31
31
  * and replaces whatever is active. On success it writes back what the server
32
- * now holds: the canonical manifest, both .d.ts files, and the new
33
- * base_version.
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
34
  */
35
35
  export async function flowDeploy(argv, io) {
36
36
  const { target, client } = await connect(argv, io);
@@ -45,13 +45,16 @@ export async function flowDeploy(argv, io) {
45
45
  return deployOnChange({
46
46
  io,
47
47
  target,
48
- paths: [{ path: join(io.cwd, FLOW_TS) }, { path: join(io.cwd, MANIFEST) }],
48
+ paths: [
49
+ { path: join(io.cwd, SRC_DIR), recursive: true },
50
+ { path: join(io.cwd, MANIFEST) },
51
+ ],
49
52
  once: async () => {
50
53
  const project = await readFlowProject(io.cwd);
51
- const key = JSON.stringify([project.manifestRaw, project.flowTS]);
54
+ const key = JSON.stringify([project.manifestRaw, project.files]);
52
55
  if (key === last) return;
53
56
  const after = await deployProject(client, project, argv, io, target);
54
- last = JSON.stringify([after.manifestRaw, project.flowTS]);
57
+ last = JSON.stringify([after.manifestRaw, project.files]);
55
58
  },
56
59
  });
57
60
  }
@@ -73,7 +76,7 @@ async function deployProject(client, project, argv, io, target) {
73
76
  const result = await client.ingestFlow({
74
77
  flowId: project.flowId,
75
78
  manifest: project.manifest,
76
- flowTS: project.flowTS,
79
+ files: project.files,
77
80
  baseVersion: argv.force ? undefined : project.baseVersion,
78
81
  });
79
82
 
@@ -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
+ }
@@ -1,4 +1,5 @@
1
1
  import { connect } from '../api.js';
2
+ import { UserError } from '../errors.js';
2
3
  import {
3
4
  readFlowManifest,
4
5
  readFlowPackage,
@@ -9,17 +10,23 @@ import {
9
10
  export const FLOW_GEN_TYPES_USAGE = 'juno flow gen-types';
10
11
 
11
12
  /**
12
- * `juno flow gen-types` — regenerate flow-env.d.ts and flow-api.d.ts from
13
- * manifest.json against the instance's live catalog. manifest.json itself is
14
- * left alone: this refreshes what the editor type-checks against, nothing
15
- * more. Stale types cannot ship regardless, because ingest regenerates them.
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.
16
18
  */
17
19
  export async function flowGenTypes(argv, io) {
18
20
  await readFlowPackage(io.cwd);
19
21
  const { manifest } = await readFlowManifest(io.cwd);
20
22
  const { client } = await connect(argv, io);
21
23
  const generated = await client.typegenFlow(manifest);
22
- const files = typeFiles(generated?.types);
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
+ }
23
30
  const written = await writeFlowFiles(io.cwd, files);
24
31
  io.log(
25
32
  written.length
@@ -1,46 +1,50 @@
1
1
  import { connect } from '../api.js';
2
2
  import { UserError } from '../errors.js';
3
3
  import {
4
- FLOW_TS,
5
4
  MANIFEST,
5
+ manifestText,
6
6
  PACKAGE_JSON,
7
7
  readFlowPackage,
8
8
  updatePackageJSON,
9
9
  writeFlowFiles,
10
10
  } from '../flow-project.js';
11
11
  import { printScaffoldWarnings } from './flow-init.js';
12
- import { dirtyPaths } from './pull.js';
12
+ import {
13
+ baseArtifactPaths,
14
+ baseVersionOf,
15
+ pullArtifacts,
16
+ requireCleanTree,
17
+ setDependencies,
18
+ } from '../working-copy.js';
13
19
 
14
20
  export const FLOW_PULL_USAGE = 'juno flow pull [--force]';
15
21
 
16
22
  /**
17
- * `juno flow pull` — overwrite the working copy with the flow's active
18
- * version: manifest.json, flow.ts and both .d.ts files, plus package.json's
19
- * `scripts`, `dependencies` and `juno.base_version` — every other key in
20
- * package.json is the author's and stays. Everything comes from
21
- * POST /api/flows/scaffold, the same render `init` writes, so a pulled copy
22
- * and a fresh one cannot differ.
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.
23
35
  *
24
36
  * It refuses a dirty git tree without --force, as integration pull does: an
25
37
  * uncommitted edit is the one thing nothing can get back.
26
38
  */
27
39
  export async function flowPull(argv, io) {
28
- const { juno } = await readFlowPackage(io.cwd);
29
- const flowId = juno.flow_id;
40
+ const pkg = await readFlowPackage(io.cwd);
41
+ const flowId = pkg.juno.flow_id;
30
42
 
31
- if (!argv.force) {
32
- const dirty = await dirtyPaths(io.cwd);
33
- if (dirty === null) {
34
- io.warn(
35
- `not a git repository — pull will overwrite ${MANIFEST} and ${FLOW_TS} with no undo`
36
- );
37
- } else if (dirty.length > 0) {
38
- throw new UserError(
39
- `working tree has uncommitted changes:\n${dirty.map((p) => ` ${p}`).join('\n')}\n` +
40
- 'Commit or stash them, or pass --force to overwrite.'
41
- );
42
- }
43
- }
43
+ await requireCleanTree(io.cwd, {
44
+ force: argv.force,
45
+ overwrites: `${MANIFEST}, src/, test/ and types/`,
46
+ io,
47
+ });
44
48
 
45
49
  const { target, client } = await connect(argv, io);
46
50
  const { files, warnings } = await client.scaffoldFlow(flowId, { target });
@@ -53,28 +57,52 @@ export async function flowPull(argv, io) {
53
57
  );
54
58
  }
55
59
  const version = scaffoldPkg.juno?.base_version;
56
- // No base_version is the scaffold's stub for a flow with no active
57
- // version; writing it over the author's work would lose it for nothing.
58
- if (!Number.isInteger(version)) {
59
- throw new UserError(
60
+ const noActive = () =>
61
+ new UserError(
60
62
  `${flowId} has no active version on ${target} — nothing to pull`
61
63
  );
62
- }
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();
63
67
 
64
- const written = await writeFlowFiles(
65
- io.cwd,
66
- Object.fromEntries(
67
- Object.entries(files).filter(
68
- ([name]) =>
69
- name === MANIFEST || name === FLOW_TS || name.endsWith('.d.ts')
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/'))
70
100
  )
71
- )
101
+ ))
72
102
  );
73
- await updatePackageJSON(io.cwd, (pkg) => {
74
- pkg.scripts = scaffoldPkg.scripts;
75
- if (scaffoldPkg.dependencies) pkg.dependencies = scaffoldPkg.dependencies;
76
- else delete pkg.dependencies;
77
- pkg.juno.base_version = version;
103
+ await updatePackageJSON(io.cwd, (p) => {
104
+ setDependencies(p, manifest);
105
+ p.juno.base_version = version;
78
106
  });
79
107
  written.push(PACKAGE_JSON);
80
108
  printScaffoldWarnings(warnings, io);
@@ -82,5 +110,6 @@ export async function flowPull(argv, io) {
82
110
  io.log(
83
111
  `pulled ${flowId} v${version} from ${target} (${written.sort().join(', ')})`
84
112
  );
113
+ if (removed.length) io.log(`removed ${removed.join(', ')}`);
85
114
  return 0;
86
115
  }