@junoflow/cli 0.1.0 → 0.1.1

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.
package/README.md CHANGED
@@ -5,7 +5,7 @@ project, generate types from your manifest, check it, and deploy it.
5
5
 
6
6
  ```bash
7
7
  npm install --save-dev @junoflow/cli
8
- npx juno init my-integration
8
+ npx juno init my-integration --url http://juno.local:8080
9
9
  ```
10
10
 
11
11
  An integration is a git repository holding `manifest.json` and TypeScript. Juno
@@ -16,9 +16,10 @@ packaging format and nothing is published to npm.
16
16
 
17
17
  | Command | What it does |
18
18
  | ----------------------- | ------------------------------------------------------------------------------ |
19
- | `juno init <name>` | scaffold a project. **No server needed.** |
19
+ | `juno init <name>` | fetch a starting project from a Juno and write it down |
20
20
  | `juno types` | POST `manifest.json`, write `types/` |
21
- | `juno check` | local `tsc --noEmit` + `eslint`, then validate against a Juno (writes nothing) |
21
+ | `juno verify-types` | check `types/` still matches `manifest.json` — talks to nothing |
22
+ | `juno check` | local `verify-types` + `tsc --noEmit` + `eslint`, then validate against a Juno |
22
23
  | `juno deploy [--watch]` | upload, verify, activate — and write back the returned types |
23
24
  | `juno pull` | write back the manifest and sources the instance holds |
24
25
 
@@ -31,7 +32,7 @@ packaging format and nothing is published to npm.
31
32
  | `--watch` | redeploy on every save (`deploy`) |
32
33
  | `--force` | overwrite a dirty working tree (`pull`) |
33
34
  | `--here` | scaffold into the current directory (`init`) |
34
- | `--skip-local` | skip the local tsc/eslint pass (`check`) |
35
+ | `--skip-local` | skip the local verify-types/tsc/eslint pass (`check`) |
35
36
 
36
37
  ## Choosing an instance
37
38
 
@@ -61,14 +62,37 @@ interface. Most importantly, a drive-by contributor fixing a bug in someone
61
62
  else's integration never needs an instance — only the person changing the
62
63
  manifest does.
63
64
 
65
+ The cost of that decision is drift: edit `manifest.json`, forget
66
+ `npm run types`, and every other signal stays green while `types/` describes a
67
+ contract you no longer declare. `tsc` compiles happily against stale types,
68
+ `eslint` is happy, and the testkit's conformance check compares the manifest
69
+ with your _module_, never with `types/` — so the mismatch surfaces at deploy, or
70
+ later, as a flow author calling an action whose shape changed.
71
+
72
+ `juno verify-types` closes it. `juno types` records a fingerprint of the
73
+ `manifest.json` it generated from, as a comment in `types/integration-env.d.ts`:
74
+
75
+ ```ts
76
+ // integration-env.d.ts for 'pushover'
77
+ // DO NOT EDIT — generated by 'juno types' from 'manifest.json'.
78
+ // juno:manifest-sha256 5c5ca8eabe12cf44
79
+ ```
80
+
81
+ `verify-types` recomputes it and compares. It runs first in `npm test` and
82
+ inside `juno check`, and it contacts nothing — which is the point: the mistake
83
+ is caught in a contributor's CI, and that CI deliberately has no instance to
84
+ ask. Reformatting `manifest.json` also invalidates the stamp; the fix is the
85
+ same `npm run types` either way.
86
+
64
87
  ## What runs where
65
88
 
66
89
  | | offline | needs an instance |
67
90
  | --------------------------------------------------------------- | ------- | ----------------- |
68
91
  | `tsc --noEmit`, `eslint`, `vitest` | ✅ | |
92
+ | `juno verify-types` (is `types/` current?) | ✅ | |
69
93
  | structural manifest check via `"$schema"` in your editor | ✅ | |
70
94
  | reserved names, cross-surface collisions, JSON Schema compiling | | `juno check` |
71
- | `juno types`, `juno deploy` | | ✅ |
95
+ | `juno init`, `juno types`, `juno deploy` | | ✅ |
72
96
 
73
97
  Everything semantic lives in the Go server. This package knows some HTTP
74
98
  endpoints and a config preset — that is deliberate, so there is one answer to
@@ -76,8 +100,9 @@ endpoints and a config preset — that is deliberate, so there is one answer to
76
100
 
