@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,53 @@
|
|
|
1
|
+
// WHERE A PERSON CAN LOOK AT THIS RIGHT NOW.
|
|
2
|
+
//
|
|
3
|
+
// One file, written by a running dev server and read by every check's verdict line. It exists because
|
|
4
|
+
// a green report describes FILES, while the thing the work is for is a rendered region — and across a
|
|
5
|
+
// whole bench run no agent ever handed one over: each finished on a passing check, and the person who
|
|
6
|
+
// asked for the work had nothing to open.
|
|
7
|
+
//
|
|
8
|
+
// ONE MODULE FOR THE PATH, and that is not tidiness. The first version spelled the directory two ways
|
|
9
|
+
// (`paths.cacheDir`, which does not exist, against the config's `cacheDir`, which does), so the writer
|
|
10
|
+
// silently wrote nothing and the reader silently found nothing — while the dev server printed a
|
|
11
|
+
// perfectly correct "live at:" line. Both halves agreed, both were wrong, and nothing said so.
|
|
12
|
+
import { mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { resolve } from 'node:path';
|
|
14
|
+
import { loadMotuConfig } from './config.mjs';
|
|
15
|
+
|
|
16
|
+
/** The dev server rewrites this every heartbeat; two beats of silence means the process is gone. */
|
|
17
|
+
const STALE_MS = 90_000;
|
|
18
|
+
|
|
19
|
+
function file() {
|
|
20
|
+
return resolve(loadMotuConfig().cacheDir, 'lagoon-live.url');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Record the member URL a dev server is currently serving.
|
|
25
|
+
*
|
|
26
|
+
* Rewritten on EVERY heartbeat rather than once, so the file's own mtime carries how long ago this
|
|
27
|
+
* was true. A URL left behind by a killed dev server is worse than no URL at all: it sends whoever
|
|
28
|
+
* reads it to a page that no longer exists, and it reads exactly like a working one.
|
|
29
|
+
*/
|
|
30
|
+
export function writeLiveUrl(url) {
|
|
31
|
+
const f = file();
|
|
32
|
+
mkdirSync(resolve(f, '..'), { recursive: true });
|
|
33
|
+
writeFileSync(f, `${url}\n`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function clearLiveUrl() {
|
|
37
|
+
try {
|
|
38
|
+
rmSync(file(), { force: true });
|
|
39
|
+
} catch {
|
|
40
|
+
/* nothing to clean up */
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The live member URL, or null when there is none or the last heartbeat is too old to believe. */
|
|
45
|
+
export function readLiveUrl() {
|
|
46
|
+
try {
|
|
47
|
+
const f = file();
|
|
48
|
+
if (Date.now() - statSync(f).mtimeMs >= STALE_MS) return null;
|
|
49
|
+
return readFileSync(f, 'utf8').trim() || null;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// The lagoon's alias list, serialised for the node loader (RegExp does not survive JSON).
|
|
2
|
+
import { buildLagoonViteConfig, loadLagoonJson } from './lagoon-vite.mjs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { loadMotuConfig } from './config.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The browser env the lagoon declares, for the node harness.
|
|
9
|
+
*
|
|
10
|
+
* `lagoon.config.json`'s `env` is what the app's modules read at import time — acme's Supabase client
|
|
11
|
+
* throws without it. Vite hands it to the browser through `define`; nothing handed it to node, so an
|
|
12
|
+
* island whose graph touches that client died on a missing variable rather than on anything about the
|
|
13
|
+
* island. One declaration, both paths.
|
|
14
|
+
*/
|
|
15
|
+
export function lagoonEnv() {
|
|
16
|
+
try {
|
|
17
|
+
return { ...(loadLagoonJson(loadMotuConfig()).env ?? {}) };
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ONE React, resolved from the project — node's answer to Vite's `dedupe: ['react', 'react-dom']`.
|
|
25
|
+
*
|
|
26
|
+
* The framework's packages resolve their own copy and the project resolves its own, so a component
|
|
27
|
+
* that calls a hook meets a null dispatcher. It is the failure that made `--fast` unusable for any
|
|
28
|
+
* island with state, and it is not the island's fault: the same island mounts fine in the browser,
|
|
29
|
+
* where vite dedupes. Pointing both at the project's copy is the same fix in the other module system.
|
|
30
|
+
*/
|
|
31
|
+
function reactDedupe(root) {
|
|
32
|
+
const out = [];
|
|
33
|
+
try {
|
|
34
|
+
const require = createRequire(resolve(root, 'package.json'));
|
|
35
|
+
for (const [specifier, pattern] of [
|
|
36
|
+
['react', '^react$'],
|
|
37
|
+
['react/jsx-runtime', '^react/jsx-runtime$'],
|
|
38
|
+
['react/jsx-dev-runtime', '^react/jsx-dev-runtime$'],
|
|
39
|
+
['react-dom', '^react-dom$'],
|
|
40
|
+
['react-dom/client', '^react-dom/client$'],
|
|
41
|
+
]) {
|
|
42
|
+
try {
|
|
43
|
+
out.push({ source: pattern, flags: '', replacement: require.resolve(specifier) });
|
|
44
|
+
} catch {
|
|
45
|
+
// Not installed here — nothing to dedupe to, and inventing a path would be worse.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
/* no package.json to resolve from */
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function nodeAliasEnv() {
|
|
55
|
+
try {
|
|
56
|
+
const cfg = loadMotuConfig();
|
|
57
|
+
const vite = await buildLagoonViteConfig(cfg, process.env);
|
|
58
|
+
const alias = vite?.resolve?.alias ?? [];
|
|
59
|
+
// The project's React wins, and it is listed FIRST so nothing else can claim the specifier.
|
|
60
|
+
return JSON.stringify(
|
|
61
|
+
[...reactDedupe(cfg.hostRoot), ...reactDedupe(cfg.root)].concat(
|
|
62
|
+
alias
|
|
63
|
+
.filter((a) => typeof a?.replacement === 'string')
|
|
64
|
+
.map((a) =>
|
|
65
|
+
a.find instanceof RegExp
|
|
66
|
+
? { source: a.find.source, flags: a.find.flags, replacement: a.replacement }
|
|
67
|
+
: { find: String(a.find), replacement: a.replacement },
|
|
68
|
+
)),
|
|
69
|
+
);
|
|
70
|
+
} catch {
|
|
71
|
+
return '[]';
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The BACKEND'S OWN LIST OF OPERATIONS, and how a region's ask resolves to one of them.
|
|
2
|
+
//
|
|
3
|
+
// motu records what a region asked for (`recordOutbound`, `@motujs/core`) and has never been able to
|
|
4
|
+
// say whether the thing it asked for EXISTS. It cannot know on its own: an operation is a fact about
|
|
5
|
+
// the backend, and motu has no view of one. So the list is read from a file the project already
|
|
6
|
+
// maintains, and this module is everything motu knows about it.
|
|
7
|
+
//
|
|
8
|
+
// ## Opaque, on purpose
|
|
9
|
+
//
|
|
10
|
+
// A name and nothing else. The file this is pointed at in practice is assay's
|
|
11
|
+
// `.assay/operations.json`, which also declares the tables each operation reads and writes, whether
|
|
12
|
+
// it runs with RLS bypassed, and prohibitions on how it may be keyed — and none of that is read
|
|
13
|
+
// here, because the moment motu interprets a declaration's CONTENTS it has married one backend tool.
|
|
14
|
+
// A list of names is a shape an OpenAPI document, a tRPC router or a hand-written array can also
|
|
15
|
+
// produce, and every one of them can fill this slot.
|
|
16
|
+
//
|
|
17
|
+
// ## Two kinds of name, because two doors carry an identifier
|
|
18
|
+
//
|
|
19
|
+
// The wire door records `route:<METHOD> <path>` and `fn:<name>` (`reachEntry()` in
|
|
20
|
+
// `@motujs/runtime/postgrest-fetch`). Those are the only asks that can name an operation at all — a
|
|
21
|
+
// contract call is `service.method` and a traced host module is a bare function name, neither of
|
|
22
|
+
// which says which backend thing answered. This module resolves the two that can, and says nothing
|
|
23
|
+
// about the two that cannot.
|
|
24
|
+
|
|
25
|
+
/** A universe entry that looks like a Next route handler file. */
|
|
26
|
+
const ROUTE_FILE = /(^|\/)route\.(ts|tsx|js|mjs)$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The operation names out of whatever the project pointed us at.
|
|
30
|
+
*
|
|
31
|
+
* Two accepted shapes and no more: an object whose KEYS are the names (assay's file, whose values
|
|
32
|
+
* this deliberately ignores), or a plain array of names. Anything else returns an error rather than
|
|
33
|
+
* an empty list — a universe that parsed to nothing would make every ask unreachable, which is a
|
|
34
|
+
* wall of red that says the opposite of what happened.
|
|
35
|
+
*/
|
|
36
|
+
export function readOperationNames(text) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = JSON.parse(text);
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { names: [], error: `is not valid JSON (${String(err.message).split('\n')[0]})` };
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(parsed)) return { names: parsed.filter((n) => typeof n === 'string') };
|
|
44
|
+
if (parsed && typeof parsed === 'object') {
|
|
45
|
+
const source = parsed.operations && typeof parsed.operations === 'object' ? parsed.operations : parsed;
|
|
46
|
+
const names = Object.keys(source).filter((k) => k !== 'version');
|
|
47
|
+
return { names };
|
|
48
|
+
}
|
|
49
|
+
return { names: [], error: 'is neither an object of operation names nor an array of them' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A universe split into the two things an ask can name.
|
|
54
|
+
*
|
|
55
|
+
* Routes are indexed by SEGMENTS rather than compared as strings, because a route file is a PATTERN
|
|
56
|
+
* and a request is an instance of it: `app/api/admin/email-previews/[id]/route.ts` answers
|
|
57
|
+
* `/api/admin/email-previews/9f3c`, and a string compare would report that real, declared,
|
|
58
|
+
* successfully-answered ask as naming an operation nobody declares.
|
|
59
|
+
*/
|
|
60
|
+
export function indexOperations(names) {
|
|
61
|
+
const routes = [];
|
|
62
|
+
const fns = new Set();
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
if (ROUTE_FILE.test(name)) routes.push({ name, segments: routeSegments(name) });
|
|
65
|
+
else fns.add(name);
|
|
66
|
+
}
|
|
67
|
+
return { names, routes, fns };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The URL segments a route FILE answers.
|
|
72
|
+
*
|
|
73
|
+
* Drops the `app/` (or `src/app/`) prefix and the trailing `route.ts`, and drops route GROUPS —
|
|
74
|
+
* `app/(dashboard)/api/x/route.ts` answers `/api/x`, because a parenthesised segment organises files
|
|
75
|
+
* and never appears in a URL. Getting that wrong would silently fail to match a whole directory's
|
|
76
|
+
* worth of real operations.
|
|
77
|
+
*/
|
|
78
|
+
function routeSegments(name) {
|
|
79
|
+
return String(name)
|
|
80
|
+
.replace(/^src\//, '')
|
|
81
|
+
.replace(/^app\//, '')
|
|
82
|
+
.replace(ROUTE_FILE, '')
|
|
83
|
+
.split('/')
|
|
84
|
+
.filter((s) => s && !/^\(.*\)$/.test(s));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The path segments an ask actually requested. */
|
|
88
|
+
function askSegments(path) {
|
|
89
|
+
return String(path).split('?')[0].split('#')[0].split('/').filter(Boolean);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Does a route file's segment pattern answer this request's segments?
|
|
94
|
+
*
|
|
95
|
+
* `[id]` matches exactly one segment; `[...slug]` and `[[...slug]]` match one or more (and the
|
|
96
|
+
* optional form matches none), which is what a catch-all route does.
|
|
97
|
+
*/
|
|
98
|
+
function segmentsMatch(pattern, actual) {
|
|
99
|
+
let p = 0;
|
|
100
|
+
let a = 0;
|
|
101
|
+
while (p < pattern.length) {
|
|
102
|
+
const seg = pattern[p];
|
|
103
|
+
const catchAll = /^\[\[?\.\.\..*\]\]?$/.test(seg);
|
|
104
|
+
if (catchAll) {
|
|
105
|
+
// A catch-all is last by construction, so it takes whatever is left. The optional form
|
|
106
|
+
// `[[...x]]` is allowed to take nothing.
|
|
107
|
+
const optional = seg.startsWith('[[');
|
|
108
|
+
return optional ? true : a < actual.length;
|
|
109
|
+
}
|
|
110
|
+
if (a >= actual.length) return false;
|
|
111
|
+
if (!/^\[.*\]$/.test(seg) && seg !== actual[a]) return false;
|
|
112
|
+
p += 1;
|
|
113
|
+
a += 1;
|
|
114
|
+
}
|
|
115
|
+
return a === actual.length;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 'route' | 'fn' for an ask that can name an operation, null for one that cannot. */
|
|
119
|
+
export function askKind(canonical) {
|
|
120
|
+
const s = String(canonical);
|
|
121
|
+
if (s.startsWith('route:')) return 'route';
|
|
122
|
+
if (s.startsWith('fn:')) return 'fn';
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The operation a canonical wire entry names, or null when the universe has no such operation.
|
|
128
|
+
*
|
|
129
|
+
* Takes the canonical form the runtime records and `reaches`/`effects` declare — the same vocabulary
|
|
130
|
+
* on both sides, which is the point of `lib/effects.mjs`.
|
|
131
|
+
*/
|
|
132
|
+
export function resolveOperation(canonical, index) {
|
|
133
|
+
const s = String(canonical);
|
|
134
|
+
if (s.startsWith('fn:')) {
|
|
135
|
+
const name = s.slice(3);
|
|
136
|
+
return index.fns.has(name) ? name : null;
|
|
137
|
+
}
|
|
138
|
+
if (!s.startsWith('route:')) return null;
|
|
139
|
+
// `route:GET /api/x` and `route:/api/x` — the method is not part of the identity here. A route file
|
|
140
|
+
// exports every verb it answers, so METHOD does not select between operations; assay keys the file.
|
|
141
|
+
const path = s.slice(6).replace(/^\S+\s+/, '');
|
|
142
|
+
const actual = askSegments(path);
|
|
143
|
+
const hit = index.routes.find((r) => segmentsMatch(r.segments, actual));
|
|
144
|
+
return hit ? hit.name : null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// PROVENANCE, WITHOUT ANYONE HAVING TO ASK FOR IT.
|
|
2
|
+
//
|
|
3
|
+
// Every entry in lagoon.config.json's "alias" is a host module the lagoon stands DOWN — which is
|
|
4
|
+
// exactly the statement that it is a boundary this screen's data comes across. So each one is loaded
|
|
5
|
+
// through a wrapper that records the calls islands make into it, and the seam lens can say where a
|
|
6
|
+
// rendered row came from.
|
|
7
|
+
//
|
|
8
|
+
// This was per-export (`traced(...)` by hand in the stub) and that is the wrong shape for a fact
|
|
9
|
+
// about the boundary rather than about the function: acme's club stub had opted in, so the club region
|
|
10
|
+
// showed its two fetches while the actions and directory regions — whose stubs nobody had wrapped —
|
|
11
|
+
// reported "traced export(s), none called", which reads as "these islands fetched nothing" and is a
|
|
12
|
+
// different claim entirely. A stub added tomorrow is traced the day it lands.
|
|
13
|
+
import { readFileSync } from 'node:fs';
|
|
14
|
+
import { resolve } from 'node:path';
|
|
15
|
+
|
|
16
|
+
const PREFIX = '\0motu-traced:';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The names a module exports at runtime, by reading its source.
|
|
20
|
+
*
|
|
21
|
+
* Deliberately regex and deliberately allowed to miss: the wrapper below re-exports the real module
|
|
22
|
+
* with `export *` FIRST, and ESM lets an explicit export shadow a star. A name missed here is still
|
|
23
|
+
* exported — unwrapped and unrecorded — rather than missing, so the worst case is a gap in the lens
|
|
24
|
+
* and never a build that fails on an import.
|
|
25
|
+
*/
|
|
26
|
+
function exportedNames(code) {
|
|
27
|
+
const out = new Set();
|
|
28
|
+
for (const m of code.matchAll(/^\s*export\s+(?:async\s+)?(?:function|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm)) out.add(m[1]);
|
|
29
|
+
for (const m of code.matchAll(/^\s*export\s*\{([^}]*)\}(?!\s*from)/gm))
|
|
30
|
+
for (const part of m[1].split(',')) out.add((part.split(/\bas\b/).pop() ?? '').trim());
|
|
31
|
+
// `export type` / `export interface` are erased before this runs anywhere it matters, and the
|
|
32
|
+
// patterns above do not match them.
|
|
33
|
+
return [...out].filter((n) => n && n !== 'default');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Wrap every aliased host module so its calls are recorded. Debug builds only. */
|
|
37
|
+
export function motuProvenance(alias, lagoonDir) {
|
|
38
|
+
// BY RESOLVED FILE, NOT BY SPECIFIER. Vite's own alias plugin runs BEFORE user `pre` plugins, so a
|
|
39
|
+
// `resolveId` matching '@/lib/services/directory' is never called with it — the specifier is already
|
|
40
|
+
// the stub's path by then. This cost a whole build to find, because the one region that did record
|
|
41
|
+
// was the one whose stub had been wrapped by hand: the plugin looked like it worked.
|
|
42
|
+
const stubs = new Map();
|
|
43
|
+
for (const [spec, rel] of Object.entries(alias ?? {})) stubs.set(resolve(lagoonDir, rel), spec);
|
|
44
|
+
return {
|
|
45
|
+
name: 'motu:provenance',
|
|
46
|
+
enforce: 'pre',
|
|
47
|
+
resolveId(source, importer) {
|
|
48
|
+
// The wrapper's own `import … from '<the stub>'` must reach the real file, or this recurses.
|
|
49
|
+
if (importer?.startsWith(PREFIX)) return null;
|
|
50
|
+
if (source.startsWith(PREFIX)) return source;
|
|
51
|
+
return stubs.has(source) ? PREFIX + source : null;
|
|
52
|
+
},
|
|
53
|
+
load(id) {
|
|
54
|
+
if (!id.startsWith(PREFIX)) return null;
|
|
55
|
+
const file = id.slice(PREFIX.length);
|
|
56
|
+
const spec = stubs.get(file);
|
|
57
|
+
let code = '';
|
|
58
|
+
try {
|
|
59
|
+
code = readFileSync(file, 'utf8');
|
|
60
|
+
} catch {
|
|
61
|
+
return null; // unreadable: let the normal path take it and fail where it usually would
|
|
62
|
+
}
|
|
63
|
+
const path = JSON.stringify(file);
|
|
64
|
+
return [
|
|
65
|
+
`import * as __impl from ${path};`,
|
|
66
|
+
`import { traceModule } from '@motujs/core';`,
|
|
67
|
+
`export * from ${path};`,
|
|
68
|
+
`const __traced = traceModule(${JSON.stringify(spec)}, __impl);`,
|
|
69
|
+
...exportedNames(code).map((n) => `export const ${n} = __traced[${JSON.stringify(n)}];`),
|
|
70
|
+
/(^|\n)\s*export\s+default\b/.test(code) ? 'export default __impl.default;' : '',
|
|
71
|
+
].join('\n');
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// REGIONS INSIDE REGIONS — `slots: { prop: { region: '<id>' } }`, read from every archipelago on disk.
|
|
2
|
+
//
|
|
3
|
+
// The graph is the project's, not one region's: whether `base → equipage` is a cycle depends on what
|
|
4
|
+
// `equipage` references, so a single file cannot answer it. The verdict on the graph is
|
|
5
|
+
// `regionRefProblems` from @motujs/core — the same function the lagoon uses to refuse a placeholder — so
|
|
6
|
+
// the check and the preview cannot disagree about which references are broken.
|
|
7
|
+
import { regionRefProblems } from '@motujs/core';
|
|
8
|
+
import { readRegions } from './eject.mjs';
|
|
9
|
+
import { regionRefsDeclared } from './lagoon-declares.mjs';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `{ graph, unreadable }` — every archipelago's root-ness and references, and the ids whose file did
|
|
13
|
+
* not parse (their references are unknown, which is not the same as none).
|
|
14
|
+
*/
|
|
15
|
+
export function regionRefGraphOnDisk(archipelagosDir) {
|
|
16
|
+
const graph = {};
|
|
17
|
+
const unreadable = [];
|
|
18
|
+
for (const region of readRegions(archipelagosDir)) {
|
|
19
|
+
const refs = regionRefsDeclared(region.file);
|
|
20
|
+
if (refs === null) unreadable.push(region.id);
|
|
21
|
+
graph[region.id] = { root: Boolean(region.root), refs: refs ?? {} };
|
|
22
|
+
}
|
|
23
|
+
return { graph, unreadable };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* What one region's references look like from the whole graph: the props it fills with another
|
|
28
|
+
* region, what is wrong with them, and who fills a slot with IT.
|
|
29
|
+
*/
|
|
30
|
+
export function regionRefsOf(id, archipelagosDir) {
|
|
31
|
+
const { graph, unreadable } = regionRefGraphOnDisk(archipelagosDir);
|
|
32
|
+
const refs = graph[id]?.refs ?? {};
|
|
33
|
+
const problems = regionRefProblems(graph).filter((p) => p.region === id);
|
|
34
|
+
const referencedBy = Object.entries(graph).filter(([other]) => other !== id).flatMap(([other, node]) =>
|
|
35
|
+
Object.entries(node.refs)
|
|
36
|
+
.filter(([, ref]) => ref === id)
|
|
37
|
+
.map(([prop]) => `${other}.${prop}`),
|
|
38
|
+
);
|
|
39
|
+
return { refs, problems, referencedBy, unreadable: unreadable.includes(id) };
|
|
40
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Publishing a lagoon to a host instead of to a file.
|
|
2
|
+
//
|
|
3
|
+
// Two shapes. `uploadManifest` is the default: the lagoon as content-addressed chunks plus a manifest,
|
|
4
|
+
// so a republish sends only what changed (lib/lagoon-bundle.mjs). `uploadLagoon` is the one-file shape
|
|
5
|
+
// (`--single`): the same self-contained fragment `publish` writes for an Artifact, POSTed as is.
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { basename, resolve } from 'node:path';
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import { gzipSync } from 'node:zlib';
|
|
12
|
+
import { sendMissingChunks, MANIFEST_TYPE } from './lagoon-bundle.mjs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* ONE host for every agent on this machine.
|
|
16
|
+
*
|
|
17
|
+
* The point of a long-running host is that nobody has to be told where it is: an agent runs
|
|
18
|
+
* `motu lagoon publish --remote` with no argument, in whatever project it happens to be working on,
|
|
19
|
+
* and lands in the same place as every other agent. So the URL and token have a well-known home,
|
|
20
|
+
* and the flag and the environment variable stay available to override it.
|
|
21
|
+
*
|
|
22
|
+
* Precedence: an explicit flag, then $MOTU_HOST_URL / $MOTU_HOST_TOKEN, then this file.
|
|
23
|
+
*/
|
|
24
|
+
export function loadHostConfig() {
|
|
25
|
+
const file = resolve(process.env.MOTU_CONFIG_HOME || resolve(homedir(), '.config/motu'), 'host.json');
|
|
26
|
+
if (!existsSync(file)) return {};
|
|
27
|
+
try {
|
|
28
|
+
const cfg = JSON.parse(readFileSync(file, 'utf8'));
|
|
29
|
+
return { url: typeof cfg.url === 'string' ? cfg.url : undefined, token: typeof cfg.token === 'string' ? cfg.token : undefined, file };
|
|
30
|
+
} catch {
|
|
31
|
+
// A malformed file must not stop a publish that also has a flag or an env var.
|
|
32
|
+
return {};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Run git, quietly. Absence of git, or of a repo, is a normal case here — not an error. */
|
|
37
|
+
function git(cwd, args) {
|
|
38
|
+
const res = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
39
|
+
return res.status === 0 ? res.stdout.trim() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Trim a host-safe segment out of a git ref or a directory name. */
|
|
43
|
+
function segment(raw, max = 64) {
|
|
44
|
+
const s = String(raw ?? '')
|
|
45
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
46
|
+
.replace(/^-+|-+$/g, '')
|
|
47
|
+
.slice(0, max);
|
|
48
|
+
return /^[A-Za-z0-9]/.test(s) ? s : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* WHO IS PUBLISHING? With no accounts yet, the repo id has to come from the repo itself, and the git
|
|
53
|
+
* remote is the only name that is stable across machines and clones — a directory name is whatever
|
|
54
|
+
* someone typed when they cloned. Falls back to the directory when there is no remote (or no git),
|
|
55
|
+
* because a project that has not been pushed anywhere must still be publishable.
|
|
56
|
+
*/
|
|
57
|
+
export function gitIdentity(cwd) {
|
|
58
|
+
const origin = git(cwd, ['remote', 'get-url', 'origin']);
|
|
59
|
+
let repo = null;
|
|
60
|
+
if (origin) {
|
|
61
|
+
// Both spellings of a remote: git@host:owner/name.git and https://host/owner/name.git
|
|
62
|
+
const m = origin.replace(/\.git$/, '').match(/[:/]([^/:]+)\/([^/]+)$/);
|
|
63
|
+
if (m) {
|
|
64
|
+
const owner = segment(m[1]);
|
|
65
|
+
const name = segment(m[2]);
|
|
66
|
+
if (owner && name) repo = `${owner}/${name}`;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!repo) repo = segment(basename(cwd));
|
|
70
|
+
|
|
71
|
+
const sha = git(cwd, ['rev-parse', 'HEAD']);
|
|
72
|
+
const branchRaw = git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
73
|
+
const dirty = git(cwd, ['status', '--porcelain']);
|
|
74
|
+
return {
|
|
75
|
+
repo,
|
|
76
|
+
// A dirty tree's HEAD sha would be a LIE about what those bytes are: the immutable URL would name
|
|
77
|
+
// a commit that does not contain what you are looking at. Let the host fall back to the content
|
|
78
|
+
// hash instead — it is always true, and it is what content addressing is for.
|
|
79
|
+
sha: sha && !dirty ? sha.slice(0, 12) : null,
|
|
80
|
+
branch: branchRaw && branchRaw !== 'HEAD' ? segment(branchRaw) : null,
|
|
81
|
+
dirty: !!dirty,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** POST the fragment. Gzipped: ~430 kB of inlined bundle goes over the wire at roughly a quarter. */
|
|
86
|
+
export async function uploadLagoon({ url, token, repo, slug, title, sha, branch, brand, body }) {
|
|
87
|
+
const base = String(url).replace(/\/+$/, '');
|
|
88
|
+
const qs = new URLSearchParams({ repo, slug, title });
|
|
89
|
+
if (sha) qs.set('sha', sha);
|
|
90
|
+
if (branch) qs.set('branch', branch);
|
|
91
|
+
// The project's own colour, so tools that list repositories can be the right one. Kept off the
|
|
92
|
+
// record and on the repo by the host: it describes the project, not this build.
|
|
93
|
+
if (brand) qs.set('brand', brand);
|
|
94
|
+
|
|
95
|
+
const gz = gzipSync(Buffer.from(body, 'utf8'));
|
|
96
|
+
const res = await fetch(`${base}/api/publish?${qs}`, {
|
|
97
|
+
method: 'POST',
|
|
98
|
+
headers: {
|
|
99
|
+
'content-type': 'text/html; charset=utf-8',
|
|
100
|
+
'content-encoding': 'gzip',
|
|
101
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
102
|
+
},
|
|
103
|
+
body: gz,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const text = await res.text();
|
|
107
|
+
let payload = null;
|
|
108
|
+
try {
|
|
109
|
+
payload = JSON.parse(text);
|
|
110
|
+
} catch {
|
|
111
|
+
/* a non-JSON body means a proxy answered, not the host — report it verbatim below */
|
|
112
|
+
}
|
|
113
|
+
if (!res.ok) {
|
|
114
|
+
const detail = payload?.error ?? text.slice(0, 200).replace(/\s+/g, ' ').trim();
|
|
115
|
+
throw new Error(`host refused the upload (${res.status}): ${detail || 'no detail'}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// DID IT STORE WHAT WE SENT? A 200 with a URL in it is not an answer to that question, and for a
|
|
119
|
+
// while it was the only one we had: the CLI printed the host's URLs as fact and never checked one.
|
|
120
|
+
// When a publish then looked wrong, there was nothing to distinguish "the host mis-stored it" from
|
|
121
|
+
// "you are looking at the wrong thing" — which cost an afternoon of diagnosing a host bug that did
|
|
122
|
+
// not exist.
|
|
123
|
+
//
|
|
124
|
+
// Hashing costs nothing over the wire, which is why it is the check rather than fetching the page
|
|
125
|
+
// back: both sides hold the bytes, so both can name them. A host that returns no hash is older than
|
|
126
|
+
// this and is simply unverified — say that, never silently pass.
|
|
127
|
+
const sentHash = createHash('sha256').update(body, 'utf8').digest('hex');
|
|
128
|
+
const verified = payload?.hash ? payload.hash === sentHash : null;
|
|
129
|
+
if (verified === false) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`host stored different bytes than were sent (sent ${sentHash.slice(0, 12)}, host has ${String(payload.hash).slice(0, 12)}) — ` +
|
|
132
|
+
`the URLs it returned would show someone else's build`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ...payload, base, sentBytes: gz.length, verified };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Publish a CHUNKED lagoon: the chunks the host lacks, then the manifest as the record.
|
|
141
|
+
*
|
|
142
|
+
* Verified the same way the single file is: the host names the content it stored, and that must be
|
|
143
|
+
* the manifest's own hash — which pins every chunk, because the manifest names each one by its hash.
|
|
144
|
+
*/
|
|
145
|
+
export async function uploadManifest({ url, token, repo, slug, title, sha, branch, brand, bundle, manifest }) {
|
|
146
|
+
const base = String(url).replace(/\/+$/, '');
|
|
147
|
+
const chunks = await sendMissingChunks({ base, token, files: bundle.files, hashes: manifest.hashes });
|
|
148
|
+
const qs = new URLSearchParams({ repo, slug, title });
|
|
149
|
+
if (sha) qs.set('sha', sha);
|
|
150
|
+
if (branch) qs.set('branch', branch);
|
|
151
|
+
if (brand) qs.set('brand', brand);
|
|
152
|
+
const gz = gzipSync(Buffer.from(manifest.json, 'utf8'));
|
|
153
|
+
const res = await fetch(`${base}/api/publish?${qs}`, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': MANIFEST_TYPE, 'content-encoding': 'gzip', ...(token ? { authorization: `Bearer ${token}` } : {}) },
|
|
156
|
+
body: gz,
|
|
157
|
+
});
|
|
158
|
+
const text = await res.text();
|
|
159
|
+
let payload = null;
|
|
160
|
+
try {
|
|
161
|
+
payload = JSON.parse(text);
|
|
162
|
+
} catch {
|
|
163
|
+
/* reported below */
|
|
164
|
+
}
|
|
165
|
+
if (!res.ok) throw new Error(`host refused the manifest (${res.status}): ${payload?.error ?? text.slice(0, 200)}`);
|
|
166
|
+
const verified = payload?.hash ? payload.hash === manifest.hash : null;
|
|
167
|
+
if (verified === false) {
|
|
168
|
+
throw new Error(`host stored a different manifest than was sent (sent ${manifest.hash.slice(0, 12)}, host has ${String(payload.hash).slice(0, 12)})`);
|
|
169
|
+
}
|
|
170
|
+
return { ...payload, base, chunks, sentBytes: chunks.sentBytes + gz.length, verified };
|
|
171
|
+
}
|