@motujs/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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,194 @@
1
+ // `motu archipelago adopt-root <id>` — turn a frame-composed region into a root-composed one.
2
+ //
3
+ // A region composes either from its archipelago's `root` — safe by construction, because the page and
4
+ // the lagoon read the same map — or from a hand-written lagoon frame, which is a second description of
5
+ // the page and is only as safe as the checks comparing it. `region-root` names the difference on every
6
+ // run and does not fail on it, because moving a page is a real refactor of the host's own JSX.
7
+ //
8
+ // This does the half that is DERIVABLE, which is most of the tedium and none of the judgement:
9
+ // - the host component the frame renders -> `root`
10
+ // - each `island('x')` and the prop it sits in -> `slots`
11
+ // and prints, rather than guesses, the half that is not: the page's own rewrite.
12
+ //
13
+ // It REFUSES rather than approximating. A frame that nests two host components (acme's actions page
14
+ // stacks `ActionsPageStack` around `ActionsLayout`) has an arrangement no single component expresses
15
+ // yet, and the answer is a decision about the application's structure — which is exactly the kind of
16
+ // thing a codemod should hand back.
17
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { SyntaxKind } from 'ts-morph';
19
+ import { color, paths, blankComments } from '../lib/util.mjs';
20
+ import { sourceFileAt } from '../lib/ts-project.mjs';
21
+ import { readRegions } from '../lib/eject.mjs';
22
+
23
+ /** `received-summary` -> `receivedSummary`. */
24
+ const camel = (kebab) => kebab.split(/[-_]/).map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
25
+ const pascal = (id) => {
26
+ const c = camel(id);
27
+ return c[0].toUpperCase() + c.slice(1);
28
+ };
29
+
30
+ /** The region's frame module, from the lagoon overrides. Mirrors `frameModuleFor` in verify. */
31
+ function frameModuleFor(id) {
32
+ for (const f of ['src/lagoon.tsx', 'src/lagoon.ts']) {
33
+ const overrides = paths.lagoonDir ? `${paths.lagoonDir}/${f}` : null;
34
+ if (!overrides || !existsSync(overrides)) continue;
35
+ const src = readFileSync(overrides, 'utf8');
36
+ const named = new RegExp(`(^|[\\s{,])['"\`]?${id}['"\`]?\\s*:\\s*([A-Za-z_$][\\w$]*)?`, 'm');
37
+ const moduleOf = (ident) => {
38
+ const spec = src.match(new RegExp(`import\\s*\\{[^}]*\\b${ident}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`));
39
+ if (!spec) return null;
40
+ const base = new URL(spec[1].replace(/\.js$/, ''), `file://${overrides}`).pathname;
41
+ return ['.tsx', '.ts', ''].map((e) => base + e).find((c) => existsSync(c)) ?? null;
42
+ };
43
+ const record = src.match(/export const regions[^=]*=\s*\{([\s\S]*?)\s*\};/);
44
+ if (record) {
45
+ const entry = record[1].match(named);
46
+ return entry?.[2] ? moduleOf(entry[2]) : null;
47
+ }
48
+ const array = src.match(/export const regions[^=]*=\s*\[([\s\S]*?)\s*\];/);
49
+ for (const ident of array?.[1].match(/[A-Za-z_$][\w$]*/g) ?? []) {
50
+ const module = moduleOf(ident);
51
+ if (!module) continue;
52
+ const body = readFileSync(module, 'utf8');
53
+ const bound = body.match(/overridesFor\(\s*([A-Za-z_$][\w$]*)/);
54
+ if (!bound) continue;
55
+ const from = body.match(new RegExp(`import\\s*\\{[^}]*\\b${bound[1]}\\b[^}]*\\}\\s*from\\s*['"]([^'"]+)['"]`));
56
+ if (from && new RegExp(`(^|/)${id}\\.archipelago`).test(from[1])) return module;
57
+ }
58
+ }
59
+ return null;
60
+ }
61
+
62
+ export async function archipelagoAdoptRootCommand(argv) {
63
+ const id = argv._[0];
64
+ if (!id) {
65
+ console.error('usage: motu archipelago adopt-root <id>');
66
+ process.exit(2);
67
+ }
68
+ const archPath = paths.archipelagoFile(id);
69
+ if (!existsSync(archPath)) {
70
+ console.error(color.red(`✗ no archipelago '${id}' at ${paths.rel(archPath)}`));
71
+ process.exit(2);
72
+ }
73
+ const archText = readFileSync(archPath, 'utf8');
74
+ if (/^\s*root\s*:/m.test(blankComments(archText))) {
75
+ console.log(color.yellow(`! '${id}' already declares a root — nothing to do`));
76
+ return;
77
+ }
78
+ const frame = frameModuleFor(id);
79
+ if (!frame) {
80
+ console.error(color.red(`✗ '${id}' has no lagoon frame to read`));
81
+ console.error(color.dim(' there is nothing to derive from — declare `root` + `slots` by hand'));
82
+ process.exit(2);
83
+ }
84
+
85
+ const sf = sourceFileAt(frame, { allowJs: true, jsx: 4 });
86
+ // The frame's returned tree. Its host components are the app's; `island(slot)` calls are motu's.
87
+ // The frame's OWN components are the frame, not the app's — `<SetupAccountDetailsRegionFrame>` is
88
+ // the thing being replaced, and counting it made the command report the frame as its own root.
89
+ const own = new Set(
90
+ [...sf.getText().matchAll(/(?:export\s+)?(?:function|const)\s+([A-Z][A-Za-z0-9]*)/g)].map((m) => m[1]),
91
+ );
92
+ const hosts = [
93
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement),
94
+ ...sf.getDescendantsOfKind(SyntaxKind.JsxOpeningElement),
95
+ ].filter((el) => {
96
+ const tag = el.getTagNameNode().getText();
97
+ return /^[A-Z]/.test(tag) && !own.has(tag);
98
+ });
99
+
100
+ if (hosts.length === 0) {
101
+ console.error(color.red(`✗ ${paths.rel(frame)} renders no application component`));
102
+ console.error(color.dim(' a root IS the app\'s own layout component; this frame has none to point at'));
103
+ process.exit(2);
104
+ }
105
+ if (hosts.length > 1) {
106
+ console.error(color.red(`✗ ${paths.rel(frame)} nests ${hosts.length} application components:`));
107
+ for (const el of hosts) console.error(color.dim(` <${el.getTagNameNode().getText()}>`));
108
+ console.error(
109
+ color.dim(' a root is ONE component, so this arrangement is not expressed by any of them yet.\n') +
110
+ color.dim(' Extract a component that takes the whole region as props, then run this again.\n') +
111
+ color.dim(' That is a decision about the application\'s structure, which is why it is not guessed here.'),
112
+ );
113
+ process.exit(2);
114
+ }
115
+
116
+ const el = hosts[0];
117
+ const rootName = el.getTagNameNode().getText();
118
+ const rootFrom = sf
119
+ .getImportDeclarations()
120
+ .find((i) => (i.getNamedImports?.() ?? []).some((n) => n.getName() === rootName))
121
+ ?.getModuleSpecifierValue();
122
+ if (!rootFrom) {
123
+ console.error(color.red(`✗ could not find where <${rootName}> is imported from in ${paths.rel(frame)}`));
124
+ process.exit(2);
125
+ }
126
+
127
+ // prop -> slot, from `prop={island('x')}`; plus the nesting form, `<X>{island('x')}</X>` -> children.
128
+ //
129
+ // A prop holding MORE THAN ONE island is the shape `slots` cannot express: acme's sign-in shows
130
+ // EITHER `auth-error` OR `login-form` in one child, and a region cannot declare that two slots are
131
+ // exclusive. Taking the first match would have written a map that silently drops the other island —
132
+ // which this did, on its first run, and produced a plausible wrong answer instead of a refusal.
133
+ const slots = {};
134
+ const exclusive = [];
135
+ const islandsIn = (text) => [...text.matchAll(/\bisland\(\s*['"`]([^'"`]+)['"`]/g)].map((m) => m[1]);
136
+ for (const attr of el.getAttributes?.() ?? []) {
137
+ const prop = attr.getNameNode?.().getText?.();
138
+ const found = islandsIn(attr.getInitializer?.()?.getText?.() ?? '');
139
+ if (!prop || !found.length) continue;
140
+ if (found.length > 1) exclusive.push([prop, found]);
141
+ else slots[prop] = found[0];
142
+ }
143
+ const parent = el.getParent?.();
144
+ if (parent?.getKind?.() === SyntaxKind.JsxElement) {
145
+ const found = islandsIn(parent.getJsxChildren().map((c) => c.getText()).join(''));
146
+ if (found.length > 1) exclusive.push(['children', found]);
147
+ else if (found.length === 1) slots.children = found[0];
148
+ }
149
+ if (exclusive.length) {
150
+ console.error(color.red(`✗ ${paths.rel(frame)} puts more than one island in a single prop:`));
151
+ for (const [prop, found] of exclusive) console.error(color.dim(` ${prop}: ${found.join(' | ')}`));
152
+ console.error(
153
+ color.dim(' `slots` maps one prop to one island, and a region cannot declare that two of them\n') +
154
+ color.dim(' are exclusive. Give the root a prop per island (each rendering nothing when it is\n') +
155
+ color.dim(' not the one), or leave this region on its frame — that is what the frame is for.'),
156
+ );
157
+ process.exit(2);
158
+ }
159
+
160
+ if (!Object.keys(slots).length) {
161
+ console.error(color.red(`✗ ${paths.rel(frame)} passes no island to <${rootName}>`));
162
+ process.exit(2);
163
+ }
164
+
165
+ // --- write the archipelago ---------------------------------------------------------------------
166
+ const slotLines = Object.entries(slots).map(([p, s]) => ` ${p}: '${s}',`).join('\n');
167
+ let out = archText.replace(
168
+ /(\n\s*id:\s*'[^']+',\n)/,
169
+ `$1 // THE ARRANGEMENT, DECLARED ONCE: the page renders \`<${pascal(id)}.Root>\` with these prop names and\n` +
170
+ ` // never writes a slot, and the lagoon renders the same component from this same map.\n` +
171
+ ` root: ${rootName},\n slots: {\n${slotLines}\n },\n`,
172
+ );
173
+ const importLine = `import { ${rootName} } from '${rootFrom}';\n`;
174
+ if (!out.includes(importLine)) {
175
+ out = out.replace(/(^import [\s\S]*?;\n)/m, `$1${importLine}`);
176
+ }
177
+ writeFileSync(archPath, out);
178
+
179
+ console.log(color.green(`✓ ${id} now composes from its own root`));
180
+ console.log(' ' + color.dim(`${paths.rel(archPath)} root: ${rootName}, slots: ${Object.keys(slots).length}`));
181
+ console.log('');
182
+ console.log(color.bold('Still yours to do — this cannot be derived:'));
183
+ console.log(color.dim(` 1. the page renders <${pascal(id)}.Root …/> instead of <${rootName}> + <${pascal(id)}.Island>:`));
184
+ for (const [prop, slot] of Object.entries(slots)) {
185
+ console.log(color.dim(` ${prop}={…} (was <${pascal(id)}.Island slot="${slot}">)`));
186
+ }
187
+ console.log(color.dim(' pass null for a slot the page decides not to show; a prop it never passes'));
188
+ console.log(color.dim(' is an island that never renders, and `integrate check` will say so.'));
189
+ console.log(color.dim(` 2. delete the frame in ${paths.rel(frame)} and its \`layout:\` entry.`));
190
+ console.log(color.dim(' 3. any prop of the root the islands do NOT fill: `hostSlots` if it is a'));
191
+ console.log(color.dim(' component, a plain prop otherwise — and give the lagoon its value in `hostProps`.'));
192
+ console.log('');
193
+ console.log('Then: ' + color.bold(`motu archipelago verify ${id} --runtime`));
194
+ }
@@ -0,0 +1,314 @@
1
+ // `motu archipelago init <id> --page <page.tsx>` — everything a page needs before its FIRST island.
2
+ // `motu archipelago init <id>` (no page) — a NEW region: the archipelago alone, `root` declared first.
3
+ //
4
+ // Two cases, one verb. They used to be `init` and `create`, and the difference is real but it is a
5
+ // difference in INPUT, not in kind: a page that exists has a type, a composition root and a lagoon
6
+ // module to derive; a region whose page does not exist yet has only its declaration, and the safe
7
+ // shape (`root` first) is free there because nothing has to be restructured.
8
+ //
9
+ // The second island on a page is cheap. The first is not: an archipelago, an app-side region type the
10
+ // config is declared against, a composition root that binds the region to this environment, a lagoon
11
+ // module that seeds and arranges it, and two entries in the lagoon overrides. None of it is a
12
+ // decision — every one of those files is derivable from "this id, that page" — but all of it has to
13
+ // exist before anything can be verified, which is what makes adopting motu on a new page feel
14
+ // expensive when adding to an existing one does not.
15
+ //
16
+ // It derives from PRECEDENT, not from a template: the transport and host bridge of a new composition
17
+ // root are copied from the one this project already wrote, because those are environment decisions
18
+ // the project has already made once and must not answer differently by accident.
19
+ //
20
+ // It does NOT scaffold evidence. A file full of `TODO` looks like coverage and rots — the same reason
21
+ // `island create` stopped writing `fixtures.mock.ts`. Evidence appears when there is something real.
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { dirname, relative, resolve } from 'node:path';
24
+ import { color, paths, HOST_ROOT, APP_ROOT, hostAliasSpecifier } from '../lib/util.mjs';
25
+ import { scaffoldArchipelago } from './archipelago.mjs';
26
+
27
+ const written = [];
28
+ const skipped = [];
29
+
30
+ function put(path, contents) {
31
+ if (existsSync(path)) return skipped.push(path);
32
+ mkdirSync(dirname(path), { recursive: true });
33
+ writeFileSync(path, contents);
34
+ written.push(path);
35
+ }
36
+
37
+ /** `client-portfolio` -> { pascal: 'ClientPortfolio', camel: 'clientPortfolio' }. */
38
+ function names(id) {
39
+ const words = id.split(/[-_]/).filter(Boolean);
40
+ return {
41
+ pascal: words.map((w) => w[0].toUpperCase() + w.slice(1)).join(''),
42
+ camel: words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join(''),
43
+ };
44
+ }
45
+
46
+ /**
47
+ * How this project imports host files: the host's own alias when one covers the file, else relative.
48
+ *
49
+ * Resolved through the tsconfig's real alias TARGET, never by assuming `@/*` means the host root.
50
+ * The assumption held on Next (`"@/*": ["./*"]`) and broke on a Vite app mapping `"@/*": ["./src/*"]`,
51
+ * where this generated `@/src/pages/…` — a doubled `src/` resolving to nothing, in a file motu had
52
+ * just written for the adopter to use.
53
+ */
54
+ function hostSpecifier(fromFile, targetFile) {
55
+ const aliased = hostAliasSpecifier(targetFile);
56
+ if (aliased) return aliased;
57
+ const rel = relative(dirname(fromFile), targetFile).replace(/\.tsx?$/, '');
58
+ return rel.startsWith('.') ? rel : `./${rel}`;
59
+ }
60
+
61
+ /** An existing composition root, so a new one inherits this project's transport and host bridge. */
62
+ function precedent() {
63
+ const found = [];
64
+ const walk = (dir) => {
65
+ let entries;
66
+ try {
67
+ entries = readdirSync(dir, { withFileTypes: true });
68
+ } catch {
69
+ return;
70
+ }
71
+ for (const e of entries) {
72
+ const full = resolve(dir, e.name);
73
+ if (full.startsWith(APP_ROOT)) continue;
74
+ if (e.isDirectory()) {
75
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
76
+ walk(full);
77
+ } else if (/\.tsx?$/.test(e.name)) {
78
+ const text = readFileSync(full, 'utf8');
79
+ if (/createRegion\(/.test(text)) found.push({ file: full, text });
80
+ }
81
+ }
82
+ };
83
+ for (const top of ['app', 'components', 'lib', 'src']) walk(resolve(HOST_ROOT, top));
84
+ return found[0] ?? null;
85
+ }
86
+
87
+ export async function archipelagoInitCommand(argv) {
88
+ const id = argv._[0];
89
+ const page = argv.page ? resolve(process.cwd(), argv.page) : null;
90
+ if (!id) {
91
+ console.error('usage: motu archipelago init <id> [--page <path/to/page.tsx>]');
92
+ process.exit(2);
93
+ }
94
+ // NO PAGE YET: the region's declaration alone, `root` first. Everything else derives from a page,
95
+ // so it is written when there is one — re-run with `--page`, which keeps this archipelago.
96
+ if (!page) return scaffoldArchipelago(argv);
97
+ if (!existsSync(page)) {
98
+ console.error(color.red(`✗ no page at ${page}`));
99
+ process.exit(2);
100
+ }
101
+ const { pascal, camel } = names(id);
102
+ const archConst = `${camel}Archipelago`;
103
+
104
+ // 1 — the APP's region type, beside its page. It belongs to the application: no motu import, and it
105
+ // erases at runtime, so the vocabulary survives motu's removal.
106
+ const typeFile = resolve(dirname(page), `${id}-region.ts`);
107
+ put(
108
+ typeFile,
109
+ `// The ${id} region's shared state, as a type.
110
+ //
111
+ // Belongs to the APPLICATION: no motu import, and it erases at runtime. It is the region's SHARED
112
+ // vocabulary, not everything the page holds — a key earns a place here when more than one part of the
113
+ // region needs it, or when motu binds it.
114
+ //
115
+ // EMPTY until the first key is shared. \`Record<string, never>\` rather than a speculative key: a key
116
+ // with no reader is not a key.
117
+ export type ${pascal}Region = Record<string, never>
118
+ `,
119
+ );
120
+
121
+ // 2 — the archipelago, DECLARED AGAINST that type (the shape `motu check`'s region-type rule wants).
122
+ const archFile = paths.archipelagoFile(id);
123
+ if (!existsSync(archFile)) {
124
+ await scaffoldArchipelago({ _: [id] });
125
+ written.push(archFile);
126
+ } else {
127
+ skipped.push(archFile);
128
+ }
129
+ const archText = readFileSync(archFile, 'utf8');
130
+ if (!/ArchipelagoConfig<|archipelago</.test(archText)) {
131
+ // UPGRADED TO THE CHECKED CALL, which this step is the first that can do it: the cross-checks are
132
+ // stated against the app-side region type, and `archipelago init` is what just wrote that type.
133
+ // The page-less form cannot — there is nothing to check against yet — which is why the plain
134
+ // config form still exists and why a region scaffolded that way is asserting nothing.
135
+ //
136
+ // `as const` is not decoration. The checks are derived from the config's LITERAL type, and `const A`
137
+ // is best effort: past a certain size TypeScript falls back to the constraint and every check
138
+ // silently passes without checking. `ConstInferenceLost` turns that into an error, and this is what
139
+ // stops a new region ever meeting it.
140
+ writeFileSync(
141
+ archFile,
142
+ archText
143
+ .replace(
144
+ "import type { ArchipelagoConfig } from '@motujs/core';",
145
+ `import { archipelago } from '@motujs/core';\n// TYPE-ONLY, from the app: the page's vocabulary is the application's to name.\nimport type { ${pascal}Region } from '${hostSpecifier(archFile, typeFile)}';`,
146
+ )
147
+ .replace(`export const ${camel}Archipelago: ArchipelagoConfig = {`, `export const ${camel}Archipelago = archipelago<${pascal}Region>()({`)
148
+ .replace(
149
+ /\n\};\n$/,
150
+ `\n} as const,\n// The region's cross-checks, as the declaration's SECOND ARGUMENT. Each property is the check's\n// own result type, so \`true\` is the only value that compiles and a drift errors on that line naming\n// the offending key. Add \`wiring\` once this region has islands (it needs the generated elements map\n// as the second type argument) and \`produced\` once the app declares its produced-key type.\n{ ownership: true });\n`,
151
+ ),
152
+ );
153
+ }
154
+
155
+ // 3 — the lagoon's module for this region: what the page establishes, and where its islands sit.
156
+ const regionModule = resolve(paths.lagoonDir, 'src/regions', `${id}.tsx`);
157
+ put(
158
+ regionModule,
159
+ `// The ${id} region, for the lagoon: what the page establishes, and where the islands sit.
160
+ import type { ReactNode } from 'react';
161
+ import type { LagoonOverrides } from '@motujs/react';
162
+
163
+ /** What the PAGE establishes on first paint. Add a key when an island binds one. */
164
+ export const ${camel}Seed: NonNullable<LagoonOverrides['seed']>[string] = {};
165
+
166
+ /**
167
+ * The region's arrangement.
168
+ *
169
+ * Point this at the APPLICATION's own layout component as soon as the page has one — one description
170
+ * of the page's shape, rendered by the page with live content and here with islands. Restating the
171
+ * arrangement in JSX here is the drift the region type exists to prevent, one level up.
172
+ */
173
+ export const ${pascal}RegionFrame = (island: (slot: string) => ReactNode): ReactNode => {
174
+ // A FRAGMENT, NOT A STYLED <div>. \`region-root\` allows a frame to hold only the application's own
175
+ // components, fragments and \`island(slot)\` calls — so the placeholder that shipped here (a
176
+ // \`<div className="flex flex-col gap-6 …">\`) failed that check the moment it was first run, and the
177
+ // adopter's first \`motu check\` reported a violation motu had written for them. Arrangement belongs
178
+ // to the app's own layout component; reach for \`root\` in the archipelago when the page has one.
179
+ // THE SIGNATURE IS \`layout\`'s OWN, not a component's. This shipped as a component taking
180
+ // \`{ island }\` as PROPS while \`layout\` is \`(island) => ReactNode\` — so wiring the frame motu had
181
+ // just written required an adapter the adopter had to guess, and the first guess (the props shape
182
+ // the scaffold itself suggested) was wrong. Typed from the override so the two cannot drift again.
183
+ return <>{/* {island('your-slot')} */}</>;
184
+ };
185
+ `,
186
+ );
187
+
188
+ // 4 — the composition root, modelled on the one this project already has.
189
+ const model = precedent();
190
+ const bindingDir = model ? dirname(model.file) : dirname(page);
191
+ // ONE MODULE SPECIFIER, TWO FILES — the collision that only bites the FIRST region in a project,
192
+ // which is the case this command exists for. With no precedent the binding lands beside the page,
193
+ // where the region TYPE already lives as `<id>-region.ts`; TypeScript resolves `./<id>-region` to
194
+ // the `.ts` and the composition root is unreachable, so the screen importing `MotuRegion` fails to
195
+ // compile with no hint that a second file is involved. A project with a precedent never sees it —
196
+ // acme's binding sits in `components/motu/` and its type in `app/`.
197
+ const collides = bindingDir === dirname(typeFile);
198
+ const bindingFile = resolve(bindingDir, `${id}-${collides ? 'binding' : 'region'}.tsx`);
199
+ const transportLine = model?.text.match(/^\s*transport:.*$/m)?.[0]?.trim() ?? 'transport: undefined, // TODO(motu): the transport this environment uses';
200
+ const useHostLine = model?.text.match(/^\s*useHost:.*$/m)?.[0]?.trim() ?? 'useHost: undefined, // TODO(motu): the host bridge for this stack';
201
+ const modelImports = model
202
+ ? model.text
203
+ .split('\n')
204
+ .filter((l) => /^import /.test(l) && !/Archipelago|ELEMENT_REGISTRY/.test(l))
205
+ .join('\n')
206
+ : '';
207
+ // `createRegion` IS THE ONE IMPORT THIS FILE CANNOT DO WITHOUT, and it was only ever inherited.
208
+ // `modelImports` copies the precedent's imports, so the FIRST region in a project — the one with no
209
+ // precedent — got a composition root that calls `createRegion(...)` and never imports it. Measured
210
+ // on a cold adoption: the adopter's first act after `archipelago init` was hand-adding an import
211
+ // motu had just made necessary.
212
+ const createRegionImport = /^import .*\bcreateRegion\b/m.test(modelImports)
213
+ ? ''
214
+ : "\nimport { createRegion } from '@motujs/react';";
215
+
216
+ // THE BARREL, AS A SPECIFIER THAT RESOLVES. `appPackage` defaults to the app directory's name, which
217
+ // is a real package name only when the directory and the package agree — on a monorepo app called
218
+ // `apps/dashboard` publishing `@novu/dashboard`, it generated `from 'dashboard'`, importing a
219
+ // package that does not exist. The barrel is a FILE motu itself wrote, so prefer the host's own way
220
+ // of naming that file and keep `appPackage` for when it genuinely is a resolvable package.
221
+ const barrelSpecifier = (() => {
222
+ const declared = paths.appPackage;
223
+ const pkgFile = resolve(APP_ROOT, 'package.json');
224
+ try {
225
+ if (existsSync(pkgFile) && JSON.parse(readFileSync(pkgFile, 'utf8')).name === declared) return declared;
226
+ } catch {}
227
+ return hostSpecifier(bindingFile, paths.barrel) ?? declared;
228
+ })();
229
+
230
+ // Compared as SPECIFIERS, not as paths: `a-region.ts` and `a-region.tsx` are different paths and
231
+ // the same module, which is what let the collision above through.
232
+ const specifierOf = (f) => f.replace(/\.tsx?$/, '');
233
+ if (specifierOf(bindingFile) !== specifierOf(typeFile)) {
234
+ put(
235
+ bindingFile,
236
+ `'use client';
237
+ // The composition root for the \`${id}\` PAGE archipelago.
238
+ //
239
+ // 100% motu, by design: it is the one file that has to go when motu does, and \`motu removal-check\`
240
+ // deletes it whole and unwraps <${pascal}Region> wherever the page used it.
241
+ //
242
+ // The transport and host bridge are COPIED from this project's existing composition root, not chosen
243
+ // again: they are environment decisions the project already made, and answering them differently by
244
+ // accident is how two regions end up with two securities.
245
+ ${modelImports}${createRegionImport}
246
+ import { ELEMENT_REGISTRY, ${archConst} } from '${barrelSpecifier}';
247
+
248
+ export const ${pascal} = createRegion(${archConst}, {
249
+ elements: ELEMENT_REGISTRY,
250
+ ${transportLine}
251
+ ${useHostLine}
252
+ });
253
+
254
+ /** The region wrapper, for the page to wrap its content in. */
255
+ export const MotuRegion = ${pascal}.Region;
256
+ `,
257
+ );
258
+ }
259
+
260
+ // 5 — the lagoon overrides: two entries, added in place when the maps are where we expect them.
261
+ const overrides = resolve(paths.lagoonDir, 'src/lagoon.tsx');
262
+ const manual = [];
263
+ if (existsSync(overrides)) {
264
+ let text = readFileSync(overrides, 'utf8');
265
+ const before = text;
266
+ // ANCHORED, AND NOT INTO A COMMENT. These patterns used to match anywhere, and the scaffold's own
267
+ // overrides file ships every map COMMENTED OUT as a template — so the first `archipelago init` in a
268
+ // project spliced its entry into the middle of `// export const seed: … = {`, leaving a bare
269
+ // `'id': seed, someArchipelago: { … } };` on the next line. A file that cannot parse, written by
270
+ // the command whose job is to make the region work, and reported as a success.
271
+ const seedMap = /^export const seed: LagoonOverrides\['seed'\] = \{/m;
272
+ const layoutMap = /^export const layout: LagoonOverrides\['layout'\] = \{/m;
273
+ const canSplice = seedMap.test(text) || layoutMap.test(text);
274
+ const importLine = `import { ${pascal}RegionFrame, ${camel}Seed } from './regions/${id}.js';`;
275
+ // The import goes in only if something will USE it. Adding it to a file whose maps are all
276
+ // commented out left a dangling import of a module nothing referenced, and — because the file had
277
+ // changed — suppressed the instruction telling the user to wire it up by hand.
278
+ if (canSplice && !text.includes(importLine)) {
279
+ text = text.replace(/^(import [\s\S]*?;\n)(?!import )/m, `$1${importLine}\n`);
280
+ }
281
+ if (seedMap.test(text) && !new RegExp(`\\b${id}:`).test(text)) {
282
+ text = text.replace(seedMap, `$&\n '${id}': ${camel}Seed,`);
283
+ }
284
+ if (layoutMap.test(text)) {
285
+ text = text.replace(
286
+ layoutMap,
287
+ `$&\n '${id}': (island) => <${pascal}RegionFrame island={island} />,`,
288
+ );
289
+ }
290
+ if (text !== before) {
291
+ writeFileSync(overrides, text);
292
+ written.push(overrides);
293
+ } else {
294
+ manual.push(
295
+ `wire \`${id}\` into ${relative(process.cwd(), overrides)} — its maps are still the scaffold's ` +
296
+ `commented-out template, so there is nothing to add an entry to. Uncomment \`seed\` and ` +
297
+ `\`layout\` (or use the \`regions\` array form) and reference \`${camel}Seed\` and ` +
298
+ `\`${pascal}RegionFrame\` from ./regions/${id}.js`,
299
+ );
300
+ }
301
+ }
302
+
303
+ const rel = (f) => relative(process.cwd(), f);
304
+ console.log(color.bold(`\nmotu archipelago init — ${id}\n`));
305
+ for (const f of written) console.log(' ' + color.green('+ ') + rel(f));
306
+ for (const f of skipped) console.log(' ' + color.dim('= ' + rel(f) + ' (exists, untouched)'));
307
+ console.log('');
308
+ console.log(color.bold('Next, and these are the parts nobody can generate:'));
309
+ console.log(` 1. wrap the page's content in ${color.bold('<MotuRegion>')} (${rel(bindingFile)})`);
310
+ console.log(` 2. add islands: ${color.bold(`motu island integrate <name> --archipelago ${id}`)}`);
311
+ console.log(` 3. place each one in the page: ${color.bold(`<${pascal}.Island slot="…">`)}`);
312
+ console.log(` 4. prove the host really uses it: ${color.bold('motu integrate check ' + id)}`);
313
+ for (const m of manual) console.log(' ' + color.yellow('! ') + m);
314
+ }
@@ -0,0 +1,138 @@
1
+ // `motu archipelago init <id>` with no `--page` — scaffolds a new (empty) archipelago as its own folder and
2
+ // registers it so the lagoon / CLI can resolve it by id. It:
3
+ // 1. Writes <archipelagos>/<id>/<id>.archipelago.ts with
4
+ // `export const <id>Archipelago: ArchipelagoConfig = { id, islands: [] }`.
5
+ // 2. Registers it in <archipelagos>/registry.ts (value import + ARCHIPELAGOS map) and
6
+ // re-exports it from the app barrel.
7
+ // Islands are added later with `motu island integrate <name> --archipelago <id>`.
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { Project, QuoteKind, SyntaxKind } from 'ts-morph';
11
+ import { paths, color, FMT } from '../lib/util.mjs';
12
+
13
+ /** 'client-portfolio' | 'clientPortfolio' -> { id:'client-portfolio', constName:'clientPortfolioArchipelago' }. */
14
+ function archNames(input) {
15
+ const words = String(input)
16
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
17
+ .replace(/[_-]+/g, ' ')
18
+ .trim()
19
+ .split(/\s+/)
20
+ .filter(Boolean)
21
+ .map((w) => w.toLowerCase());
22
+ if (words.length === 0) throw new Error('archipelago id is empty');
23
+ const id = words.join('-');
24
+ const camel = words.map((w, i) => (i === 0 ? w : w[0].toUpperCase() + w.slice(1))).join('');
25
+ return { id, constName: `${camel}Archipelago` };
26
+ }
27
+
28
+ function archipelagoSource(id, constName) {
29
+ // The BINDING's name, PascalCase — a component identifier and a JSX tag, so the archipelago's
30
+ // camelCase const name cannot stand in for it.
31
+ const base = constName.replace(/Archipelago$/, '');
32
+ const Base = base[0].toUpperCase() + base.slice(1);
33
+ return `// The ${id} region. Add islands with \`motu island integrate <name> --archipelago ${id}\`.
34
+ //
35
+ // DECLARE THE ROOT FIRST. An archipelago is the scope of one root component — usually a page, and it
36
+ // does not have to be. Naming it here is what leaves ONE description of how this region composes:
37
+ // the page renders \`<${Base}.Root prop={…} />\` using its own prop names and never
38
+ // writes a slot, and the lagoon renders the same component from the same map. Skip it and the two
39
+ // sides each compose the region their own way, with nothing comparing them — which is a drift that
40
+ // ships green.
41
+
42
+ import type { ArchipelagoConfig } from '@motujs/core';
43
+ // import { ${Base}Layout } from '@/app/${id}/${id}-layout';
44
+
45
+ export const ${constName}: ArchipelagoConfig = {
46
+ id: '${id}',
47
+ // The APPLICATION's own layout component, imported — not a copy of it, and not a template.
48
+ // root: ${Base}Layout,
49
+ // Which of the root's props an island fills: the app's vocabulary on the left, motu's on the right.
50
+ // slots: { results: '${id}-results' },
51
+ // Props the HOST fills, and the app component that fills them — region UI that reads no region key
52
+ // and writes none. A COMPONENT, never a hole: the lagoon then supplies only its props, as data.
53
+ // hostSlots: { header: ${Base}Header },
54
+ islands: [],
55
+ // layout: \`<div class="gm-arch"></div>\`, // an OCEAN's answer: a legacy page cannot hand React anything
56
+ };
57
+ `;
58
+ }
59
+
60
+ export async function scaffoldArchipelago(argv) {
61
+ const raw = argv._[0];
62
+ if (!raw) {
63
+ console.error('usage: motu archipelago init <id> [--page <path/to/page.tsx>]');
64
+ process.exit(2);
65
+ }
66
+ const { id, constName } = archNames(raw);
67
+ const archPath = paths.archipelagoFile(id);
68
+
69
+ // "ALREADY EXISTS" IS NOT "ALREADY DONE". Creating a region is three writes — the config, the
70
+ // registry entry, the barrel re-export — and only the first had a guard. So a run that wrote the
71
+ // config and then failed (a bad `barrel` path throwing out of ts-morph, say) left the project
72
+ // half-created, and the RETRY reported "nothing to do" and exited 0 while the registry and barrel
73
+ // it still owed were never written. Measured on a cold adoption: the region existed, was
74
+ // unregistered, and the command that should have finished the job said there was nothing to finish.
75
+ //
76
+ // So the guard asks whether the region is COMPLETE, not whether one file is present, and when it is
77
+ // not it carries on and finishes the missing halves.
78
+ const alreadyRegistered = () => {
79
+ try {
80
+ return (
81
+ readFileSync(paths.archipelagosRegistry, 'utf8').includes(constName) &&
82
+ readFileSync(paths.barrel, 'utf8').includes(`${id}.archipelago`)
83
+ );
84
+ } catch {
85
+ return false;
86
+ }
87
+ };
88
+ if (existsSync(archPath) && !argv.force) {
89
+ if (alreadyRegistered()) {
90
+ console.log(color.yellow(`! ${archPath} already exists — nothing to do`));
91
+ process.exit(0);
92
+ }
93
+ console.log(
94
+ color.yellow(`! ${paths.rel(archPath)} exists but is not fully registered — finishing what a previous run left`),
95
+ );
96
+ }
97
+
98
+ mkdirSync(dirname(archPath), { recursive: true });
99
+ // The existing config is KEPT when it is only the registration that is missing: a half-finished run
100
+ // must not silently discard edits made to the file it did manage to write.
101
+ if (!existsSync(archPath) || argv.force) writeFileSync(archPath, archipelagoSource(id, constName));
102
+
103
+ const project = new Project({
104
+ manipulationSettings: { quoteKind: QuoteKind.Single, useTrailingCommas: true },
105
+ skipAddingFilesFromTsConfig: true,
106
+ skipFileDependencyResolution: true,
107
+ compilerOptions: { allowJs: true },
108
+ });
109
+
110
+ // 1) registry.ts — value import + ARCHIPELAGOS registry entry.
111
+ const registrySf = project.addSourceFileAtPath(paths.archipelagosRegistry);
112
+ const spec = `./${id}/${id}.archipelago`;
113
+ if (!registrySf.getImportDeclarations().some((d) => d.getModuleSpecifierValue() === spec)) {
114
+ registrySf.addImportDeclaration({ moduleSpecifier: spec, namedImports: [constName] });
115
+ }
116
+ const registry = registrySf.getVariableDeclaration('ARCHIPELAGOS')?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
117
+ if (registry && !registry.getProperty(`[${constName}.id]`)) {
118
+ registry.addPropertyAssignment({ name: `[${constName}.id]`, initializer: constName });
119
+ }
120
+
121
+ // 2) barrel (src/index.ts) — re-export the config const (barrel is one level above archipelagos/).
122
+ const barrelSpec = `./archipelagos/${id}/${id}.archipelago`;
123
+ const barrelSf = project.addSourceFileAtPath(paths.barrel);
124
+ if (!barrelSf.getExportDeclarations().some((d) => d.getModuleSpecifierValue() === barrelSpec)) {
125
+ barrelSf.addExportDeclaration({ moduleSpecifier: barrelSpec, namedExports: [constName] });
126
+ }
127
+
128
+ registrySf.formatText(FMT);
129
+ barrelSf.formatText(FMT);
130
+ await project.save();
131
+
132
+ console.log(color.green(`✓ created archipelago ${color.bold(id)} (${constName})`));
133
+ console.log(' ' + color.dim(`${paths.rel(paths.archipelagoFile(id))} (config const)`));
134
+ console.log(' ' + color.dim(`${paths.rel(paths.archipelagosRegistry)} (import + ARCHIPELAGOS)`));
135
+ console.log(' ' + color.dim(`${paths.rel(paths.barrel)} (barrel re-export)`));
136
+ console.log('');
137
+ console.log('Next: ' + color.bold(`motu island integrate <name> --archipelago ${id}`));
138
+ }