@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.
@@ -1,30 +1,47 @@
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
1
  import { clientName, JunoClient } from '../api.js';
7
2
  import { UserError } from '../errors.js';
8
- import { readManifest } from '../project.js';
3
+ import {
4
+ readIntegrationBaseVersion,
5
+ readManifest,
6
+ updateIntegrationPackage,
7
+ } from '../project.js';
8
+ import { stampTypeFiles } from '../stamp.js';
9
9
  import { resolveTarget } from '../target.js';
10
-
11
- const execFileAsync = promisify(execFile);
10
+ import {
11
+ baseArtifactPaths,
12
+ MANIFEST,
13
+ manifestText,
14
+ pullArtifacts,
15
+ requireCleanTree,
16
+ setDependencies,
17
+ writeChangedFiles,
18
+ } from '../working-copy.js';
19
+ import { pickTypeFiles } from './gen-types.js';
12
20
 
13
21
  export const PULL_USAGE = 'juno integration pull [--force] [--id <id>]';
14
22
 
15
23
  /**
16
- * `juno integration pull` — write back the manifest and sources Juno holds,
17
- * and package.json's `scripts`.
24
+ * `juno integration pull` — make the working copy's artifact files the
25
+ * install's active version: manifest.json and every file under src/ and
26
+ * test/, exactly as the version stores them, plus types/ regenerated and
27
+ * stamped against the pulled manifest, and package.json's
28
+ * `juno.base_version` (the version pulled, which the next check and deploy
29
+ * send).
18
30
  *
19
31
  * This exists because the zip is a good first touch and a bad second one: once
20
32
  * the authoring agent has edited an integration you deployed, downloading the
21
33
  * 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.
34
+ * files Juno stores. The scaffold's script lines are not among them: they are
35
+ * project content, and an author's own edits to them are theirs — a fresh
36
+ * `juno integration init` is how to get the current ones. `dependencies` is
37
+ * replaced only when the pulled manifest declares some (an agent-authored
38
+ * integration); otherwise they live in package.json alone, which Juno does not
39
+ * store, and stay. Every other key in package.json, and of `juno`, stays.
40
+ *
41
+ * A local file is deleted only when the base version had it and the pulled
42
+ * one does not; a file neither has is one not deployed yet, and stays. It
43
+ * still refuses a dirty tree without --force, because an uncommitted edit is
44
+ * the one thing git cannot get back for you.
28
45
  */
29
46
  export async function pull(argv, io) {
30
47
  const target = await resolveTarget({
@@ -32,26 +49,26 @@ export async function pull(argv, io) {
32
49
  cwd: io.cwd,
33
50
  env: io.env,
34
51
  });
35
- const id = argv.id ?? (await readManifest(io.cwd)).id;
52
+ let manifestId;
53
+ try {
54
+ manifestId = (await readManifest(io.cwd)).id;
55
+ } catch (err) {
56
+ // With --id the manifest is about to be replaced, so a broken one is
57
+ // no reason to refuse.
58
+ if (argv.id === undefined) throw err;
59
+ }
60
+ const id = argv.id ?? manifestId;
36
61
  if (!id) {
37
62
  throw new UserError(
38
63
  'manifest.json declares no id — pass --id <integration>'
39
64
  );
40
65
  }
41
66
 
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
- }
67
+ await requireCleanTree(io.cwd, {
68
+ force: argv.force,
69
+ overwrites: `${MANIFEST}, src/, test/ and types/`,
70
+ io,
71
+ });
55
72
 
56
73
  const client =
57
74
  argv.client ?? new JunoClient(target, { client: clientName(io.env) });
@@ -61,83 +78,60 @@ export async function pull(argv, io) {
61
78
  `${id} has no active version on ${target} — nothing to pull`
62
79
  );
63
80
  }
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
81
+ // Everything is fetched before anything is written, so a failure leaves
82
+ // the working copy as it was.
83
+ const manifestRaw = manifestText(detail.manifest);
84
+ const stored = { [MANIFEST]: manifestRaw, ...detail.source_files };
85
+ const types = stampTypeFiles(
86
+ pickTypeFiles(await client.typegen(detail.manifest)),
87
+ manifestRaw
69
88
  );
