@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,112 @@
|
|
|
1
|
+
// Island discovery + the generated element registry.
|
|
2
|
+
//
|
|
3
|
+
// Two layouts are supported on purpose (C1: the verbose form stays reachable):
|
|
4
|
+
// FLAT src/islands/<kebab>.island.ts — exports `element`; evidence in <kebab>.evidence.ts
|
|
5
|
+
// FOLDER src/islands/<kebab>/element.ts — the original; the reference ocean still uses it
|
|
6
|
+
//
|
|
7
|
+
// The registry is GENERATED rather than hand-maintained. It cannot be a glob: the barrel that exports
|
|
8
|
+
// it is imported by the host application, and `import.meta.glob` is a Vite feature — Next's bundler
|
|
9
|
+
// does not have it. Generating gives the same "adding an island is not an edit" property with static
|
|
10
|
+
// imports that every bundler understands, and lets verify detect a registry that has drifted from the
|
|
11
|
+
// files on disk.
|
|
12
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
|
|
15
|
+
/** Every island in the project, whichever layout it uses. */
|
|
16
|
+
export function listIslands(islandsDir) {
|
|
17
|
+
if (!existsSync(islandsDir)) return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const e of readdirSync(islandsDir, { withFileTypes: true })) {
|
|
20
|
+
if (e.isDirectory()) {
|
|
21
|
+
if (existsSync(resolve(islandsDir, e.name, 'element.ts')))
|
|
22
|
+
out.push({ kebab: e.name, layout: 'folder', element: resolve(islandsDir, e.name, 'element.ts') });
|
|
23
|
+
} else if (e.name.endsWith('.island.ts') || e.name.endsWith('.island.tsx')) {
|
|
24
|
+
out.push({
|
|
25
|
+
kebab: e.name.replace(/\.island\.tsx?$/, ''),
|
|
26
|
+
layout: 'flat',
|
|
27
|
+
element: resolve(islandsDir, e.name),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out.sort((a, b) => a.kebab.localeCompare(b.kebab));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The exported ElementSpec binding in an island's file (`element` in flat form, `<camel>Element` in folder form). */
|
|
35
|
+
export function elementExportName(file) {
|
|
36
|
+
const m = readFileSync(file, 'utf8').match(/export const (\w+)\s*:\s*ElementSpec/);
|
|
37
|
+
return m?.[1] ?? 'element';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Render the registry from what is on disk. */
|
|
41
|
+
/**
|
|
42
|
+
* COVERAGE IS NOT HERE ANY MORE, and its absence is the point.
|
|
43
|
+
*
|
|
44
|
+
* `configureCoverage({ enabled, regions })` used to be emitted into this file. Nothing about it is
|
|
45
|
+
* island-shaped — `regions: ["actions"]` names REGIONS — and it lived here only because the island
|
|
46
|
+
* registry was the one generated module an application is guaranteed to import at startup. That is a
|
|
47
|
+
* fact about which files happened to exist, not a reason, and it made the island registry responsible
|
|
48
|
+
* for something outside its own noun. See lib/archipelagos.mjs.
|
|
49
|
+
*/
|
|
50
|
+
export function renderRegistry(islands, islandsDir, isolation) {
|
|
51
|
+
// NO SILENT DEFAULT. Writing `'shadow'` when the caller passed nothing is how this generator put the
|
|
52
|
+
// wrong isolation into a project declaring `light` — the value was missing from the `paths` object,
|
|
53
|
+
// not from the config, and a fallback turned a plumbing bug into a wrong file nobody would question.
|
|
54
|
+
if (isolation !== 'light' && isolation !== 'shadow') {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`motu: renderRegistry needs the project's isolation ('light' or 'shadow'), got ${JSON.stringify(isolation)}. ` +
|
|
57
|
+
`It comes from motu.config.json via \`paths.isolation\`.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const rows = islands.map((i) => {
|
|
61
|
+
const name = elementExportName(i.element);
|
|
62
|
+
// Extensionless. motu's packages declare `moduleResolution: "Bundler"`, and the registry this
|
|
63
|
+
// writes is compiled by the HOST's bundler — where a '.js' specifier pointing at a '.ts' file is
|
|
64
|
+
// a mapping each one has to be told about. webpack has `extensionAlias`; Turbopack has nothing,
|
|
65
|
+
// and an Angular build resolves it only from inside node_modules. Extensionless needs no mapping.
|
|
66
|
+
const spec = i.layout === 'flat' ? `./${i.kebab}.island` : `./${i.kebab}/element`;
|
|
67
|
+
// Local alias when the export name would collide across islands (folder form names them uniquely).
|
|
68
|
+
const local = name === 'element' ? `${i.kebab.replace(/-(\w)/g, (_, c) => c.toUpperCase())}Element` : name;
|
|
69
|
+
const text = readFileSync(i.element, 'utf8');
|
|
70
|
+
const tag = (text.match(/\btag:\s*'([^']+)'/) ?? text.match(/\bisland\(\s*'([^']+)'/))?.[1] ?? i.kebab;
|
|
71
|
+
return { import: `import { ${name}${local !== name ? ` as ${local}` : ''} } from '${spec}';`, local, tag };
|
|
72
|
+
});
|
|
73
|
+
return `// GENERATED by \`motu sync\` — do not edit by hand.
|
|
74
|
+
//
|
|
75
|
+
// Static imports, not a glob: this file is re-exported by the project barrel, which the host
|
|
76
|
+
// application imports through its own bundler, and \`import.meta.glob\` is Vite-only.
|
|
77
|
+
import type { ElementSpec } from '@motujs/react';
|
|
78
|
+
import { setDefaultIsolation } from '@motujs/core';
|
|
79
|
+
${rows.map((r) => r.import).join('\n')}
|
|
80
|
+
|
|
81
|
+
// ISOLATION, from motu.config.json, applied by IMPORTING this file.
|
|
82
|
+
//
|
|
83
|
+
// The lagoon has always had it — its root is scaffolded with \`setDefaultIsolation(__MOTU_ISOLATION__)\`
|
|
84
|
+
// — and a host application had no way to say it at all: \`registerElements\` takes css and a theme and
|
|
85
|
+
// nothing else. So a project declaring \`light\` previewed in light DOM and SHIPPED in shadow, which
|
|
86
|
+
// changes whether the host's own stylesheet reaches the island. Approving a baseline then means
|
|
87
|
+
// approving something the page does not render.
|
|
88
|
+
//
|
|
89
|
+
// Set here rather than as an argument to \`registerElements\` so no host has to repeat the value and
|
|
90
|
+
// no host can disagree with the config: importing the registry is already what a host does, and this
|
|
91
|
+
// file is generated, so a stale copy is caught the same way every other drift in it is.
|
|
92
|
+
setDefaultIsolation('${isolation}');
|
|
93
|
+
export const ELEMENT_REGISTRY: ElementSpec[] = [${rows.map((r) => r.local).join(', ')}];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tag -> element spec, as a TYPE. Lets an archipelago check its wiring against the islands it names
|
|
97
|
+
* (\`RegionWiringOk\`) without importing them at runtime — the values above are the registry; this is
|
|
98
|
+
* the same information in a form the compiler can look up by tag.
|
|
99
|
+
*/
|
|
100
|
+
export interface ElementTypes {
|
|
101
|
+
${rows.map((r) => ` '${r.tag}': typeof ${r.local};`).join('\n')}
|
|
102
|
+
}
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Write the registry for a project. Returns the path written. */
|
|
107
|
+
export function syncRegistry(islandsDir, isolation) {
|
|
108
|
+
const islands = listIslands(islandsDir);
|
|
109
|
+
const out = resolve(islandsDir, 'registry.ts');
|
|
110
|
+
writeFileSync(out, renderRegistry(islands, islandsDir, isolation));
|
|
111
|
+
return { path: out, count: islands.length };
|
|
112
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// A lagoon as CHUNKS: the page, the files it loads, and a manifest naming each file by its content.
|
|
2
|
+
//
|
|
3
|
+
// WHY. A lagoon used to travel as one self-contained file, so a one-line edit in one region re-sent
|
|
4
|
+
// (and the host re-stored) the whole thing: every dependency, the framework, every other region. The
|
|
5
|
+
// build now cuts stable chunks (`chunkOf` in lagoon-vite.mjs), and this module turns that output into
|
|
6
|
+
// something a content-addressed host can take a piece at a time:
|
|
7
|
+
//
|
|
8
|
+
// manifest { motu: 'lagoon-manifest/1', html, files: { 'region-members.js': '<sha256>', … } }
|
|
9
|
+
//
|
|
10
|
+
// The host is asked which hashes it lacks, is sent only those, and then the manifest. An edit in one
|
|
11
|
+
// region sends that region's chunk and the manifest; everything else is already there.
|
|
12
|
+
//
|
|
13
|
+
// THE PAGE DOES NOT KNOW WHERE ITS CHUNKS LIVE. Its references read `__MOTU_ASSETS__/<file>`, and
|
|
14
|
+
// whoever serves the page fills that in: the host with `/__motu_assets/<manifest hash>`, the local
|
|
15
|
+
// `lagoon serve` with its own. Chunks find each other relatively, so only the page needs filling.
|
|
16
|
+
//
|
|
17
|
+
// The self-contained single file still exists (`motu lagoon publish` without --remote, `--single`):
|
|
18
|
+
// it is what an Artifact needs, and what opens from disk with nothing behind it.
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { resolve } from 'node:path';
|
|
22
|
+
import { gzipSync } from 'node:zlib';
|
|
23
|
+
|
|
24
|
+
export const MANIFEST_FORMAT = 'lagoon-manifest/1';
|
|
25
|
+
export const MANIFEST_TYPE = 'application/vnd.motu.lagoon+json';
|
|
26
|
+
/** What the page says where a server must say where its chunks are. */
|
|
27
|
+
export const ASSETS_PLACEHOLDER = '__MOTU_ASSETS__';
|
|
28
|
+
|
|
29
|
+
export const sha256 = (buf) => createHash('sha256').update(buf).digest('hex');
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read a CHUNKED build's output into `{ html, files }`.
|
|
33
|
+
*
|
|
34
|
+
* `html` is the fragment (no doctype/html/head/body, like the single-file artifact) with every
|
|
35
|
+
* `./assets/x` reference rewritten to the placeholder. `files` maps a flat name to its bytes.
|
|
36
|
+
*/
|
|
37
|
+
export function readChunkedBuild(htmlPath, title) {
|
|
38
|
+
const distDir = resolve(htmlPath, '..');
|
|
39
|
+
const assetsDir = resolve(distDir, 'assets');
|
|
40
|
+
let html = readFileSync(htmlPath, 'utf8');
|
|
41
|
+
html = html.replace(/(["'])\.?\/assets\//g, `$1${ASSETS_PLACEHOLDER}/`);
|
|
42
|
+
html = html
|
|
43
|
+
.replace(/<!doctype html>\s*/i, '')
|
|
44
|
+
.replace(/<\/?(?:html|head|body)\b[^>]*>\s*/gi, '')
|
|
45
|
+
.replace(/<title>[\s\S]*?<\/title>/i, `<title>${title}</title>`)
|
|
46
|
+
.trim() + '\n';
|
|
47
|
+
const files = new Map();
|
|
48
|
+
if (existsSync(assetsDir)) {
|
|
49
|
+
for (const name of readdirSync(assetsDir)) {
|
|
50
|
+
const file = resolve(assetsDir, name);
|
|
51
|
+
if (statSync(file).isFile()) files.set(name, readFileSync(file));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// THE SAME CHECK THE SINGLE FILE MAKES: a page still pointing at /assets/ is one whose chunks the
|
|
55
|
+
// server will not find, and it renders blank rather than failing.
|
|
56
|
+
const dangling = html.match(/["'(]\/assets\/[^"')]+/g);
|
|
57
|
+
if (dangling) throw new Error(`chunked page still references ${[...new Set(dangling)].join(', ')}`);
|
|
58
|
+
return { html, files };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The manifest for a page whose html has already been through whatever the caller injects. */
|
|
62
|
+
export function manifestOf(html, files) {
|
|
63
|
+
const hashes = {};
|
|
64
|
+
for (const [name, bytes] of [...files].sort(([a], [b]) => (a < b ? -1 : 1))) hashes[name] = sha256(bytes);
|
|
65
|
+
const json = JSON.stringify({ motu: MANIFEST_FORMAT, html, files: hashes });
|
|
66
|
+
return { json, hash: sha256(Buffer.from(json, 'utf8')), hashes };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Fill the placeholder: where this server serves the chunks of the manifest it is serving. */
|
|
70
|
+
export function placeAssets(html, base) {
|
|
71
|
+
return html.split(ASSETS_PLACEHOLDER).join(base.replace(/\/+$/, ''));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const TYPES = {
|
|
75
|
+
js: 'text/javascript; charset=utf-8',
|
|
76
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
77
|
+
css: 'text/css; charset=utf-8',
|
|
78
|
+
json: 'application/json',
|
|
79
|
+
svg: 'image/svg+xml',
|
|
80
|
+
png: 'image/png',
|
|
81
|
+
jpg: 'image/jpeg',
|
|
82
|
+
jpeg: 'image/jpeg',
|
|
83
|
+
gif: 'image/gif',
|
|
84
|
+
webp: 'image/webp',
|
|
85
|
+
avif: 'image/avif',
|
|
86
|
+
ico: 'image/x-icon',
|
|
87
|
+
woff: 'font/woff',
|
|
88
|
+
woff2: 'font/woff2',
|
|
89
|
+
ttf: 'font/ttf',
|
|
90
|
+
otf: 'font/otf',
|
|
91
|
+
wasm: 'application/wasm',
|
|
92
|
+
mp4: 'video/mp4',
|
|
93
|
+
webm: 'video/webm',
|
|
94
|
+
};
|
|
95
|
+
export const contentTypeOf = (name) => TYPES[String(name).split('.').pop().toLowerCase()] ?? 'application/octet-stream';
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send a host the chunks it does not have yet. Returns what was sent and what was already there.
|
|
99
|
+
*
|
|
100
|
+
* Asked first, in one request, so an unchanged chunk costs one hash on the wire. A host older than
|
|
101
|
+
* this protocol answers 404 to the question, which is reported as such rather than as a failed upload.
|
|
102
|
+
*/
|
|
103
|
+
export async function sendMissingChunks({ base, token, files, hashes }) {
|
|
104
|
+
const auth = token ? { authorization: `Bearer ${token}` } : {};
|
|
105
|
+
const all = [...new Set(Object.values(hashes))];
|
|
106
|
+
const ask = await fetch(`${base}/api/blobs/missing`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'content-type': 'application/json', ...auth },
|
|
109
|
+
body: JSON.stringify({ hashes: all }),
|
|
110
|
+
});
|
|
111
|
+
if (ask.status === 404) {
|
|
112
|
+
throw Object.assign(new Error('this host stores single files only — update it, or publish with --single'), { oldHost: true });
|
|
113
|
+
}
|
|
114
|
+
if (!ask.ok) throw new Error(`host refused the chunk check (${ask.status}): ${(await ask.text()).slice(0, 200)}`);
|
|
115
|
+
const { missing = [] } = await ask.json();
|
|
116
|
+
const wanted = new Set(missing);
|
|
117
|
+
let sentBytes = 0;
|
|
118
|
+
let sent = 0;
|
|
119
|
+
const byHash = new Map();
|
|
120
|
+
for (const [name, h] of Object.entries(hashes)) if (!byHash.has(h)) byHash.set(h, files.get(name));
|
|
121
|
+
// A FEW AT ONCE: one request per chunk, but a cold upload is dozens of them.
|
|
122
|
+
const queue = [...wanted];
|
|
123
|
+
const worker = async () => {
|
|
124
|
+
for (let h = queue.shift(); h; h = queue.shift()) {
|
|
125
|
+
const gz = gzipSync(byHash.get(h));
|
|
126
|
+
const res = await fetch(`${base}/api/blobs/${h}`, {
|
|
127
|
+
method: 'PUT',
|
|
128
|
+
headers: { 'content-type': 'application/octet-stream', 'content-encoding': 'gzip', ...auth },
|
|
129
|
+
body: gz,
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) throw new Error(`host refused chunk ${h.slice(0, 12)} (${res.status}): ${(await res.text()).slice(0, 200)}`);
|
|
132
|
+
sentBytes += gz.length;
|
|
133
|
+
sent++;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
await Promise.all(Array.from({ length: 6 }, worker));
|
|
137
|
+
return { sent, reused: all.length - sent, total: all.length, sentBytes };
|
|
138
|
+
}
|