@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.
Files changed (43) hide show
  1. package/README.md +214 -80
  2. package/app-client.js +306 -0
  3. package/eslint.app.config.js +34 -0
  4. package/eslint.flow.config.js +4 -2
  5. package/package.json +9 -4
  6. package/src/api.js +213 -51
  7. package/src/app-dev/build.js +134 -0
  8. package/src/app-dev/harness.js +108 -0
  9. package/src/app-dev/panel.html +336 -0
  10. package/src/app-dev/panel.js +290 -0
  11. package/src/app-dev/server.js +347 -0
  12. package/src/app-dev/stream.js +224 -0
  13. package/src/app-dev/watch.js +47 -0
  14. package/src/app-project.js +133 -0
  15. package/src/authoring-skills.js +2 -1
  16. package/src/commands/app-check.js +75 -0
  17. package/src/commands/app-deploy.js +113 -0
  18. package/src/commands/app-dev.js +141 -0
  19. package/src/commands/app-gen-types.js +37 -0
  20. package/src/commands/app-init.js +46 -0
  21. package/src/commands/app-pull.js +106 -0
  22. package/src/commands/check.js +33 -24
  23. package/src/commands/config.js +26 -7
  24. package/src/commands/deploy.js +59 -13
  25. package/src/commands/flow-check.js +4 -22
  26. package/src/commands/flow-deploy.js +11 -8
  27. package/src/commands/flow-fixture.js +67 -0
  28. package/src/commands/flow-gen-types.js +12 -5
  29. package/src/commands/flow-pull.js +69 -40
  30. package/src/commands/gen-types.js +2 -9
  31. package/src/commands/init.js +2 -9
  32. package/src/commands/pull.js +98 -111
  33. package/src/config.js +20 -5
  34. package/src/errors.js +3 -3
  35. package/src/exit.js +2 -2
  36. package/src/flow-project.js +47 -97
  37. package/src/index.js +99 -23
  38. package/src/project.js +76 -8
  39. package/src/token.js +30 -0
  40. package/src/watch.js +48 -11
  41. package/src/working-copy.js +262 -0
  42. package/tsconfig.page.json +15 -0
  43. 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
+ }
@@ -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
+ }