89
+ // The base belongs to the install the manifest names; pulling another
90
+ // (--id) has no base to diff against, so it deletes nothing.
91
+ const base = await baseArtifactPaths({
92
+ base:
93
+ id === manifestId ? await readIntegrationBaseVersion(io.cwd) : undefined,
94
+ version: detail.version,
95
+ stored,
96
+ read: async (v) => {
97
+ const b = await client.getIntegrationVersion(id, v);
98
+ return b ? [MANIFEST, ...Object.keys(b.SourceFiles ?? {})] : null;
99
+ },
100
+ id,
101
+ target,
102
+ io,
103
+ });
70
104
 
71
- const written = ['manifest.json'];
72
- await writeFile(
73
- join(io.cwd, 'manifest.json'),
74
- `${JSON.stringify(detail.manifest, null, 2)}\n`,
75
- 'utf8'
105
+ const { written, removed } = await pullArtifacts(io.cwd, {
106
+ stored,
107
+ base,
108
+ kind: 'integration',
109
+ });
110
+ written.push(
111
+ ...(await writeChangedFiles(
112
+ io.cwd,
113
+ Object.fromEntries(
114
+ Object.entries(types).map(([name, content]) => [
115
+ `types/${name}`,
116
+ content,
117
+ ])
118
+ )
119
+ ))
76
120
  );
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(', ')})`
121
+ const wrote = await updateIntegrationPackage(
122
+ io.cwd,
123
+ (pkg) => {
124
+ if (Object.keys(detail.manifest.dependencies ?? {}).length) {
125
+ setDependencies(pkg, detail.manifest);
126
+ }
127
+ pkg.juno.base_version = detail.version;
128
+ },
129
+ io
86
130
  );
131
+ if (wrote) written.push('package.json');
87
132
  io.log(
88
- 'run `npm run types` to regenerate types/ against the pulled manifest'
133
+ `pulled ${id} v${detail.version} from ${target} (${written.sort().join(', ')})`
89
134
  );
135
+ if (removed.length) io.log(`removed ${removed.join(', ')}`);
90
136
  return 0;
91
137
  }
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/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;
package/src/index.js CHANGED
@@ -4,6 +4,12 @@ import {
4
4
  AGENT_SKILL_INSTALL_USAGE,
5
5
  agentSkillInstall,
6
6
  } from './commands/agent-skill-install.js';
7
+ import { APP_CHECK_USAGE, appCheck } from './commands/app-check.js';
8
+ import { APP_DEPLOY_USAGE, appDeploy } from './commands/app-deploy.js';
9
+ import { APP_DEV_USAGE, appDev } from './commands/app-dev.js';
10
+ import { APP_GEN_TYPES_USAGE, appGenTypes } from './commands/app-gen-types.js';
11
+ import { APP_INIT_USAGE, appInit } from './commands/app-init.js';
12
+ import { APP_PULL_USAGE, appPull } from './commands/app-pull.js';
7
13
  import { ASK_USAGE, ask } from './commands/ask.js';
8
14
  import { CHECK_USAGE, check } from './commands/check.js';
9
15
  import { CONFIG_SET_USAGE, configSet } from './commands/config.js';
@@ -18,6 +24,7 @@ import {
18
24
  FLOW_GEN_TYPES_USAGE,
19
25
  flowGenTypes,
20
26
  } from './commands/flow-gen-types.js';
27
+ import { FLOW_FIXTURE_USAGE, flowFixture } from './commands/flow-fixture.js';
21
28
  import { FLOW_INIT_USAGE, flowInit } from './commands/flow-init.js';
22
29
  import { FLOW_PULL_USAGE, flowPull } from './commands/flow-pull.js';
23
30
  import { GEN_TYPES_USAGE, genTypes } from './commands/gen-types.js';
@@ -151,6 +158,15 @@ const WORKING_COPY = {
151
158
  options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
152
159
  usage: FLOW_PULL_USAGE,
153
160
  },
161
+ 'flow fixture': {
162
+ run: flowFixture,
163
+ options: {
164
+ ...URL_OPTION,
165
+ run: { type: 'string' },
166
+ force: { type: 'boolean', default: false },
167
+ },
168
+ usage: FLOW_FIXTURE_USAGE,
169
+ },
154
170
  'integration init': {
155
171
  run: init,
156
172
  options: {
@@ -189,6 +205,7 @@ const WORKING_COPY = {
189
205
  ...URL_OPTION,
190
206
  id: { type: 'string' },
191
207
  watch: { type: 'boolean', default: false },
208
+ force: { type: 'boolean', default: false },
192
209
  json: { type: 'boolean', default: false },
193
210
  },
194
211
  usage: DEPLOY_USAGE,
@@ -202,6 +219,50 @@ const WORKING_COPY = {
202
219
  },
203
220
  usage: PULL_USAGE,
204
221
  },
222
+ 'app init': {
223
+ run: appInit,
224
+ options: { ...URL_OPTION, here: { type: 'boolean', default: false } },
225
+ usage: APP_INIT_USAGE,
226
+ positional: true,
227
+ },
228
+ 'app gen-types': {
229
+ run: appGenTypes,
230
+ options: URL_OPTION,
231
+ usage: APP_GEN_TYPES_USAGE,
232
+ },
233
+ 'app check': {
234
+ run: appCheck,
235
+ options: {
236
+ ...URL_OPTION,
237
+ 'skip-local': { type: 'boolean', default: false },
238
+ json: { type: 'boolean', default: false },
239
+ },
240
+ usage: APP_CHECK_USAGE,
241
+ },
242
+ 'app deploy': {
243
+ run: appDeploy,
244
+ options: {
245
+ ...URL_OPTION,
246
+ watch: { type: 'boolean', default: false },
247
+ force: { type: 'boolean', default: false },
248
+ json: { type: 'boolean', default: false },
249
+ },
250
+ usage: APP_DEPLOY_USAGE,
251
+ },
252
+ 'app pull': {
253
+ run: appPull,
254
+ options: { ...URL_OPTION, force: { type: 'boolean', default: false } },
255
+ usage: APP_PULL_USAGE,
256
+ },
257
+ // No --url: the dev server is offline and never reads juno.target.
258
+ 'app dev': {
259
+ run: appDev,
260
+ options: {
261
+ port: { type: 'string' },
262
+ scenario: { type: 'string' },
263
+ },
264
+ usage: APP_DEV_USAGE,
265
+ },
205
266
  };
206
267
 
207
268
  /** The handler of a non-tool row, working-copy or not, or undefined. */
@@ -223,7 +284,7 @@ function dispatchable(command) {
223
284
  return row && DISPATCHABLE.includes(row) ? row : undefined;
224
285
  }
225
286
 
226
- const USAGE = `juno — operate a Juno instance, and develop flows and integrations for it
287
+ const USAGE = `juno — operate a Juno instance, and develop flows, integrations and apps for it
227
288
 
228
289
  Usage: juno <command> [options]
229
290
  juno <group> <verb> [id] [--input <json>|-|@file] [--jq <expr>]
package/src/project.js CHANGED
@@ -2,15 +2,22 @@ import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
2
2
  import { dirname, join, posix, relative, sep } from 'node:path';
3
3
 
4
4
  import { UserError } from './errors.js';
5
+ import { PACKAGE_JSON, writePackageJSON } from './working-copy.js';
5
6
 
6
7
  /** Only these reach the server. Everything else is project furniture. */
7
8
  const SOURCE_EXTENSIONS = ['.ts', '.json'];
8
9
  const SOURCE_DIR = 'src';
10
+ const TEST_DIR = 'test';
9
11
 
10
12
  /**
11
- * Read the four things Juno looks at: manifest.json, the sources under src/,
12
- * package.json's dependencies, and the lockfile. types/ and test/ are the
13
- * author's — they are never uploaded, and never compiled server-side.
13
+ * Read the four things Juno looks at: manifest.json, the sources under src/
14
+ * and test/, package.json's dependencies, and the lockfile. test/ is stored
15
+ * with the version but never compiled, linted or run server-side; types/ is
16
+ * generated and never uploaded.
17
+ *
18
+ * `baseVersion` is package.json's `juno.base_version`, the version this copy
19
+ * was pulled from or last deployed as, or undefined when it records none (a
20
+ * copy made before the check existed, or one with no `juno` block at all).
14
21
  */
15
22
  export async function readProject(cwd = process.cwd()) {
16
23
  const manifest = await readManifest(cwd);
@@ -18,14 +25,61 @@ export async function readProject(cwd = process.cwd()) {
18
25
  if (Object.keys(files).length === 0) {
19
26
  throw new UserError(`no TypeScript sources found under ${SOURCE_DIR}/`);
20
27
  }
28
+ const packageJSON = await readOptional(join(cwd, PACKAGE_JSON));
21
29
  return {
22
30
  manifest,
23
- files,
24
- packageJSON: await readOptional(join(cwd, 'package.json')),
31
+ files: { ...files, ...(await readTestFiles(cwd)) },
32
+ packageJSON,
25
33
  lockfile: await readOptional(join(cwd, 'package-lock.json')),
34
+ baseVersion: baseVersionOfText(packageJSON),
26
35
  };
27
36
  }
28
37
 
38
+ /**
39
+ * readIntegrationBaseVersion is package.json's `juno.base_version`, or
40
+ * undefined: what `pull` diffs against without reading the sources.
41
+ */
42
+ export async function readIntegrationBaseVersion(cwd) {
43
+ return baseVersionOfText(await readOptional(join(cwd, PACKAGE_JSON)));
44
+ }
45
+
46
+ /** `juno.base_version` of package.json's text, or undefined. */
47
+ function baseVersionOfText(text) {
48
+ let pkg;
49
+ try {
50
+ pkg = JSON.parse(text);
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ const base = pkg?.juno?.base_version;
55
+ return Number.isInteger(base) ? base : undefined;
56
+ }
57
+
58
+ /**
59
+ * updateIntegrationPackage rewrites package.json through `mutate`, keeping
60
+ * every key it does not touch (and their order). `juno` is handed over as an
61
+ * object even when the copy has none yet: an integration copy carries one only
62
+ * once it records a target or a base. A missing or unreadable package.json is
63
+ * left alone with a warning — no command here is the place to invent one.
64
+ * Returns whether it wrote.
65
+ */
66
+ export async function updateIntegrationPackage(cwd, mutate, io) {
67
+ let pkg;
68
+ try {
69
+ pkg = JSON.parse(await readFile(join(cwd, PACKAGE_JSON), 'utf8'));
70
+ } catch {
71
+ pkg = null;
72
+ }
73
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
74
+ io.warn(`no readable ${PACKAGE_JSON} — it was not updated`);
75
+ return false;
76
+ }
77
+ if (pkg.juno === null || typeof pkg.juno !== 'object') pkg.juno = {};
78
+ mutate(pkg);
79
+ await writePackageJSON(cwd, pkg);
80
+ return true;
81
+ }
82
+
29
83
  /**
30
84
  * The manifest as it sits on disk. The stamp in types/integration-env.d.ts
31
85
  * fingerprints these exact bytes, so both writing and verifying it go through
@@ -51,15 +105,29 @@ export async function readManifest(cwd = process.cwd()) {
51
105
  }
52
106
 
53
107
  /** Walks src/ and returns repo-relative POSIX paths → content. */
54
- export async function readSourceFiles(cwd = process.cwd()) {
55
- const root = join(cwd, SOURCE_DIR);
108
+ export function readSourceFiles(cwd = process.cwd()) {
109
+ return readSourceTree(cwd, SOURCE_DIR);
110
+ }
111
+
112
+ /**
113
+ * readTestFiles is readSourceFiles for test/: the same .ts and .json rule,
114
+ * because Juno stores tests beside the sources under the same path rules.
115
+ * Every kind's working copy sends them; none requires them.
116
+ */
117
+ export function readTestFiles(cwd = process.cwd()) {
118
+ return readSourceTree(cwd, TEST_DIR);
119
+ }
120
+
121
+ /** readSourceTree walks dir for .ts and .json files, by repo-relative POSIX path. */
122
+ async function readSourceTree(cwd, dir) {
123
+ const root = join(cwd, dir);
56
124
  const files = {};
57
125
  async function walk(dir) {
58
126
  let entries;
59
127
  try {
60
128
  entries = await readdir(dir, { withFileTypes: true });
61
129
  } catch {
62
- return; // no src/ at all; the caller reports it
130
+ return; // no such directory at all; the caller reports a missing src/
63
131
  }
64
132
  for (const entry of entries) {
65
133
  const full = join(dir, entry.name);