@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,11 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
- import { clientName, JunoClient } from '../api.js';
4
+ import { connect } from '../api.js';
5
5
  import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
6
6
  import { DiagnosticsError, UserError } from '../errors.js';
7
7
  import { readProject } from '../project.js';
8
- import { resolveTarget } from '../target.js';
8
+ import { baseFor } from './deploy.js';
9
9
  import { verifyTypes } from './verify-types.js';
10
10
 
11
11
  export const CHECK_USAGE =
@@ -17,7 +17,8 @@ export const CHECK_USAGE =
17
17
  * tsc and eslint run locally against the committed types; reserved names,
18
18
  * cross-surface collisions and JSON Schema compilation need the server, so
19
19
  * they go through ingest?dry_run=1, which stores nothing and is safe to point
20
- * at a production instance.
20
+ * at a production instance. It sends `juno.base_version` like deploy does, so
21
+ * a stale copy is a 409 (exit 4) here, before deploy hits it.
21
22
  *
22
23
  * --json prints the route's body (or the 422's diagnostics) on stdout and
23
24
  * nothing else; the local tools' own output goes to stderr.
@@ -36,32 +37,18 @@ export async function check(argv, io) {
36
37
  io.error(err.message);
37
38
  failed = true;
38
39
  }
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
- }
40
+ if (await runLocalChecks(argv, io)) failed = true;
52
41
  }
53
42
 
54
- const target = await resolveTarget({
55
- url: argv.url,
56
- cwd: io.cwd,
57
- env: io.env,
58
- });
59
- const client =
60
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
43
+ const { target, client } = await connect(argv, io);
61
44
  const project = await readProject(io.cwd);
62
45
  let result;
