@motujs/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
// `motu lagoon publish [target]` — build the lagoon as ONE self-contained HTML file, ready to be
|
|
2
|
+
// published as an Artifact (a hosted page on claude.ai) so the lagoon can be looked at from a phone
|
|
3
|
+
// with no dev server, no tunnel and no backend running.
|
|
4
|
+
//
|
|
5
|
+
// This command produces the FILE; the agent driving it publishes that file with the Artifact tool.
|
|
6
|
+
// The split is not a shortcut — publishing is a hosted-page operation, not something a node script
|
|
7
|
+
// can do — but the file is the whole deliverable: everything else is a single tool call.
|
|
8
|
+
//
|
|
9
|
+
// Why a single file: an artifact page is served under a strict CSP with nothing behind it. No
|
|
10
|
+
// /assets/*.js, no /api proxy, no dev server. So the build must be one chunk (MOTU_SINGLEFILE, see
|
|
11
|
+
// the lagoon's vite.config) with the JS and CSS inlined, and the transport must be `mock` — which
|
|
12
|
+
// is the lagoon's contract anyway: fixtures, no ocean.
|
|
13
|
+
//
|
|
14
|
+
// `motu lagoon serve [target]` builds the SAME artifact and serves it over http instead of writing
|
|
15
|
+
// it. That is the check nothing else performs: `dev:lagoon` serves the source through vite with the
|
|
16
|
+
// dev proxy, so it never proves the inlining worked, the mock transport renders with no backend, or
|
|
17
|
+
// the page survives with no /assets/ behind it. Serving the artifact does.
|
|
18
|
+
import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync, statSync, watch as fsWatch } from 'node:fs';
|
|
19
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
20
|
+
import { regionDeclaresPage } from '../lib/lagoon-declares.mjs';
|
|
21
|
+
import { injectDock } from '../lib/dock-inject.mjs';
|
|
22
|
+
import { clearLiveUrl, writeLiveUrl } from '../lib/live-url.mjs';
|
|
23
|
+
import { createServer } from 'node:http';
|
|
24
|
+
import { motuDockCss, motuDockJs } from '@motujs/chrome/dock';
|
|
25
|
+
import { networkInterfaces } from 'node:os';
|
|
26
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import { REPO_ROOT, APP_ROOT, paths, names, color } from '../lib/util.mjs';
|
|
29
|
+
import { gitIdentity, uploadLagoon, uploadManifest, loadHostConfig } from '../lib/remote.mjs';
|
|
30
|
+
import { gzipSync } from 'node:zlib';
|
|
31
|
+
import { readChunkedBuild, manifestOf, placeAssets, contentTypeOf, sendMissingChunks, MANIFEST_TYPE } from '../lib/lagoon-bundle.mjs';
|
|
32
|
+
|
|
33
|
+
/** The framework's own lagoon build runner — replaces the vite.config.ts each project used to carry. */
|
|
34
|
+
const LAGOON_BUILD = fileURLToPath(new URL('../lagoon-build.mjs', import.meta.url));
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the CLI's target flags into what the lagoon build needs.
|
|
39
|
+
* No target => the switcher entry (index.html), which shows every archipelago — the best default for
|
|
40
|
+
* browsing on a phone. A target => the focused entry (lagoon.html), one island or one archipelago.
|
|
41
|
+
*/
|
|
42
|
+
function resolveTarget(argv) {
|
|
43
|
+
const positional = argv._[0];
|
|
44
|
+
const island = argv.island || (positional && !argv.archipelago ? positional : undefined);
|
|
45
|
+
const archipelago = argv.archipelago === true ? positional : argv.archipelago;
|
|
46
|
+
|
|
47
|
+
if (archipelago) {
|
|
48
|
+
if (!existsSync(paths.archipelagoFile(archipelago)))
|
|
49
|
+
throw new Error(`unknown archipelago "${archipelago}" — no ${paths.rel(paths.archipelagoFile(archipelago))}`);
|
|
50
|
+
// The GALLERY entry, focused on this region — not the bare single-target one. `serve` and
|
|
51
|
+
// `publish` are for a human to look at, and the chrome (tide line, seam lens, the transport and
|
|
52
|
+
// fit chips) is most of what makes the lagoon worth looking at. The bare entry exists for
|
|
53
|
+
// `motu island verify`, which drives lagoon.html directly and wants nothing else on the page.
|
|
54
|
+
return {
|
|
55
|
+
entry: 'main',
|
|
56
|
+
target: `archipelago:${archipelago}`,
|
|
57
|
+
slug: `archipelago-${archipelago}`,
|
|
58
|
+
title: `${names(archipelago).pascal} Lagoon`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (island) {
|
|
62
|
+
const { kebab, tag, pascal } = names(island);
|
|
63
|
+
// BOTH LAYOUTS. This looked for the FOLDER form (`src/islands/<kebab>/`) only, while
|
|
64
|
+
// `motu island create` scaffolds the FLAT form (`src/islands/<kebab>.island.ts`) — so the command
|
|
65
|
+
// that exists for looking at a new island rejected every island the scaffolder makes, with
|
|
66
|
+
// "unknown island" for one that is registered and passes verify. `paths.elementFile` already
|
|
67
|
+
// resolves either shape; the rest of the CLI has used it all along.
|
|
68
|
+
if (!existsSync(paths.elementFile(kebab)))
|
|
69
|
+
throw new Error(
|
|
70
|
+
`unknown island "${island}" — neither ${paths.rel(paths.islandDir(kebab))}/element.ts nor ` +
|
|
71
|
+
`src/islands/${kebab}.island.ts exists`,
|
|
72
|
+
);
|
|
73
|
+
return { entry: 'lagoon', target: `island:${tag}`, slug: `island-${kebab}`, title: `${pascal} Lagoon` };
|
|
74
|
+
}
|
|
75
|
+
return { entry: 'main', target: '', slug: 'all', title: 'Motu Lagoon' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build the chosen entry as one chunk, mock-backed. Returns the built HTML path. */
|
|
79
|
+
function buildSingleFile({ entry, target, fit }) {
|
|
80
|
+
return buildLagoon({ entry, target, fit, chunked: false });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the chosen entry as STABLY NAMED CHUNKS and read it back as `{ html, files }` — what a
|
|
85
|
+
* content-addressed host takes a piece at a time. See lib/lagoon-bundle.mjs.
|
|
86
|
+
*/
|
|
87
|
+
function buildChunked({ entry, target, fit, title }) {
|
|
88
|
+
return readChunkedBuild(buildLagoon({ entry, target, fit, chunked: true }), title);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildLagoon({ entry, target, fit, chunked }) {
|
|
92
|
+
const res = spawnSync(process.execPath, [LAGOON_BUILD], {
|
|
93
|
+
// The project root, not the lagoon: the build resolves motu.config.json by walking up from cwd.
|
|
94
|
+
cwd: REPO_ROOT,
|
|
95
|
+
env: {
|
|
96
|
+
...process.env,
|
|
97
|
+
...(chunked ? { MOTU_CHUNKED: entry, MOTU_SINGLEFILE: '' } : { MOTU_SINGLEFILE: entry, MOTU_CHUNKED: '' }),
|
|
98
|
+
MOTU_NO_SSL: '1',
|
|
99
|
+
// Non-negotiable: an artifact has no /api proxy behind it, so `http` would render an island
|
|
100
|
+
// that fails every call. Fixtures are what makes the published page work standalone.
|
|
101
|
+
MOTU_TRANSPORT: 'mock',
|
|
102
|
+
MOTU_TARGET: target,
|
|
103
|
+
...(fit ? { MOTU_FIT: fit } : {}),
|
|
104
|
+
},
|
|
105
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
});
|
|
108
|
+
if (res.status !== 0)
|
|
109
|
+
throw new Error(`lagoon build failed:\n${(res.stdout || '') + (res.stderr || '')}`);
|
|
110
|
+
// WHAT THE BUILD LEFT OUT, surfaced from the child. A successful build that quietly dropped an
|
|
111
|
+
// island is worse than a failed one: the gallery opens, the island is a placeholder, and nobody
|
|
112
|
+
// knows why. See the `[motu:excluded]` marker in lagoon-vite.mjs.
|
|
113
|
+
reportExclusions((res.stdout || '') + (res.stderr || ''));
|
|
114
|
+
const html = resolve(paths.lagoonDir, 'dist', entry === 'lagoon' ? 'lagoon.html' : 'index.html');
|
|
115
|
+
if (!existsSync(html)) throw new Error(`build produced no ${paths.rel(html)}`);
|
|
116
|
+
return html;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/** Print the islands a build stubbed out, read from the child's marked lines. */
|
|
121
|
+
export function reportExclusions(output) {
|
|
122
|
+
const lines = String(output)
|
|
123
|
+
.split('\n')
|
|
124
|
+
.filter((l) => l.includes('[motu:excluded]'))
|
|
125
|
+
.map((l) => l.slice(l.indexOf('[motu:excluded]') + '[motu:excluded]'.length).trim());
|
|
126
|
+
if (!lines.length) return;
|
|
127
|
+
console.warn(
|
|
128
|
+
color.yellow(`! ${lines.length} island(s) left out of this build — they cannot be bundled, and the rest of the lagoon still works:`),
|
|
129
|
+
);
|
|
130
|
+
for (const l of lines) console.warn(color.dim(` · ${l}`));
|
|
131
|
+
console.warn(color.dim(' Their slots render a placeholder saying why. `motu island verify <name>` gives the full chain.'));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Inline a built asset reference's contents, keeping the file safe to embed inside HTML. */
|
|
135
|
+
function readAsset(distDir, url) {
|
|
136
|
+
const file = resolve(distDir, url.replace(/^\//, ''));
|
|
137
|
+
if (!existsSync(file)) throw new Error(`asset referenced but not built: ${url}`);
|
|
138
|
+
// A literal "</script" inside a string in the bundle would close the inline tag early. Escaping the
|
|
139
|
+
// slash is inert inside JS/CSS but stops the HTML parser dead.
|
|
140
|
+
return readFileSync(file, 'utf8').replace(/<\/script/gi, '<\\/script');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Turn the built multi-file page into one artifact-ready document: assets inlined, and the
|
|
145
|
+
* <!doctype>/<html>/<head>/<body> skeleton dropped (the artifact wrapper supplies its own).
|
|
146
|
+
*/
|
|
147
|
+
function inlineToArtifact(htmlPath, title) {
|
|
148
|
+
const distDir = resolve(htmlPath, '..');
|
|
149
|
+
let html = readFileSync(htmlPath, 'utf8');
|
|
150
|
+
|
|
151
|
+
html = html.replace(
|
|
152
|
+
/<script\b[^>]*\bsrc="([^"]+)"[^>]*><\/script>/gi,
|
|
153
|
+
(_m, src) => `<script type="module">\n${readAsset(distDir, src)}\n</script>`,
|
|
154
|
+
);
|
|
155
|
+
html = html.replace(
|
|
156
|
+
/<link\b[^>]*\brel="stylesheet"[^>]*\bhref="([^"]+)"[^>]*>/gi,
|
|
157
|
+
(_m, href) => `<style>\n${readAsset(distDir, href)}\n</style>`,
|
|
158
|
+
);
|
|
159
|
+
// Preload hints point at files that will not exist once everything is inlined.
|
|
160
|
+
html = html.replace(/<link\b[^>]*\brel="modulepreload"[^>]*>\s*/gi, '');
|
|
161
|
+
|
|
162
|
+
html = html
|
|
163
|
+
.replace(/<!doctype html>\s*/i, '')
|
|
164
|
+
.replace(/<\/?(?:html|head|body)\b[^>]*>\s*/gi, '')
|
|
165
|
+
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`);
|
|
166
|
+
|
|
167
|
+
// The honest check: anything still pointing at /assets/ is a request the CSP will block, which
|
|
168
|
+
// would show up as a silently blank page rather than an error. Fail here instead.
|
|
169
|
+
const dangling = html.match(/["'(]\/assets\/[^"')]+/g);
|
|
170
|
+
if (dangling) throw new Error(`could not inline every asset — still referenced: ${[...new Set(dangling)].join(', ')}`);
|
|
171
|
+
|
|
172
|
+
return html.trim() + '\n';
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A STABLE path per target: republishing the same file path redeploys to the same artifact URL, so a
|
|
177
|
+
* link already sitting on your phone keeps working instead of being superseded. `serve --no-build`
|
|
178
|
+
* reads back exactly what `publish` wrote here.
|
|
179
|
+
*/
|
|
180
|
+
function publishFile(slug) {
|
|
181
|
+
return resolve(REPO_ROOT, '.motu/publish', `lagoon-${slug}.html`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export async function lagoonPublishCommand(argv) {
|
|
185
|
+
const json = !!argv.json;
|
|
186
|
+
let resolved;
|
|
187
|
+
try {
|
|
188
|
+
resolved = resolveTarget(argv);
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
const { entry, target } = resolved;
|
|
194
|
+
// Same override as `serve`, and it has to be BOTH: publishing under one slug while the dev server
|
|
195
|
+
// registers itself under another is a live member the gallery can never match to a published one.
|
|
196
|
+
const slug = paths.publishAs?.slug ?? resolved.slug;
|
|
197
|
+
// The derived title says WHAT was built ('Motu Lagoon'); in a composed view listing several repos'
|
|
198
|
+
// switcher entries it says nothing, so a project can name its own.
|
|
199
|
+
//
|
|
200
|
+
// DECLARED, not only passed. It used to be a flag alone, so a republish that did not repeat the
|
|
201
|
+
// flag silently renamed the project back to 'Motu Lagoon' in every gallery — which is exactly what
|
|
202
|
+
// happened to acme here. The flag still wins for a one-off.
|
|
203
|
+
const title =
|
|
204
|
+
typeof argv.title === 'string' ? argv.title.slice(0, 200) : (declaredTitle() ?? resolved.title);
|
|
205
|
+
const fit = argv.fit === 'legacy' ? 'legacy' : argv.fit === 'native' ? 'native' : '';
|
|
206
|
+
|
|
207
|
+
// TO A HOST, AS CHUNKS. The host stores each chunk by its content, so a republish after an edit in
|
|
208
|
+
// one region sends that region's chunk and the manifest rather than the whole lagoon. `--single`
|
|
209
|
+
// keeps the one-file upload, for a host older than the chunk protocol.
|
|
210
|
+
const hostCfgEarly = loadHostConfig();
|
|
211
|
+
const remoteEarly = argv.remote === true ? process.env.MOTU_HOST_URL || hostCfgEarly.url : argv.remote;
|
|
212
|
+
if (remoteEarly && argv.single !== true) {
|
|
213
|
+
return publishChunked({ argv, remote: remoteEarly, entry, target, fit, slug, title, json });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!json) console.log(color.dim(`building ${target || 'all archipelagos'} (mock fixtures, one chunk)…`));
|
|
217
|
+
const built = buildSingleFile({ entry, target, fit });
|
|
218
|
+
const page = inlineToArtifact(built, title);
|
|
219
|
+
|
|
220
|
+
const out = argv.out ? resolve(REPO_ROOT, argv.out) : publishFile(slug);
|
|
221
|
+
mkdirSync(resolve(out, '..'), { recursive: true });
|
|
222
|
+
writeFileSync(out, page, 'utf8');
|
|
223
|
+
|
|
224
|
+
const bytes = statSync(out).size;
|
|
225
|
+
|
|
226
|
+
// BEFORE the --json early return, not after: `--json --remote` is the agent's spelling of this
|
|
227
|
+
// command, and returning the local report there would print `ok: true` for an upload that never
|
|
228
|
+
// happened. uploadPublished owns the json shape for its own leg.
|
|
229
|
+
const hostCfg = loadHostConfig();
|
|
230
|
+
const remote = argv.remote === true ? process.env.MOTU_HOST_URL || hostCfg.url : argv.remote;
|
|
231
|
+
if (remote) return uploadPublished({ remote, token: typeof argv.token === 'string' ? argv.token : null, page, slug, title, out, bytes, json });
|
|
232
|
+
|
|
233
|
+
if (json) {
|
|
234
|
+
console.log(JSON.stringify({ ok: true, file: out, title, target: target || null, fit: fit || null, bytes }, null, 2));
|
|
235
|
+
return 0;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log('');
|
|
239
|
+
console.log(`${color.green('✓')} ${color.bold(title)} — ${(bytes / 1024).toFixed(0)} kB, self-contained`);
|
|
240
|
+
console.log(' ' + color.dim(paths.rel(out)));
|
|
241
|
+
console.log('');
|
|
242
|
+
console.log(' Publish it with the Artifact tool, same file path every time to keep one URL:');
|
|
243
|
+
console.log(' ' + color.dim(`Artifact({ file_path: "${out}", favicon: "🏝️", description: "…" })`));
|
|
244
|
+
if (!process.env.MOTU_HOST_URL)
|
|
245
|
+
console.log(' ' + color.dim('Or host it yourself: motu lagoon publish --remote <url> (see motu-host)'));
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Send the file that was just written to a lagoon host.
|
|
251
|
+
*
|
|
252
|
+
* Deliberately AFTER the local write, never instead of it: `--remote` adds a destination, it does not
|
|
253
|
+
* replace the artifact. If the host is down, or the token is wrong, the page is still on disk and
|
|
254
|
+
* still publishable as an Artifact — a network failure must not cost you the build.
|
|
255
|
+
*/
|
|
256
|
+
/**
|
|
257
|
+
* The project's own colour, from `lagoon.config.json`'s `chrome.brand`.
|
|
258
|
+
*
|
|
259
|
+
* NOT `chrome.primary`, deliberately. That one is for the lagoon itself and is allowed to reference
|
|
260
|
+
* the host's own CSS variables (acme writes `hsl(var(--primary-control))`), which resolve inside that
|
|
261
|
+
* app and nowhere else — a host listing repositories would be handed a colour it cannot compute.
|
|
262
|
+
* `brand` is the same decision written so it travels: any self-contained CSS colour.
|
|
263
|
+
*/
|
|
264
|
+
/** What this project calls its lagoon, from `lagoon.config.json`'s `title`. */
|
|
265
|
+
function declaredTitle() {
|
|
266
|
+
return readLagoonConfig()?.title?.trim?.() || null;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** The project's lagoon config, or null. Parsed once per process, and never fatal here. */
|
|
270
|
+
function readLagoonConfig() {
|
|
271
|
+
const file = resolve(paths.lagoonDir, 'lagoon.config.json');
|
|
272
|
+
if (!existsSync(file)) return null;
|
|
273
|
+
try {
|
|
274
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
275
|
+
} catch {
|
|
276
|
+
// A lagoon config that does not parse is a problem the lagoon itself reports, with a line number.
|
|
277
|
+
// Publishing should not die second, with a worse message.
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function declaredBrand() {
|
|
283
|
+
const brand = readLagoonConfig()?.chrome?.brand;
|
|
284
|
+
return typeof brand === 'string' && brand.trim() ? brand.trim() : null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Publish to a host as chunks: build, ask which chunks it lacks, send those, then the manifest.
|
|
289
|
+
*
|
|
290
|
+
* The manifest is also written beside the single-file artifact's stable path, so what was published
|
|
291
|
+
* can be read back without the host.
|
|
292
|
+
*/
|
|
293
|
+
async function publishChunked({ argv, remote, entry, target, fit, slug, title, json }) {
|
|
294
|
+
const url = remote === true ? null : String(remote);
|
|
295
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
296
|
+
console.error(color.red('✗ --remote needs a host URL — pass one, set MOTU_HOST_URL, or put {"url","token"} in ~/.config/motu/host.json'));
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
const id = gitIdentity(REPO_ROOT);
|
|
300
|
+
if (paths.publishAs?.repo) id.repo = paths.publishAs.repo;
|
|
301
|
+
if (!id.repo) {
|
|
302
|
+
console.error(color.red('✗ could not derive a repo name from the git remote or the directory name'));
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
const token = (typeof argv.token === 'string' ? argv.token : null) || process.env.MOTU_HOST_TOKEN || loadHostConfig().token || null;
|
|
306
|
+
|
|
307
|
+
if (!json) console.log(color.dim(`building ${target || 'all archipelagos'} (mock fixtures, chunked)…`));
|
|
308
|
+
let bundle;
|
|
309
|
+
try {
|
|
310
|
+
bundle = buildChunked({ entry, target, fit, title });
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
const manifest = manifestOf(bundle.html, bundle.files);
|
|
316
|
+
const out = publishFile(slug).replace(/\.html$/, '.manifest.json');
|
|
317
|
+
mkdirSync(resolve(out, '..'), { recursive: true });
|
|
318
|
+
writeFileSync(out, manifest.json, 'utf8');
|
|
319
|
+
const bytes = Buffer.byteLength(bundle.html) + [...bundle.files.values()].reduce((n, b) => n + b.length, 0);
|
|
320
|
+
|
|
321
|
+
let res;
|
|
322
|
+
try {
|
|
323
|
+
res = await uploadManifest({ url, token, repo: id.repo, slug, title, sha: id.sha, branch: id.branch, brand: declaredBrand(), bundle, manifest });
|
|
324
|
+
} catch (err) {
|
|
325
|
+
if (json) {
|
|
326
|
+
console.log(JSON.stringify({ ok: false, file: out, bytes, error: err.message }, null, 2));
|
|
327
|
+
return 1;
|
|
328
|
+
}
|
|
329
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
330
|
+
return 1;
|
|
331
|
+
}
|
|
332
|
+
const latest = `${res.base}${res.urls.latest}`;
|
|
333
|
+
const immutable = `${res.base}${res.urls.immutable}`;
|
|
334
|
+
const chunks = res.chunks;
|
|
335
|
+
if (json) {
|
|
336
|
+
console.log(JSON.stringify({ ok: true, file: out, bytes, repo: id.repo, slug, chunks, deduped: !!res.deduped, verified: res.verified, live: !!res.live, urls: { latest, immutable, ...(res.urls.branch ? { branch: `${res.base}${res.urls.branch}` } : {}) } }, null, 2));
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
console.log('');
|
|
340
|
+
console.log(`${color.green('✓')} ${color.bold(title)} — ${(bytes / 1024).toFixed(0)} kB in ${chunks.total} chunk(s)` +
|
|
341
|
+
color.dim(` · sent ${chunks.sent}, ${chunks.reused} already on the host (${(res.sentBytes / 1024).toFixed(0)} kB gzipped)${res.deduped ? ' · unchanged, deduped' : ''}`));
|
|
342
|
+
console.log(` ${latest} ${color.dim('(the bookmark — always current)')}`);
|
|
343
|
+
console.log(` ${immutable} ${color.dim('(this build, forever)')}`);
|
|
344
|
+
if (res.live)
|
|
345
|
+
console.log(color.yellow(' a live lagoon is registered for this slug — it is what the `latest` URL serves right now.\n') +
|
|
346
|
+
color.yellow(' stop it (`motu lagoon serve --stop`) to see THIS build there, or use the immutable URL above.'));
|
|
347
|
+
if (id.dirty) console.log(color.yellow(' working tree is dirty — the immutable URL is keyed by content, not by HEAD'));
|
|
348
|
+
for (const w of res.warnings ?? []) console.log(color.yellow(` ${w}`));
|
|
349
|
+
console.log(' ' + color.dim(paths.rel(out)));
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function uploadPublished({ remote, token: flagToken, page, slug, title, out, bytes, json }) {
|
|
354
|
+
const url = remote === true ? null : String(remote);
|
|
355
|
+
if (!url || !/^https?:\/\//.test(url)) {
|
|
356
|
+
console.error(color.red('✗ --remote needs a host URL — pass one, set MOTU_HOST_URL, or put {"url","token"} in ~/.config/motu/host.json'));
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const id = gitIdentity(REPO_ROOT);
|
|
360
|
+
// A project may say who it is, for the case git cannot answer: several publishable apps in one
|
|
361
|
+
// repository. The commit and branch still come from git — those are facts about this build.
|
|
362
|
+
if (paths.publishAs?.repo) id.repo = paths.publishAs.repo;
|
|
363
|
+
if (!id.repo) {
|
|
364
|
+
console.error(color.red('✗ could not derive a repo name from the git remote or the directory name'));
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
const token = flagToken || process.env.MOTU_HOST_TOKEN || loadHostConfig().token || null;
|
|
368
|
+
|
|
369
|
+
let res;
|
|
370
|
+
try {
|
|
371
|
+
res = await uploadLagoon({ url, token, repo: id.repo, slug, title, sha: id.sha, branch: id.branch, brand: declaredBrand(), body: page, });
|
|
372
|
+
} catch (err) {
|
|
373
|
+
if (json) {
|
|
374
|
+
console.log(JSON.stringify({ ok: false, file: out, bytes, error: err.message }, null, 2));
|
|
375
|
+
return 1;
|
|
376
|
+
}
|
|
377
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
378
|
+
console.error(color.dim(` the page is still on disk: ${paths.rel(out)}`));
|
|
379
|
+
return 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const latest = `${res.base}${res.urls.latest}`;
|
|
383
|
+
const immutable = `${res.base}${res.urls.immutable}`;
|
|
384
|
+
if (json) {
|
|
385
|
+
console.log(JSON.stringify({ ok: true, file: out, bytes, repo: id.repo, slug, deduped: !!res.deduped, verified: res.verified, live: !!res.live, urls: { latest, immutable, ...(res.urls.branch ? { branch: `${res.base}${res.urls.branch}` } : {}) } }, null, 2));
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
console.log('');
|
|
389
|
+
console.log(`${color.green('✓')} ${color.bold(title)} — ${(bytes / 1024).toFixed(0)} kB` +
|
|
390
|
+
color.dim(` (${(res.sentBytes / 1024).toFixed(0)} kB gzipped)${res.deduped ? ' · unchanged, deduped' : ''}`));
|
|
391
|
+
console.log(` ${latest} ${color.dim('(the bookmark — always current)')}`);
|
|
392
|
+
console.log(` ${immutable} ${color.dim('(this build, forever)')}`);
|
|
393
|
+
// A LIVE LAGOON IS SERVING THAT FIRST URL, not this build. The host proxies a registered
|
|
394
|
+
// `lagoon serve --watch` ahead of the store for `latest` — deliberately — so publishing mid-session
|
|
395
|
+
// hands back a bookmark that shows the watcher's build. Indistinguishable from a publish that never
|
|
396
|
+
// landed, until you stop it. The immutable URL is never shadowed, so point at it.
|
|
397
|
+
if (res.live)
|
|
398
|
+
console.log(color.yellow(' a live lagoon is registered for this slug — it is what the `latest` URL serves right now.\n') +
|
|
399
|
+
color.yellow(' stop it (`motu lagoon serve --stop`) to see THIS build there, or use the immutable URL above.'));
|
|
400
|
+
if (res.verified === null)
|
|
401
|
+
console.log(color.yellow(' host returned no content hash — it is older than this check, so nothing confirms it stored what was sent'));
|
|
402
|
+
if (id.dirty) console.log(color.yellow(' working tree is dirty — the immutable URL is keyed by content, not by HEAD'));
|
|
403
|
+
// The host sees what only a host can: paths that resolve against the ORIGIN. They resolve on a
|
|
404
|
+
// local lagoon at the root of its own origin and 404 once hosted, so this is where they surface.
|
|
405
|
+
for (const w of res.warnings ?? []) console.log(color.yellow(` ${w}`));
|
|
406
|
+
console.log(' ' + color.dim(paths.rel(out)));
|
|
407
|
+
return 0;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Put back the document skeleton the artifact host would have supplied.
|
|
412
|
+
*
|
|
413
|
+
* `inlineToArtifact` deliberately drops <!doctype>/<html>/<head>/<body> — the artifact wrapper owns
|
|
414
|
+
* them. A browser will limp along without them, but it gets no charset and no viewport meta, so the
|
|
415
|
+
* page renders desktop-width on exactly the device this command exists to test. The fragment keeps
|
|
416
|
+
* its own <title>, so this adds nothing but the skeleton.
|
|
417
|
+
*/
|
|
418
|
+
function wrapForBrowser(page) {
|
|
419
|
+
return `<!doctype html>
|
|
420
|
+
<html lang="en">
|
|
421
|
+
<head>
|
|
422
|
+
<meta charset="utf-8" />
|
|
423
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
424
|
+
</head>
|
|
425
|
+
<body>
|
|
426
|
+
${page}</body>
|
|
427
|
+
</html>
|
|
428
|
+
`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The dock, drawn by the SERVER rather than bundled into the page.
|
|
433
|
+
*
|
|
434
|
+
* This is the second consumer of `@motujs/chrome/dock`, and the reason that module exists rather than a
|
|
435
|
+
* stylesheet in the host: the host app frames a published artifact, `motu lagoon serve` serves one
|
|
436
|
+
* directly, and both must draw the same dock. A copy in each is two chromes that drift — and the dev
|
|
437
|
+
* loop and every runtime check run through THIS one, so a divergence here is a divergence between
|
|
438
|
+
* what a person sees while working and what the checks assert on.
|
|
439
|
+
*
|
|
440
|
+
* IT ONLY TAKES OVER A LAGOON IT CAN DRIVE, per page, decided in the browser. An artifact built
|
|
441
|
+
* before `__motuLagoonControl` existed can list nothing and switch nothing, so hiding its own dock
|
|
442
|
+
* would take the controls away and put nothing in their place. Same rule the host applies, for the
|
|
443
|
+
* same reason.
|
|
444
|
+
*
|
|
445
|
+
* Injected, never bundled: that is the whole point. Change the dock and no lagoon needs rebuilding.
|
|
446
|
+
*/
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* A rebuild is only useful if the phone in your hand notices it, so `--watch` also injects a tiny
|
|
450
|
+
* live-reload client that listens on an SSE endpoint this same server owns.
|
|
451
|
+
*
|
|
452
|
+
* Injected ONLY in watch mode, never by `publish`: a published artifact is served under a strict CSP
|
|
453
|
+
* with nothing behind it, so a page that dials home would be both broken and a lie about what the
|
|
454
|
+
* artifact is. This is the one place the served page deliberately differs from the published one.
|
|
455
|
+
*/
|
|
456
|
+
function injectReloadClient(page) {
|
|
457
|
+
return `${page}
|
|
458
|
+
<script>
|
|
459
|
+
(function () {
|
|
460
|
+
// Reconnects on its own: the server restarts (or the phone sleeps) far more often than you reload.
|
|
461
|
+
function listen() {
|
|
462
|
+
// Relative to WHERE THIS PAGE IS, not to the origin root: framed in a host's shell the page lives
|
|
463
|
+
// at /<repo>/<ref>/<slug>/__motu_frame, and an absolute '/__motu_reload' would ask the host for a
|
|
464
|
+
// stream it does not have instead of the dev server for the one it does.
|
|
465
|
+
//
|
|
466
|
+
// NO REGEX HERE, deliberately. This whole client lives inside a template literal, where a
|
|
467
|
+
// backslash starts an escape sequence JS resolves before the string is ever written out — so a
|
|
468
|
+
// trailing-slash regex was emitted with its backslash eaten, turning the rest of the line into a
|
|
469
|
+
// comment. The script then failed to parse, no EventSource was ever constructed, and the only
|
|
470
|
+
// symptom was a page that quietly stopped reloading. Nothing here needs a backslash, so nothing
|
|
471
|
+
// here has one. (This comment cannot carry backticks either, for the same reason.)
|
|
472
|
+
var here = location.pathname;
|
|
473
|
+
while (here.length > 1 && here.charAt(here.length - 1) === '/') here = here.slice(0, -1);
|
|
474
|
+
// AT THE ROOT this must become the empty string, not '/'. Served standalone the page IS at '/',
|
|
475
|
+
// and '/' + '/__motu_reload' is '//__motu_reload' — a protocol-relative URL naming a host called
|
|
476
|
+
// __motu_reload, which fails on the one path this feature has always worked on.
|
|
477
|
+
if (here === '/') here = '';
|
|
478
|
+
var es = new EventSource(here + '/__motu_reload');
|
|
479
|
+
es.onmessage = function (e) { if (e.data === 'reload') location.reload(); };
|
|
480
|
+
es.onerror = function () { es.close(); setTimeout(listen, 1500); };
|
|
481
|
+
}
|
|
482
|
+
listen();
|
|
483
|
+
})();
|
|
484
|
+
</script>`;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Where a rebuild can come from. The lagoon entry, the app's own sources, and — in this monorepo,
|
|
489
|
+
* where @motujs/* resolve to workspace source rather than to a published tarball — the framework
|
|
490
|
+
* packages, so editing the lagoon chrome in packages/react rebuilds too.
|
|
491
|
+
*
|
|
492
|
+
* Nested roots are dropped (the lagoon lives inside the app root), because a recursive watch on both
|
|
493
|
+
* would deliver every event twice.
|
|
494
|
+
*/
|
|
495
|
+
/**
|
|
496
|
+
* The live `FSWatcher`s, held at module scope so nothing collects them AND so they can be re-armed.
|
|
497
|
+
*
|
|
498
|
+
* Two failures, one symptom — `--watch` stops rebuilding, silently, while the server keeps serving a
|
|
499
|
+
* stale page. That is the worst shape this can take: on the phone in your hand there is no console,
|
|
500
|
+
* and a page that loads looks like a page that is current.
|
|
501
|
+
*
|
|
502
|
+
* 1. `fsWatch()`'s return value was dropped on the floor. An FSWatcher nobody references can be
|
|
503
|
+
* garbage-collected, and when V8 takes it the watch ends.
|
|
504
|
+
* 2. The BUILD kills the watch. Node's recursive watch on Linux is per-directory underneath, and a
|
|
505
|
+
* vite build churns directories inside the watched root (`.motu/`, vite's caches); when those go,
|
|
506
|
+
* the watcher can close without ever emitting an error anyone sees.
|
|
507
|
+
*
|
|
508
|
+
* So holding the reference is necessary and not sufficient: they are also re-armed after every
|
|
509
|
+
* rebuild, which is precisely when they die. Re-arming is a handful of inotify calls.
|
|
510
|
+
*/
|
|
511
|
+
const WATCHERS = [];
|
|
512
|
+
|
|
513
|
+
function watchRoots() {
|
|
514
|
+
const candidates = [APP_ROOT, paths.lagoonDir, resolve(REPO_ROOT, 'packages')].filter((p) => existsSync(p));
|
|
515
|
+
return candidates.filter((p) => !candidates.some((other) => other !== p && p.startsWith(other + sep)));
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Source files worth a rebuild. Everything else — build output above all — must not feed the loop. */
|
|
519
|
+
function isSourceChange(file) {
|
|
520
|
+
if (!file) return false;
|
|
521
|
+
const path = file.split(sep).join('/');
|
|
522
|
+
// dist/ is the vite build's OWN output: reacting to it would make every build trigger the next one.
|
|
523
|
+
if (/(^|\/)(node_modules|dist|\.motu|\.git)(\/|$)/.test(path)) return false;
|
|
524
|
+
return /\.(ts|tsx|js|jsx|css|html|json)$/.test(path);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** First real LAN IPv4 — docker/virtual bridges are skipped, they are never the phone's route here. */
|
|
528
|
+
function lanAddress() {
|
|
529
|
+
for (const [name, addrs] of Object.entries(networkInterfaces())) {
|
|
530
|
+
if (/^(docker|br-|veth|virbr|tun|tap)/.test(name)) continue;
|
|
531
|
+
for (const a of addrs ?? []) if (a.family === 'IPv4' && !a.internal) return a.address;
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export async function lagoonServeCommand(argv) {
|
|
537
|
+
// A LAGOON THAT OUTLIVES THE SHELL. `--detach` re-runs this same command as a detached child and
|
|
538
|
+
// returns; `--stop` ends it. They were `lagoon dev`'s, which is gone: `serve --watch` builds what
|
|
539
|
+
// `publish` ships, so it is the one lagoon to iterate in, and an agent has to be able to leave it on.
|
|
540
|
+
if (argv.stop || argv.detach) {
|
|
541
|
+
const { loadMotuConfig } = await import('../lib/config.mjs');
|
|
542
|
+
return argv.stop ? stopDetached(loadMotuConfig()) : detach(loadMotuConfig());
|
|
543
|
+
}
|
|
544
|
+
let resolved;
|
|
545
|
+
try {
|
|
546
|
+
resolved = resolveTarget(argv);
|
|
547
|
+
} catch (err) {
|
|
548
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
549
|
+
process.exit(1);
|
|
550
|
+
}
|
|
551
|
+
const { entry, target, title } = resolved;
|
|
552
|
+
// BRANCH-SCOPED. `serve --watch` announces to the host, so without this a preview of a
|
|
553
|
+
// work-in-progress branch overwrites the member a team bookmarks. A publish is deliberate and keeps
|
|
554
|
+
// its own slug.
|
|
555
|
+
const slug = liveSlugFor(paths.publishAs?.slug ?? resolved.slug, argv, REPO_ROOT);
|
|
556
|
+
const fit = argv.fit === 'legacy' ? 'legacy' : argv.fit === 'native' ? 'native' : '';
|
|
557
|
+
|
|
558
|
+
const port = Number.parseInt(String(argv.port ?? 8817), 10);
|
|
559
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
560
|
+
console.error(color.red(`✗ --port must be 1-65535, got "${argv.port}"`));
|
|
561
|
+
process.exit(1);
|
|
562
|
+
}
|
|
563
|
+
// Loopback by default: serving the app to the whole network is opt-in, not a side effect of preview.
|
|
564
|
+
const lan = argv.host === true || argv.host === '0.0.0.0';
|
|
565
|
+
const bind = lan ? '0.0.0.0' : '127.0.0.1';
|
|
566
|
+
|
|
567
|
+
// WHERE THE HOST SHOULD COME AND FETCH THIS, when it is not here.
|
|
568
|
+
//
|
|
569
|
+
// The registry has always stored a URL rather than a port, and this is the flag that finally makes
|
|
570
|
+
// that mean something: the announcement said `http://127.0.0.1:<port>` unconditionally, so a live
|
|
571
|
+
// lagoon only worked when the host happened to share a machine with the dev server. Give it a
|
|
572
|
+
// reachable address — a tailnet name, an `ssh -R` tunnel, anything the host can resolve — and the
|
|
573
|
+
// same feature works from a laptop the host has never heard of.
|
|
574
|
+
//
|
|
575
|
+
// VALIDATED HERE, LOUDLY, because the failure is otherwise silent in the worst way: the host stores
|
|
576
|
+
// whatever it is given, every fetch of it fails, and each viewer quietly falls back to the last
|
|
577
|
+
// published build. The page renders. It is just not live, and nothing says so.
|
|
578
|
+
const liveUrlRaw = typeof argv['live-url'] === 'string' ? argv['live-url'] : process.env.MOTU_LIVE_URL || '';
|
|
579
|
+
let liveUrl = '';
|
|
580
|
+
if (liveUrlRaw) {
|
|
581
|
+
let parsed;
|
|
582
|
+
try {
|
|
583
|
+
parsed = new URL(liveUrlRaw);
|
|
584
|
+
} catch {
|
|
585
|
+
console.error(color.red(`✗ --live-url must be an absolute URL, got "${liveUrlRaw}"`));
|
|
586
|
+
console.error(color.dim(' e.g. --live-url https://my-laptop.tailnet.ts.net:8901'));
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
590
|
+
console.error(color.red(`✗ --live-url must be http or https, got "${parsed.protocol}"`));
|
|
591
|
+
process.exit(1);
|
|
592
|
+
}
|
|
593
|
+
// A LOOPBACK ADDRESS HERE IS ALMOST CERTAINLY A MISTAKE, and it is the mistake this flag exists to
|
|
594
|
+
// fix — passing it means announcing the default, from a machine that needed the flag. Refused
|
|
595
|
+
// rather than warned: a warning scrolls past and the symptom is a badge that lies.
|
|
596
|
+
if (/^(127\.|0\.0\.0\.0$|\[?::1\]?$|localhost$)/.test(parsed.hostname)) {
|
|
597
|
+
console.error(color.red(`✗ --live-url points at ${parsed.hostname}, which is this machine.`));
|
|
598
|
+
console.error(color.dim(' That is what the announcement already says. Give the host an address IT can reach —'));
|
|
599
|
+
console.error(color.dim(' a tailnet name, a tunnel, a LAN IP — or drop the flag if the host runs here too.'));
|
|
600
|
+
process.exit(1);
|
|
601
|
+
}
|
|
602
|
+
// Trailing slash trimmed: the host joins this with a path, and `//__motu_reload` is a
|
|
603
|
+
// protocol-relative URL naming a host called __motu_reload. The reload client learned that one.
|
|
604
|
+
liveUrl = liveUrlRaw.replace(/\/+$/, '');
|
|
605
|
+
}
|
|
606
|
+
// PUSH INSTEAD OF BEING PULLED. `--live-url` tells the host where to come and fetch this; this
|
|
607
|
+
// sends the artifact instead, over the same outbound connection the announcement already uses. It
|
|
608
|
+
// is the option for a machine nothing can reach — no tunnel, no tailnet, no LAN route — and it
|
|
609
|
+
// works because a lagoon is ONE self-contained file, so there is nothing else a viewer can ask for.
|
|
610
|
+
const pushing = argv['live-push'] === true;
|
|
611
|
+
if (pushing && liveUrl) {
|
|
612
|
+
console.error(color.red('✗ --live-push and --live-url are two answers to the same question.'));
|
|
613
|
+
console.error(color.dim(' --live-url: the host fetches from you (it must be able to reach you).'));
|
|
614
|
+
console.error(color.dim(' --live-push: you send it to the host (nothing has to reach you).'));
|
|
615
|
+
process.exit(1);
|
|
616
|
+
}
|
|
617
|
+
if (liveUrl && !lan) {
|
|
618
|
+
// Announcing an address the host can reach while listening only on loopback means the host will
|
|
619
|
+
// arrive and find nothing. The two flags belong together, and the pair is easy to half-remember.
|
|
620
|
+
console.error(color.red('✗ --live-url without --host: this would listen on 127.0.0.1 only,'));
|
|
621
|
+
console.error(color.dim(' so the host would resolve the address you gave it and then be refused.'));
|
|
622
|
+
console.error(color.dim(' Add --host to accept connections from outside this machine.'));
|
|
623
|
+
process.exit(1);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Tell the host we are going, if we ever told it we were here.
|
|
628
|
+
*
|
|
629
|
+
* HOISTED TO ONE PLACE because there were TWO SIGINT handlers and they raced: `registerLive`
|
|
630
|
+
* registered one that awaited a network round trip, and the server's own registered one that closed
|
|
631
|
+
* the socket and called `process.exit(0)`. Exit won essentially always, so Ctrl-C — the way every
|
|
632
|
+
* person actually stops this — left the member registered and the badge lit until the 90s TTL ran
|
|
633
|
+
* out. It looked like it worked because a SIGTERM (`pkill`) hits only the first handler, and that
|
|
634
|
+
* is what I happened to test with.
|
|
635
|
+
*/
|
|
636
|
+
let sayGoodbye = async () => {};
|
|
637
|
+
|
|
638
|
+
const watching = argv.watch === true;
|
|
639
|
+
if (watching && argv.build === false) {
|
|
640
|
+
console.error(color.red('✗ --watch and --no-build contradict each other — --no-build never rebuilds'));
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// CHUNKED unless asked otherwise: a rebuild then changes only the chunks an edit touched, and a
|
|
645
|
+
// pushed draft sends only those. `--single` keeps the one-file build; `--no-build` serves the
|
|
646
|
+
// single file `publish` wrote.
|
|
647
|
+
const single = argv.single === true;
|
|
648
|
+
const build = () => (single ? inlineToArtifact(buildSingleFile({ entry, target, fit }), title) : buildChunked({ entry, target, fit, title }));
|
|
649
|
+
let page;
|
|
650
|
+
if (argv.build === false) {
|
|
651
|
+
const file = publishFile(slug);
|
|
652
|
+
if (!existsSync(file)) {
|
|
653
|
+
console.error(color.red(`✗ nothing published at ${paths.rel(file)} — drop --no-build, or run motu lagoon publish first`));
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
page = readFileSync(file, 'utf8');
|
|
657
|
+
} else {
|
|
658
|
+
console.log(color.dim(`building ${target || 'all archipelagos'} (mock fixtures, ${single ? 'one chunk' : 'chunked'})…`));
|
|
659
|
+
try {
|
|
660
|
+
page = build();
|
|
661
|
+
} catch (err) {
|
|
662
|
+
console.error(color.red(`✗ ${err.message}`));
|
|
663
|
+
process.exit(1);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* The chunks of the last few builds, by manifest hash. More than the current one, because a viewer
|
|
669
|
+
* that loaded the page a moment before a rebuild still asks for ITS chunks, and answering 404 to a
|
|
670
|
+
* page that is reloading anyway would put an error in a console for nothing.
|
|
671
|
+
*/
|
|
672
|
+
const recent = new Map();
|
|
673
|
+
/** What a build looks like served: the page as a document, its chunks, and its manifest. */
|
|
674
|
+
const showFrom = (built) => {
|
|
675
|
+
const raw = typeof built === 'string' ? built : built.html;
|
|
676
|
+
const html = wrapForBrowser(injectDock(watching ? injectReloadClient(raw) : raw));
|
|
677
|
+
const files = typeof built === 'string' ? new Map() : built.files;
|
|
678
|
+
const manifest = files.size ? manifestOf(html, files) : null;
|
|
679
|
+
if (manifest) {
|
|
680
|
+
recent.set(manifest.hash, files);
|
|
681
|
+
while (recent.size > 4) recent.delete(recent.keys().next().value);
|
|
682
|
+
}
|
|
683
|
+
const bytes = Buffer.byteLength(html) + [...files.values()].reduce((n, b) => n + b.length, 0);
|
|
684
|
+
return { html, files, manifest, bytes };
|
|
685
|
+
};
|
|
686
|
+
// The served build is a VARIABLE, not a constant: --watch swaps it in place, so the funnel or LAN
|
|
687
|
+
// URL in front of this server never has to be re-pointed to see new work.
|
|
688
|
+
let shown = showFrom(page);
|
|
689
|
+
/**
|
|
690
|
+
* WHERE THIS PAGE'S CHUNKS ARE, from the viewer's side. Standing alone, at this server's root. When
|
|
691
|
+
* a host PULLS this server for a live member, it forwards everything under the member's prefix and
|
|
692
|
+
* says which prefix with `x-motu-base` — so the chunks are asked for under that prefix, which the
|
|
693
|
+
* host forwards here, instead of at the host's root, where nothing would answer.
|
|
694
|
+
*/
|
|
695
|
+
const pageFor = (req) => {
|
|
696
|
+
if (!shown.manifest) return Buffer.from(shown.html, 'utf8');
|
|
697
|
+
const hdr = String(req.headers['x-motu-base'] ?? '');
|
|
698
|
+
const prefix = /^\/[A-Za-z0-9._/-]*$/.test(hdr) ? hdr.replace(/\/+$/, '') : '';
|
|
699
|
+
return Buffer.from(placeAssets(shown.html, `${prefix}/__motu_assets/${shown.manifest.hash}`), 'utf8');
|
|
700
|
+
};
|
|
701
|
+
/** Open live-reload streams, one per viewer (a laptop and a phone on the same URL is the point). */
|
|
702
|
+
const viewers = new Set();
|
|
703
|
+
|
|
704
|
+
// One artifact, no asset requests to route: every path serves the page, so deep links work too.
|
|
705
|
+
const server = createServer((req, res) => {
|
|
706
|
+
if (req.url === '/favicon.ico') return void res.writeHead(204).end(); // keeps the console clean
|
|
707
|
+
// ENDS WITH, not equals. Inside a host's shell the page is served at
|
|
708
|
+
// /<repo>/<ref>/<slug>/__motu_frame, and the injected client asks for `<that path>/__motu_reload`
|
|
709
|
+
// so the host can route the stream back here.
|
|
710
|
+
// Standing alone at :8817 the path is exactly '/__motu_reload', which still ends with it.
|
|
711
|
+
if (watching && String(req.url).split('?')[0].endsWith('/__motu_reload')) {
|
|
712
|
+
res.writeHead(200, {
|
|
713
|
+
'content-type': 'text/event-stream',
|
|
714
|
+
'cache-control': 'no-store',
|
|
715
|
+
connection: 'keep-alive',
|
|
716
|
+
// The funnel sits in front of this; without it the stream is buffered and nothing arrives.
|
|
717
|
+
'x-accel-buffering': 'no',
|
|
718
|
+
});
|
|
719
|
+
res.write('retry: 1500\n\n');
|
|
720
|
+
viewers.add(res);
|
|
721
|
+
req.on('close', () => viewers.delete(res));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// A CHUNK, by the manifest it belongs to. Matched anywhere in the path, because a host pulling
|
|
725
|
+
// this server forwards it under the member's prefix.
|
|
726
|
+
const reqPath = String(req.url).split('?')[0];
|
|
727
|
+
const at = reqPath.indexOf('/__motu_assets/');
|
|
728
|
+
if (at !== -1) {
|
|
729
|
+
const [mh, ...rest] = reqPath.slice(at + '/__motu_assets/'.length).split('/');
|
|
730
|
+
const name = decodeURIComponent(rest.join('/'));
|
|
731
|
+
const bytes = recent.get(mh)?.get(name);
|
|
732
|
+
if (!bytes) return void res.writeHead(404, { 'content-type': 'text/plain' }).end('no such chunk');
|
|
733
|
+
res.writeHead(200, {
|
|
734
|
+
'content-type': contentTypeOf(name),
|
|
735
|
+
'content-length': bytes.length,
|
|
736
|
+
// Keyed by the manifest's content: these bytes can never change at this address.
|
|
737
|
+
'cache-control': 'public, max-age=31536000, immutable',
|
|
738
|
+
});
|
|
739
|
+
return void res.end(req.method === 'HEAD' ? undefined : bytes);
|
|
740
|
+
}
|
|
741
|
+
const body = pageFor(req);
|
|
742
|
+
res.writeHead(200, {
|
|
743
|
+
'content-type': 'text/html; charset=utf-8',
|
|
744
|
+
'content-length': body.length,
|
|
745
|
+
'cache-control': 'no-store', // a republish behind the same URL must not serve a stale page
|
|
746
|
+
});
|
|
747
|
+
res.end(req.method === 'HEAD' ? undefined : body);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
server.on('error', (err) => {
|
|
751
|
+
if (err.code === 'EADDRINUSE') console.error(color.red(`✗ port ${port} is already in use — pass --port <n>`));
|
|
752
|
+
else if (err.code === 'EACCES') console.error(color.red(`✗ not allowed to bind port ${port} — pick one above 1024`));
|
|
753
|
+
else console.error(color.red(`✗ ${err.message}`));
|
|
754
|
+
process.exit(1);
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
server.listen(port, bind, () => {
|
|
758
|
+
const ip = lan ? lanAddress() : null;
|
|
759
|
+
console.log('');
|
|
760
|
+
console.log(`${color.green('✓')} ${color.bold(title)} — ${(shown.bytes / 1024).toFixed(0)} kB` +
|
|
761
|
+
(shown.manifest ? `, ${shown.files.size} chunk(s), mock-backed` : ', self-contained, mock-backed'));
|
|
762
|
+
console.log(` http://localhost:${port}`);
|
|
763
|
+
if (ip) console.log(` http://${ip}:${port} ${color.dim('(LAN — open this on a phone on the same wifi)')}`);
|
|
764
|
+
else if (lan) console.log(color.dim(' (no LAN address found — only loopback is up)'));
|
|
765
|
+
else console.log(color.dim(` --host also serves it on your LAN, for testing on a phone`));
|
|
766
|
+
console.log('');
|
|
767
|
+
console.log(color.dim(' Off-network? Tunnel it — no install, uses the ssh you already have:'));
|
|
768
|
+
console.log(color.dim(` ssh -R 80:localhost:${port} nokey@localhost.run`));
|
|
769
|
+
console.log(color.dim(' That URL is PUBLIC while the tunnel runs. If the host resolves to 127.0.0.1'));
|
|
770
|
+
console.log(color.dim(' (systemd-resolved does this — the name starts with "localhost"), dial the IP:'));
|
|
771
|
+
console.log(color.dim(` ssh -o HostKeyAlias=localhost.run -R 80:localhost:${port} nokey@$(dig +short localhost.run @1.1.1.1 | head -1)`));
|
|
772
|
+
console.log('');
|
|
773
|
+
console.log(color.dim(' Ctrl-C to stop.'));
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
if (watching) startWatching();
|
|
777
|
+
// `--no-live`: a lagoon on a shared machine that should not become the address a team is looking at.
|
|
778
|
+
if (watching && argv.live !== false && !argv['no-live']) registerLive();
|
|
779
|
+
/**
|
|
780
|
+
* Send the built artifact to the host, as the current draft for this lagoon.
|
|
781
|
+
*
|
|
782
|
+
* BEST EFFORT, like the announcement, and for the same reason: a preview must not fail because a
|
|
783
|
+
* host is down. But a REFUSAL is said once — the announcement taught that lesson by swallowing one
|
|
784
|
+
* for twenty-six seconds while the gallery quietly showed the last published build.
|
|
785
|
+
*
|
|
786
|
+
* NOT AWAITED by the rebuild, so a slow link never delays the local reload. The pushes are
|
|
787
|
+
* SERIALISED though: two saves a second apart must not race and leave the older bytes as the draft,
|
|
788
|
+
* which is a bug that would show up as "it reloaded and nothing changed" once in a while.
|
|
789
|
+
*/
|
|
790
|
+
// DECLARED ABOVE THE FIRST CALL, and that is not stylistic. `function pushDraft` hoists and these
|
|
791
|
+
// do not, so the startup push below — which used to sit above this block — threw
|
|
792
|
+
// "Cannot access 'pushInFlight' before initialization" and took the whole server down with it.
|
|
793
|
+
/**
|
|
794
|
+
* WHERE A PERSON CAN LOOK, for every check's verdict line (`motu check` prints it). Rewritten on
|
|
795
|
+
* every beat so its mtime says how long ago it was true; an unwritable cache must not take the
|
|
796
|
+
* server down, and is said once rather than swallowed.
|
|
797
|
+
*/
|
|
798
|
+
let warnedUrlFile = false;
|
|
799
|
+
const noteLive = (url) => {
|
|
800
|
+
try {
|
|
801
|
+
writeLiveUrl(url);
|
|
802
|
+
} catch (err) {
|
|
803
|
+
if (!warnedUrlFile) {
|
|
804
|
+
warnedUrlFile = true;
|
|
805
|
+
console.error(color.dim(` (could not record the live URL for motu check: ${err?.message ?? err})`));
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
let pushInFlight = null;
|
|
810
|
+
let pushQueued = false;
|
|
811
|
+
let pushRefused = false;
|
|
812
|
+
function pushDraft() {
|
|
813
|
+
if (!pushing) return;
|
|
814
|
+
if (pushInFlight) {
|
|
815
|
+
pushQueued = true;
|
|
816
|
+
return;
|
|
817
|
+
}
|
|
818
|
+
const cfg = loadHostConfig();
|
|
819
|
+
const base = (process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
|
|
820
|
+
const hostToken = process.env.MOTU_HOST_TOKEN || cfg.token || null;
|
|
821
|
+
if (!base || !hostToken) return;
|
|
822
|
+
const repo = paths.publishAs?.repo ?? gitIdentity(REPO_ROOT).repo;
|
|
823
|
+
const qs = `repo=${encodeURIComponent(repo)}&slug=${encodeURIComponent(slug)}`;
|
|
824
|
+
// SNAPSHOT THE BYTES. `body` is reassigned by every rebuild, so reading it after the await would
|
|
825
|
+
// sometimes send a newer artifact under an older push's completion — harmless here, and exactly
|
|
826
|
+
// the kind of thing that makes a race impossible to reason about later.
|
|
827
|
+
const snap = shown;
|
|
828
|
+
// CHUNKED: the chunks the host lacks first, then the manifest as the draft — so a save sends what
|
|
829
|
+
// it changed. A host older than the chunk protocol gets the whole page, as it always did.
|
|
830
|
+
pushInFlight = (snap.manifest
|
|
831
|
+
? sendMissingChunks({ base, token: hostToken, files: snap.files, hashes: snap.manifest.hashes }).then(
|
|
832
|
+
() =>
|
|
833
|
+
fetch(`${base}/api/live/draft?${qs}`, {
|
|
834
|
+
method: 'POST',
|
|
835
|
+
// GZIPPED: the manifest carries the page, and the page carries the dock (~240 kB raw).
|
|
836
|
+
headers: { 'content-type': MANIFEST_TYPE, 'content-encoding': 'gzip', authorization: `Bearer ${hostToken}` },
|
|
837
|
+
body: gzipSync(Buffer.from(snap.manifest.json, 'utf8')),
|
|
838
|
+
}),
|
|
839
|
+
(err) => {
|
|
840
|
+
if (err?.oldHost && !pushRefused) {
|
|
841
|
+
pushRefused = true;
|
|
842
|
+
console.error(color.red(` ✗ ${err.message} — drafts need it; serve with --single meanwhile`));
|
|
843
|
+
}
|
|
844
|
+
throw err;
|
|
845
|
+
},
|
|
846
|
+
)
|
|
847
|
+
: fetch(`${base}/api/live/draft?${qs}`, {
|
|
848
|
+
method: 'POST',
|
|
849
|
+
headers: { 'content-type': 'text/html; charset=utf-8', authorization: `Bearer ${hostToken}` },
|
|
850
|
+
body: Buffer.from(snap.html, 'utf8'),
|
|
851
|
+
}))
|
|
852
|
+
.then(async (res) => {
|
|
853
|
+
if (res.ok) noteLive(`${base}/${repo}/latest/${slug}`);
|
|
854
|
+
if (!res.ok && !pushRefused) {
|
|
855
|
+
pushRefused = true;
|
|
856
|
+
const why = await res.json().catch(() => null);
|
|
857
|
+
console.error(color.red(` ✗ the host refused this draft: ${why?.error ?? res.status}`));
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
.catch(() => {
|
|
861
|
+
/* the host is unreachable; the local preview is unaffected and the next save tries again */
|
|
862
|
+
})
|
|
863
|
+
.finally(() => {
|
|
864
|
+
pushInFlight = null;
|
|
865
|
+
if (pushQueued) {
|
|
866
|
+
pushQueued = false;
|
|
867
|
+
pushDraft();
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// The first draft goes up immediately: the artifact is already built by the time we get here, and
|
|
873
|
+
// waiting for the first SAVE would leave the host showing the last published build until somebody
|
|
874
|
+
// typed — which is the exact confusion this feature exists to remove.
|
|
875
|
+
if (watching && pushing) pushDraft();
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* Tell the lagoon host this member is being served live, and keep telling it.
|
|
879
|
+
*
|
|
880
|
+
* The composed gallery then serves THIS process in that member's frame — one URL for the whole
|
|
881
|
+
* gallery, live where a dev server happens to be running and the last published build everywhere
|
|
882
|
+
* else. Without it the gallery could only ever show what had been published, which is why a page
|
|
883
|
+
* under active work looked static there.
|
|
884
|
+
*
|
|
885
|
+
* The heartbeat is what makes a killed process harmless: the host expires an entry that stops being
|
|
886
|
+
* refreshed and falls back to the stored bytes. `off` on exit is the polite version of the same
|
|
887
|
+
* thing, and it is best-effort by design — nothing here should keep the CLI alive.
|
|
888
|
+
*/
|
|
889
|
+
function registerLive() {
|
|
890
|
+
const cfg = loadHostConfig();
|
|
891
|
+
const base = (process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
|
|
892
|
+
const hostToken = process.env.MOTU_HOST_TOKEN || cfg.token || null;
|
|
893
|
+
if (!base || !hostToken) return; // no host configured: serving locally is the whole feature
|
|
894
|
+
// THE SAME IDENTITY PUBLISHING USES. Taking the git repo here while publish takes the override
|
|
895
|
+
// registers a live member under a name the gallery has no published member for — so the frame
|
|
896
|
+
// never goes live and nothing says why. (It did exactly that: Scorbutics/motu:all against a
|
|
897
|
+
// published motu-review:all.)
|
|
898
|
+
const repo = paths.publishAs?.repo ?? gitIdentity(REPO_ROOT).repo;
|
|
899
|
+
const qs = `repo=${encodeURIComponent(repo)}&slug=${encodeURIComponent(slug)}`;
|
|
900
|
+
const call = (path, body, extra) => {
|
|
901
|
+
const search = new URLSearchParams(qs);
|
|
902
|
+
for (const [k, v] of Object.entries(extra ?? {})) search.set(k, v);
|
|
903
|
+
return fetch(`${base}${path}?${search}`, {
|
|
904
|
+
method: 'POST',
|
|
905
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${hostToken}` },
|
|
906
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
907
|
+
}).catch(() => null);
|
|
908
|
+
};
|
|
909
|
+
|
|
910
|
+
let announced = false;
|
|
911
|
+
let refused = false;
|
|
912
|
+
const beat = async () => {
|
|
913
|
+
// PUSHING REPLACES ANNOUNCING. Doing both registered the member TWICE — once as a URL to come
|
|
914
|
+
// and fetch, once as bytes already held — for one lagoon a viewer cannot tell apart. When we
|
|
915
|
+
// are pushing, this loop is only a HEARTBEAT: it stops the draft expiring during a long think,
|
|
916
|
+
// and the bytes themselves go up on save.
|
|
917
|
+
if (pushing) {
|
|
918
|
+
const beatRes = await call('/api/live/draft', null, { touch: '1' });
|
|
919
|
+
// A 404 MEANS THE HOST NO LONGER HOLDS IT, and the heartbeat cannot revive what is not there.
|
|
920
|
+
// Drafts live in the host's memory, so a host restart — or the 32-draft cap evicting the
|
|
921
|
+
// least recently refreshed — forgets one while the dev server it belongs to is still running
|
|
922
|
+
// happily. Without this the lagoon goes dark until somebody saves a file, and nothing says
|
|
923
|
+
// why: the CLI is fine, the host is fine, and the page quietly serves the last publish.
|
|
924
|
+
// Re-send the bytes we already have, which is exactly what a save would have done.
|
|
925
|
+
if (beatRes?.status === 404) pushDraft();
|
|
926
|
+
if (beatRes?.ok) noteLive(`${base}/${repo}/latest/${slug}`);
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const announceUrl = liveUrl || `http://127.0.0.1:${port}`;
|
|
930
|
+
const res = await call('/api/live', { url: announceUrl });
|
|
931
|
+
// A REFUSAL IS SAID ONCE, and until now it was said never. The announcement is best-effort by
|
|
932
|
+
// design — nothing here should keep the CLI alive or fail a preview — but "best effort" was
|
|
933
|
+
// implemented as swallowing the response, so a host that REJECTED the registration produced no
|
|
934
|
+
// output at all. I watched this happen for twenty-six seconds: the server started, the page
|
|
935
|
+
// served, and the only symptom was a gallery that never went live and never said why.
|
|
936
|
+
//
|
|
937
|
+
// Once, not every beat: this retries every 30 seconds and a wrong URL stays wrong.
|
|
938
|
+
if (res && !res.ok && !refused) {
|
|
939
|
+
refused = true;
|
|
940
|
+
const why = await res.json().catch(() => null);
|
|
941
|
+
console.error(color.red(` ✗ the host refused this as a live member: ${why?.error ?? res.status}`));
|
|
942
|
+
console.error(color.dim(` announced: ${announceUrl}`));
|
|
943
|
+
// THE WAY OUT, which the host now names: an address it cannot reach has two fixes, and which
|
|
944
|
+
// one depends on whether this machine can be reached at all.
|
|
945
|
+
if (why?.hint) console.error(color.dim(` ${why.hint}`));
|
|
946
|
+
}
|
|
947
|
+
if (res?.ok) noteLive(`${base}/${repo}/latest/${slug}`);
|
|
948
|
+
if (res?.ok && !announced) {
|
|
949
|
+
announced = true;
|
|
950
|
+
// ITS OWN ADDRESS, which is the only one there is now. This printed `${base}/g/<group>` — a
|
|
951
|
+
// placeholder the reader had to resolve to a group name, for a route that no longer exists.
|
|
952
|
+
console.log(color.dim(` live at: ${base}/${repo}/latest/${slug}`));
|
|
953
|
+
// THE ADDRESS IS PRINTED, always. It is the one value here nobody can check from the outside:
|
|
954
|
+
// if the host cannot reach it, every viewer silently sees the last published build instead,
|
|
955
|
+
// and this line is the only place the actual announcement is visible.
|
|
956
|
+
console.log(color.dim(` the host will fetch it from: ${announceUrl}`));
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
void beat();
|
|
960
|
+
const timer = setInterval(beat, 30_000);
|
|
961
|
+
timer.unref?.();
|
|
962
|
+
|
|
963
|
+
let leaving = false;
|
|
964
|
+
sayGoodbye = async () => {
|
|
965
|
+
if (leaving) return;
|
|
966
|
+
leaving = true;
|
|
967
|
+
clearInterval(timer);
|
|
968
|
+
clearLiveUrl();
|
|
969
|
+
// A DEADLINE, because shutting down must not hang on a host that has gone away. Two seconds is
|
|
970
|
+
// long enough for a round trip to a machine on the same network and short enough that nobody
|
|
971
|
+
// reaches for a second Ctrl-C, which would skip this entirely.
|
|
972
|
+
await Promise.race([call('/api/live/off'), new Promise((r) => setTimeout(r, 2000))]);
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/**
|
|
977
|
+
* Rebuild on source change, debounced, and never concurrently: a vite build takes seconds, and a
|
|
978
|
+
* burst of saves (or one editor writing several files) must collapse into one build. A build that
|
|
979
|
+
* fails keeps the LAST GOOD bytes on the wire — a broken page is worse than a slightly stale one,
|
|
980
|
+
* especially on the phone you are holding, where there is no console to explain it.
|
|
981
|
+
*/
|
|
982
|
+
function startWatching() {
|
|
983
|
+
const roots = watchRoots();
|
|
984
|
+
let timer = null;
|
|
985
|
+
let building = false;
|
|
986
|
+
let queued = false;
|
|
987
|
+
|
|
988
|
+
const rebuild = () => {
|
|
989
|
+
if (building) {
|
|
990
|
+
queued = true;
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
building = true;
|
|
994
|
+
const started = Date.now();
|
|
995
|
+
try {
|
|
996
|
+
const before = shown.manifest?.hashes ?? {};
|
|
997
|
+
shown = showFrom(build());
|
|
998
|
+
const secs = ((Date.now() - started) / 1000).toFixed(1);
|
|
999
|
+
// WHICH CHUNKS THE EDIT TOUCHED: the number that says whether the cut is doing its job.
|
|
1000
|
+
const changed = shown.manifest ? Object.entries(shown.manifest.hashes).filter(([n, h]) => before[n] !== h).map(([n]) => n) : [];
|
|
1001
|
+
console.log(
|
|
1002
|
+
`${color.green('✓')} rebuilt in ${secs}s — ${(shown.bytes / 1024).toFixed(0)} kB` +
|
|
1003
|
+
(shown.manifest ? color.dim(` · changed: ${changed.length ? changed.join(', ') : 'nothing'}`) : '') +
|
|
1004
|
+
(viewers.size ? color.dim(` · reloading ${viewers.size} viewer(s)`) : ''),
|
|
1005
|
+
);
|
|
1006
|
+
for (const res of viewers) res.write('data: reload\n\n');
|
|
1007
|
+
// AND THE HOST, if we are pushing. After the local viewers, because a rebuild that reaches
|
|
1008
|
+
// the browser on this machine and fails to reach the host is a better outcome than the
|
|
1009
|
+
// reverse — and because `pushDraft` is fire-and-forget and must not delay either.
|
|
1010
|
+
pushDraft();
|
|
1011
|
+
} catch (err) {
|
|
1012
|
+
console.error(color.red(`✗ rebuild failed — still serving the last good page`));
|
|
1013
|
+
console.error(color.dim(` ${err.message.split('\n')[0]}`));
|
|
1014
|
+
} finally {
|
|
1015
|
+
building = false;
|
|
1016
|
+
// The build is what kills the watch, so re-arm on the way out — before any queued rebuild,
|
|
1017
|
+
// because that one is a build too.
|
|
1018
|
+
arm();
|
|
1019
|
+
if (queued) {
|
|
1020
|
+
queued = false;
|
|
1021
|
+
rebuild();
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
arm();
|
|
1027
|
+
|
|
1028
|
+
/** (Re)create every watcher. Idempotent: the previous set is closed first. */
|
|
1029
|
+
function arm() {
|
|
1030
|
+
for (const w of WATCHERS.splice(0)) {
|
|
1031
|
+
try {
|
|
1032
|
+
w.close();
|
|
1033
|
+
} catch {
|
|
1034
|
+
/* already closed — that is the case this exists for */
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
for (const root of roots) {
|
|
1038
|
+
try {
|
|
1039
|
+
const w = fsWatch(root, { recursive: true }, (_event, file) => {
|
|
1040
|
+
if (!isSourceChange(file)) return;
|
|
1041
|
+
clearTimeout(timer);
|
|
1042
|
+
timer = setTimeout(rebuild, 250);
|
|
1043
|
+
});
|
|
1044
|
+
// An 'error' on an EventEmitter with no listener THROWS. A dying watcher must not take the
|
|
1045
|
+
// server with it — re-arm instead, since the watch is exactly what we are trying to keep.
|
|
1046
|
+
w.on('error', () => setTimeout(arm, 100));
|
|
1047
|
+
WATCHERS.push(w);
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
console.error(color.red(`✗ cannot watch ${paths.rel(root)} — ${err.message}`));
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
console.log(color.dim(` watching ${roots.map((r) => paths.rel(r)).join(', ')} — saves rebuild and reload viewers`));
|
|
1054
|
+
console.log('');
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ONE SHUTDOWN, in order: tell the host first, then drop the viewers and close. The other way
|
|
1058
|
+
// round is what was happening by accident, and it meant the host kept serving a lagoon whose
|
|
1059
|
+
// process had exited — proxying to a port nothing was listening on, or holding a draft nobody
|
|
1060
|
+
// would refresh — for up to ninety seconds.
|
|
1061
|
+
let stopping = false;
|
|
1062
|
+
const stop = async () => {
|
|
1063
|
+
if (stopping) return;
|
|
1064
|
+
stopping = true;
|
|
1065
|
+
console.log('');
|
|
1066
|
+
await sayGoodbye();
|
|
1067
|
+
for (const res of viewers) res.end();
|
|
1068
|
+
server.close(() => process.exit(0));
|
|
1069
|
+
// If the server has open connections that will not close, do not hang for ever on the way out.
|
|
1070
|
+
setTimeout(() => process.exit(0), 1500).unref?.();
|
|
1071
|
+
};
|
|
1072
|
+
process.on('SIGINT', stop);
|
|
1073
|
+
process.on('SIGTERM', stop);
|
|
1074
|
+
return 0;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* The member a DEV SERVER announces itself as — the published slug, scoped by branch.
|
|
1080
|
+
*
|
|
1081
|
+
* One slug per project was right while a dev server was something a person started on their laptop.
|
|
1082
|
+
* It stops being right the moment several agents work the same repository at once: they all announce
|
|
1083
|
+
* `<repo>/latest/all`, each heartbeat overwrites the last, and whoever looks sees whichever agent
|
|
1084
|
+
* beat most recently — with nothing anywhere saying that is what happened.
|
|
1085
|
+
*
|
|
1086
|
+
* A branch is the identity that already distinguishes concurrent work, so `all@fix-search` and
|
|
1087
|
+
* `all@add-filters` are separate members of the same repo and coexist in the gallery. The default
|
|
1088
|
+
* branch keeps the bare slug, because that is the address a person bookmarks and it should not move.
|
|
1089
|
+
*
|
|
1090
|
+
* `--as <slug>` overrides, for work that is not on its own branch.
|
|
1091
|
+
*/
|
|
1092
|
+
export function liveSlugFor(baseSlug, argv, cwd) {
|
|
1093
|
+
// THE HOST'S RULE, NOT A LOOSER ONE. A slug is a path segment on the host's disk, so the host
|
|
1094
|
+
// validates it as `[A-Za-z0-9][A-Za-z0-9._-]*` and refuses anything else. Building a nicer-looking
|
|
1095
|
+
// `all@branch` here got the announcement rejected with "repo and slug are required" while the dev
|
|
1096
|
+
// server carried on serving a base path for a member that would never exist — so the sanitiser is
|
|
1097
|
+
// the host's own alphabet, applied here, and a slug that cannot be announced is not invented.
|
|
1098
|
+
const clean = (s) => s.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[^A-Za-z0-9]+/, '');
|
|
1099
|
+
const explicit = typeof argv?.as === 'string' ? argv.as : null;
|
|
1100
|
+
if (explicit) return clean(explicit) || baseSlug;
|
|
1101
|
+
const { branch } = gitIdentity(cwd);
|
|
1102
|
+
if (!branch || /^(main|master|trunk)$/.test(branch)) return baseSlug;
|
|
1103
|
+
return clean(`${baseSlug}.${branch}`) || baseSlug;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** Where a detached lagoon records itself, so a later `--stop` can find it. */
|
|
1107
|
+
function pidFile(cfg) {
|
|
1108
|
+
return resolve(cfg.cacheDir, 'lagoon-serve.pid');
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* `--detach` / `--stop` — a lagoon that outlives the shell that started it.
|
|
1113
|
+
*
|
|
1114
|
+
* An agent's lagoon dies with the agent's session. Watched repeatedly across four bench rounds:
|
|
1115
|
+
* the run ends, the URL a person was told to open goes dark, and the only trace is a heartbeat that
|
|
1116
|
+
* stops. The host degrades correctly — the member expires after 90s and `latest` falls back to the
|
|
1117
|
+
* last publish — so nothing is broken, but nothing is watchable either, and watching an agent work is
|
|
1118
|
+
* the point of announcing at all.
|
|
1119
|
+
*
|
|
1120
|
+
* A pidfile rather than a daemon: the process is an ordinary detached child, so `--stop` is a signal
|
|
1121
|
+
* and a crash needs no cleanup beyond the host's own expiry.
|
|
1122
|
+
*/
|
|
1123
|
+
function detach(cfg) {
|
|
1124
|
+
const file = pidFile(cfg);
|
|
1125
|
+
const existing = readPid(file);
|
|
1126
|
+
if (existing) {
|
|
1127
|
+
console.error(color.red(`✗ a detached lagoon is already running (pid ${existing})`));
|
|
1128
|
+
console.error(color.dim(' stop it first: motu lagoon serve --stop'));
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
// The same argv minus the flag that got us here, so the child does the ordinary thing.
|
|
1132
|
+
const args = process.argv.slice(2).filter((a) => a !== '--detach');
|
|
1133
|
+
const out = openSync(resolve(cfg.cacheDir, 'lagoon-serve.log'), 'a');
|
|
1134
|
+
// `process.argv[1]` is the CLI entry that is running us — the only path guaranteed to be the same
|
|
1135
|
+
// motu the caller invoked, which matters when several checkouts exist on one machine.
|
|
1136
|
+
const child = spawn(process.execPath, [process.argv[1], ...args], {
|
|
1137
|
+
detached: true,
|
|
1138
|
+
stdio: ['ignore', out, out],
|
|
1139
|
+
cwd: process.cwd(),
|
|
1140
|
+
env: process.env,
|
|
1141
|
+
});
|
|
1142
|
+
child.unref();
|
|
1143
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
1144
|
+
writeFileSync(file, String(child.pid));
|
|
1145
|
+
console.log(color.dim(` detached (pid ${child.pid}) — logs: ${paths.rel(resolve(cfg.cacheDir, 'lagoon-serve.log'))}`));
|
|
1146
|
+
console.log(color.dim(' stop it with: motu lagoon serve --stop'));
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** The pid a previous `--detach` wrote, when that process is still alive. */
|
|
1150
|
+
function readPid(file) {
|
|
1151
|
+
try {
|
|
1152
|
+
const pid = Number(readFileSync(file, 'utf8').trim());
|
|
1153
|
+
if (!pid) return null;
|
|
1154
|
+
process.kill(pid, 0); // throws when it is gone
|
|
1155
|
+
return pid;
|
|
1156
|
+
} catch {
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function stopDetached(cfg) {
|
|
1162
|
+
const file = pidFile(cfg);
|
|
1163
|
+
const pid = readPid(file);
|
|
1164
|
+
if (!pid) {
|
|
1165
|
+
console.log(color.dim(' no detached lagoon is running'));
|
|
1166
|
+
try {
|
|
1167
|
+
rmSync(file, { force: true });
|
|
1168
|
+
} catch {}
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
// SIGTERM, not SIGKILL: the lagoon's own handler deregisters it from the host on the way out,
|
|
1172
|
+
// which is the difference between `latest` falling back at once and doing it 90 seconds later.
|
|
1173
|
+
process.kill(pid, 'SIGTERM');
|
|
1174
|
+
try {
|
|
1175
|
+
rmSync(file, { force: true });
|
|
1176
|
+
} catch {}
|
|
1177
|
+
// AND DEREGISTER FROM HERE, rather than trusting the child's own exit path. Measured: the process
|
|
1178
|
+
// died and the host still listed the member 54 seconds later, because a signal handler racing an
|
|
1179
|
+
// async POST against process exit is not something to rely on. The host would have expired it at
|
|
1180
|
+
// 90s anyway — this is the difference between `latest` falling back at once and a minute of
|
|
1181
|
+
// serving an address with nothing behind it.
|
|
1182
|
+
void deregisterLive();
|
|
1183
|
+
console.log(color.dim(` stopped the detached lagoon (pid ${pid})`));
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
/** Tell the host this member is gone. Best effort and bounded: stopping must not hang on a dead host. */
|
|
1187
|
+
async function deregisterLive() {
|
|
1188
|
+
const cfg = loadHostConfig();
|
|
1189
|
+
const base = (process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
|
|
1190
|
+
const hostToken = process.env.MOTU_HOST_TOKEN || cfg.token || null;
|
|
1191
|
+
if (!base || !hostToken) return;
|
|
1192
|
+
const repo = paths.publishAs?.repo ?? gitIdentity(REPO_ROOT).repo;
|
|
1193
|
+
const slug = liveSlugFor(paths.publishAs?.slug ?? 'all', {}, REPO_ROOT);
|
|
1194
|
+
const qs = `repo=${encodeURIComponent(repo)}&slug=${encodeURIComponent(slug)}`;
|
|
1195
|
+
await Promise.race([
|
|
1196
|
+
fetch(`${base}/api/live/off?${qs}`, { method: 'POST', headers: { authorization: `Bearer ${hostToken}` } }).catch(() => null),
|
|
1197
|
+
new Promise((r) => setTimeout(r, 2000)),
|
|
1198
|
+
]);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
/**
|
|
1202
|
+
* `motu lagoon eject` — write the framework's lagoon entries into the project.
|
|
1203
|
+
*
|
|
1204
|
+
* The escape hatch C1 promises: everything motu derives, motu can also write out. After ejecting, the
|
|
1205
|
+
* project owns `index.html` and the entries, the materializer stops running for it (ownership is
|
|
1206
|
+
* decided by `index.html`), and the files are yours to diverge — which is exactly the state the
|
|
1207
|
+
* reference ocean is in, and why its 180-line composition root was never at risk from this.
|
|
1208
|
+
*/
|
|
1209
|
+
export async function lagoonEjectCommand() {
|
|
1210
|
+
const { loadMotuConfig } = await import('../lib/config.mjs');
|
|
1211
|
+
const { materializeLagoon, projectOwnsLagoon } = await import('../lib/lagoon-materialize.mjs');
|
|
1212
|
+
const cfg = loadMotuConfig();
|
|
1213
|
+
if (projectOwnsLagoon(cfg)) {
|
|
1214
|
+
console.error(color.red(`already ejected: ${paths.rel(cfg.lagoonDir)}/index.html exists`));
|
|
1215
|
+
console.log(color.dim(' delete it (and src/main.tsx, src/lagoon.tsx, src/fixtures.ts, src/env.ts)'));
|
|
1216
|
+
console.log(color.dim(' to hand the lagoon back to the framework.'));
|
|
1217
|
+
process.exit(2);
|
|
1218
|
+
}
|
|
1219
|
+
materializeLagoon(cfg, cfg.lagoonDir);
|
|
1220
|
+
console.log(`ejected into ${paths.rel(cfg.lagoonDir)}`);
|
|
1221
|
+
console.log(color.dim(' index.html, lagoon.html, src/{main.tsx,lagoon.tsx,fixtures.ts,env.ts}'));
|
|
1222
|
+
console.log(color.dim(' the project now owns these — motu will not regenerate them.'));
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
/**
|
|
1226
|
+
* `motu lagoon states [target]` — every state this project's lagoon can be OPENED in, as a URL.
|
|
1227
|
+
*
|
|
1228
|
+
* The addresses (`?scenario=`, `?flow=`) are only half of a first-class thing; the other half is
|
|
1229
|
+
* being able to find out what exists without reading four evidence files. This is that half, and it
|
|
1230
|
+
* is deliberately the same source the browser gets: island `scenarios` and region flows, read with
|
|
1231
|
+
* the loader every runtime check already uses.
|
|
1232
|
+
*
|
|
1233
|
+
* Paths, not absolute URLs, unless `--base` says otherwise — the port belongs to whichever lagoon is
|
|
1234
|
+
* running (`dev` picks one, `serve` defaults to 8817, a published lagoon has a host), and printing a
|
|
1235
|
+
* guess that resolves to nothing is worse than printing the part that is always true.
|
|
1236
|
+
*/
|
|
1237
|
+
export async function lagoonStatesCommand(argv) {
|
|
1238
|
+
const { readScenarios } = await import('./verify.mjs');
|
|
1239
|
+
const { listIslands } = await import('../lib/islands.mjs');
|
|
1240
|
+
const { readRegions } = await import('../lib/eject.mjs');
|
|
1241
|
+
const { declaredScreens } = await import('../lib/screens.mjs');
|
|
1242
|
+
|
|
1243
|
+
const base = typeof argv.base === 'string' ? argv.base.replace(/\/$/, '') : '';
|
|
1244
|
+
const only = argv._[0];
|
|
1245
|
+
const q = (params) => new URLSearchParams(params).toString();
|
|
1246
|
+
|
|
1247
|
+
/** The same slug the page accepts, so a URL carries "a-week-to-answer" rather than 40 escaped bytes. */
|
|
1248
|
+
const slug = (name) =>
|
|
1249
|
+
name
|
|
1250
|
+
.normalize('NFD')
|
|
1251
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
1252
|
+
.toLowerCase()
|
|
1253
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
1254
|
+
.replace(/^-|-$/g, '');
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Address each state by its SLUG where that is unambiguous, and by its exact name where it is not.
|
|
1258
|
+
*
|
|
1259
|
+
* The page resolves both. Printing the slug is what makes these URLs quotable in a commit message
|
|
1260
|
+
* or a review comment; falling back the moment two names collide is what keeps them addresses.
|
|
1261
|
+
*/
|
|
1262
|
+
const addressFor = (list) => {
|
|
1263
|
+
const slugs = list.map((s, i) => slug(s.name ?? `#${i + 1}`));
|
|
1264
|
+
return (name, i) => (slugs.filter((x) => x === slugs[i]).length === 1 ? slugs[i] : name);
|
|
1265
|
+
};
|
|
1266
|
+
|
|
1267
|
+
const islands = listIslands(paths.islandsDir)
|
|
1268
|
+
.filter(({ kebab }) => !only || kebab === only || names(kebab).tag === only)
|
|
1269
|
+
.map(({ kebab }) => {
|
|
1270
|
+
const file = paths.fixturesFile(kebab);
|
|
1271
|
+
const scenarios = existsSync(file) ? readScenarios(file) : [];
|
|
1272
|
+
const { tag } = names(kebab);
|
|
1273
|
+
const address = addressFor(scenarios);
|
|
1274
|
+
return {
|
|
1275
|
+
target: `island:${tag}`,
|
|
1276
|
+
states: scenarios.map((s, i) => {
|
|
1277
|
+
const name = s.name ?? `#${i + 1}`;
|
|
1278
|
+
const steps = (s.interactions ?? []).length;
|
|
1279
|
+
return {
|
|
1280
|
+
name,
|
|
1281
|
+
// The gallery, like the region rows below — NOT lagoon.html. `lagoon serve` and
|
|
1282
|
+
// `lagoon publish` build the gallery and serve it at every path, so this address used to
|
|
1283
|
+
// land on the gallery with a `target` it ignored: the first region rendered, no banner,
|
|
1284
|
+
// and the URL looked like it had worked. The gallery reads an `island:` target now.
|
|
1285
|
+
url: `${base}/?${q({ target: `island:${tag}`, scenario: address(name, i) })}`,
|
|
1286
|
+
// A scenario with interactions is not ONE state. Reported exactly as a region's flow
|
|
1287
|
+
// reports its own — a COUNT, which the printer already turns into "&step=<n> stops
|
|
1288
|
+
// earlier" — so the states BETWEEN the clicks are as findable here as a flow's are, and
|
|
1289
|
+
// the last one stops looking like the only one there is. Omitted (not 0) when a scenario
|
|
1290
|
+
// has none, so a plain seeded state does not advertise a step vocabulary it cannot use.
|
|
1291
|
+
...(steps ? { steps } : {}),
|
|
1292
|
+
};
|
|
1293
|
+
}),
|
|
1294
|
+
};
|
|
1295
|
+
})
|
|
1296
|
+
.filter((i) => i.states.length);
|
|
1297
|
+
|
|
1298
|
+
// Every island's scenarios by TAG, unfiltered: a region plays its members' what-ifs whether or not
|
|
1299
|
+
// the island itself was the target asked for.
|
|
1300
|
+
const scenariosByTag = new Map(
|
|
1301
|
+
listIslands(paths.islandsDir).map(({ kebab }) => {
|
|
1302
|
+
const file = paths.fixturesFile(kebab);
|
|
1303
|
+
return [names(kebab).tag, existsSync(file) ? readScenarios(file) : []];
|
|
1304
|
+
}),
|
|
1305
|
+
);
|
|
1306
|
+
|
|
1307
|
+
const regions = readRegions(paths.archipelagosDir)
|
|
1308
|
+
.filter(({ id }) => !only || id === only)
|
|
1309
|
+
.map(({ id, islands: members }) => {
|
|
1310
|
+
const file = paths.archipelagoEvidence(id);
|
|
1311
|
+
const flows = existsSync(file) ? readScenarios(file) : [];
|
|
1312
|
+
const address = addressFor(flows);
|
|
1313
|
+
return {
|
|
1314
|
+
target: `archipelago:${id}`,
|
|
1315
|
+
// THE PAGE IS A STATE TOO, where the region declares one — the application's own page module,
|
|
1316
|
+
// rendered with the region's providers and wire. Listed first because it is the only address
|
|
1317
|
+
// here that shows the page a person actually visits, and read by PARSING the overrides module
|
|
1318
|
+
// rather than matching it, so a shape nobody anticipated cannot silently hide the address.
|
|
1319
|
+
page: regionDeclaresPage(id) ? `${base}/?${q({ region: id, view: 'page' })}` : null,
|
|
1320
|
+
states: flows.map((f, i) => {
|
|
1321
|
+
const name = f.name ?? `#${i + 1}`;
|
|
1322
|
+
return {
|
|
1323
|
+
name,
|
|
1324
|
+
steps: (f.steps ?? []).length,
|
|
1325
|
+
// The gallery, not lagoon.html: that is the entry `lagoon serve` and `lagoon publish`
|
|
1326
|
+
// build, so this URL works on the lagoon a human is actually looking at.
|
|
1327
|
+
//
|
|
1328
|
+
// `region` is always written out, even though the page can infer it from a unique flow
|
|
1329
|
+
// name. Flow names DO collide across regions ("each slot renders its own island" is a
|
|
1330
|
+
// good name in every region that has one), and a printed URL is the thing someone pastes
|
|
1331
|
+
// six months later into a lagoon that has grown another region since.
|
|
1332
|
+
// A flow in a WORLD carries it: the flow was written against that world's answers, and the
|
|
1333
|
+
// link must open them (the page would switch anyway; the address should say so).
|
|
1334
|
+
...(f.world ? { world: f.world } : {}),
|
|
1335
|
+
url: `${base}/?${q({ region: id, flow: address(name, i), ...(f.world ? { world: f.world } : {}) })}`,
|
|
1336
|
+
};
|
|
1337
|
+
}),
|
|
1338
|
+
// WHAT-IFS, PLAYED IN THE REGION — what the dock calls dropping anchor at an island, and what
|
|
1339
|
+
// its Share copies. An island's scenario forged into this region's keys, the rest of the
|
|
1340
|
+
// region live around it. Addressed by `state=`, the one form the dock writes, so a URL
|
|
1341
|
+
// printed here and a URL copied off the dock are the same string. Planned islands are not
|
|
1342
|
+
// built yet, so they have nothing to play.
|
|
1343
|
+
//
|
|
1344
|
+
// ONLY THE ONES THE REGION CAN PLAY. A scenario that sets a prop this region does not bind
|
|
1345
|
+
// has no region key to forge — the lagoon refuses it ("this scenario only exists for the
|
|
1346
|
+
// island alone"), and it is already listed under the island's own address above. The same
|
|
1347
|
+
// rule as the player's `unboundInRegion`: what it sets on mount AND after (`afterMount`).
|
|
1348
|
+
whatIfs: (members ?? [])
|
|
1349
|
+
.filter((m) => !m.planned && m.element)
|
|
1350
|
+
.flatMap((m) =>
|
|
1351
|
+
(scenariosByTag.get(m.element) ?? []).flatMap((s, i) => {
|
|
1352
|
+
const sets = [...Object.keys(s.seed ?? {}), ...Object.keys(s.afterMount ?? {})];
|
|
1353
|
+
if (!sets.every((prop) => prop in (m.bind ?? {}))) return [];
|
|
1354
|
+
const name = s.name ?? `#${i + 1}`;
|
|
1355
|
+
return [{
|
|
1356
|
+
island: m.element,
|
|
1357
|
+
slot: m.slot,
|
|
1358
|
+
name,
|
|
1359
|
+
// `region` TOO, and it is not redundant: a `state=` applies to a region IN VIEW, and a
|
|
1360
|
+
// bare address opens the first screen — where most regions are not. Measured: without
|
|
1361
|
+
// it, 88 of 94 of these refused with "region is not on this view".
|
|
1362
|
+
url: `${base}/?${q({ region: id, state: `${id}.scenario.${m.element}.${slug(name) || `#${i + 1}`}` })}`,
|
|
1363
|
+
}];
|
|
1364
|
+
}),
|
|
1365
|
+
),
|
|
1366
|
+
};
|
|
1367
|
+
})
|
|
1368
|
+
.filter((r) => r.states.length || r.page || r.whatIfs.length);
|
|
1369
|
+
|
|
1370
|
+
// SCREENS — a route, the app's layout, a region per slot. The bare address opens the first one, so
|
|
1371
|
+
// these are the addresses a person lands on before they pick anything.
|
|
1372
|
+
const declared = only ? null : declaredScreens();
|
|
1373
|
+
const screens = (declared?.screens ?? []).map((sc) => ({
|
|
1374
|
+
id: sc.id,
|
|
1375
|
+
path: sc.path,
|
|
1376
|
+
url: `${base}/?${q({ screen: sc.id })}`,
|
|
1377
|
+
}));
|
|
1378
|
+
|
|
1379
|
+
if (argv.json) {
|
|
1380
|
+
console.log(JSON.stringify({ screens, islands, regions }, null, 2));
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (!islands.length && !regions.length && !screens.length) {
|
|
1384
|
+
console.log(color.dim(only ? `no declared states for "${only}"` : 'no island scenarios and no region flows yet'));
|
|
1385
|
+
console.log(color.dim(' an island declares `scenarios` in <kebab>.evidence.ts; a region declares flows in <id>.evidence.ts'));
|
|
1386
|
+
return;
|
|
1387
|
+
}
|
|
1388
|
+
if (screens.length) {
|
|
1389
|
+
console.log(color.bold('screens') + color.dim(' · the bare address opens the first'));
|
|
1390
|
+
for (const sc of screens) {
|
|
1391
|
+
console.log(` ${sc.id}${color.dim(` · ${sc.path}`)}`);
|
|
1392
|
+
console.log(color.dim(` ${sc.url}`));
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
if (declared?.unread)
|
|
1396
|
+
console.log(color.yellow(` ! ${declared.unread} screen(...) call(s) not read — write \`id\` and \`path\` as string literals`));
|
|
1397
|
+
for (const { target, states, page, whatIfs } of [...islands, ...regions]) {
|
|
1398
|
+
console.log(color.bold(target));
|
|
1399
|
+
if (page) {
|
|
1400
|
+
console.log(` the application's own page${color.dim(' · what the page renders, not the region')}`);
|
|
1401
|
+
console.log(color.dim(` ${page}`));
|
|
1402
|
+
}
|
|
1403
|
+
for (const s of states) {
|
|
1404
|
+
const steps = s.steps === undefined ? '' : color.dim(` · ${s.steps} step(s), &step=<n> stops earlier`);
|
|
1405
|
+
console.log(` ${s.name}${steps}`);
|
|
1406
|
+
console.log(color.dim(` ${s.url}`));
|
|
1407
|
+
}
|
|
1408
|
+
let lastIsland = null;
|
|
1409
|
+
for (const w of whatIfs ?? []) {
|
|
1410
|
+
if (w.island !== lastIsland) {
|
|
1411
|
+
lastIsland = w.island;
|
|
1412
|
+
console.log(` ${color.dim('what-ifs ·')} ${w.island}${color.dim(` (slot ${w.slot})`)}`);
|
|
1413
|
+
}
|
|
1414
|
+
console.log(` ${w.name}`);
|
|
1415
|
+
console.log(color.dim(` ${w.url}`));
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
if (!base) console.log(color.dim('\n paths are relative to a running lagoon — pass --base http://localhost:8817 for full URLs'));
|
|
1419
|
+
}
|