@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 +83 -0
- package/bin/juno.js +4 -0
- package/eslint.config.js +38 -0
- package/package.json +47 -0
- package/src/api.js +74 -0
- package/src/commands/check.js +60 -0
- package/src/commands/deploy.js +85 -0
- package/src/commands/init.js +224 -0
- package/src/commands/pull.js +89 -0
- package/src/commands/types.js +33 -0
- package/src/diagnostics.js +16 -0
- package/src/errors.js +26 -0
- package/src/index.js +114 -0
- package/src/project.js +92 -0
- package/src/target.js +46 -0
- package/tsconfig.base.json +13 -0
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
package/eslint.config.js
ADDED
|
@@ -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
|
+
}
|