@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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/bundled/github/agents/archipelago-build.agent.md +298 -0
  3. package/bundled/github/agents/island-create.agent.md +261 -0
  4. package/bundled/github/agents/island-extract.agent.md +360 -0
  5. package/bundled/github/agents/island-locate.agent.md +208 -0
  6. package/bundled/github/agents/motu-checks.agent.md +158 -0
  7. package/bundled/github/agents/motu-evidence.agent.md +267 -0
  8. package/bundled/github/agents/motu-regions.agent.md +304 -0
  9. package/bundled/github/host-rules.md +43 -0
  10. package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
  11. package/bundled/github/prompts/island-create.prompt.md +37 -0
  12. package/bundled/github/prompts/island-extract.prompt.md +59 -0
  13. package/bundled/github/prompts/island-locate.prompt.md +43 -0
  14. package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
  15. package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
  16. package/bundled/packages/adapters/next/vite.mjs +121 -0
  17. package/bundled/packages/adapters/vite/vite.mjs +179 -0
  18. package/package.json +64 -3
  19. package/src/alias-loader.mjs +63 -0
  20. package/src/alias-register.mjs +28 -0
  21. package/src/asset-stub.cjs +3 -0
  22. package/src/asset-stub.mjs +5 -0
  23. package/src/cli.mjs +78 -0
  24. package/src/commands/adopt-root.mjs +194 -0
  25. package/src/commands/archipelago-init.mjs +314 -0
  26. package/src/commands/archipelago.mjs +138 -0
  27. package/src/commands/assurance.mjs +40 -0
  28. package/src/commands/check.mjs +346 -0
  29. package/src/commands/codegen.mjs +29 -0
  30. package/src/commands/coverage.mjs +358 -0
  31. package/src/commands/create.mjs +277 -0
  32. package/src/commands/defaults.mjs +129 -0
  33. package/src/commands/diff.mjs +595 -0
  34. package/src/commands/fixtures.mjs +153 -0
  35. package/src/commands/init.mjs +473 -0
  36. package/src/commands/integrate.mjs +208 -0
  37. package/src/commands/integration.mjs +1349 -0
  38. package/src/commands/lagoon-try.mjs +272 -0
  39. package/src/commands/lagoon.mjs +1419 -0
  40. package/src/commands/link.mjs +110 -0
  41. package/src/commands/region-coverage.mjs +604 -0
  42. package/src/commands/removal-check.mjs +878 -0
  43. package/src/commands/skills.mjs +156 -0
  44. package/src/commands/snapshot.mjs +456 -0
  45. package/src/commands/sync.mjs +38 -0
  46. package/src/commands/verify/island-runtime.mjs +581 -0
  47. package/src/commands/verify/island-static.mjs +586 -0
  48. package/src/commands/verify/island.mjs +135 -0
  49. package/src/commands/verify/reach.mjs +426 -0
  50. package/src/commands/verify/region-composition.mjs +391 -0
  51. package/src/commands/verify/region-config.mjs +330 -0
  52. package/src/commands/verify/region-flows.mjs +760 -0
  53. package/src/commands/verify/region-sources.mjs +516 -0
  54. package/src/commands/verify/region.mjs +271 -0
  55. package/src/commands/verify/report.mjs +338 -0
  56. package/src/commands/verify/screens-runtime.mjs +123 -0
  57. package/src/commands/verify.mjs +18 -0
  58. package/src/lagoon-build.mjs +25 -0
  59. package/src/lagoon-dev.mjs +32 -0
  60. package/src/lib/alias-core.mjs +49 -0
  61. package/src/lib/archipelagos.mjs +111 -0
  62. package/src/lib/assurance.mjs +144 -0
  63. package/src/lib/baselines.mjs +107 -0
  64. package/src/lib/bundlability.mjs +123 -0
  65. package/src/lib/changed.mjs +157 -0
  66. package/src/lib/component-props.mjs +145 -0
  67. package/src/lib/config.mjs +451 -0
  68. package/src/lib/contracts.mjs +244 -0
  69. package/src/lib/data-flow.mjs +47 -0
  70. package/src/lib/declared-sources.mjs +101 -0
  71. package/src/lib/dock-inject.mjs +47 -0
  72. package/src/lib/effects.mjs +97 -0
  73. package/src/lib/eject.mjs +715 -0
  74. package/src/lib/evidence-json.mjs +74 -0
  75. package/src/lib/expect-calls.mjs +53 -0
  76. package/src/lib/finding-memory.mjs +0 -0
  77. package/src/lib/flow-calls.mjs +53 -0
  78. package/src/lib/framework.mjs +98 -0
  79. package/src/lib/host-rules.mjs +55 -0
  80. package/src/lib/host-sources.mjs +121 -0
  81. package/src/lib/island-placement.mjs +64 -0
  82. package/src/lib/island-render.mjs +105 -0
  83. package/src/lib/islands.mjs +112 -0
  84. package/src/lib/lagoon-bundle.mjs +138 -0
  85. package/src/lib/lagoon-declares.mjs +553 -0
  86. package/src/lib/lagoon-materialize.mjs +217 -0
  87. package/src/lib/lagoon-vite.mjs +788 -0
  88. package/src/lib/live-url.mjs +53 -0
  89. package/src/lib/node-aliases.mjs +73 -0
  90. package/src/lib/operations.mjs +145 -0
  91. package/src/lib/provenance-plugin.mjs +74 -0
  92. package/src/lib/region-refs.mjs +40 -0
  93. package/src/lib/remote.mjs +171 -0
  94. package/src/lib/scaffold.mjs +606 -0
  95. package/src/lib/screens.mjs +266 -0
  96. package/src/lib/snapshots.mjs +85 -0
  97. package/src/lib/stubs.mjs +114 -0
  98. package/src/lib/ts-project.mjs +42 -0
  99. package/src/lib/util.mjs +626 -0
  100. package/src/lib/worlds.mjs +236 -0
  101. package/src/lib/write-if-changed.mjs +35 -0
  102. package/src/lib/writes-coverage.mjs +32 -0
  103. package/src/playwright-lagoon.mjs +2305 -0
  104. package/src/run.mjs +385 -0
  105. package/src/runtime-harness.mjs +225 -0
  106. 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
+ }