77
101
  ## `init` vs. downloading a project
78
102
 
79
- `juno init` and the instance's **Develop locally → Download project** button
80
- produce the same layout. They differ in what they can know: `init` needs no
81
- server, while the download can pre-generate `types/` and carries the sources of
82
- an integration that already exists there. Use `juno pull` afterwards — a second
83
- download would clobber your working tree.
103
+ Both produce the same project, from the same generator on the instance: `init`
104
+ starts from a stub manifest, the **Develop locally → Download project** button
105
+ starts from an integration that already exists there. Neither is a local
106
+ template — the CLI writes the files it is handed, so a scaffold cannot drift
107
+ from what the server ejects. To refresh an existing checkout use `juno pull`; a
108
+ second download would clobber your working tree.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@junoflow/cli",
3
- "version": "0.1.0",
4
- "description": "Command line tools for developing Juno integrations: scaffold, generate types, check and deploy.",
3
+ "version": "0.1.1",
4
+ "description": "Command line tools for developing Juno integrations",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -25,12 +25,6 @@
25
25
  "eslint.config.js",
26
26
  "README.md"
27
27
  ],
28
- "keywords": [
29
- "juno",
30
- "junoflow",
31
- "cli",
32
- "integration"
33
- ],
34
28
  "scripts": {
35
29
  "test": "node --test \"test/**/*.test.js\""
36
30
  },
package/src/api.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { DiagnosticsError, UserError } from './errors.js';
2
2
 
3
3
  /**
4
- * Client for the four endpoints this CLI knows about. It knows nothing
5
- * semantic: validation, reserved names, schema compilation and typegen all
6
- * live in the Go server, so there is one answer to "is this manifest valid"
7
- * rather than two that can disagree.
4
+ * Client for the handful of endpoints this CLI knows about. It knows nothing
5
+ * semantic: validation, reserved names, schema compilation, typegen and the
6
+ * shape of a project all live in the Go server, so there is one answer to "is
7
+ * this manifest valid" — and one project layout — rather than two that can
8
+ * disagree.
8
9
  */
9
10
  export class JunoClient {
10
11
  constructor(target, { fetch: fetchImpl = globalThis.fetch } = {}) {
@@ -12,6 +13,20 @@ export class JunoClient {
12
13
  this.fetch = fetchImpl;
13
14
  }
14
15
 
16
+ /**
17
+ * POST /api/integrations/scaffold — a whole starting project, path →
18
+ * content. `target` is what this client reached the instance at; the server
19
+ * prefers its own configured public URL and falls back to it.
20
+ */
21
+ async scaffold(name, { description, target } = {}) {
22
+ const body = await this.#json('POST', '/api/integrations/scaffold', {
23
+ name,
24
+ description: description ?? undefined,
25
+ target: target ?? undefined,
26
+ });
27
+ return body?.files ?? {};
28
+ }
29
+
15
30
  /** POST /api/typegen/integration — both .d.ts halves, one call. */
