@junoflow/cli 0.6.0 → 0.10.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 +214 -80
- package/app-client.js +306 -0
- package/eslint.app.config.js +34 -0
- package/eslint.flow.config.js +4 -2
- package/package.json +9 -4
- package/src/api.js +213 -51
- 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 +2 -1
- package/src/commands/app-check.js +75 -0
- package/src/commands/app-deploy.js +113 -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/check.js +33 -24
- package/src/commands/config.js +26 -7
- package/src/commands/deploy.js +59 -13
- package/src/commands/flow-check.js +4 -22
- package/src/commands/flow-deploy.js +11 -8
- package/src/commands/flow-fixture.js +67 -0
- package/src/commands/flow-gen-types.js +12 -5
- package/src/commands/flow-pull.js +69 -40
- package/src/commands/gen-types.js +2 -9
- package/src/commands/init.js +2 -9
- package/src/commands/pull.js +98 -111
- package/src/config.js +20 -5
- package/src/errors.js +3 -3
- package/src/exit.js +2 -2
- package/src/flow-project.js +47 -97
- package/src/index.js +99 -23
- package/src/project.js +76 -8
- package/src/token.js +30 -0
- package/src/watch.js +48 -11
- package/src/working-copy.js +262 -0
- package/tsconfig.page.json +15 -0
- package/verbs.json +52 -3
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { join, posix, relative, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { UserError } from './errors.js';
|
|
5
|
+
import { readSourceFiles, readTestFiles } from './project.js';
|
|
6
|
+
import {
|
|
7
|
+
baseVersionOf,
|
|
8
|
+
PAGE_TSCONFIG,
|
|
9
|
+
readBoundPackage,
|
|
10
|
+
readManifestFile,
|
|
11
|
+
writePackageJSON,
|
|
12
|
+
} from './working-copy.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An app working copy is the archive's own layout — src/ compiled, page/
|
|
16
|
+
* built at ingest, public/ served as is, test/ stored and never compiled —
|
|
17
|
+
* plus manifest.json, types/ (generated), page/tsconfig.json and
|
|
18
|
+
* package.json, whose `juno` block binds the directory to one app. The
|
|
19
|
+
* layout is the server's (POST /api/apps/scaffold); this module only knows
|
|
20
|
+
* what a command reads or writes back, and what it shares with a flow working
|
|
21
|
+
* copy lives in working-copy.js.
|
|
22
|
+
*/
|
|
23
|
+
export const SRC_DIR = 'src';
|
|
24
|
+
export const PAGE_DIR = 'page';
|
|
25
|
+
export const PUBLIC_DIR = 'public';
|
|
26
|
+
export { PAGE_TSCONFIG };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* readAppProject reads what ingest takes: the manifest (parsed, so a broken
|
|
30
|
+
* one fails here with its parse error rather than as a server 422), src/,
|
|
31
|
+
* test/, page/ and public/ as one archive, the lockfile, and the binding in
|
|
32
|
+
* package.json.
|
|
33
|
+
*
|
|
34
|
+
* Ingest keys a version on the manifest's id, so a manifest whose id is not
|
|
35
|
+
* `juno.app_id` would make a second app rather than a version of this one.
|
|
36
|
+
* That is refused here, before anything is sent, as the flow ingest route
|
|
37
|
+
* refuses a manifest naming another flow.
|
|
38
|
+
*/
|
|
39
|
+
export async function readAppProject(cwd) {
|
|
40
|
+
const pkg = await readAppPackage(cwd);
|
|
41
|
+
const { manifest, manifestRaw } = await readManifestFile(cwd);
|
|
42
|
+
const appId = pkg.juno.app_id;
|
|
43
|
+
if (manifest?.id !== appId) {
|
|
44
|
+
throw new UserError(
|
|
45
|
+
`manifest.json's id is ${JSON.stringify(manifest?.id)}, but this working copy is app "${appId}" ` +
|
|
46
|
+
'(juno.app_id in package.json) — deploying it would create a second app rather than a version of this one. ' +
|
|
47
|
+
`Set the id back to "${appId}", or run \`juno app init <new-id>\` for a new app.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
manifest,
|
|
52
|
+
manifestRaw,
|
|
53
|
+
files: {
|
|
54
|
+
...(await readSourceFiles(cwd)),
|
|
55
|
+
...(await readTestFiles(cwd)),
|
|
56
|
+
...(await readPageFiles(cwd)),
|
|
57
|
+
...(await readPublicFiles(cwd)),
|
|
58
|
+
},
|
|
59
|
+
lockfile: await readOptional(join(cwd, 'package-lock.json')),
|
|
60
|
+
packageJSON: pkg,
|
|
61
|
+
appId,
|
|
62
|
+
baseVersion: baseVersionOf(pkg),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** readAppPackage is package.json, refusing a directory that is not an app working copy. */
|
|
67
|
+
export function readAppPackage(cwd) {
|
|
68
|
+
return readBoundPackage(cwd, { kind: 'app', idKey: 'app_id' });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* updateAppPackage rewrites an app working copy's package.json through
|
|
73
|
+
* `mutate`, keeping every key it does not touch (and their order).
|
|
74
|
+
*/
|
|
75
|
+
export async function updateAppPackage(cwd, mutate) {
|
|
76
|
+
const pkg = await readAppPackage(cwd);
|
|
77
|
+
mutate(pkg);
|
|
78
|
+
await writePackageJSON(cwd, pkg);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* readPublicFiles walks public/ and returns repo-relative POSIX paths →
|
|
83
|
+
* content: every file, since all of public/ is served, except dotfiles
|
|
84
|
+
* (.DS_Store, an editor's swap file), which are the machine's and not the
|
|
85
|
+
* page's. The server enforces the caps and the reserved names.
|
|
86
|
+
*/
|
|
87
|
+
export function readPublicFiles(cwd) {
|
|
88
|
+
return readTree(cwd, PUBLIC_DIR);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* readPageFiles is readPublicFiles for page/, less page/tsconfig.json. What
|
|
93
|
+
* may be in page/ (the entry document and .ts files) is the server's to
|
|
94
|
+
* enforce, so a stray file is sent and refused by name rather than dropped.
|
|
95
|
+
*/
|
|
96
|
+
export async function readPageFiles(cwd) {
|
|
97
|
+
const files = await readTree(cwd, PAGE_DIR);
|
|
98
|
+
delete files[PAGE_TSCONFIG];
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** readTree reads every file under dir but dotfiles, by repo-relative POSIX path. */
|
|
103
|
+
async function readTree(cwd, dir) {
|
|
104
|
+
const files = {};
|
|
105
|
+
async function walk(dir) {
|
|
106
|
+
let entries;
|
|
107
|
+
try {
|
|
108
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
109
|
+
} catch {
|
|
110
|
+
return; // no such directory at all; ingest names a missing entry document
|
|
111
|
+
}
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (entry.name.startsWith('.')) continue;
|
|
114
|
+
const full = join(dir, entry.name);
|
|
115
|
+
if (entry.isDirectory()) {
|
|
116
|
+
await walk(full);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const rel = relative(cwd, full).split(sep).join(posix.sep);
|
|
120
|
+
files[rel] = await readFile(full, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
await walk(join(cwd, dir));
|
|
124
|
+
return files;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function readOptional(path) {
|
|
128
|
+
try {
|
|
129
|
+
return await readFile(path, 'utf8');
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
package/src/authoring-skills.js
CHANGED
|
@@ -4,10 +4,11 @@ import { warnIfStale } from './installed-skill.js';
|
|
|
4
4
|
* The kinds of working copy with an authoring skill index, and the heading
|
|
5
5
|
* each is printed under. The index itself is the server's
|
|
6
6
|
* (GET /api/skills?authoring=<kind>): the same name and description lines the
|
|
7
|
-
* in-app flow and integration authors are given, so it cannot drift from the
|
|
7
|
+
* in-app app, flow and integration authors are given, so it cannot drift from the
|
|
8
8
|
* skills.
|
|
9
9
|
*/
|
|
10
10
|
export const AUTHORING_HEADINGS = {
|
|
11
|
+
app: 'Skills for writing an app',
|
|
11
12
|
flow: 'Skills for writing a flow',
|
|
12
13
|
integration: 'Skills for writing an integration',
|
|
13
14
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { connect } from '../api.js';
|
|
2
|
+
import { readAppProject } from '../app-project.js';
|
|
3
|
+
import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
|
|
4
|
+
import { DiagnosticsError } from '../errors.js';
|
|
5
|
+
import { EXIT_CONFLICT } from '../exit.js';
|
|
6
|
+
import { runLocalChecks } from './check.js';
|
|
7
|
+
import { warnOnDependencyDrift } from './flow-check.js';
|
|
8
|
+
|
|
9
|
+
export const APP_CHECK_USAGE = 'juno app check [--skip-local] [--json]';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `juno app check` — local tsc and eslint, then an ingest with
|
|
13
|
+
* `activate: false`: the verification deploy runs, stored as a draft and not
|
|
14
|
+
* activated, so the page can be opened at its `~draft/` URL before anyone
|
|
15
|
+
* else sees it. Unlike a flow's check this stores something — an app's draft
|
|
16
|
+
* is how it is previewed — which is why its effect is `change`. The draft's
|
|
17
|
+
* handlers run for real: a preview is live.
|
|
18
|
+
*
|
|
19
|
+
* It sends `juno.base_version`. The server never refuses a preview, so a stale
|
|
20
|
+
* copy still stores its draft; the answer's `stale_base` is the sentence a
|
|
21
|
+
* deploy would be refused with, printed after the preview, and the exit is 4
|
|
22
|
+
* — the code every kind's check gives a stale copy.
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
export async function appCheck(argv, io) {
|
|
28
|
+
const project = await readAppProject(io.cwd);
|
|
29
|
+
const failed = !argv['skip-local'] && (await runLocalChecks(argv, io));
|
|
30
|
+
warnOnDependencyDrift(project, io);
|
|
31
|
+
|
|
32
|
+
const { target, client } = await connect(argv, io);
|
|
33
|
+
let result;
|
|
34
|
+
try {
|
|
35
|
+
result = await client.ingestApp(project, {
|
|
36
|
+
activate: false,
|
|
37
|
+
baseVersion: project.baseVersion,
|
|
38
|
+
});
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (argv.json && err instanceof DiagnosticsError) {
|
|
41
|
+
return printDiagnosticsJSON(err, io);
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (argv.json) {
|
|
47
|
+
printJSON(result, io);
|
|
48
|
+
} else {
|
|
49
|
+
for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
|
|
50
|
+
io.log(
|
|
51
|
+
`${result.id}: verified and stored as draft v${result.version} on ${target}`
|
|
52
|
+
);
|
|
53
|
+
printPreview(result, io);
|
|
54
|
+
if (result.stale_base) io.error(result.stale_base);
|
|
55
|
+
}
|
|
56
|
+
if (result.stale_base) return EXIT_CONFLICT;
|
|
57
|
+
return failed ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The draft's preview URL is the app's URL with `~draft/` on the end. Juno
|
|
62
|
+
* reports no URL while the apps_url site setting is unset, and the CLI does
|
|
63
|
+
* not guess one: the apps listener's address is not the hostname a proxy
|
|
64
|
+
* puts in front of it.
|
|
65
|
+
*/
|
|
66
|
+
function printPreview(result, io) {
|
|
67
|
+
if (result.url) {
|
|
68
|
+
io.log(`preview: ${result.url}~draft/`);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
io.warn(
|
|
72
|
+
'no preview URL: apps_url is not set on this instance (Settings → Site). ' +
|
|
73
|
+
`Once it is, the draft is served at <apps_url>/${result.id}/~draft/`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { connect } from '../api.js';
|
|
4
|
+
import {
|
|
5
|
+
PAGE_DIR,
|
|
6
|
+
PUBLIC_DIR,
|
|
7
|
+
readAppProject,
|
|
8
|
+
SRC_DIR,
|
|
9
|
+
updateAppPackage,
|
|
10
|
+
} from '../app-project.js';
|
|
11
|
+
import { printDiagnosticsJSON, printJSON } from '../diagnostics.js';
|
|
12
|
+
import { DiagnosticsError } from '../errors.js';
|
|
13
|
+
import { deployOnChange } from '../watch.js';
|
|
14
|
+
import {
|
|
15
|
+
MANIFEST,
|
|
16
|
+
manifestText,
|
|
17
|
+
typeFiles,
|
|
18
|
+
writeChangedFiles,
|
|
19
|
+
} from '../working-copy.js';
|
|
20
|
+
|
|
21
|
+
export const APP_DEPLOY_USAGE = 'juno app deploy [--watch] [--force] [--json]';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* `juno app deploy` — upload manifest.json, src/, page/ and public/ to
|
|
25
|
+
* POST /api/apps/ingest, which verifies them through the same path the
|
|
26
|
+
* in-app assistant's submit_code uses, stores a version and activates it.
|
|
27
|
+
*
|
|
28
|
+
* It sends `juno.base_version`, so an app changed elsewhere since this copy
|
|
29
|
+
* was pulled is a 409 (exit 4) rather than a silent overwrite; --force sends
|
|
30
|
+
* none and replaces whatever is active.
|
|
31
|
+
*
|
|
32
|
+
* When the version binds something the previous one did not, the answer's
|
|
33
|
+
* `widened_links` says how many active app links it widens, and a line says so.
|
|
34
|
+
*
|
|
35
|
+
* On success it writes back what the server now holds: the canonical
|
|
36
|
+
* manifest, the three files under types/, and the new juno.base_version.
|
|
37
|
+
* --watch redeploys on every save to src/, page/, public/ or manifest.json,
|
|
38
|
+
* re-reading the base each time, so the loop is checked against its own
|
|
39
|
+
* previous deploy rather than refused by it.
|
|
40
|
+
*/
|
|
41
|
+
export async function appDeploy(argv, io) {
|
|
42
|
+
const { target, client } = await connect(argv, io);
|
|
43
|
+
if (!argv.watch) {
|
|
44
|
+
const project = await readAppProject(io.cwd);
|
|
45
|
+
try {
|
|
46
|
+
await deployProject(client, project, argv, io, target);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if (argv.json && err instanceof DiagnosticsError) {
|
|
49
|
+
return printDiagnosticsJSON(err, io);
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A deploy writes manifest.json back, which the watcher sees. Remembering
|
|
57
|
+
// what was last deployed makes that echo — and any save that changed
|
|
58
|
+
// nothing — a no-op instead of another version.
|
|
59
|
+
let last = null;
|
|
60
|
+
return deployOnChange({
|
|
61
|
+
io,
|
|
62
|
+
target,
|
|
63
|
+
paths: [
|
|
64
|
+
{ path: join(io.cwd, SRC_DIR), recursive: true },
|
|
65
|
+
{ path: join(io.cwd, PAGE_DIR), recursive: true },
|
|
66
|
+
{ path: join(io.cwd, PUBLIC_DIR), recursive: true },
|
|
67
|
+
{ path: join(io.cwd, MANIFEST) },
|
|
68
|
+
],
|
|
69
|
+
once: async () => {
|
|
70
|
+
const project = await readAppProject(io.cwd);
|
|
71
|
+
const key = JSON.stringify([project.manifestRaw, project.files]);
|
|
72
|
+
if (key === last) return;
|
|
73
|
+
const after = await deployProject(client, project, argv, io, target);
|
|
74
|
+
last = JSON.stringify([after.manifestRaw, project.files]);
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function deployProject(client, project, argv, io, target) {
|
|
80
|
+
const result = await client.ingestApp(project, {
|
|
81
|
+
baseVersion: argv.force ? undefined : project.baseVersion,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const manifestRaw = manifestText(result.manifest);
|
|
85
|
+
const written = await writeChangedFiles(io.cwd, {
|
|
86
|
+
[MANIFEST]: manifestRaw,
|
|
87
|
+
...typeFiles(result.types),
|
|
88
|
+
});
|
|
89
|
+
if (project.baseVersion !== result.version) {
|
|
90
|
+
await updateAppPackage(io.cwd, (pkg) => {
|
|
91
|
+
pkg.juno.base_version = result.version;
|
|
92
|
+
});
|
|
93
|
+
written.push('package.json');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (argv.json) {
|
|
97
|
+
printJSON(result, io);
|
|
98
|
+
} else {
|
|
99
|
+
for (const w of result.warnings ?? []) io.warn(`warning: ${w}`);
|
|
100
|
+
const files = written.length ? ` (${written.sort().join(', ')})` : '';
|
|
101
|
+
io.log(`deployed ${result.id} v${result.version} to ${target}${files}`);
|
|
102
|
+
if (result.url) io.log(`open: ${result.url}`);
|
|
103
|
+
// A notice, not a refusal: the version is already live. It binds something
|
|
104
|
+
// the previous one did not, and every active app link gets it too.
|
|
105
|
+
const widened = result.widened_links ?? 0;
|
|
106
|
+
if (widened > 0) {
|
|
107
|
+
io.warn(
|
|
108
|
+
`This version widens what ${widened} active ${widened === 1 ? 'link' : 'links'} can do.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { manifestRaw };
|
|
113
|
+
}
|
|
@@ -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
|
+
}
|