@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 +35 -10
- package/package.json +2 -8
- package/src/api.js +19 -4
- package/src/commands/check.js +11 -0
- package/src/commands/deploy.js +8 -2
- package/src/commands/init.js +30 -186
- package/src/commands/types.js +7 -2
- package/src/commands/verify-types.js +57 -0
- package/src/index.js +11 -2
- package/src/project.js +11 -4
- package/src/stamp.js +84 -0
- package/tsconfig.base.json +4 -4
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>` |
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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.
|
|
4
|
-
"description": "Command line tools for developing Juno integrations
|
|
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
|
|
5
|
-
* semantic: validation, reserved names, schema compilation and
|
|
6
|
-
* live in the Go server, so there is one answer to "is
|
|
7
|
-
* rather than two that can
|
|
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 });
|
package/src/commands/check.js
CHANGED
|
@@ -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', ['.']],
|
package/src/commands/deploy.js
CHANGED
|
@@ -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
|
-
|
|
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(', ')})`
|
package/src/commands/init.js
CHANGED
|
@@ -1,39 +1,36 @@
|
|
|
1
|
-
import { mkdir,
|
|
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]` —
|
|
9
|
+
* `juno init [name]` — ask a Juno for a starting project and write it down.
|
|
9
10
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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 {
|
package/src/commands/types.js
CHANGED
|
@@ -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(
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/tsconfig.base.json
CHANGED
|
@@ -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
|
-
"
|
|
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
|
}
|