63
46
  try {
64
- result = await client.ingest(project, { dryRun: true, id: argv.id });
47
+ result = await client.ingest(project, {
48
+ dryRun: true,
49
+ id: argv.id,
50
+ baseVersion: baseFor(project, argv, io),
51
+ });
65
52
  } catch (err) {
66
53
  if (argv.json && err instanceof DiagnosticsError) {
67
54
  return printDiagnosticsJSON(err, io);
@@ -81,6 +68,28 @@ export async function check(argv, io) {
81
68
 
82
69
  export const SKIPPED = -1;
83
70
 
71
+ /**
72
+ * runLocalChecks runs tsc and eslint from the working copy's node_modules,
73
+ * the offline half of every kind's `check`, and answers whether either
74
+ * failed. A tool that is not installed is a warning, not a failure: the
75
+ * server-side half still runs.
76
+ */
77
+ export async function runLocalChecks(argv, io) {
78
+ let failed = false;
79
+ for (const [label, bin, args] of [
80
+ ['typecheck', 'tsc', ['--noEmit']],
81
+ ['lint', 'eslint', ['.']],
82
+ ]) {
83
+ const code = await runLocal(bin, args, io, { quietStdout: argv.json });
84
+ if (code === SKIPPED) {
85
+ io.warn(`${label}: ${bin} is not installed — run npm install`);
86
+ continue;
87
+ }
88
+ if (code !== 0) failed = true;
89
+ }
90
+ return failed;
91
+ }
92
+
84
93
  /**
85
94
  * Runs a local devtool from node_modules/.bin. Its output goes straight to the
86
95
  * terminal: tsc and eslint already print file:line:col, and reformatting them
@@ -2,25 +2,44 @@ import { readUserConfig, writeUserConfig } from '../config.js';
2
2
  import { UsageError } from '../errors.js';
3
3
  import { normalizeTarget } from '../target.js';
4
4
 
5
- export const CONFIG_SET_USAGE = 'juno config set url <url>';
5
+ export const CONFIG_SET_USAGE =
6
+ 'juno config set url <url> | juno config set token <key>';
7
+
8
+ const KEYS = ['url', 'token'];
6
9
 
7
10
  /**
8
- * `juno config set url <url>` writes the user config file, the last place
9
- * resolveTarget looks. `url` is the only key; it is normalised the way every
10
- * other source is, so what lands in the file is what a request would use.
11
+ * `juno config set url <url>` and `juno config set token <key>` write the
12
+ * user config file, the last place resolveTarget and resolveToken look. A url
13
+ * is normalised the way every other source is, so what lands in the file is
14
+ * what a request would use. A token must start `juno_`, the prefix of every
15
+ * API key, which catches a pasted password or a key's id alone; the file is
16
+ * written 0600, and neither the token nor any part of it is echoed, the
17
+ * refusal included.
11
18
  */
12
19
  export async function configSet(argv, io) {
13
20
  const [key, value, ...extra] = argv._;
14
21
  const usage = `usage: ${CONFIG_SET_USAGE}`;
15
22
  if (key === undefined) throw new UsageError(usage);
16
- if (key !== 'url') {
23
+ if (!KEYS.includes(key)) {
17
24
  throw new UsageError(`unknown config key "${key}"; ${usage}`);
18
25
  }
19
26
  if (value === undefined || extra.length) throw new UsageError(usage);
20
27
 
21
- const url = normalizeTarget(value);
28
+ let entry;
29
+ if (key === 'url') {
30
+ entry = { url: normalizeTarget(value) };
31
+ } else {
32
+ const token = value.trim();
33
+ if (!token.startsWith('juno_')) {
34
+ throw new UsageError(
35
+ 'that is not an API key: a key starts with "juno_". Create one under ' +
36
+ 'Settings → API keys.'
37
+ );
38
+ }
39
+ entry = { token };
40
+ }
22
41
  const current = await readUserConfig(io.env);
23
- const path = await writeUserConfig(io.env, { ...current, url });
42
+ const path = await writeUserConfig(io.env, { ...current, ...entry });
24
43
  io.warn(`wrote ${path}`);
25
44
  return 0;
26
45
  }
@@ -1,20 +1,31 @@
1
1
  import { join } from 'node:path';
2
2
 
3
- import { clientName, JunoClient } from '../api.js';
3
+ import { connect } 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';
7
- import { resolveTarget } from '../target.js';
6
+ import {
7
+ readManifestRaw,
8
+ readProject,
9
+ updateIntegrationPackage,
10
+ writeTypes,
11
+ } from '../project.js';
8
12
  import { stampTypeFiles } from '../stamp.js';
9
13
  import { pickTypeFiles } from './gen-types.js';
10
14
  import { deployOnChange } from '../watch.js';
11
15
 
12
16
  export const DEPLOY_USAGE =
13
- 'juno integration deploy [--watch] [--id <id>] [--json]';
17
+ 'juno integration deploy [--watch] [--force] [--id <id>] [--json]';
14
18
 
15
19
  /**
16
20
  * `juno integration deploy [--watch]` — upload the working copy, verify, activate.
17
21
  *
22
+ * It sends `juno.base_version`, so an install changed elsewhere since this
23
+ * copy was pulled is a 409 (exit 4) rather than a silent overwrite; --force
24
+ * sends none and replaces whatever is active. A successful deploy writes the
25
+ * new version back as the base. --id naming another install sends no base and
26
+ * writes none back: the base belongs to the install the manifest names (see
27
+ * baseFor).
28
+ *
18
29
  * The response carries the regenerated types, so a deploy keeps the committed
19
30
  * types/ honest without a second round trip that could answer for a different
20
31
  * manifest.
@@ -23,13 +34,7 @@ export const DEPLOY_USAGE =
23
34
  * of the summary line, for CI.
24
35
  */
25
36
  export async function deploy(argv, io) {
26
- const target = await resolveTarget({
27
- url: argv.url,
28
- cwd: io.cwd,
29
- env: io.env,
30
- });
31
- const client =
32
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
37
+ const { target, client } = await connect(argv, io);
33
38
 
34
39
  if (!argv.watch) {
35
40
  try {
@@ -46,25 +51,66 @@ export async function deploy(argv, io) {
46
51
  }
47
52
 
48
53
  async function deployOnce(client, argv, io, target) {
54
+ // Re-read on every deploy, base included, so the watch loop is checked
55
+ // against its own previous deploy rather than refused by it.
49
56
  const project = await readProject(io.cwd);
50
- const result = await client.ingest(project, { id: argv.id });
57
+ const result = await client.ingest(project, {
58
+ id: argv.id,
59
+ baseVersion: argv.force ? undefined : baseFor(project, argv, io),
60
+ });
51
61
  // Stamped against the manifest this deploy was built from, so the watch loop
52
62
  // leaves a tree that `juno integration verify-types` accepts.
53
63
  const written = await writeTypes(
54
64
  stampTypeFiles(pickTypeFiles(result.types), await readManifestRaw(io.cwd)),
55
65
  io.cwd
56
66
  );
67
+ if (
68
+ !deploysElsewhere(project, argv) &&
69
+ project.baseVersion !== result.version
70
+ ) {
71
+ const wrote = await updateIntegrationPackage(
72
+ io.cwd,
73
+ (pkg) => {
74
+ pkg.juno.base_version = result.version;
75
+ },
76
+ io
77
+ );
78
+ if (wrote) written.push('package.json');
79
+ }
57
80
  if (argv.json) {
58
81
  printJSON(result, io);
59
82
  return result;
60
83
  }
61
84
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
62
85
  io.log(
63
- `deployed ${result.id} v${result.version} to ${target} (${written.join(', ')})`
86
+ `deployed ${result.id} v${result.version} to ${target} (${written.sort().join(', ')})`
64
87
  );
65
88
  return result;
66
89
  }
67
90
 
91
+ /**
92
+ * baseFor is the base a check or deploy sends: the copy's
93
+ * `juno.base_version`, unless --id names an install other than the
94
+ * manifest's. That install's versions are ones this copy has never seen, so
95
+ * there is nothing to compare against, and the warning says the check is off
96
+ * rather than letting it pass silently. A first deploy to a new id has nothing
97
+ * to be stale against either, so this needs no --force.
98
+ */
99
+ export function baseFor(project, argv, io) {
100
+ if (deploysElsewhere(project, argv)) {
101
+ io.warn(
102
+ `no stale-base check: deploying to ${argv.id}, not ${project.manifest?.id}`
103
+ );
104
+ return undefined;
105
+ }
106
+ return project.baseVersion;
107
+ }
108
+
109
+ /** Whether --id names an install other than the one the manifest does. */
110
+ function deploysElsewhere(project, argv) {
111
+ return argv.id !== undefined && argv.id !== project.manifest?.id;
112
+ }
113
+
68
114
  /**
69
115
  * Watch mode is the loop the whole design is for: edit the manifest, the types
70
116
  * 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
  }
@@ -1,7 +1,6 @@
1
- import { clientName, JunoClient } from '../api.js';
1
+ import { connect } from '../api.js';
2
2
  import { readManifest, readManifestRaw, writeTypes } from '../project.js';
3
3
  import { stampTypeFiles } from '../stamp.js';
4
- import { resolveTarget } from '../target.js';
5
4
 
6
5
  export const GEN_TYPES_USAGE = 'juno integration gen-types';
7
6
 
@@ -14,15 +13,9 @@ export const GEN_TYPES_USAGE = 'juno integration gen-types';
14
13
  * manifest does, which is the right person to require it of.
15
14
  */
16
15
  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
+ const { target, client } = await connect(argv, io);
22
17
  const raw = await readManifestRaw(io.cwd);
23
18
  const manifest = await readManifest(io.cwd);
24
- const client =
25
- argv.client ?? new JunoClient(target, { client: clientName(io.env) });
26
19
 
27
20
  const generated = await client.typegen(manifest);
28
21
  const written = await writeTypes(