@junoflow/cli 0.1.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.
package/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # @junoflow/cli
2
+
3
+ Develop [Juno](https://github.com/boivie/juno) integrations locally: scaffold a
4
+ project, generate types from your manifest, check it, and deploy it.
5
+
6
+ ```bash
7
+ npm install --save-dev @junoflow/cli
8
+ npx juno init my-integration
9
+ ```
10
+
11
+ An integration is a git repository holding `manifest.json` and TypeScript. Juno
12
+ fetches it at a tag, compiles it, and runs it in a sandbox. There is no
13
+ packaging format and nothing is published to npm.
14
+
15
+ ## Commands
16
+
17
+ | Command | What it does |
18
+ | ----------------------- | ------------------------------------------------------------------------------ |
19
+ | `juno init <name>` | scaffold a project. **No server needed.** |
20
+ | `juno types` | POST `manifest.json`, write `types/` |
21
+ | `juno check` | local `tsc --noEmit` + `eslint`, then validate against a Juno (writes nothing) |
22
+ | `juno deploy [--watch]` | upload, verify, activate — and write back the returned types |
23
+ | `juno pull` | write back the manifest and sources the instance holds |
24
+
25
+ ### Options
26
+
27
+ | Flag | Meaning |
28
+ | --------------- | -------------------------------------------------------------------- |
29
+ | `--url <url>` | the instance to talk to |
30
+ | `--name <name>` | address an install under a different name than the manifest declares |
31
+ | `--watch` | redeploy on every save (`deploy`) |
32
+ | `--force` | overwrite a dirty working tree (`pull`) |
33
+ | `--here` | scaffold into the current directory (`init`) |
34
+ | `--skip-local` | skip the local tsc/eslint pass (`check`) |
35
+
36
+ ## Choosing an instance
37
+
38
+ In order: `--url`, then `$JUNO_URL`, then `"juno": { "target": … }` in
39
+ `package.json`. A project downloaded from a Juno already has the third, so a
40
+ fresh clone deploys with no setup.
41
+
42
+ Juno has **no authentication on any endpoint**. An instance is a trusted-LAN
43
+ appliance; do not expose one to the internet.
44
+
45
+ ## The loop
46
+
47
+ ```bash
48
+ npm run watch # juno deploy --watch
49
+ ```
50
+
51
+ Add an action to `manifest.json`. The types regenerate, and your editor
52
+ immediately red-squiggles `src/integration.ts` because `IntegrationActions` now
53
+ requires a method you have not written — with the parameter and return types
54
+ your JSON Schema implies. The manifest drives the types drive the compiler.
55
+
56
+ ## Why the types are committed
57
+
58
+ `types/` is generated and checked in. CI needs no Juno, a fresh clone compiles
59
+ offline, and the diff in a pull request shows that adding an action changed the
60
+ interface. Most importantly, a drive-by contributor fixing a bug in someone
61
+ else's integration never needs an instance — only the person changing the
62
+ manifest does.
63
+
64
+ ## What runs where
65
+
66
+ | | offline | needs an instance |
67
+ | --------------------------------------------------------------- | ------- | ----------------- |
68
+ | `tsc --noEmit`, `eslint`, `vitest` | ✅ | |
69
+ | structural manifest check via `"$schema"` in your editor | ✅ | |
70
+ | reserved names, cross-surface collisions, JSON Schema compiling | | `juno check` |
71
+ | `juno types`, `juno deploy` | | ✅ |
72
+
73
+ Everything semantic lives in the Go server. This package knows some HTTP
74
+ endpoints and a config preset — that is deliberate, so there is one answer to
75
+ "is this manifest valid" rather than two that can disagree.
76
+
77
+ ## `init` vs. downloading a project
78
+
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.
package/bin/juno.js ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/index.js';
3
+
4
+ process.exitCode = await run(process.argv.slice(2));
@@ -0,0 +1,38 @@
1
+ import tsParser from '@typescript-eslint/parser';
2
+ import juno from '@junoflow/eslint-plugin';
3
+
4
+ /**
5
+ * The lint preset for an integration project.
6
+ *
7
+ * It enables exactly the rules Juno enforces server-side for an integration —
8
+ * `no-juno-sdk-import` and nothing else. The flow-only rules
9
+ * (`no-dynamic-step-id`, `no-sleep-in-step`) are about `step.run`, which
10
+ * integrations do not have; enabling them here would report on code that is
11
+ * fine.
12
+ *
13
+ * The one accepted asymmetry: Juno lints the **bundled output** (that is how
14
+ * `no-juno-sdk-import` catches a surviving `require`), while this lints your
15
+ * **source**. Both catch it; they catch it differently, and the rule knows both
16
+ * spellings. It is also why only this half needs a TypeScript parser — by the
17
+ * time the server lints, esbuild has already turned it into JavaScript.
18
+ *
19
+ * `tsconfig.base.json` is the other half of the preset, and a Go test asserts
20
+ * it matches what Juno type-checks with.
21
+ */
22
+ export default [
23
+ {
24
+ ignores: ['node_modules/**', 'dist/**', 'types/**'],
25
+ },
26
+ {
27
+ files: ['**/*.ts'],
28
+ languageOptions: {
29
+ parser: tsParser,
30
+ ecmaVersion: 2022,
31
+ sourceType: 'module',
32
+ },
33
+ plugins: { juno },
34
+ rules: {
35
+ 'juno/no-juno-sdk-import': 'error',
36
+ },
37
+ },
38
+ ];
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@junoflow/cli",
3
+ "version": "0.1.0",
4
+ "description": "Command line tools for developing Juno integrations: scaffold, generate types, check and deploy.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/boivie/juno.git",
9
+ "directory": "tools/npm/cli"
10
+ },
11
+ "type": "module",
12
+ "bin": {
13
+ "juno": "bin/juno.js"
14
+ },
15
+ "exports": {
16
+ ".": "./src/index.js",
17
+ "./package.json": "./package.json",
18
+ "./tsconfig.base.json": "./tsconfig.base.json",
19
+ "./eslint.config.js": "./eslint.config.js"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "src",
24
+ "tsconfig.base.json",
25
+ "eslint.config.js",
26
+ "README.md"
27
+ ],
28
+ "keywords": [
29
+ "juno",
30
+ "junoflow",
31
+ "cli",
32
+ "integration"
33
+ ],
34
+ "scripts": {
35
+ "test": "node --test \"test/**/*.test.js\""
36
+ },
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "dependencies": {
41
+ "@junoflow/eslint-plugin": "0.1.0",
42
+ "@typescript-eslint/parser": "^8.0.0"
43
+ },
44
+ "devDependencies": {
45
+ "typescript": "^5.9.0"
46
+ }
47
+ }
package/src/api.js ADDED
@@ -0,0 +1,74 @@
1
+ import { DiagnosticsError, UserError } from './errors.js';
2
+
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.
8
+ */
9
+ export class JunoClient {
10
+ constructor(target, { fetch: fetchImpl = globalThis.fetch } = {}) {
11
+ this.target = target;
12
+ this.fetch = fetchImpl;
13
+ }
14
+
15
+ /** POST /api/typegen/integration — both .d.ts halves, one call. */
16
+ async typegen(manifest) {
17
+ return this.#json('POST', '/api/typegen/integration', { manifest });
18
+ }
19
+
20
+ /** POST /api/integrations/ingest — deploy, or validate only with dryRun. */
21
+ async ingest(project, { dryRun = false, name } = {}) {
22
+ const query = dryRun ? '?dry_run=true' : '';
23
+ return this.#json('POST', `/api/integrations/ingest${query}`, {
24
+ manifest: project.manifest,
25
+ files: project.files,
26
+ package_json: project.packageJSON ?? undefined,
27
+ lockfile: project.lockfile ?? undefined,
28
+ name: name ?? undefined,
29
+ });
30
+ }
31
+
32
+ /** GET /api/integrations/{name} — the manifest and sources Juno holds. */
33
+ async getIntegration(name) {
34
+ return this.#json('GET', `/api/integrations/${encodeURIComponent(name)}`);
35
+ }
36
+
37
+ async #json(method, path, body) {
38
+ const url = `${this.target}${path}`;
39
+ let res;
40
+ try {
41
+ res = await this.fetch(url, {
42
+ method,
43
+ headers: body ? { 'content-type': 'application/json' } : {},
44
+ body: body ? JSON.stringify(body) : undefined,
45
+ });
46
+ } catch (err) {
47
+ throw new UserError(
48
+ `cannot reach Juno at ${this.target}: ${err.message}`
49
+ );
50
+ }
51
+
52
+ const text = await res.text();
53
+ let parsed = null;
54
+ try {
55
+ parsed = text ? JSON.parse(text) : null;
56
+ } catch {
57
+ // A non-JSON body from a proxy or a wrong URL; reported below as-is.
58
+ }
59
+
60
+ if (res.ok) return parsed;
61
+
62
+ // A 422 is the interesting failure: it carries the compiler's own
63
+ // findings, and reprinting them beats summarising them.
64
+ if (parsed?.diagnostics?.length) {
65
+ throw new DiagnosticsError(
66
+ parsed.detail || 'verification failed',
67
+ parsed.diagnostics
68
+ );
69
+ }
70
+ const detail =
71
+ parsed?.detail || parsed?.title || text.trim() || res.statusText;
72
+ throw new UserError(`${method} ${path} failed (${res.status}): ${detail}`);
73
+ }
74
+ }
@@ -0,0 +1,60 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { join } from 'node:path';
3
+
4
+ import { JunoClient } from '../api.js';
5
+ import { readProject } from '../project.js';
6
+ import { resolveTarget } from '../target.js';
7
+
8
+ /**
9
+ * `juno check` — the offline checks, then the ones only a Juno can answer.
10
+ *
11
+ * tsc and eslint run locally against the committed types; reserved names,
12
+ * cross-surface collisions and JSON Schema compilation need the server, so
13
+ * they go through ingest?dry_run=1, which stores nothing and is safe to point
14
+ * at a production instance.
15
+ */
16
+ export async function check(argv, io) {
17
+ let failed = false;
18
+
19
+ if (!argv['skip-local']) {
20
+ for (const [label, bin, args] of [
21
+ ['typecheck', 'tsc', ['--noEmit']],
22
+ ['lint', 'eslint', ['.']],
23
+ ]) {
24
+ const code = await runLocal(bin, args, io);
25
+ if (code === SKIPPED) {
26
+ io.warn(`${label}: ${bin} is not installed — run npm install`);
27
+ continue;
28
+ }
29
+ if (code !== 0) failed = true;
30
+ }
31
+ }
32
+
33
+ const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
34
+ const client = argv.client ?? new JunoClient(target);
35
+ const project = await readProject(io.cwd);
36
+ const result = await client.ingest(project, {
37
+ dryRun: true,
38
+ name: argv.name,
39
+ });
40
+ for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
41
+ io.log(`${result.name}: manifest and sources verified against ${target}`);
42
+
43
+ return failed ? 1 : 0;
44
+ }
45
+
46
+ const SKIPPED = -1;
47
+
48
+ /**
49
+ * Runs a local devtool from node_modules/.bin. Its output goes straight to the
50
+ * terminal: tsc and eslint already print file:line:col, and reformatting them
51
+ * would only lose the column.
52
+ */
53
+ function runLocal(bin, args, io) {
54
+ const path = join(io.cwd, 'node_modules', '.bin', bin);
55
+ return new Promise((resolve) => {
56
+ const child = spawn(path, args, { cwd: io.cwd, stdio: 'inherit' });
57
+ child.on('error', () => resolve(SKIPPED));
58
+ child.on('close', (code) => resolve(code ?? 1));
59
+ });
60
+ }
@@ -0,0 +1,85 @@
1
+ import { watch } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { JunoClient } from '../api.js';
5
+ import { DiagnosticsError } from '../errors.js';
6
+ import { readProject, writeTypes } from '../project.js';
7
+ import { resolveTarget } from '../target.js';
8
+ import { formatDiagnostics } from '../diagnostics.js';
9
+ import { pickTypeFiles } from './types.js';
10
+
11
+ /** How long to wait for edits to settle before redeploying. */
12
+ const WATCH_DEBOUNCE_MS = 200;
13
+
14
+ /**
15
+ * `juno deploy [--watch]` — upload the working copy, verify, activate.
16
+ *
17
+ * The response carries the regenerated types, so a deploy keeps the committed
18
+ * types/ honest without a second round trip that could answer for a different
19
+ * manifest.
20
+ */
21
+ export async function deploy(argv, io) {
22
+ const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
23
+ const client = argv.client ?? new JunoClient(target);
24
+
25
+ if (!argv.watch) {
26
+ await deployOnce(client, argv, io, target);
27
+ return 0;
28
+ }
29
+ return watchAndDeploy(client, argv, io, target);
30
+ }
31
+
32
+ async function deployOnce(client, argv, io, target) {
33
+ const project = await readProject(io.cwd);
34
+ const result = await client.ingest(project, { name: argv.name });
35
+ const written = await writeTypes(pickTypeFiles(result.types), io.cwd);
36
+ for (const warning of result.warnings ?? []) io.warn(`warning: ${warning}`);
37
+ io.log(
38
+ `deployed ${result.name} v${result.version} to ${target} (${written.join(', ')})`
39
+ );
40
+ return result;
41
+ }
42
+
43
+ /**
44
+ * Watch mode is the loop the whole design is for: edit the manifest, the types
45
+ * regenerate, and the editor red-squiggles the method you have not written yet.
46
+ * A failed deploy prints and keeps watching — the next save is the retry.
47
+ */
48
+ async function watchAndDeploy(client, argv, io, target) {
49
+ io.log(`watching ${io.cwd} — deploying to ${target}`);
50
+ await runWatchIteration(client, argv, io, target);
51
+
52
+ let timer = null;
53
+ const schedule = () => {
54
+ if (timer) clearTimeout(timer);
55
+ timer = setTimeout(() => {
56
+ timer = null;
57
+ void runWatchIteration(client, argv, io, target);
58
+ }, WATCH_DEBOUNCE_MS);
59
+ };
60
+
61
+ const watchers = [
62
+ watch(join(io.cwd, 'src'), { recursive: true }, schedule),
63
+ watch(join(io.cwd, 'manifest.json'), schedule),
64
+ ];
65
+ // Resolves only on SIGINT: watch mode ends when the author ends it.
66
+ await new Promise((resolve) => {
67
+ process.once('SIGINT', () => {
68
+ for (const w of watchers) w.close();
69
+ resolve();
70
+ });
71
+ });
72
+ return 0;
73
+ }
74
+
75
+ async function runWatchIteration(client, argv, io, target) {
76
+ try {
77
+ await deployOnce(client, argv, io, target);
78
+ } catch (err) {
79
+ if (err instanceof DiagnosticsError) {
80
+ io.error(formatDiagnostics(err.diagnostics));
81
+ } else {
82
+ io.error(err.message);
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,224 @@
1
+ import { mkdir, readFile, writeFile, access } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ import { UserError } from '../errors.js';
6
+
7
+ /**
8
+ * `juno init [name]` — scaffold a project with no server involved.
9
+ *
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.
16
+ */
17
+ export async function init(argv, io) {
18
+ const name = argv._[0] ?? argv.name;
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
+
24
+ const dir = resolve(io.cwd, argv.here ? '.' : name);
25
+ if (!argv.here && (await exists(dir))) {
26
+ throw new UserError(`${dir} already exists`);
27
+ }
28
+
29
+ const version = await toolingRange();
30
+ const files = scaffold(
31
+ name,
32
+ version,
33
+ argv.url ?? process.env.JUNO_URL ?? null
34
+ );
35
+ for (const [rel, content] of Object.entries(files)) {
36
+ const path = join(dir, rel);
37
+ await mkdir(dirname(path), { recursive: true });
38
+ await writeFile(path, content, 'utf8');
39
+ }
40
+
41
+ io.log(`created ${dir}`);
42
+ io.log('');
43
+ io.log(argv.here ? 'npm install' : `cd ${name} && npm install`);
44
+ io.log('npm run types # generate types/ from manifest.json');
45
+ io.log('npm test');
46
+ io.log('npm run deploy');
47
+ return 0;
48
+ }
49
+
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"]
167
+ }
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
+
217
+ async function exists(path) {
218
+ try {
219
+ await access(path);
220
+ return true;
221
+ } catch {
222
+ return false;
223
+ }
224
+ }
@@ -0,0 +1,89 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+
6
+ import { JunoClient } from '../api.js';
7
+ import { UserError } from '../errors.js';
8
+ import { readManifest } from '../project.js';
9
+ import { resolveTarget } from '../target.js';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * `juno pull` — write back the manifest and sources Juno holds.
15
+ *
16
+ * This exists because the zip is a good first touch and a bad second one: once
17
+ * the authoring agent has edited an integration you deployed, downloading the
18
+ * project again would clobber your working tree, while pull touches only the
19
+ * two things Juno owns. It still refuses a dirty tree without --force, because
20
+ * an uncommitted edit is the one thing git cannot get back for you.
21
+ */
22
+ export async function pull(argv, io) {
23
+ const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
24
+ const name = argv.name ?? (await readManifest(io.cwd)).name;
25
+ if (!name) {
26
+ throw new UserError(
27
+ 'manifest.json declares no name — pass --name <integration>'
28
+ );
29
+ }
30
+
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.json and src/ 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
+ }
44
+
45
+ const client = argv.client ?? new JunoClient(target);
46
+ const detail = await client.getIntegration(name);
47
+ if (!detail?.manifest) {
48
+ throw new UserError(
49
+ `${name} has no active version on ${target} — nothing to pull`
50
+ );
51
+ }
52
+
53
+ const written = ['manifest.json'];
54
+ await writeFile(
55
+ join(io.cwd, 'manifest.json'),
56
+ `${JSON.stringify(detail.manifest, null, 2)}\n`,
57
+ 'utf8'
58
+ );
59
+ for (const [rel, content] of Object.entries(detail.source_files ?? {})) {
60
+ const path = join(io.cwd, rel);
61
+ await mkdir(dirname(path), { recursive: true });
62
+ await writeFile(path, content, 'utf8');
63
+ written.push(rel);
64
+ }
65
+ io.log(
66
+ `pulled ${name} v${detail.version} from ${target} (${written.sort().join(', ')})`
67
+ );
68
+ io.log('run `juno types` to regenerate types/ against the pulled manifest');
69
+ return 0;
70
+ }
71
+
72
+ /**
73
+ * Returns the paths git reports as changed, [] for a clean tree, or null when
74
+ * this is not a git repository at all — three states the caller treats
75
+ * differently, so they must not collapse into a boolean.
76
+ */
77
+ async function dirtyPaths(cwd) {
78
+ try {
79
+ const { stdout } = await execFileAsync('git', ['status', '--porcelain'], {
80
+ cwd,
81
+ });
82
+ return stdout
83
+ .split('\n')
84
+ .map((line) => line.trim())
85
+ .filter(Boolean);
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
@@ -0,0 +1,33 @@
1
+ import { JunoClient } from '../api.js';
2
+ import { readManifest, writeTypes } from '../project.js';
3
+ import { resolveTarget } from '../target.js';
4
+
5
+ /**
6
+ * `juno types` — POST manifest.json, write back types/.
7
+ *
8
+ * Both halves come from one call so they cannot skew, and they are committed:
9
+ * a fresh clone compiles offline, and a contributor fixing a bug in someone
10
+ * else's integration never needs a Juno instance. Only the person editing the
11
+ * manifest does, which is the right person to require it of.
12
+ */
13
+ export async function types(argv, io) {
14
+ const target = await resolveTarget({ url: argv.url, cwd: io.cwd });
15
+ const manifest = await readManifest(io.cwd);
16
+ const client = argv.client ?? new JunoClient(target);
17
+
18
+ const generated = await client.typegen(manifest);
19
+ const written = await writeTypes(pickTypeFiles(generated), io.cwd);
20
+ io.log(`wrote ${written.join(', ')}`);
21
+ return 0;
22
+ }
23
+
24
+ /**
25
+ * The response carries huma's $schema alongside the two files. Filtering by
26
+ * extension rather than deleting a known key keeps a future third file working
27
+ * without a CLI release.
28
+ */
29
+ export function pickTypeFiles(body) {
30
+ return Object.fromEntries(
31
+ Object.entries(body ?? {}).filter(([name]) => name.endsWith('.d.ts'))
32
+ );
33
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Render one diagnostic as `file:line:col message`, the format editors and
3
+ * CI log parsers already know how to make clickable. A diagnostic with no
4
+ * position still leads with its file, so the shape never varies.
5
+ */
6
+ export function formatDiagnostic(d) {
7
+ const where = [d.file || 'manifest.json', d.line, d.col]
8
+ .filter(Boolean)
9
+ .join(':');
10
+ const code = d.code ? ` [${d.code}]` : '';
11
+ return `${where} ${d.message}${code}`;
12
+ }
13
+
14
+ export function formatDiagnostics(diagnostics) {
15
+ return diagnostics.map(formatDiagnostic).join('\n');
16
+ }
package/src/errors.js ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * UserError is something the person at the keyboard can fix: a missing target,
3
+ * an unreadable manifest, a dirty working tree. The top-level handler prints
4
+ * its message alone and exits 1 — no stack trace, because a stack trace tells
5
+ * them about our code when the problem is their input.
6
+ */
7
+ export class UserError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'UserError';
11
+ }
12
+ }
13
+
14
+ /**
15
+ * DiagnosticsError carries a server 422's structured findings. It is separate
16
+ * from UserError because it renders as a list of file:line:col lines rather
17
+ * than a sentence — that is what makes an editor's error parser click through
18
+ * to the offending line.
19
+ */
20
+ export class DiagnosticsError extends Error {
21
+ constructor(message, diagnostics) {
22
+ super(message);
23
+ this.name = 'DiagnosticsError';
24
+ this.diagnostics = diagnostics ?? [];
25
+ }
26
+ }
package/src/index.js ADDED
@@ -0,0 +1,114 @@
1
+ import { parseArgs } from 'node:util';
2
+
3
+ import { check } from './commands/check.js';
4
+ import { deploy } from './commands/deploy.js';
5
+ import { init } from './commands/init.js';
6
+ import { pull } from './commands/pull.js';
7
+ import { types } from './commands/types.js';
8
+ import { DiagnosticsError, UserError } from './errors.js';
9
+ import { formatDiagnostics } from './diagnostics.js';
10
+
11
+ const COMMANDS = { init, types, check, deploy, pull };
12
+
13
+ const USAGE = `juno — develop Juno integrations
14
+
15
+ Usage: juno <command> [options]
16
+
17
+ Commands:
18
+ init <name> scaffold a new integration project (no server needed)
19
+ types generate types/ from manifest.json
20
+ check typecheck and lint locally, then validate against a Juno
21
+ deploy [--watch] upload, verify, and activate a new version
22
+ pull write back the manifest and sources Juno holds
23
+
24
+ Options:
25
+ --url <url> the Juno instance; else $JUNO_URL, else package.json juno.target
26
+ --name <name> address an install under a different name than the manifest
27
+ --watch deploy on every save (deploy only)
28
+ --force overwrite a dirty working tree (pull only)
29
+ --here scaffold into the current directory (init only)
30
+ --skip-local skip the local tsc/eslint pass (check only)
31
+ -h, --help this
32
+ `;
33
+
34
+ /**
35
+ * run is the whole CLI, injectable so the tests drive it without spawning a
36
+ * process: io carries cwd and the three output streams, and a command may be
37
+ * handed a client instead of building one from a URL.
38
+ */
39
+ export async function run(argv, io = defaultIO()) {
40
+ const [command, ...rest] = argv;
41
+
42
+ if (
43
+ !command ||
44
+ command === '--help' ||
45
+ command === '-h' ||
46
+ command === 'help'
47
+ ) {
48
+ io.log(USAGE);
49
+ return command ? 0 : 1;
50
+ }
51
+ const handler = COMMANDS[command];
52
+ if (!handler) {
53
+ io.error(`unknown command: ${command}\n`);
54
+ io.error(USAGE);
55
+ return 1;
56
+ }
57
+
58
+ let parsed;
59
+ try {
60
+ parsed = parseArgs({
61
+ args: rest,
62
+ allowPositionals: true,
63
+ options: {
64
+ url: { type: 'string' },
65
+ name: { type: 'string' },
66
+ watch: { type: 'boolean', default: false },
67
+ force: { type: 'boolean', default: false },
68
+ here: { type: 'boolean', default: false },
69
+ 'skip-local': { type: 'boolean', default: false },
70
+ help: { type: 'boolean', short: 'h', default: false },
71
+ },
72
+ });
73
+ } catch (err) {
74
+ io.error(err.message);
75
+ return 1;
76
+ }
77
+ if (parsed.values.help) {
78
+ io.log(USAGE);
79
+ return 0;
80
+ }
81
+
82
+ const args = { ...parsed.values, _: parsed.positionals, client: io.client };
83
+ try {
84
+ return (await handler(args, io)) ?? 0;
85
+ } catch (err) {
86
+ // Diagnostics print as file:line:col so an editor can click through; a
87
+ // UserError is a sentence about their input, and neither deserves a stack.
88
+ if (err instanceof DiagnosticsError) {
89
+ io.error(formatDiagnostics(err.diagnostics));
90
+ io.error(`\n${err.diagnostics.length} problem(s): ${err.message}`);
91
+ return 1;
92
+ }
93
+ if (err instanceof UserError) {
94
+ io.error(err.message);
95
+ return 1;
96
+ }
97
+ throw err;
98
+ }
99
+ }
100
+
101
+ export function defaultIO() {
102
+ return {
103
+ cwd: process.cwd(),
104
+ client: null,
105
+ log: (msg) => process.stdout.write(`${msg}\n`),
106
+ warn: (msg) => process.stderr.write(`${msg}\n`),
107
+ error: (msg) => process.stderr.write(`${msg}\n`),
108
+ };
109
+ }
110
+
111
+ export { JunoClient } from './api.js';
112
+ export { DiagnosticsError, UserError } from './errors.js';
113
+ export { resolveTarget } from './target.js';
114
+ export { readProject } from './project.js';
package/src/project.js ADDED
@@ -0,0 +1,92 @@
1
+ import { readFile, readdir, mkdir, writeFile } from 'node:fs/promises';
2
+ import { dirname, join, posix, relative, sep } from 'node:path';
3
+
4
+ import { UserError } from './errors.js';
5
+
6
+ /** Only these reach the server. Everything else is project furniture. */
7
+ const SOURCE_EXTENSIONS = ['.ts', '.json'];
8
+ const SOURCE_DIR = 'src';
9
+
10
+ /**
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.
14
+ */
15
+ export async function readProject(cwd = process.cwd()) {
16
+ const manifest = await readManifest(cwd);
17
+ const files = await readSourceFiles(cwd);
18
+ if (Object.keys(files).length === 0) {
19
+ throw new UserError(`no TypeScript sources found under ${SOURCE_DIR}/`);
20
+ }
21
+ return {
22
+ manifest,
23
+ files,
24
+ packageJSON: await readOptional(join(cwd, 'package.json')),
25
+ lockfile: await readOptional(join(cwd, 'package-lock.json')),
26
+ };
27
+ }
28
+
29
+ export async function readManifest(cwd = process.cwd()) {
30
+ const path = join(cwd, 'manifest.json');
31
+ let raw;
32
+ try {
33
+ raw = await readFile(path, 'utf8');
34
+ } catch {
35
+ throw new UserError(
36
+ `no manifest.json in ${cwd} — run this from an integration project root`
37
+ );
38
+ }
39
+ try {
40
+ return JSON.parse(raw);
41
+ } catch (err) {
42
+ throw new UserError(`manifest.json is not valid JSON: ${err.message}`);
43
+ }
44
+ }
45
+
46
+ /** Walks src/ and returns repo-relative POSIX paths → content. */
47
+ export async function readSourceFiles(cwd = process.cwd()) {
48
+ const root = join(cwd, SOURCE_DIR);
49
+ const files = {};
50
+ async function walk(dir) {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(dir, { withFileTypes: true });
54
+ } catch {
55
+ return; // no src/ at all; the caller reports it
56
+ }
57
+ for (const entry of entries) {
58
+ const full = join(dir, entry.name);
59
+ if (entry.isDirectory()) {
60
+ await walk(full);
61
+ continue;
62
+ }
63
+ if (!SOURCE_EXTENSIONS.some((ext) => entry.name.endsWith(ext))) continue;
64
+ // Server-side paths are always POSIX: they become keys in a JSON map and
65
+ // then filenames in a Linux sandbox.
66
+ const rel = relative(cwd, full).split(sep).join(posix.sep);
67
+ files[rel] = await readFile(full, 'utf8');
68
+ }
69
+ }
70
+ await walk(root);
71
+ return files;
72
+ }
73
+
74
+ /** Writes the .d.ts pair the server returned into types/. */
75
+ export async function writeTypes(types, cwd = process.cwd()) {
76
+ const written = [];
77
+ for (const [name, content] of Object.entries(types)) {
78
+ const path = join(cwd, 'types', name);
79
+ await mkdir(dirname(path), { recursive: true });
80
+ await writeFile(path, content, 'utf8');
81
+ written.push(posix.join('types', name));
82
+ }
83
+ return written.sort();
84
+ }
85
+
86
+ async function readOptional(path) {
87
+ try {
88
+ return await readFile(path, 'utf8');
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
package/src/target.js ADDED
@@ -0,0 +1,46 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ import { UserError } from './errors.js';
5
+
6
+ /**
7
+ * Resolve which Juno instance to talk to, in precedence order: an explicit
8
+ * --url, then $JUNO_URL, then `juno.target` in package.json — which is what an
9
+ * ejected project arrives with, so a fresh clone deploys with no setup.
10
+ *
11
+ * A missing target names all three: "no target configured" leaves the reader
12
+ * guessing which of the three they were supposed to have used.
13
+ */
14
+ export async function resolveTarget({ url, cwd = process.cwd() } = {}) {
15
+ const candidate =
16
+ url ?? process.env.JUNO_URL ?? (await targetFromPackageJSON(cwd));
17
+ if (!candidate) {
18
+ throw new UserError(
19
+ 'no Juno instance to deploy to. Pass --url <url>, set $JUNO_URL, or add\n' +
20
+ ' "juno": { "target": "http://juno.local:8080" }\n' +
21
+ 'to package.json.'
22
+ );
23
+ }
24
+ return normalizeTarget(candidate);
25
+ }
26
+
27
+ async function targetFromPackageJSON(cwd) {
28
+ try {
29
+ const raw = await readFile(join(cwd, 'package.json'), 'utf8');
30
+ return JSON.parse(raw).juno?.target ?? null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /** Accepts a bare host the way a browser address bar does. */
37
+ export function normalizeTarget(target) {
38
+ let t = target.trim().replace(/\/+$/, '');
39
+ if (!/^https?:\/\//.test(t)) t = `http://${t}`;
40
+ try {
41
+ new URL(t);
42
+ } catch {
43
+ throw new UserError(`not a usable Juno URL: ${target}`);
44
+ }
45
+ return t;
46
+ }
@@ -0,0 +1,13 @@
1
+ {
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
+ "compilerOptions": {
4
+ "esModuleInterop": true,
5
+ "module": "CommonJS",
6
+ "moduleResolution": "node",
7
+ "noEmit": true,
8
+ "resolveJsonModule": true,
9
+ "skipLibCheck": true,
10
+ "strict": true,
11
+ "target": "ES2022"
12
+ }
13
+ }