16
31
  async typegen(manifest) {
17
32
  return this.#json('POST', '/api/typegen/integration', { manifest });
@@ -2,8 +2,10 @@ import { spawn } from 'node:child_process';
2
2
  import { join } from 'node:path';
3
3
 
4
4
  import { JunoClient } from '../api.js';
5
+ import { UserError } from '../errors.js';
5
6
  import { readProject } from '../project.js';
6
7
  import { resolveTarget } from '../target.js';
8
+ import { verifyTypes } from './verify-types.js';
7
9
 
8
10
  /**
9
11
  * `juno check` — the offline checks, then the ones only a Juno can answer.
@@ -17,6 +19,15 @@ export async function check(argv, io) {
17
19
  let failed = false;
18
20
 
19
21
  if (!argv['skip-local']) {
22
+ // Before tsc, deliberately: type-checking against types/ says nothing if
23
+ // types/ describes a manifest that is no longer on disk.
24
+ try {
25
+ await verifyTypes(argv, io);
26
+ } catch (err) {
27
+ if (!(err instanceof UserError)) throw err;
28
+ io.error(err.message);
29
+ failed = true;
30
+ }
20
31
  for (const [label, bin, args] of [
21
32
  ['typecheck', 'tsc', ['--noEmit']],
22
33
  ['lint', 'eslint', ['.']],
@@ -3,8 +3,9 @@ import { join } from 'node:path';
3
3
 
4
4
  import { JunoClient } from '../api.js';
5
5
  import { DiagnosticsError } from '../errors.js';
6
- import { readProject, writeTypes } from '../project.js';
6
+ import { readManifestRaw, readProject, writeTypes } from '../project.js';
7
7
  import { resolveTarget } from '../target.js';
8
+ import { stampTypeFiles } from '../stamp.js';
8
9
  import { formatDiagnostics } from '../diagnostics.js';
9
10
  import { pickTypeFiles } from './types.js';
10
11
 
@@ -32,7 +33,12 @@ export async function deploy(argv, io) {
32
33
  async function deployOnce(client, argv, io, target) {
33
34
  const project = await readProject(io.cwd);
34
35
  const result = await client.ingest(project, { name: argv.name });
35
- const written = await writeTypes(pickTypeFiles(result.types), io.cwd);
36
+ // Stamped against the manifest this deploy was built from, so the watch loop
37
+ // leaves a tree that `juno verify-types` accepts.
38
+ const written = await writeTypes(
39
+ stampTypeFiles(pickTypeFiles(result.types), await readManifestRaw(io.cwd)),
40
+ io.cwd
41
+ );
36
42
  for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
37
43
  io.log(
38
44
  `deployed ${result.name} v${result.version} to ${target} (${written.join(', ')})`
@@ -1,39 +1,36 @@
1
- import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
2
- import { dirname, join, resolve } from 'node:path';
3
- import { fileURLToPath } from 'node:url';
1
+ import { mkdir, writeFile, access } from 'node:fs/promises';
2
+ import { dirname, join, resolve, sep } from 'node:path';
4
3
 
4
+ import { JunoClient } from '../api.js';
5
5
  import { UserError } from '../errors.js';
6
+ import { resolveTarget } from '../target.js';
6
7
 
7
8
  /**
8
- * `juno init [name]` — scaffold a project with no server involved.
9
+ * `juno init [name]` — ask a Juno for a starting project and write it down.
9
10
  *
10
- * This deliberately duplicates what `GET /api/integrations/{name}/package.zip`
11
- * generates server-side. It has to: init's whole point is that you can start
12
- * without a Juno, and the server's whole point is that it can eject an
13
- * integration that already exists there. The two agree on layout and scripts;
14
- * only the server's version can pre-generate types/, because only it knows the
15
- * manifest's compiled shape.
11
+ * Every file comes from `POST /api/integrations/scaffold`, which renders it
12
+ * with the same generator behind **Develop locally → Download project**. The
13
+ * CLI holds no copy of the layout: a scaffold and an ejected project cannot
14
+ * drift, and the starter arrives with `types/` already generated, which only
15
+ * the server can produce.
16
16
  */
17
17
  export async function init(argv, io) {
18
18
  const name = argv._[0] ?? argv.name;
19
19
  if (!name) throw new UserError('usage: juno init <name>');
20
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
21
- throw new UserError(`"${name}" must be kebab-case: [a-z][a-z0-9-]*`);
22
- }
23
20
 
24
21
  const dir = resolve(io.cwd, argv.here ? '.' : name);
25
22
  if (!argv.here && (await exists(dir))) {
26
23
  throw new UserError(`${dir} already exists`);
27
24
  }
28
25
 
29
- const version = await toolingRange();
30
- const files = scaffold(
31
- name,
32
- version,
33
- argv.url ?? process.env.JUNO_URL ?? null
34
- );
26
+ const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
27
+ const client = argv.client ?? new JunoClient(target);
28
+ // The name is validated server-side, against the same rules an install is:
29
+ // one answer to "is this a valid integration name" rather than two.
30
+ const files = await client.scaffold(name, { target });
31
+
35
32
  for (const [rel, content] of Object.entries(files)) {
36
- const path = join(dir, rel);
33
+ const path = join(dir, safeRelative(rel));
37
34
  await mkdir(dirname(path), { recursive: true });
38
35
  await writeFile(path, content, 'utf8');
39
36
  }
@@ -41,178 +38,25 @@ export async function init(argv, io) {
41
38
  io.log(`created ${dir}`);
42
39
  io.log('');
43
40
  io.log(argv.here ? 'npm install' : `cd ${name} && npm install`);
44
- io.log('npm run types # generate types/ from manifest.json');
45
41
  io.log('npm test');
46
42
  io.log('npm run deploy');
47
43
  return 0;
48
44
  }
49
45
 
50
- /** @junoflow/* are version-locked, so the CLI's own version is the range. */
51
- async function toolingRange() {
52
- const here = dirname(fileURLToPath(import.meta.url));
53
- const raw = await readFile(join(here, '..', '..', 'package.json'), 'utf8');
54
- return `^${JSON.parse(raw).version}`;
55
- }
56
-
57
- function scaffold(name, toolingRange, target) {
58
- const pkg = {
59
- name,
60
- version: '0.1.0',
61
- description: '',
62
- private: true,
63
- type: 'module',
64
- scripts: {
65
- typecheck: 'tsc --noEmit',
66
- lint: 'eslint .',
67
- test: 'npm run typecheck && npm run lint && vitest run',
68
- types: 'juno types',
69
- check: 'juno check',
70
- deploy: 'juno deploy',
71
- watch: 'juno deploy --watch',
72
- pull: 'juno pull',
73
- },
74
- devDependencies: {
75
- '@junoflow/cli': toolingRange,
76
- '@junoflow/testkit': toolingRange,
77
- eslint: '^9.34.0',
78
- typescript: '^5.9.0',
79
- vitest: '^3.2.0',
80
- },
81
- ...(target ? { juno: { target } } : {}),
82
- };
83
-
84
- const manifest = {
85
- ...(target
86
- ? {
87
- $schema: `${target.replace(/\/+$/, '')}/api/schemas/integration-manifest.json`,
88
- }
89
- : {}),
90
- name,
91
- description: `The ${name} integration.`,
92
- config: {
93
- apiToken: {
94
- type: 'string',
95
- credential: true,
96
- required: true,
97
- title: 'API token',
98
- description: 'Filled in by whoever installs this.',
99
- },
100
- },
101
- actions: {
102
- ping: {
103
- description: 'Replace me with something the service actually does.',
104
- params: {
105
- type: 'object',
106
- properties: { message: { type: 'string' } },
107
- required: ['message'],
108
- additionalProperties: false,
109
- },
110
- response: {
111
- type: 'object',
112
- properties: { ok: { type: 'boolean' } },
113
- },
114
- },
115
- },
116
- };
117
-
118
- return {
119
- 'manifest.json': `${JSON.stringify(manifest, null, 2)}\n`,
120
- 'package.json': `${JSON.stringify(pkg, null, 2)}\n`,
121
- 'src/integration.ts': SRC,
122
- 'test/integration.test.ts': TEST(name),
123
- 'tsconfig.json': TSCONFIG,
124
- 'eslint.config.js': ESLINT,
125
- '.gitignore': GITIGNORE,
126
- '.github/workflows/ci.yml': CI,
127
- 'README.md': README(name),
128
- };
129
- }
130
-
131
- // No imports: every type is an ambient global from types/, and verify rejects
132
- // an import of juno:sdk.
133
- const SRC = `export default {
134
- actions: {
135
- ping: async (params, ctx) => {
136
- ctx.log(\`ping: \${params.message}\`);
137
- // ctx.config and ctx.credentials are typed from manifest.json's config.
138
- return { ok: true };
139
- },
140
- },
141
- } satisfies Integration;
142
- `;
143
-
144
- const TEST = (name) => `import { describe, expect, it } from 'vitest';
145
- import { harness } from '@junoflow/testkit';
146
-
147
- import manifest from '../manifest.json';
148
- import integration from '../src/integration';
149
-
150
- const h = harness({ manifest, integration });
151
-
152
- describe('${name}', () => {
153
- it('conforms to its manifest', () => {
154
- expect(h.conformance()).toEqual([]);
155
- });
156
-
157
- it('ping returns ok', async () => {
158
- h.credentials({ apiToken: 'test-token' });
159
- expect(await h.action('ping', { message: 'hello' })).toEqual({ ok: true });
160
- });
161
- });
162
- `;
163
-
164
- const TSCONFIG = `{
165
- "extends": "@junoflow/cli/tsconfig.base.json",
166
- "include": ["src/**/*.ts", "types/**/*.d.ts", "test/**/*.ts"]
46
+ /**
47
+ * The server hands back repo-relative POSIX paths. It is a trusted instance,
48
+ * but "write these paths" is the one place a bad answer escapes the project
49
+ * directory, and checking costs a line.
50
+ */
51
+ function safeRelative(rel) {
52
+ const parts = rel.split('/');
53
+ if (rel.startsWith('/') || parts.includes('..') || parts.includes('')) {
54
+ throw new UserError(
55
+ `refusing to write ${rel}: not a project-relative path`
56
+ );
57
+ }
58
+ return parts.join(sep);
167
59
  }
168
- `;
169
-
170
- const ESLINT = `import juno from '@junoflow/cli/eslint.config.js';
171
-
172
- export default juno;
173
- `;
174
-
175
- const GITIGNORE = `node_modules/
176
- dist/
177
- *.log
178
- .DS_Store
179
- `;
180
-
181
- const CI = `name: CI
182
-
183
- on:
184
- push:
185
- pull_request:
186
-
187
- jobs:
188
- test:
189
- runs-on: ubuntu-latest
190
- steps:
191
- - uses: actions/checkout@v4
192
- - uses: actions/setup-node@v4
193
- with:
194
- node-version: '20'
195
- - run: npm install
196
- - run: npm test
197
- `;
198
-
199
- const README = (name) => `# ${name}
200
-
201
- A [Juno](https://github.com/boivie/juno) integration.
202
-
203
- \`\`\`bash
204
- npm install
205
- npm run types # generate types/ from manifest.json
206
- npm test
207
- npm run deploy
208
- \`\`\`
209
-
210
- \`types/\` is generated and **committed**, so a fresh clone compiles offline and a
211
- contributor fixing a bug needs no Juno instance. Regenerate it after every
212
- \`manifest.json\` edit.
213
-
214
- Publish by tagging: Juno installs this repository at \`refs/tags/<tag>\`.
215
- `;
216
60
 
217
61
  async function exists(path) {
218
62
  try {
@@ -1,5 +1,6 @@
1
1
  import { JunoClient } from '../api.js';
2
- import { readManifest, writeTypes } from '../project.js';
2
+ import { readManifest, readManifestRaw, writeTypes } from '../project.js';
3
+ import { stampTypeFiles } from '../stamp.js';
3
4
  import { resolveTarget } from '../target.js';
4
5
 
5
6
  /**
@@ -12,11 +13,15 @@ import { resolveTarget } from '../target.js';
12
13
  */
13
14
  export async function types(argv, io) {
14
15
  const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
16
+ const raw = await readManifestRaw(io.cwd);
15
17
  const manifest = await readManifest(io.cwd);
16
18
  const client = argv.client ?? new JunoClient(target);
17
19
 
18
20
  const generated = await client.typegen(manifest);
19
- const written = await writeTypes(pickTypeFiles(generated), io.cwd);
21
+ const written = await writeTypes(
22
+ stampTypeFiles(pickTypeFiles(generated), raw),
23
+ io.cwd
24
+ );
20
25
  io.log(`wrote ${written.join(', ')}`);
21
26
  return 0;
22
27
  }
@@ -0,0 +1,57 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { UserError } from '../errors.js';
5
+ import { readManifestRaw } from '../project.js';
6
+ import { ENV_DTS, manifestStamp, readStamp } from '../stamp.js';
7
+
8
+ /**
9
+ * `juno verify-types` — does types/ still describe manifest.json?
10
+ *
11
+ * The one check in this CLI that talks to nothing. It is the counterweight to
12
+ * committing types/: that decision is what lets a drive-by contributor work
13
+ * without an instance, and its cost is that the committed types can silently
14
+ * fall behind the manifest they were generated from. Everything else in the
15
+ * gate stays green when they do — tsc type-checks against the stale types
16
+ * rather than noticing they are stale, and h.conformance() compares the
17
+ * manifest with the module, never with types/.
18
+ *
19
+ * So this runs first in `npm test` and inside `juno check`. A failure here is
20
+ * always the same one sentence of advice, which is why the message says it
21
+ * rather than describing the mismatch.
22
+ */
23
+ export async function verifyTypes(argv, io) {
24
+ const raw = await readManifestRaw(io.cwd);
25
+ const path = join(io.cwd, ...ENV_DTS.split('/'));
26
+
27
+ let generated;
28
+ try {
29
+ generated = await readFile(path, 'utf8');
30
+ } catch {
31
+ throw new UserError(
32
+ `${ENV_DTS} is missing — run 'npm run types' (needs a Juno instance) to generate it.`
33
+ );
34
+ }
35
+
36
+ const found = readStamp(generated);
37
+ if (found === null) {
38
+ // Every generator writes one. A file without it predates the stamp or was
39
+ // hand-edited, and either way nothing here can vouch for it.
40
+ throw new UserError(
41
+ `${ENV_DTS} carries no manifest stamp, so it cannot be checked against manifest.json.\n` +
42
+ `Regenerate it with 'npm run types'.`
43
+ );
44
+ }
45
+
46
+ const want = manifestStamp(raw);
47
+ if (found !== want) {
48
+ throw new UserError(
49
+ `${ENV_DTS} was generated from a different manifest.json (stamped ${found}, on disk ${want}).\n` +
50
+ `Run 'npm run types' and commit the result — until then every other check ` +
51
+ `passes against a contract that is no longer the one you declared.`
52
+ );
53
+ }
54
+
55
+ io.log(`${ENV_DTS} matches manifest.json`);
56
+ return 0;
57
+ }
package/src/index.js CHANGED
@@ -5,18 +5,27 @@ import { deploy } from './commands/deploy.js';
5
5
  import { init } from './commands/init.js';
6
6
  import { pull } from './commands/pull.js';
7
7
  import { types } from './commands/types.js';
8
+ import { verifyTypes } from './commands/verify-types.js';
8
9
  import { DiagnosticsError, UserError } from './errors.js';
9
10
  import { formatDiagnostics } from './diagnostics.js';
10
11
 
11
- const COMMANDS = { init, types, check, deploy, pull };
12
+ const COMMANDS = {
13
+ init,
14
+ types,
15
+ 'verify-types': verifyTypes,
16
+ check,
17
+ deploy,
18
+ pull,
19
+ };
12
20
 
13
21
  const USAGE = `juno — develop Juno integrations
14
22
 
15
23
  Usage: juno <command> [options]
16
24
 
17
25
  Commands:
18
- init <name> scaffold a new integration project (no server needed)
26
+ init <name> scaffold a new integration project from a Juno
19
27
  types generate types/ from manifest.json
28
+ verify-types check types/ still matches manifest.json (offline)
20
29
  check typecheck and lint locally, then validate against a Juno
21
30
  deploy [--watch] upload, verify, and activate a new version
22
31
  pull write back the manifest and sources Juno holds
package/src/project.js CHANGED
@@ -26,16 +26,23 @@ export async function readProject(cwd = process.cwd()) {
26
26
  };
27
27
  }
28
28
 
29
- export async function readManifest(cwd = process.cwd()) {
30
- const path = join(cwd, 'manifest.json');
31
- let raw;
29
+ /**
30
+ * The manifest as it sits on disk. The stamp in types/integration-env.d.ts
31
+ * fingerprints these exact bytes, so both writing and verifying it go through
32
+ * here rather than through a re-encoding of the parsed object.
33
+ */
34
+ export async function readManifestRaw(cwd = process.cwd()) {
32
35
  try {
33
- raw = await readFile(path, 'utf8');
36
+ return await readFile(join(cwd, 'manifest.json'), 'utf8');
34
37
  } catch {
35
38
  throw new UserError(
36
39
  `no manifest.json in ${cwd} — run this from an integration project root`
37
40
  );
38
41
  }
42
+ }
43
+
44
+ export async function readManifest(cwd = process.cwd()) {
45
+ const raw = await readManifestRaw(cwd);
39
46
  try {
40
47
  return JSON.parse(raw);
41
48
  } catch (err) {
package/src/stamp.js ADDED
@@ -0,0 +1,84 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ /**
4
+ * The manifest fingerprint carried in types/integration-env.d.ts, and the
5
+ * mirror of internal/typegen/stamp.go. Both halves must produce the same 16
6
+ * hex characters for the same manifest.json — stampParity in test/stamp.test.js
7
+ * and TestManifestStampParity pin the same input to the same output from each
8
+ * side, which is the only thing that keeps them together.
9
+ *
10
+ * Why it exists: "edit manifest.json, forget `juno types`" is the one mistake
11
+ * this workflow reliably produces, and nothing else catches it. tsc compiles
12
+ * happily against stale types, eslint is happy, and h.conformance() checks the
13
+ * manifest against the *module*, never against types/. The drift surfaces at
14
+ * deploy, or later still, as a flow author calling an action with a shape that
15
+ * no longer exists.
16
+ *
17
+ * Why a hash rather than asking a Juno: the check has to run where the mistake
18
+ * is caught — a contributor's CI, which deliberately has no instance to ask.
19
+ * `juno types` needs a server; verifying what it wrote must not.
20
+ */
21
+ const STAMP_PREFIX = '// juno:manifest-sha256 ';
22
+ const STAMP_LENGTH = 16;
23
+
24
+ /** The generated file that carries the stamp. */
25
+ export const ENV_DTS = 'types/integration-env.d.ts';
26
+
27
+ /**
28
+ * Fingerprints the exact bytes of a manifest.json.
29
+ *
30
+ * Raw bytes, not a canonical re-encoding: the other half of this is Go, and any
31
+ * normalisation beyond line endings would need encoding/json and JSON.stringify
32
+ * to agree on key order, number formatting and string escaping — which they do
33
+ * not promise. Hashing what is on disk needs no such treaty. The cost is that
34
+ * reformatting manifest.json invalidates the stamp, and the fix is the same
35
+ * `npm run types` the author owed anyway.
36
+ *
37
+ * CRLF folds to LF because that is the one rewrite git performs by itself, and
38
+ * a Windows checkout of a Linux-authored repository must still verify.
39
+ */
40
+ export function manifestStamp(manifestJSON) {
41
+ return createHash('sha256')
42
+ .update(manifestJSON.replace(/\r\n/g, '\n'))
43
+ .digest('hex')
44
+ .slice(0, STAMP_LENGTH);
45
+ }
46
+
47
+ /** Writes the fingerprint into a generated env .d.ts, replacing any prior one. */
48
+ export function stampEnvDTS(envDTS, manifestJSON) {
49
+ const stamp = STAMP_PREFIX + manifestStamp(manifestJSON);
50
+ const lines = envDTS.split('\n');
51
+ const existing = lines.findIndex((l) => l.startsWith(STAMP_PREFIX));
52
+ if (existing >= 0) {
53
+ lines[existing] = stamp;
54
+ return lines.join('\n');
55
+ }
56
+ // Directly under the "DO NOT EDIT" header, where a reader looking for the
57
+ // file's provenance already is.
58
+ let at = 0;
59
+ while (at < lines.length && lines[at].startsWith('//')) at++;
60
+ lines.splice(at, 0, stamp);
61
+ return lines.join('\n');
62
+ }
63
+
64
+ /**
65
+ * Stamps the env half of a `{ 'integration-env.d.ts': …, … }` map, as the
66
+ * typegen and ingest endpoints answer with.
67
+ *
68
+ * Applied client-side rather than server-side because it fingerprints the bytes
69
+ * on *this* disk: both endpoints are handed a parsed manifest and never see the
70
+ * file, and the same typegen endpoint also answers the authoring agent, which
71
+ * has no file at all. The project generator behind `juno init` does see one, so
72
+ * it stamps there — internal/typegen/stamp.go.
73
+ */
74
+ export function stampTypeFiles(types, rawManifest) {
75
+ const name = ENV_DTS.replace('types/', '');
76
+ if (typeof types?.[name] !== 'string') return types;
77
+ return { ...types, [name]: stampEnvDTS(types[name], rawManifest) };
78
+ }
79
+
80
+ /** The stamp an env .d.ts carries, or null when it has none. */
81
+ export function readStamp(envDTS) {
82
+ const line = envDTS.split('\n').find((l) => l.startsWith(STAMP_PREFIX));
83
+ return line ? line.slice(STAMP_PREFIX.length).trim() : null;
84
+ }
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "//": "The compiler options Juno type-checks with server-side. internal/verify's tsconfig_preset_test.go asserts this file and verify.CompilerOptions() are identical, so `tsc --noEmit` passing here means the deploy will not 422 on a type error. Extend it; do not fork it.",
3
3
  "compilerOptions": {
4
- "esModuleInterop": true,
4
+ "target": "ES2022",
5
5
  "module": "CommonJS",
6
6
  "moduleResolution": "node",
7
+ "strict": true,
7
8
  "noEmit": true,
9
+ "esModuleInterop": true,
8
10
  "resolveJsonModule": true,
9
- "skipLibCheck": true,
10
- "strict": true,
11
- "target": "ES2022"
11
+ "skipLibCheck": true
12
12
  }
13
13
  }