@junoflow/cli 0.3.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +693 -52
- package/app-client.js +306 -0
- package/bin/juno.js +7 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +35 -0
- package/package.json +12 -4
- package/src/api.js +541 -30
- package/src/app-dev/build.js +134 -0
- package/src/app-dev/harness.js +108 -0
- package/src/app-dev/panel.html +336 -0
- package/src/app-dev/panel.js +290 -0
- package/src/app-dev/server.js +347 -0
- package/src/app-dev/stream.js +224 -0
- package/src/app-dev/watch.js +47 -0
- package/src/app-project.js +133 -0
- package/src/authoring-skills.js +43 -0
- package/src/commands/agent-skill-install.js +64 -0
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +102 -0
- package/src/commands/app-dev.js +141 -0
- package/src/commands/app-gen-types.js +37 -0
- package/src/commands/app-init.js +46 -0
- package/src/commands/app-pull.js +106 -0
- package/src/commands/ask.js +60 -0
- package/src/commands/check.js +71 -27
- package/src/commands/config.js +26 -0
- package/src/commands/credential-set.js +68 -0
- package/src/commands/deploy.js +95 -51
- package/src/commands/flow-check.js +75 -0
- package/src/commands/flow-deploy.js +110 -0
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +37 -0
- package/src/commands/flow-init.js +59 -0
- package/src/commands/flow-pull.js +115 -0
- package/src/commands/{types.js → gen-types.js} +12 -5
- package/src/commands/init.js +28 -16
- package/src/commands/pull.js +111 -63
- package/src/commands/session-approve.js +73 -0
- package/src/commands/skill.js +73 -0
- package/src/commands/tool.js +249 -0
- package/src/commands/verify-types.js +4 -2
- package/src/config.js +34 -0
- package/src/diagnostics.js +14 -0
- package/src/errors.js +40 -0
- package/src/exit.js +22 -0
- package/src/flow-project.js +78 -0
- package/src/help.js +199 -0
- package/src/index.js +567 -75
- package/src/installed-skill.js +55 -0
- package/src/project.js +76 -8
- package/src/prompt.js +196 -0
- package/src/stamp.js +10 -9
- package/src/suggest.js +67 -0
- package/src/target.js +22 -7
- package/src/verbs.js +34 -0
- package/src/watch.js +87 -0
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +276 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readAppPackage } from '../app-project.js';
|
|
2
|
+
import { createBuilder } from '../app-dev/build.js';
|
|
3
|
+
import { createDevHarness } from '../app-dev/harness.js';
|
|
4
|
+
import { createPanel } from '../app-dev/panel.js';
|
|
5
|
+
import { createDevServer } from '../app-dev/server.js';
|
|
6
|
+
import { createLive } from '../app-dev/stream.js';
|
|
7
|
+
import { watchApp } from '../app-dev/watch.js';
|
|
8
|
+
import { UsageError, UserError } from '../errors.js';
|
|
9
|
+
import { readManifestFile } from '../working-copy.js';
|
|
10
|
+
|
|
11
|
+
export const APP_DEV_USAGE = 'juno app dev [--port <n>] [--scenario <file>]';
|
|
12
|
+
|
|
13
|
+
/** The port `juno app dev` listens on without --port. */
|
|
14
|
+
export const DEFAULT_DEV_PORT = 8090;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `juno app dev` — serve this app on localhost, offline, on mocked data
|
|
18
|
+
* (old-docs/design-docs/plan-local-dev-6-app-dev-server.md). The page is
|
|
19
|
+
* page/ as written, its modules bundled per request; the handlers run in
|
|
20
|
+
* Node through the working copy's own appHarness; the world is the
|
|
21
|
+
* --scenario file, or empty. It never reads `juno.target`, never connects to
|
|
22
|
+
* an instance and never reads a credential, so its effect is `local`.
|
|
23
|
+
*
|
|
24
|
+
* Bound to 127.0.0.1 only. It runs until SIGINT (or io.signal aborts), then
|
|
25
|
+
* closes the server and removes its temp directory.
|
|
26
|
+
*/
|
|
27
|
+
export async function appDev(argv, io) {
|
|
28
|
+
const port = parsePort(argv.port);
|
|
29
|
+
const dev = await startAppDev({
|
|
30
|
+
cwd: io.cwd,
|
|
31
|
+
port,
|
|
32
|
+
scenario: argv.scenario,
|
|
33
|
+
log: io.error,
|
|
34
|
+
});
|
|
35
|
+
io.log(
|
|
36
|
+
`juno app dev: ${dev.id} on http://localhost:${dev.port}/ ` +
|
|
37
|
+
`(${dev.harness.scenario ? `scenario: ${dev.harness.scenario.path}` : 'no scenario'})`
|
|
38
|
+
);
|
|
39
|
+
await new Promise((resolve) => {
|
|
40
|
+
const stop = () => {
|
|
41
|
+
process.off('SIGINT', stop);
|
|
42
|
+
io.signal?.removeEventListener('abort', stop);
|
|
43
|
+
resolve();
|
|
44
|
+
};
|
|
45
|
+
process.once('SIGINT', stop);
|
|
46
|
+
io.signal?.addEventListener('abort', stop, { once: true });
|
|
47
|
+
if (io.signal?.aborted) stop();
|
|
48
|
+
});
|
|
49
|
+
await dev.close();
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* startAppDev reads the working copy, resolves esbuild and the testkit from
|
|
55
|
+
* it, and starts the server on port (0 picks a free one), with the live
|
|
56
|
+
* stream, the panel and the watcher wired in. It resolves to
|
|
57
|
+
* `{id, port, server, harness, builder, live, panel, close}`; `server` is
|
|
58
|
+
* createDevServer's object, whose `routes` a caller may extend. `pingMs` and
|
|
59
|
+
* `debounceMs` exist for tests.
|
|
60
|
+
*/
|
|
61
|
+
export async function startAppDev({
|
|
62
|
+
cwd,
|
|
63
|
+
port,
|
|
64
|
+
scenario,
|
|
65
|
+
log = () => {},
|
|
66
|
+
pingMs,
|
|
67
|
+
debounceMs,
|
|
68
|
+
}) {
|
|
69
|
+
await readAppPackage(cwd);
|
|
70
|
+
const { manifest } = await readManifestFile(cwd);
|
|
71
|
+
if (typeof manifest?.id !== 'string' || manifest.id === '') {
|
|
72
|
+
throw new UserError('manifest.json has no id');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const builder = await createBuilder(cwd);
|
|
76
|
+
try {
|
|
77
|
+
const harness = await createDevHarness({ cwd, manifest, builder });
|
|
78
|
+
if (scenario !== undefined) await harness.useScenario(scenario);
|
|
79
|
+
const live = createLive({ dev: harness, pingMs });
|
|
80
|
+
const panel = createPanel({
|
|
81
|
+
cwd,
|
|
82
|
+
dev: harness,
|
|
83
|
+
builder,
|
|
84
|
+
live,
|
|
85
|
+
pingMs,
|
|
86
|
+
log,
|
|
87
|
+
});
|
|
88
|
+
await panel.noteScenario();
|
|
89
|
+
const server = createDevServer({
|
|
90
|
+
cwd,
|
|
91
|
+
dev: harness,
|
|
92
|
+
builder,
|
|
93
|
+
log,
|
|
94
|
+
routes: { stream: live.serve, dev: panel.route, shell: panel.shell },
|
|
95
|
+
});
|
|
96
|
+
let bound;
|
|
97
|
+
try {
|
|
98
|
+
bound = await server.listen(port);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (err.code === 'EADDRINUSE') {
|
|
101
|
+
throw new UserError(
|
|
102
|
+
`port ${port} is in use — pass --port <n> to pick another`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
const watcher = watchApp(cwd, panel.watch, { debounceMs });
|
|
108
|
+
return {
|
|
109
|
+
id: manifest.id,
|
|
110
|
+
port: bound,
|
|
111
|
+
server,
|
|
112
|
+
harness,
|
|
113
|
+
builder,
|
|
114
|
+
live,
|
|
115
|
+
panel,
|
|
116
|
+
async close() {
|
|
117
|
+
watcher.close();
|
|
118
|
+
await panel.settled();
|
|
119
|
+
live.close();
|
|
120
|
+
panel.close();
|
|
121
|
+
await server.close();
|
|
122
|
+
await builder.close();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
} catch (err) {
|
|
126
|
+
await builder.close();
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function parsePort(value) {
|
|
132
|
+
if (value === undefined) return DEFAULT_DEV_PORT;
|
|
133
|
+
const port = Number(value);
|
|
134
|
+
// 0 is the operating system's pick; the printed URL names the port it made.
|
|
135
|
+
if (!/^\d+$/.test(String(value)) || port > 65535) {
|
|
136
|
+
throw new UsageError(
|
|
137
|
+
`--port must be a port number from 0 to 65535, got ${JSON.stringify(value)}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return port;
|
|
141
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { readAppPackage } from '../app-project.js';
|
|
3
|
+
import { UserError } from '../errors.js';
|
|
4
|
+
import {
|
|
5
|
+
readManifestFile,
|
|
6
|
+
typeFiles,
|
|
7
|
+
writeChangedFiles,
|
|
8
|
+
} from '../working-copy.js';
|
|
9
|
+
|
|
10
|
+
export const APP_GEN_TYPES_USAGE = 'juno app gen-types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `juno app gen-types` — regenerate the three files under types/ from
|
|
14
|
+
* manifest.json against the instance's live catalog: app-env.d.ts (what
|
|
15
|
+
* src/app.ts is checked against), app-client.d.ts (`juno` as the page sees
|
|
16
|
+
* it) and app-api.d.ts. manifest.json itself is left alone. Stale types
|
|
17
|
+
* cannot ship regardless, because ingest regenerates them.
|
|
18
|
+
*/
|
|
19
|
+
export async function appGenTypes(argv, io) {
|
|
20
|
+
await readAppPackage(io.cwd);
|
|
21
|
+
const { manifest } = await readManifestFile(io.cwd);
|
|
22
|
+
const { client } = await connect(argv, io);
|
|
23
|
+
const generated = await client.typegenApp(manifest);
|
|
24
|
+
const files = typeFiles(generated.types);
|
|
25
|
+
if (!Object.keys(files).length) {
|
|
26
|
+
throw new UserError(
|
|
27
|
+
`${client.target} answered no .d.ts files for this manifest`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const written = await writeChangedFiles(io.cwd, files);
|
|
31
|
+
io.log(
|
|
32
|
+
written.length
|
|
33
|
+
? `wrote ${written.join(', ')}`
|
|
34
|
+
: `${Object.keys(files).sort().join(', ')} already up to date`
|
|
35
|
+
);
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import { printAuthoringIndex } from '../authoring-skills.js';
|
|
5
|
+
import { UsageError, UserError } from '../errors.js';
|
|
6
|
+
import { writeChangedFiles } from '../working-copy.js';
|
|
7
|
+
import { exists } from './init.js';
|
|
8
|
+
|
|
9
|
+
export const APP_INIT_USAGE = 'juno app init <id> [--here]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno app init <id>` — a working copy of the app's active version, or a
|
|
13
|
+
* stub with one handler for an id with none, written into ./<id> (or the
|
|
14
|
+
* current directory with --here).
|
|
15
|
+
*
|
|
16
|
+
* Every file comes from POST /api/apps/scaffold, the single generator of an
|
|
17
|
+
* app working copy: the CLI writes what it is handed and holds no copy of the
|
|
18
|
+
* layout, the script lines or the types. After the next steps it prints the
|
|
19
|
+
* app author's skill index, which is where an external agent starts.
|
|
20
|
+
*/
|
|
21
|
+
export async function appInit(argv, io) {
|
|
22
|
+
const [id, ...extra] = argv._;
|
|
23
|
+
if (!id || extra.length > 0) {
|
|
24
|
+
throw new UsageError(`usage: ${APP_INIT_USAGE}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const dir = resolve(io.cwd, argv.here ? '.' : id);
|
|
28
|
+
if (!argv.here && (await exists(dir))) {
|
|
29
|
+
throw new UserError(`${dir} already exists`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { target, client } = await connect(argv, io);
|
|
33
|
+
// The id is validated server-side, by the validator ingest runs: one answer
|
|
34
|
+
// to "is this a valid app id".
|
|
35
|
+
const files = await client.scaffoldApp(id, { target });
|
|
36
|
+
await writeChangedFiles(dir, files);
|
|
37
|
+
|
|
38
|
+
io.log(`created ${dir}`);
|
|
39
|
+
io.log('');
|
|
40
|
+
io.log(argv.here ? 'npm install' : `cd ${id} && npm install`);
|
|
41
|
+
io.log('npm test');
|
|
42
|
+
io.log('npm run check # stores a draft and prints its preview URL');
|
|
43
|
+
io.log('npm run deploy');
|
|
44
|
+
await printAuthoringIndex('app', client, target, io);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { readAppPackage, updateAppPackage } from '../app-project.js';
|
|
3
|
+
import { UserError } from '../errors.js';
|
|
4
|
+
import {
|
|
5
|
+
baseArtifactPaths,
|
|
6
|
+
baseVersionOf,
|
|
7
|
+
MANIFEST,
|
|
8
|
+
manifestText,
|
|
9
|
+
PACKAGE_JSON,
|
|
10
|
+
pullArtifacts,
|
|
11
|
+
requireCleanTree,
|
|
12
|
+
setDependencies,
|
|
13
|
+
writeChangedFiles,
|
|
14
|
+
} from '../working-copy.js';
|
|
15
|
+
|
|
16
|
+
export const APP_PULL_USAGE = 'juno app pull [--force]';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* `juno app pull` — make the working copy's artifact files the app's active
|
|
20
|
+
* version: manifest.json and every file under src/, test/, page/ and public/,
|
|
21
|
+
* exactly as the version stores them (GET /api/apps/{id}/versions/{v}), never
|
|
22
|
+
* as the scaffold renders them. types/ is generated, so it comes from
|
|
23
|
+
* POST /api/apps/scaffold, which also names the active version. In
|
|
24
|
+
* package.json only `dependencies` (the manifest's) and `juno.base_version`
|
|
25
|
+
* are Juno's; `scripts` and every other key are the author's and stay.
|
|
26
|
+
*
|
|
27
|
+
* A local file is deleted only when the base version (`juno.base_version`)
|
|
28
|
+
* had it and the pulled one does not; a file neither has is one not deployed
|
|
29
|
+
* yet, and stays. page/tsconfig.json and the other project files are never
|
|
30
|
+
* written or deleted.
|
|
31
|
+
*
|
|
32
|
+
* It refuses a dirty git tree without --force, as the other kinds' pull
|
|
33
|
+
* does: an uncommitted edit is the one thing nothing can get back.
|
|
34
|
+
*/
|
|
35
|
+
export async function appPull(argv, io) {
|
|
36
|
+
const pkg = await readAppPackage(io.cwd);
|
|
37
|
+
const appId = pkg.juno.app_id;
|
|
38
|
+
|
|
39
|
+
await requireCleanTree(io.cwd, {
|
|
40
|
+
force: argv.force,
|
|
41
|
+
overwrites: `${MANIFEST}, src/, test/, page/, public/ and types/`,
|
|
42
|
+
io,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const { target, client } = await connect(argv, io);
|
|
46
|
+
const files = await client.scaffoldApp(appId, { target });
|
|
47
|
+
let scaffoldPkg;
|
|
48
|
+
try {
|
|
49
|
+
scaffoldPkg = JSON.parse(files[PACKAGE_JSON]);
|
|
50
|
+
} catch {
|
|
51
|
+
throw new UserError(
|
|
52
|
+
`${target} answered a working copy with no readable ${PACKAGE_JSON}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
const version = scaffoldPkg.juno?.base_version;
|
|
56
|
+
const noActive = () =>
|
|
57
|
+
new UserError(
|
|
58
|
+
`${appId} has no active version on ${target} — nothing to pull`
|
|
59
|
+
);
|
|
60
|
+
// No base_version is the scaffold's stub for an app with no active
|
|
61
|
+
// version; writing it over the author's work would lose it for nothing.
|
|
62
|
+
if (!Number.isInteger(version)) throw noActive();
|
|
63
|
+
|
|
64
|
+
// Everything is fetched before anything is written, so a failure leaves
|
|
65
|
+
// the working copy as it was.
|
|
66
|
+
const detail = await client.getAppVersion(appId, version);
|
|
67
|
+
if (!detail) throw noActive();
|
|
68
|
+
const stored = { [MANIFEST]: manifestText(detail.manifest), ...detail.files };
|
|
69
|
+
const base = await baseArtifactPaths({
|
|
70
|
+
base: baseVersionOf(pkg),
|
|
71
|
+
version,
|
|
72
|
+
stored,
|
|
73
|
+
read: async (v) => {
|
|
74
|
+
const b = await client.getAppVersion(appId, v);
|
|
75
|
+
return b ? [MANIFEST, ...Object.keys(b.files ?? {})] : null;
|
|
76
|
+
},
|
|
77
|
+
id: appId,
|
|
78
|
+
target,
|
|
79
|
+
io,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const { written, removed } = await pullArtifacts(io.cwd, {
|
|
83
|
+
stored,
|
|
84
|
+
base,
|
|
85
|
+
kind: 'app',
|
|
86
|
+
});
|
|
87
|
+
written.push(
|
|
88
|
+
...(await writeChangedFiles(
|
|
89
|
+
io.cwd,
|
|
90
|
+
Object.fromEntries(
|
|
91
|
+
Object.entries(files).filter(([name]) => name.startsWith('types/'))
|
|
92
|
+
)
|
|
93
|
+
))
|
|
94
|
+
);
|
|
95
|
+
await updateAppPackage(io.cwd, (p) => {
|
|
96
|
+
setDependencies(p, detail.manifest);
|
|
97
|
+
p.juno.base_version = version;
|
|
98
|
+
});
|
|
99
|
+
written.push(PACKAGE_JSON);
|
|
100
|
+
|
|
101
|
+
io.log(
|
|
102
|
+
`pulled ${appId} v${version} from ${target} (${written.sort().join(', ')})`
|
|
103
|
+
);
|
|
104
|
+
if (removed.length) io.log(`removed ${removed.join(', ')}`);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import { UsageError } from '../errors.js';
|
|
5
|
+
import { converse, DEFAULT_WAIT_SECONDS, finish } from '../prompt.js';
|
|
6
|
+
|
|
7
|
+
export const ASK_USAGE =
|
|
8
|
+
'juno ask ["<prompt>"] [--session <id>] [--wait <seconds>]';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `juno ask` is the CLI for the Agent Prompt API: it prompts the in-app
|
|
12
|
+
* assistant and prints the turn's final event. Off a TTY each call is one
|
|
13
|
+
* `stream: false` request that waits up to --wait seconds; on one it streams
|
|
14
|
+
* and writes progress to stderr. The exit code is the outcome (prompt.js).
|
|
15
|
+
*
|
|
16
|
+
* `--session <id>` resumes a conversation; with no prompt it observes, which
|
|
17
|
+
* is how to keep waiting on a turn that answered `pending`. A new
|
|
18
|
+
* conversation's id is minted here, so a transport retry resumes it rather
|
|
19
|
+
* than starting a second.
|
|
20
|
+
*/
|
|
21
|
+
export async function ask(args, io) {
|
|
22
|
+
const prompt = args._.length ? args._.join(' ') : undefined;
|
|
23
|
+
if (prompt === undefined && args.session === undefined) {
|
|
24
|
+
throw new UsageError(
|
|
25
|
+
`juno ask needs a prompt, or --session <id> to observe a session\nusage: ${ASK_USAGE}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (prompt !== undefined && !prompt.trim()) {
|
|
29
|
+
throw new UsageError('the prompt is empty');
|
|
30
|
+
}
|
|
31
|
+
// To a session awaiting approval, "approve" lets a tool call through. That
|
|
32
|
+
// is `juno session approve`, so a rule allowing `juno ask` cannot do it.
|
|
33
|
+
if (prompt?.trim().toLowerCase() === 'approve') {
|
|
34
|
+
throw new UsageError(
|
|
35
|
+
'juno ask does not approve tool calls; use: juno session approve ' +
|
|
36
|
+
(args.session ?? '<session-id>')
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const stream = io.isTTY;
|
|
41
|
+
const body = { session_id: args.session ?? randomUUID() };
|
|
42
|
+
if (prompt !== undefined) {
|
|
43
|
+
body.message = prompt;
|
|
44
|
+
body.message_id = randomUUID();
|
|
45
|
+
}
|
|
46
|
+
if (!stream) body.wait_timeout_seconds = waitSeconds(args.wait);
|
|
47
|
+
|
|
48
|
+
const { client } = await connect(args, io);
|
|
49
|
+
const outcome = await converse(client, body, { stream, io });
|
|
50
|
+
return finish(outcome, body.session_id, io);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** waitSeconds parses --wait; the server bounds it (1–600) and says so. */
|
|
54
|
+
export function waitSeconds(flag) {
|
|
55
|
+
if (flag === undefined) return DEFAULT_WAIT_SECONDS;
|
|
56
|
+
if (!/^\d+$/.test(flag) || Number(flag) < 1) {
|
|
57
|
+
throw new UsageError(`--wait takes a number of seconds, got "${flag}"`);
|
|
58
|
+
}
|
|
59
|
+
return Number(flag);
|
|
60
|
+
}
|
package/src/commands/check.js
CHANGED
|
@@ -1,19 +1,28 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { JunoClient } from '../api.js';
|
|
5
|
-
import {
|
|
4
|
+
import { clientName, JunoClient } from '../api.js';
|
|
5
|
+
import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
|
|
6
|
+
import { DiagnosticsError, UserError } from '../errors.js';
|
|
6
7
|
import { readProject } from '../project.js';
|
|
7
8
|
import { resolveTarget } from '../target.js';
|
|
9
|
+
import { baseFor } from './deploy.js';
|
|
8
10
|
import { verifyTypes } from './verify-types.js';
|
|
9
11
|
|
|
12
|
+
export const CHECK_USAGE =
|
|
13
|
+
'juno integration check [--skip-local] [--id <id>] [--json]';
|
|
14
|
+
|
|
10
15
|
/**
|
|
11
|
-
* `juno check` — the offline checks, then the ones only a Juno can answer.
|
|
16
|
+
* `juno integration check` — the offline checks, then the ones only a Juno can answer.
|
|
12
17
|
*
|
|
13
18
|
* tsc and eslint run locally against the committed types; reserved names,
|
|
14
19
|
* cross-surface collisions and JSON Schema compilation need the server, so
|
|
15
20
|
* they go through ingest?dry_run=1, which stores nothing and is safe to point
|
|
16
|
-
* at a production instance.
|
|
21
|
+
* at a production instance. It sends `juno.base_version` like deploy does, so
|
|
22
|
+
* a stale copy is a 409 (exit 4) here, before deploy hits it.
|
|
23
|
+
*
|
|
24
|
+
* --json prints the route's body (or the 422's diagnostics) on stdout and
|
|
25
|
+
* nothing else; the local tools' own output goes to stderr.
|
|
17
26
|
*/
|
|
18
27
|
export async function check(argv, io) {
|
|
19
28
|
let failed = false;
|
|
@@ -22,49 +31,84 @@ export async function check(argv, io) {
|
|
|
22
31
|
// Before tsc, deliberately: type-checking against types/ says nothing if
|
|
23
32
|
// types/ describes a manifest that is no longer on disk.
|
|
24
33
|
try {
|
|
25
|
-
|
|
34
|
+
// verify-types reports success on stdout, which --json reserves.
|
|
35
|
+
await verifyTypes(argv, argv.json ? { ...io, log: io.warn } : io);
|
|
26
36
|
} catch (err) {
|
|
27
37
|
if (!(err instanceof UserError)) throw err;
|
|
28
38
|
io.error(err.message);
|
|
29
39
|
failed = true;
|
|
30
40
|
}
|
|
31
|
-
|
|
32
|
-
['typecheck', 'tsc', ['--noEmit']],
|
|
33
|
-
['lint', 'eslint', ['.']],
|
|
34
|
-
]) {
|
|
35
|
-
const code = await runLocal(bin, args, io);
|
|
36
|
-
if (code === SKIPPED) {
|
|
37
|
-
io.warn(`${label}: ${bin} is not installed — run npm install`);
|
|
38
|
-
continue;
|
|
39
|
-
}
|
|
40
|
-
if (code !== 0) failed = true;
|
|
41
|
-
}
|
|
41
|
+
if (await runLocalChecks(argv, io)) failed = true;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const target = await resolveTarget({
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
dryRun: true,
|
|
49
|
-
name: argv.name,
|
|
44
|
+
const target = await resolveTarget({
|
|
45
|
+
url: argv.url,
|
|
46
|
+
cwd: io.cwd,
|
|
47
|
+
env: io.env,
|
|
50
48
|
});
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
const client =
|
|
50
|
+
argv.client ?? new JunoClient(target, { client: clientName(io.env) });
|
|
51
|
+
const project = await readProject(io.cwd);
|
|
52
|
+
let result;
|
|
53
|
+
try {
|
|
54
|
+
result = await client.ingest(project, {
|
|
55
|
+
dryRun: true,
|
|
56
|
+
id: argv.id,
|
|
57
|
+
baseVersion: baseFor(project, argv, io),
|
|
58
|
+
});
|
|
59
|
+
} catch (err) {
|
|
60
|
+
if (argv.json && err instanceof DiagnosticsError) {
|
|
61
|
+
return printDiagnosticsJSON(err, io);
|
|
62
|
+
}
|
|
63
|
+
throw err;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (argv.json) {
|
|
67
|
+
printJSON(result, io);
|
|
68
|
+
} else {
|
|
69
|
+
for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
|
|
70
|
+
io.log(`${result.id}: manifest and sources verified against ${target}`);
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
return failed ? 1 : 0;
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
const SKIPPED = -1;
|
|
76
|
+
export const SKIPPED = -1;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* runLocalChecks runs tsc and eslint from the working copy's node_modules,
|
|
80
|
+
* the offline half of every kind's `check`, and answers whether either
|
|
81
|
+
* failed. A tool that is not installed is a warning, not a failure: the
|
|
82
|
+
* server-side half still runs.
|
|
83
|
+
*/
|
|
84
|
+
export async function runLocalChecks(argv, io) {
|
|
85
|
+
let failed = false;
|
|
86
|
+
for (const [label, bin, args] of [
|
|
87
|
+
['typecheck', 'tsc', ['--noEmit']],
|
|
88
|
+
['lint', 'eslint', ['.']],
|
|
89
|
+
]) {
|
|
90
|
+
const code = await runLocal(bin, args, io, { quietStdout: argv.json });
|
|
91
|
+
if (code === SKIPPED) {
|
|
92
|
+
io.warn(`${label}: ${bin} is not installed — run npm install`);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (code !== 0) failed = true;
|
|
96
|
+
}
|
|
97
|
+
return failed;
|
|
98
|
+
}
|
|
58
99
|
|
|
59
100
|
/**
|
|
60
101
|
* Runs a local devtool from node_modules/.bin. Its output goes straight to the
|
|
61
102
|
* terminal: tsc and eslint already print file:line:col, and reformatting them
|
|
62
103
|
* would only lose the column.
|
|
63
104
|
*/
|
|
64
|
-
function runLocal(bin, args, io) {
|
|
105
|
+
export function runLocal(bin, args, io, { quietStdout = false } = {}) {
|
|
65
106
|
const path = join(io.cwd, 'node_modules', '.bin', bin);
|
|
107
|
+
// quietStdout sends the tool's stdout to stderr, for a --json run whose
|
|
108
|
+
// stdout must carry the JSON and nothing else.
|
|
109
|
+
const stdio = quietStdout ? ['inherit', 2, 'inherit'] : 'inherit';
|
|
66
110
|
return new Promise((resolve) => {
|
|
67
|
-
const child = spawn(path, args, { cwd: io.cwd, stdio
|
|
111
|
+
const child = spawn(path, args, { cwd: io.cwd, stdio });
|
|
68
112
|
child.on('error', () => resolve(SKIPPED));
|
|
69
113
|
child.on('close', (code) => resolve(code ?? 1));
|
|
70
114
|
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { readUserConfig, writeUserConfig } from '../config.js';
|
|
2
|
+
import { UsageError } from '../errors.js';
|
|
3
|
+
import { normalizeTarget } from '../target.js';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_SET_USAGE = 'juno config set url <url>';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `juno config set url <url>` writes the user config file, the last place
|
|
9
|
+
* resolveTarget looks. `url` is the only key; it is normalised the way every
|
|
10
|
+
* other source is, so what lands in the file is what a request would use.
|
|
11
|
+
*/
|
|
12
|
+
export async function configSet(argv, io) {
|
|
13
|
+
const [key, value, ...extra] = argv._;
|
|
14
|
+
const usage = `usage: ${CONFIG_SET_USAGE}`;
|
|
15
|
+
if (key === undefined) throw new UsageError(usage);
|
|
16
|
+
if (key !== 'url') {
|
|
17
|
+
throw new UsageError(`unknown config key "${key}"; ${usage}`);
|
|
18
|
+
}
|
|
19
|
+
if (value === undefined || extra.length) throw new UsageError(usage);
|
|
20
|
+
|
|
21
|
+
const url = normalizeTarget(value);
|
|
22
|
+
const current = await readUserConfig(io.env);
|
|
23
|
+
const path = await writeUserConfig(io.env, { ...current, url });
|
|
24
|
+
io.warn(`wrote ${path}`);
|
|
25
|
+
return 0;
|
|
26
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { UsageError } from '../errors.js';
|
|
3
|
+
|
|
4
|
+
export const CREDENTIAL_SET_USAGE =
|
|
5
|
+
'juno credential set <id> [--name <name>] [--description <text>]';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `juno credential set <id>` stores a secret a person types at a terminal.
|
|
9
|
+
* It goes through the REST credential routes rather than the tool gateway,
|
|
10
|
+
* which deliberately serves no write_credential: a secret an agent types
|
|
11
|
+
* lands in its transcript. For the same reason it refuses to run unless stdin
|
|
12
|
+
* is a TTY, so nothing can pipe a value in, and the value is read with echo
|
|
13
|
+
* off and never printed.
|
|
14
|
+
*
|
|
15
|
+
* An existing credential keeps its name and description unless --name or
|
|
16
|
+
* --description overrides them; a new one needs both.
|
|
17
|
+
*/
|
|
18
|
+
export async function credentialSet(args, io) {
|
|
19
|
+
const [id, ...extra] = args._;
|
|
20
|
+
if (id === undefined || extra.length) {
|
|
21
|
+
throw new UsageError(`usage: ${CREDENTIAL_SET_USAGE}`);
|
|
22
|
+
}
|
|
23
|
+
if (!io.stdinIsTTY) {
|
|
24
|
+
throw new UsageError(
|
|
25
|
+
'juno credential set reads the secret from a terminal, and stdin is not ' +
|
|
26
|
+
'one. A person must run it, e.g. as ' +
|
|
27
|
+
`\`! juno credential set ${id}\` inside Claude Code.`
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const { client } = await connect(args, io);
|
|
32
|
+
const existing = await client.getCredentialMeta(id);
|
|
33
|
+
|
|
34
|
+
// Everything that can refuse the command does so before the prompt, so
|
|
35
|
+
// nobody types a secret into a command that was never going to store it.
|
|
36
|
+
if (!existing) {
|
|
37
|
+
const missing = ['name', 'description']
|
|
38
|
+
.filter((flag) => args[flag] === undefined)
|
|
39
|
+
.map((flag) => `--${flag}`);
|
|
40
|
+
if (missing.length) {
|
|
41
|
+
throw new UsageError(
|
|
42
|
+
`credential "${id}" does not exist; creating it needs --name and ` +
|
|
43
|
+
`--description (missing: ${missing.join(', ')})`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const value = await io.readSecret(`Secret for credential "${id}": `);
|
|
49
|
+
if (!value) throw new UsageError('an empty secret was not stored');
|
|
50
|
+
|
|
51
|
+
if (existing) {
|
|
52
|
+
await client.updateCredential(id, {
|
|
53
|
+
name: args.name ?? existing.name,
|
|
54
|
+
description: args.description ?? existing.description,
|
|
55
|
+
value,
|
|
56
|
+
});
|
|
57
|
+
io.warn(`updated credential "${id}"`);
|
|
58
|
+
} else {
|
|
59
|
+
await client.createCredential({
|
|
60
|
+
id,
|
|
61
|
+
name: args.name,
|
|
62
|
+
description: args.description,
|
|
63
|
+
value,
|
|
64
|
+
});
|
|
65
|
+
io.warn(`created credential "${id}"`);
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|