@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,153 @@
1
+ // `motu fixtures record <island>` — capture real (or mock) backend responses into request-keyed
2
+ // fixtures. It boots the focused lagoon, drives the island's declared `scenarios` through the
3
+ // archipelago boundary, and records each contract call's request + response at the `call()` seam.
4
+ // With `--transport http` it records the REAL backend (the point of it); default records the mock
5
+ // (a self-consistency check of the pipeline). This closes the "fixtures are hand-written / can't
6
+ // capture semantic fidelity" gap — the recorder replays real truth, keyed by request, without
7
+ // reimplementing any backend logic.
8
+ import { parseEvidence } from '../lib/evidence-json.mjs';
9
+ import { existsSync, writeFileSync } from 'node:fs';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import { dirname, resolve } from 'node:path';
13
+ import { paths, names, color } from '../lib/util.mjs';
14
+ import { recordLagoon } from '../playwright-lagoon.mjs';
15
+ import { scenarioInputs } from '../lib/data-flow.mjs';
16
+ import { CHROMIUM_INSTALL } from '../lib/framework.mjs';
17
+
18
+ const HARNESS = resolve(dirname(fileURLToPath(import.meta.url)), '../runtime-harness.mjs');
19
+ const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ /** Read the island's declared `scenarios` via the harness (loads only the fixtures file). */
22
+ function readScenarios(fixturesPath) {
23
+ if (!fixturesPath || !existsSync(fixturesPath)) return [];
24
+ const args = ['--import', 'tsx', HARNESS, '', fixturesPath, 'native', 'scenarios'];
25
+ const res = spawnSync(process.execPath, args, { encoding: 'utf8', cwd: CLI_PKG });
26
+ if (res.status !== 0) return [];
27
+ const line = (res.stdout || '').trim().split('\n').filter(Boolean).pop();
28
+ // See lib/evidence-json.mjs — the harness marks Dates so they survive this hop.
29
+ return parseEvidence(line)?.scenarios ?? [];
30
+ }
31
+
32
+ /** Stable JSON (sorted object keys) for dedup keys. */
33
+ function stable(v) {
34
+ return JSON.stringify(v, (_k, val) =>
35
+ val && typeof val === 'object' && !Array.isArray(val)
36
+ ? Object.fromEntries(Object.keys(val).sort().map((k) => [k, val[k]]))
37
+ : val,
38
+ );
39
+ }
40
+
41
+ export async function fixturesRecordCommand(argv) {
42
+ const name = argv._[0];
43
+ if (!name) {
44
+ console.error('usage: motu fixtures record <island> [--transport http|mock] [--out <path>]');
45
+ process.exit(2);
46
+ }
47
+ const { kebab, tag } = names(name);
48
+ const fixturesPath = paths.fixturesFile(kebab);
49
+ const scenarios = readScenarios(fixturesPath);
50
+ if (scenarios.length === 0) {
51
+ console.error(color.red(`✗ ${kebab} declares no \`scenarios\` in fixtures.mock.ts — add at least one { seed } to record against.`));
52
+ process.exit(1);
53
+ }
54
+
55
+ const transport = argv.transport === 'http' ? 'http' : undefined; // default: mock
56
+ console.log(color.dim(`recording ${tag} against the ${transport ?? 'mock'} transport over ${scenarios.length} scenario(s)…`));
57
+ const port = 5400 + Math.floor(Math.random() * 300);
58
+
59
+ let result;
60
+ try {
61
+ result = await recordLagoon({ tag, port, scenarios, transport });
62
+ } catch (err) {
63
+ const msg = String(err?.message || err).split('\n')[0];
64
+ if (/Executable doesn't exist|playwright install/i.test(msg)) {
65
+ console.error(color.red(`✗ Chromium not installed — run ${CHROMIUM_INSTALL}`));
66
+ } else {
67
+ console.error(color.red(`✗ recording failed: ${msg}`));
68
+ }
69
+ process.exit(1);
70
+ }
71
+ if (!result.mounted) {
72
+ console.error(color.red('✗ island did not mount — nothing recorded'));
73
+ process.exit(1);
74
+ }
75
+
76
+ // Dedupe captured calls by (service, method, args).
77
+ const seen = new Set();
78
+ const unique = [];
79
+ for (const c of result.calls) {
80
+ const key = `${c.service}.${c.method}(${stable(c.args)})`;
81
+ if (seen.has(key)) continue;
82
+ seen.add(key);
83
+ unique.push(c);
84
+ }
85
+
86
+ // Reduce host-fed store writes (channels + provide) to a last-wins seed, MINUS the keys the
87
+ // scenarios themselves drove (those are inputs, not host config).
88
+ const scenarioKeys = new Set(scenarios.flatMap((s) => Object.keys(scenarioInputs(s))));
89
+ const seed = {};
90
+ for (const w of result.seedWrites ?? []) {
91
+ if (scenarioKeys.has(w.key)) continue;
92
+ seed[w.key] = w.value;
93
+ }
94
+ const seedKeys = Object.keys(seed);
95
+
96
+ if (unique.length === 0 && seedKeys.length === 0) {
97
+ console.error(color.red('✗ nothing captured — no contract calls and no host-fed store writes'));
98
+ process.exit(1);
99
+ }
100
+
101
+ const outPath = argv.out ? resolve(process.cwd(), argv.out) : resolve(paths.islandDir(kebab), 'fixtures.recorded.ts');
102
+ writeFileSync(outPath, renderFixtures(kebab, unique, seed, transport));
103
+ console.log(color.green(`✓ wrote ${unique.length} fixture(s)${seedKeys.length ? ` + ${seedKeys.length} seed key(s)` : ''}`) + color.dim(` -> ${outPath}`));
104
+ for (const c of unique) {
105
+ console.log(' ' + color.dim(`${c.service}.${c.method} match=${stable(c.args)}${c.status ? ` (status ${c.status})` : ''}`));
106
+ }
107
+ for (const k of seedKeys) console.log(' ' + color.dim(`seed.${k} = ${preview(seed[k])}`));
108
+ console.log(color.dim('\nReview, then merge into fixtures.mock.ts (fixtures) and the lagoon seed (seed) to replay this session offline.'));
109
+ }
110
+
111
+ /** A short one-line preview of a captured value. */
112
+ function preview(v) {
113
+ try {
114
+ const s = JSON.stringify(v);
115
+ return s && s.length > 60 ? s.slice(0, 57) + '…' : String(s);
116
+ } catch {
117
+ return String(v);
118
+ }
119
+ }
120
+
121
+ function renderFixtures(kebab, calls, seed, transport) {
122
+ const rows = calls.map((c) => {
123
+ const match = JSON.stringify(c.args);
124
+ // A CAPTURED FAILURE IS A FIXTURE NOW. This emitted the status as a commented line with
125
+ // `response: null`, because `Fixture` could not express a failing call — so recording a real 500
126
+ // through `--transport http` produced something nobody could run. `FixtureFailure` is the form it
127
+ // was reaching for.
128
+ if (c.status) {
129
+ return ` { service: ${JSON.stringify(c.service)}, method: ${JSON.stringify(c.method)}, match: ${match}, status: ${c.status} },`;
130
+ }
131
+ const response = indent(JSON.stringify(c.response, null, 2), 4);
132
+ return ` { service: ${JSON.stringify(c.service)}, method: ${JSON.stringify(c.method)}, match: ${match}, response: ${response} },`;
133
+ });
134
+ const seedBlock =
135
+ Object.keys(seed).length > 0
136
+ ? `\n// Host-fed store values (channels + provide) captured this session — pass as the lagoon seed so\n// the island receives REAL host config offline, not a hand-written stub.\nexport const seed: Record<string, unknown> = ${indent(JSON.stringify(seed, null, 2), 0)};\n`
137
+ : '';
138
+ return `// RECORDED fixtures for x-${kebab} — captured from the ${transport ?? 'mock'} transport by \`motu fixtures record\`.
139
+ // Request-keyed (\`match\` = the exact call args). Merge the ones you want into fixtures.mock.ts.
140
+ import type { Fixture } from '@motujs/runtime/mock';
141
+
142
+ export const fixtures: Fixture[] = [
143
+ ${rows.join('\n')}
144
+ ];
145
+ ${seedBlock}`;
146
+ }
147
+
148
+ /** Re-indent a multi-line JSON blob so nested lines align under the fixture row. */
149
+ function indent(json, spaces) {
150
+ if (json == null) return 'null';
151
+ const pad = ' '.repeat(spaces);
152
+ return json.split('\n').map((l, i) => (i === 0 ? l : pad + l)).join('\n');
153
+ }
@@ -0,0 +1,473 @@
1
+ // `motu init [dir]` — bootstrap a project so `motu island create` / `verify` work immediately.
2
+ //
3
+ // It writes motu.config.json (the layout declaration the rest of the CLI reads), the semantic roots +
4
+ // registries + barrel that islands slot into, AND the lagoon root. That last part is the point: the
5
+ // lagoon is where `motu island verify` closes the loop, so a project scaffolded without one gets the
6
+ // static checks and none of the verification. Init used to stop before it, on the grounds that a
7
+ // composition root is "a project concern" — but the result was a documented happy path (init ->
8
+ // island create -> island verify) that could not reach green without hand-porting the demo app.
9
+ //
10
+ // `--host` picks the stack the islands embed INTO, which decides what the lagoon has to speak:
11
+ // angularjs the reference ocean — legacy fit gates apply, AngularJS adapter available
12
+ // next a Next.js app — the lagoon inherits the host's '@/…' alias + Tailwind, stubs next/*
13
+ // none plain React — nothing host-specific
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
+ import { basename, dirname, relative, resolve } from 'node:path';
16
+ import { color } from '../lib/util.mjs';
17
+ import { applyHostRules } from '../lib/host-rules.mjs';
18
+ import { installSkills } from './skills.mjs';
19
+ import { CLI_VERSION, INSTALLED } from '../lib/framework.mjs';
20
+ import {
21
+ render,
22
+ ISLANDS_REGISTRY,
23
+ ARCHIPELAGOS_REGISTRY,
24
+ BARREL,
25
+ APP_PACKAGE_JSON,
26
+ SHARED_STYLES,
27
+ GITIGNORE,
28
+ LAGOON_PACKAGE_JSON,
29
+ LAGOON_INDEX_HTML,
30
+ LAGOON_FOCUS_HTML,
31
+ LAGOON_FIXTURES,
32
+ LAGOON_FOCUS_ENTRY,
33
+ LAGOON_GALLERY_ENTRY,
34
+ LAGOON_CONFIG,
35
+ LAGOON_CHROME_SHADCN,
36
+ LAGOON_OVERRIDES,
37
+ NEXT_STUBS,
38
+ ENV_SHIM,
39
+ NEXT_VITE_IMPORTS,
40
+ NEXT_VITE_ALIASES,
41
+ NEXT_VITE_CSS,
42
+ NEXT_TAILWIND_CONFIG,
43
+ } from '../lib/scaffold.mjs';
44
+
45
+ /** What a host's Tailwind config can be called. Mirrors @motujs/adapter-next's own list. */
46
+ const TAILWIND_CONFIGS = ['tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'];
47
+
48
+ /** Framework entry points, mapped by module specifier — @motujs/* are raw TS with no build step. */
49
+ const MOTU_ENTRIES = {
50
+ // The ONE motu module application code may import — types only, so it erases at compile time and
51
+ // the app still ships byte-identical output without motu.
52
+ '@motujs/types': 'packages/types/src/index.ts',
53
+ '@motujs/core': 'packages/core/src/index.ts',
54
+ '@motujs/react': 'packages/react/src/index.ts',
55
+ '@motujs/react/define': 'packages/react/src/defineReactElement.ts',
56
+ '@motujs/runtime/mock': 'packages/runtime/src/mock.ts',
57
+ '@motujs/runtime': 'packages/runtime/src/index.ts',
58
+ '@motujs/debug-overlay': 'packages/debug-overlay/src/index.ts',
59
+ };
60
+
61
+ const HOST_ADAPTER_ENTRY = {
62
+ angularjs: ['@motujs/adapter-angularjs', 'packages/adapters/angularjs/src/index.ts'],
63
+ next: ['@motujs/adapter-next', 'packages/adapters/next/src/index.ts'],
64
+ };
65
+
66
+ /**
67
+ * Vite aliases resolving the framework straight to THIS checkout's sources.
68
+ *
69
+ * The @motujs/* packages are unpublished workspace packages whose entry point is raw TypeScript, so an
70
+ * existing project cannot simply depend on them — that is the single thing that blocks adopting motu
71
+ * outside its own repo. The lagoon is a Vite app and transpiles TS anyway, so pointing it at the
72
+ * sources removes the package-manager problem entirely: `motu init` produces a lagoon that boots with
73
+ * no install step. Longest specifier first, so '@motujs/runtime/mock' is not eaten by '@motujs/runtime'.
74
+ */
75
+ /** Escape a module specifier for use inside a RegExp literal. */
76
+ function reEscape(spec) {
77
+ return spec.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
78
+ }
79
+
80
+ /** An anchored, exact-match alias pattern for one module specifier. */
81
+ function exact(spec) {
82
+ return `/^${reEscape(spec)}$/`;
83
+ }
84
+
85
+ function motuAliases(lagoonDir, appRoot, appPackage, host) {
86
+ const entries = Object.entries(MOTU_ENTRIES);
87
+ const adapter = HOST_ADAPTER_ENTRY[host];
88
+ if (adapter) entries.push([adapter[0], adapter[1]]);
89
+ const lines = entries.map(([spec, rel]) => ` { find: ${exact(spec)}, replacement: motu('${rel}') },`);
90
+ // The project's own barrel + stylesheet, imported by name so generated code never carries a
91
+ // relative path into the app. The stylesheet pattern is intentionally NOT end-anchored: it is
92
+ // imported as '<pkg>/styles.css?inline', and the query has to survive the rewrite.
93
+ lines.push(
94
+ ` { find: /^${reEscape(appPackage)}\\/styles\\.css/, replacement: resolve(__dirname, '${relPosix(lagoonDir, resolve(appRoot, 'src/shared/styles.css'))}') },`,
95
+ );
96
+ lines.push(
97
+ ` { find: ${exact(appPackage)}, replacement: resolve(__dirname, '${relPosix(lagoonDir, resolve(appRoot, 'src/index.ts'))}') },`,
98
+ );
99
+ return lines.join('\n') + '\n';
100
+ }
101
+
102
+ /** Where a Next/React app conventionally keeps the global stylesheet Tailwind is pulled in through. */
103
+ const HOST_GLOBAL_CSS = ['app/globals.css', 'src/app/globals.css', 'styles/globals.css', 'src/styles/globals.css'];
104
+
105
+ /** `import '<host globals.css>';` for the lagoon entries, or '' when the host has no such file. */
106
+ function hostGlobalCssImport(lagoonDir, hostRoot) {
107
+ for (const rel of HOST_GLOBAL_CSS) {
108
+ const abs = resolve(hostRoot, rel);
109
+ if (existsSync(abs)) return `import '${relPosix(resolve(lagoonDir, 'src'), abs)}';\n`;
110
+ }
111
+ return '';
112
+ }
113
+
114
+ // 'vite' was supported everywhere EXCEPT here: the adapter exists (packages/adapters/vite/vite.mjs),
115
+ // `hostContribution` resolves it, `loadMotuConfig` accepts it, and Twenty runs on it — but the one
116
+ // command that starts a project rejected it, so the only way onto a vite host was to hand-write
117
+ // motu.config.json. Found on the first greenfield init after adoption stopped needing a checkout path.
118
+ const HOSTS = new Set(['angularjs', 'next', 'none', 'vite']);
119
+
120
+ /** Hosts with a legacy skin an island must be proven to survive (mirrors lib/config.mjs). */
121
+ const LEGACY_FIT_HOSTS = new Set(['angularjs']);
122
+
123
+ /** POSIX-style relative path for generated code (never a Windows backslash in a module specifier). */
124
+ function relPosix(from, to) {
125
+ const r = relative(from, to).split('\\').join('/');
126
+ return r === '' ? '.' : r.startsWith('.') ? r : './' + r;
127
+ }
128
+
129
+ /**
130
+ * Say when the app's OWN package.json was kept and the `@motujs/*` block therefore never landed.
131
+ *
132
+ * `writeNew` is non-destructive per file, which is right — but it made the one file EVERY real
133
+ * adoption already has, `package.json`, silently skip the dependency block with it. The scaffold is
134
+ * written for a greenfield project; an existing app is the normal case, and there the block is not
135
+ * "kept", it is ABSENT. Measured on a cold adoption of a Next monorepo: no `@motujs/adapter-next`
136
+ * anywhere, `nextHostBridge` unresolvable, and `init` printed a green success line.
137
+ *
138
+ * A notice rather than a merge: editing an application's package.json is the host's call, and a
139
+ * printed block a human can paste is honest about which side owns the file.
140
+ */
141
+ function keptPackageJsonNotice(appRoot, skipped, motuDependencies) {
142
+ // NO WORKSPACE TO SATISFY `@motujs/*` — say what DOES resolve them, because nothing else will.
143
+ //
144
+ // On a single-package project the dependency block is deliberately not written (a `workspace:*`
145
+ // range nothing can resolve breaks the next `npm install` outright). The consequence, measured on a
146
+ // cold adoption of an npm project: `motu init` changed ZERO lines of package.json, so the only
147
+ // record of motu's presence was raw symlinks in `node_modules/@motujs/*` — which a fresh
148
+ // `npm install` deletes, leaving nothing to say what was needed or why the build then fails.
149
+ // The CLI re-creates them on every invocation, so this is legibility, not breakage.
150
+ if (!motuDependencies) {
151
+ // THERE IS A FIX NOW, so this stops describing the hazard and names it.
152
+ //
153
+ // This used to end with "after `npm install` wipes them, any motu command puts them back", which
154
+ // is true and is not a fix: the person who runs `npm install` is usually not the person who runs
155
+ // a motu command next. Measured on shlink — `npm install` printed `removed 9 packages` and the
156
+ // host build then failed with `Cannot find module '@motujs/react'`, an error pointing nowhere near
157
+ // the cause. CI and a fresh clone hit it every time.
158
+ console.log(color.yellow(' ! nothing in package.json records @motujs/*, and `npm install` deletes them as extraneous.'));
159
+ console.log(color.dim(' Record them as `file:` paths, which npm creates itself and never prunes:'));
160
+ console.log('');
161
+ console.log(' motu link');
162
+ console.log('');
163
+ console.log(color.dim(' (`motu link --check` asserts it, for CI. A workspace project needs neither.)'));
164
+ console.log('');
165
+ return;
166
+ }
167
+ const pkgPath = resolve(appRoot, 'package.json');
168
+ if (!skipped.includes(pkgPath)) return;
169
+ let already = false;
170
+ try {
171
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
172
+ already = Object.keys(pkg.dependencies ?? {}).some((d) => d.startsWith('@motujs/'));
173
+ } catch {}
174
+ if (already) return;
175
+ console.log(color.yellow(' ! package.json was kept, so the @motujs/* dependencies were NOT added.'));
176
+ console.log(color.dim(' The lagoon and tsconfig resolve @motujs/* without them, and the CLI links'));
177
+ console.log(color.dim(" node_modules/@motujs/* on every run — so this is not fatal. It matters when the"));
178
+ console.log(color.dim(' HOST bundler resolves them (a host adapter import). Add to its "dependencies":'));
179
+ console.log('');
180
+ for (const line of motuDependencies.split('\n').slice(1, -2)) console.log(color.dim(' ' + line.trim()));
181
+ console.log('');
182
+ }
183
+
184
+ /**
185
+ * Add the `@motujs/*` an installed project needs to its package.json, never touching one it already
186
+ * declares. Returns `"<field>: <name>@<range>"` for each line added.
187
+ *
188
+ * Runtime packages go in `dependencies` — islands ship in the app's bundle. `@motujs/debug-overlay` is
189
+ * only ever imported by the lagoon, so it is a devDependency, beside @motujs/cli itself.
190
+ */
191
+ function recordInstalledDeps(pkgPath, range, host) {
192
+ let pkg;
193
+ try {
194
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
195
+ } catch {
196
+ return [];
197
+ }
198
+ const adapter = host === 'angularjs' ? '@motujs/adapter-angularjs' : host === 'next' ? '@motujs/adapter-next' : null;
199
+ const wanted = {
200
+ dependencies: ['@motujs/core', '@motujs/react', '@motujs/runtime', ...(adapter ? [adapter] : [])],
201
+ devDependencies: ['@motujs/debug-overlay'],
202
+ };
203
+ const added = [];
204
+ for (const [field, names] of Object.entries(wanted)) {
205
+ for (const name of names) {
206
+ if (pkg.dependencies?.[name] || pkg.devDependencies?.[name] || pkg.peerDependencies?.[name]) continue;
207
+ (pkg[field] ??= {})[name] = range;
208
+ added.push(`${field}: ${name}@${range}`);
209
+ }
210
+ }
211
+ if (added.length) writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
212
+ return added;
213
+ }
214
+
215
+ function writeNew(path, contents, created, skipped) {
216
+ if (existsSync(path)) {
217
+ skipped.push(path);
218
+ return false;
219
+ }
220
+ mkdirSync(resolve(path, '..'), { recursive: true });
221
+ writeFileSync(path, contents);
222
+ created.push(path);
223
+ return true;
224
+ }
225
+
226
+ export async function initCommand(argv) {
227
+ const root = resolve(process.cwd(), argv._[0] ?? '.');
228
+ const host = argv.host === true || argv.host === undefined ? 'none' : String(argv.host);
229
+ if (!HOSTS.has(host)) {
230
+ console.error(color.red(`✗ unknown --host '${host}' — expected one of: ${[...HOSTS].join(', ')}`));
231
+ process.exit(2);
232
+ }
233
+
234
+ const configPath = resolve(root, 'motu.config.json');
235
+ if (existsSync(configPath) && !argv.force) {
236
+ console.error(color.red(`✗ ${basename(root)}/motu.config.json already exists — use --force to overwrite`));
237
+ process.exit(1);
238
+ }
239
+
240
+ // Layout. `app` is the sub-package holding islands/ui/archipelagos; `hostRoot` is where the host
241
+ // application lives (for `next`, the Next app whose aliases/Tailwind the lagoon borrows).
242
+ const appRel = argv.app ? String(argv.app) : '.';
243
+ const hostRel = argv.hostRoot ? String(argv.hostRoot) : appRel;
244
+ const appRoot = resolve(root, appRel);
245
+ const hostRoot = resolve(root, hostRel);
246
+ const lagoonRel = 'roots/lagoon';
247
+ const lagoonDir = resolve(appRoot, lagoonRel);
248
+ const appPackage = argv.appPackage ? String(argv.appPackage) : basename(appRoot) === '.' ? 'motu-app' : basename(appRoot);
249
+
250
+ // Islands mount directly in a React host — shadow DOM would cut them off from the host's own
251
+ // stylesheet (Tailwind included), which is the opposite of what a Next island wants.
252
+ const isolation = argv.isolation ? String(argv.isolation) : host === 'angularjs' ? 'shadow' : 'light';
253
+
254
+ const created = [];
255
+ const skipped = [];
256
+ mkdirSync(root, { recursive: true });
257
+
258
+ const config = {
259
+ app: appRel,
260
+ host,
261
+ hostRoot: hostRel,
262
+ islands: 'src/islands',
263
+ ui: 'src/ui',
264
+ archipelagos: 'src/archipelagos',
265
+ shared: 'src/shared',
266
+ contract: 'contract/src',
267
+ lagoon: lagoonRel,
268
+ ...(host === 'angularjs' ? { bridge: 'roots/bridge' } : {}),
269
+ appPackage,
270
+ tagPrefix: 'x-',
271
+ isolation,
272
+ // NO `motuRoot`, EVER. It used to be written here as "the only machine-specific path in the
273
+ // project" — a committed relative path to somebody's checkout, which is exactly what breaks on the
274
+ // second machine and in CI. The CLI derives it from the binary that is running (see
275
+ // FRAMEWORK_ROOT in lib/config.mjs), so the correct value is never worth committing.
276
+ //
277
+ // It used to still be written when $MOTU_ROOT was set for this init, on the reasoning that a
278
+ // deliberate arrangement is worth recording. That was backwards: it took an override that was
279
+ // correct for ONE machine and froze it into the file every other machine reads. $MOTU_ROOT stays
280
+ // in the environment, where whoever needs it sets it.
281
+ };
282
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
283
+ created.push(configPath);
284
+
285
+ // --- the app package: registries, barrel, the shared island sheet ---------------------------
286
+ // INSTALLED FROM NPM, the range is the CLI's own version: the set is released in lockstep, so the
287
+ // CLI that is running is the statement of which `@motujs/*` it was built against.
288
+ const motuDep = argv.motuDep ? String(argv.motuDep) : INSTALLED ? `^${CLI_VERSION}` : 'workspace:*';
289
+ // `workspace:*` IS A LIE IN A STANDALONE PROJECT, and an expensive one: the generated package.json
290
+ // listed three @motujs packages that no workspace resolves, so `bun install` — and therefore
291
+ // `bun add react` — failed outright in a freshly-initialised project. A greenfield adopter could
292
+ // not install their first dependency.
293
+ //
294
+ // The packages do not belong there anyway. Nothing resolves @motujs/* through node: the lagoon aliases
295
+ // them and tsconfig `paths` maps them, which is the whole "no install step" posture. So they are
296
+ // declared only when a workspace above the project can actually satisfy them.
297
+ const inWorkspace = (() => {
298
+ let dir = appRoot;
299
+ for (let up = 0; up < 6; up++) {
300
+ const parent = dirname(dir);
301
+ if (parent === dir) break;
302
+ dir = parent;
303
+ if (existsSync(resolve(dir, 'pnpm-workspace.yaml'))) return true;
304
+ try {
305
+ const pkg = JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8'));
306
+ if (pkg.workspaces) return true;
307
+ } catch {}
308
+ }
309
+ return false;
310
+ })();
311
+ const motuDependencies =
312
+ inWorkspace || argv.motuDep || INSTALLED
313
+ ? ` "dependencies": {\n "@motujs/core": "${motuDep}",\n "@motujs/react": "${motuDep}",` +
314
+ `\n "@motujs/runtime": "${motuDep}"${adapterDepFor(motuDep, host)}\n },\n`
315
+ : '';
316
+ // 'vite' contributes at BUILD time (packages/adapters/vite/vite.mjs), not as a runtime package,
317
+ // so it needs no adapter dependency — the same as 'none'.
318
+ const adapterPkg = host === 'angularjs' ? '@motujs/adapter-angularjs' : host === 'next' ? '@motujs/adapter-next' : '';
319
+ const adapterDep = adapterPkg ? `,\n "${adapterPkg}": "${motuDep}"` : '';
320
+ function adapterDepFor(dep, h) {
321
+ const pkg = h === 'angularjs' ? '@motujs/adapter-angularjs' : h === 'next' ? '@motujs/adapter-next' : '';
322
+ return pkg ? `,\n "${pkg}": "${dep}"` : '';
323
+ }
324
+
325
+ writeNew(
326
+ resolve(appRoot, 'package.json'),
327
+ render(APP_PACKAGE_JSON, { appPackage, motuDep, adapterDep, motuDependencies }),
328
+ created,
329
+ skipped,
330
+ );
331
+ // INSTALLED, AN EXISTING package.json IS EDITED, not just reported on. In checkout mode the block
332
+ // could only ever be a `workspace:*` or a path into somebody's clone, so a printed notice was the
333
+ // honest move (see keptPackageJsonNotice). Installed, it is `^<version>` of packages that are on the
334
+ // registry — exactly what `npm i` would have written — and the adopter has just run
335
+ // `npm i -D @motujs/cli`, which already said yes to motu in this manifest. Leaving them out is what
336
+ // would break: the islands import `@motujs/react` from the app's own code, and under pnpm (or after
337
+ // npm stops hoisting a transitive) nothing the app declares would provide it.
338
+ const recorded = INSTALLED ? recordInstalledDeps(resolve(appRoot, 'package.json'), motuDep, host) : null;
339
+ writeNew(resolve(appRoot, 'src/islands/registry.ts'), ISLANDS_REGISTRY, created, skipped);
340
+ writeNew(resolve(appRoot, 'src/archipelagos/registry.ts'), ARCHIPELAGOS_REGISTRY, created, skipped);
341
+ writeNew(resolve(appRoot, 'src/index.ts'), BARREL, created, skipped);
342
+ writeNew(resolve(appRoot, 'src/shared/styles.css'), SHARED_STYLES, created, skipped);
343
+ writeNew(resolve(appRoot, 'src/ui/.gitkeep'), '', created, skipped);
344
+ writeNew(resolve(root, '.gitignore'), GITIGNORE, created, skipped);
345
+
346
+ // --- the lagoon root: where `motu island verify` closes the loop ------------------------------
347
+ if (argv.lagoon !== false) {
348
+ const vars = {
349
+ appPackage,
350
+ motuDep,
351
+ adapterDep,
352
+ appDep: argv.motuDep ? relPosix(lagoonDir, appRoot) : 'workspace:*',
353
+ tagPrefix: 'x-',
354
+ // Baked at scaffold time: a glob cannot go through a tsconfig alias reliably.
355
+ fixturesGlob: `['${relPosix(resolve(lagoonDir, 'src'), resolve(appRoot, 'src/islands'))}/*.evidence.ts', '${relPosix(resolve(lagoonDir, 'src'), resolve(appRoot, 'src/islands'))}/*/fixtures.mock.ts']`,
356
+ configFromLagoon: relPosix(lagoonDir, configPath),
357
+ hostRootFromLagoon: relPosix(lagoonDir, hostRoot),
358
+ // Tailwind/autoprefixer are the host's toolchain, but the lagoon runs its own postcss pass.
359
+ lagoonExtraDevDeps: host === 'next' ? ',\n "autoprefixer": "^10.4.20",\n "tailwindcss": "^3.4.0"' : '',
360
+ viteHostImports: host === 'next' ? NEXT_VITE_IMPORTS : '',
361
+ // The app's own global stylesheet. Without it the lagoon has no Tailwind base/utilities and no
362
+ // theme tokens, so islands render unstyled — a preview that lies about how they look in the app.
363
+ hostGlobalCss: hostGlobalCssImport(lagoonDir, hostRoot),
364
+ // FIRST import in both entries: host modules read process.env while being imported.
365
+ // Only where there IS a shim to import: `src/env.ts` is written for the Next host alone, so
366
+ // every other host got an entry file importing a module that was never created.
367
+ envShim: host === 'next' ? "import '{{envImport}}';\n" : '',
368
+ // A Next/shadcn app exposes --primary / --primary-foreground; borrow them so motu's chrome
369
+ // wears the application's colour rather than motu's own.
370
+ lagoonChrome: host === 'next' ? LAGOON_CHROME_SHADCN : '',
371
+ // A React host renders islands in its own tree; the lagoon must do the same or it verifies a
372
+ // mount path the project never ships. An ocean keeps the custom elements.
373
+ lagoonMount: host === 'angularjs' ? '' : ' "mount": "react",\n',
374
+ motuAliases: motuAliases(lagoonDir, appRoot, appPackage, host),
375
+ hostAliases: host === 'next' ? render(NEXT_VITE_ALIASES, { hostRootFromLagoon: relPosix(lagoonDir, hostRoot) }) : '',
376
+ viteCss: host === 'next' ? render(NEXT_VITE_CSS, { hostRootFromLagoon: relPosix(lagoonDir, hostRoot) }) : '',
377
+ // The AngularJS ocean needs a host present for islands that read host scope; the React hosts
378
+ // need nothing — the store is the only seam.
379
+ hostImport: host === 'angularjs' ? "import { angularHostScopeChannel } from '@motujs/adapter-angularjs';\n" : '',
380
+ // Paths the entries import by. `init` writes into the lagoon root, so these are the plain
381
+ // relative forms; `motu lagoon dev|build` renders the SAME templates into .motu/cache and
382
+ // overrides them with paths computed from there (see lib/lagoon-materialize.mjs).
383
+ lagoonConfigImport: '../lagoon.config.json',
384
+ overridesImport: './lagoon.js',
385
+ fixturesImport: './fixtures.js',
386
+ framesGlob: './frames/*.css',
387
+ galleryEntrySrc: '/src/main.tsx',
388
+ focusEntrySrc: '/src/lagoon.tsx',
389
+ envImport: './env.js',
390
+ hostOption: '',
391
+ hostOptionInline: '',
392
+ };
393
+
394
+ // ENTRIES ARE NOT SCAFFOLDED, and the reason is a name collision the old layout could not survive.
395
+ //
396
+ // It wrote the focus ENTRY to `src/lagoon.tsx` and the OVERRIDES stub to `src/lagoon.ts`, and
397
+ // writing `index.html` made the project "own" its lagoon — which turns off materialization, so
398
+ // those two files became the ones Vite actually serves. But a region's `layout` override returns
399
+ // JSX, so overrides must live in a `.tsx`, which is the entry's name. Following the scaffold
400
+ // produced a lagoon that served the overrides file as its entry and rendered an empty div.
401
+ //
402
+ // acme never hit it because acme has no index.html: it materializes, and its `src/lagoon.tsx` is
403
+ // the overrides. That is the working shape, so it is now the shape `init` creates — entries,
404
+ // index.html and the vite config are rendered into `.motu/cache` by `motu lagoon dev|build`,
405
+ // which is where generated code belongs anyway.
406
+ // NEITHER IS `src/fixtures.ts`, for the same reason, and it used to be written here anyway.
407
+ // The materializer renders it — with `fixturesGlob` AND `flowsGlob`, which only it computes — so
408
+ // init's copy was a duplicate that also could not parse: `vars` here has no `flowsGlob`, so the
409
+ // file went out carrying a literal `{{flowsGlob}}` and any project that typechecked its lagoon
410
+ // directory failed on it. Neither acme nor the review console has the file at all, which is why
411
+ // nothing noticed. `motu lagoon eject` materializes into the project's own lagoon dir, so the
412
+ // rendered version is what a project gets the moment it wants one on disk.
413
+ // Declared, not coded: what the lagoon IS lives here; what it DOES lives in @motujs/react.
414
+ writeNew(resolve(lagoonDir, 'lagoon.config.json'), render(LAGOON_CONFIG, vars), created, skipped);
415
+ writeNew(resolve(lagoonDir, 'src/lagoon.tsx'), LAGOON_OVERRIDES, created, skipped);
416
+ if (host === 'next') {
417
+ writeNew(resolve(lagoonDir, 'src/next-stubs.tsx'), NEXT_STUBS, created, skipped);
418
+ // RENDERED, like every other template here. This one was written RAW, so the scaffolded
419
+ // `env.ts` kept its `{{lagoonConfigImport}}` placeholder verbatim and the file could not
420
+ // compile — invisible until something typechecked `roots/`, which nothing did.
421
+ writeNew(
422
+ resolve(lagoonDir, 'src/env.ts'),
423
+ render(ENV_SHIM, { lagoonConfigImport: '../lagoon.config.json' }),
424
+ created,
425
+ skipped,
426
+ );
427
+ // ONLY IF THE HOST HAS TAILWIND. This file's whole body is `import hostConfig from
428
+ // '../../../tailwind.config'`, so writing it for a Next app that styles itself any other way
429
+ // scaffolds a module that cannot resolve. @motujs/adapter-next makes the same check before it
430
+ // installs the postcss plugin; a Next host is not a Tailwind host.
431
+ if (TAILWIND_CONFIGS.some((f) => existsSync(resolve(hostRoot, f))))
432
+ writeNew(resolve(lagoonDir, 'tailwind.config.ts'), render(NEXT_TAILWIND_CONFIG, vars), created, skipped);
433
+ }
434
+ }
435
+
436
+ // The rules the HOST's agent has to follow — shipped with motu so they cannot drift from what the
437
+ // CLI enforces, written into the instruction files the repo already keeps.
438
+ //
439
+ // BEFORE the `--json` branch, not after. It used to sit below it, so `motu init --json` scaffolded
440
+ // the project and silently skipped the rules — and `--json` is exactly the invocation a script or a
441
+ // CI image uses, which is the one place nobody reads the output to notice they are missing.
442
+ const rules = applyHostRules(hostRoot);
443
+ // AND THE SKILLS THEY POINT AT. The rules block is short on purpose — the invariants, and which skill
444
+ // holds the detail (motu-regions, motu-evidence, motu-checks). A project that got the block without
445
+ // the skills would be told to load something it does not have. Same root as the rules: the host's,
446
+ // where its agent reads both. Never over a skill the project changed (reported, not overwritten).
447
+ const { out: skillsOut } = installSkills(hostRoot);
448
+
449
+ if (argv.json) {
450
+ console.log(JSON.stringify({ root, host, appPackage, created, skipped, rules, skills: skillsOut }, null, 2));
451
+ return;
452
+ }
453
+
454
+ console.log(color.green(`✓ initialized motu project in ${basename(root)}/`) + color.dim(` (host: ${host})`));
455
+ for (const p of created) console.log(' ' + color.dim(relPosix(root, p).replace(/^\.\//, '')));
456
+ for (const p of skipped) console.log(' ' + color.dim(relPosix(root, p).replace(/^\.\//, '')) + color.yellow(' (kept)'));
457
+ for (const p of rules) console.log(' ' + color.dim(p) + color.dim(' (motu rules block)'));
458
+ const skillFiles = skillsOut.created.length + skillsOut.updated.length;
459
+ if (skillFiles) console.log(' ' + color.dim(`${skillFiles} skill file(s) — the detail the rules block points at (.claude/skills, .github/agents)`));
460
+ for (const p of skillsOut.skipped) console.log(' ' + color.dim(p) + color.yellow(' (kept — differs from motu\'s; `motu skills install --force` to update)'));
461
+ console.log('');
462
+ if (recorded?.length) {
463
+ console.log(color.yellow(` ! recorded ${recorded.length} @motujs/* package(s) in package.json — run your package manager once:`));
464
+ for (const r of recorded) console.log(color.dim(` ${r}`));
465
+ console.log('');
466
+ console.log(' npm install');
467
+ console.log('');
468
+ } else if (!INSTALLED) keptPackageJsonNotice(appRoot, skipped, motuDependencies);
469
+ if (!LEGACY_FIT_HOSTS.has(host)) {
470
+ console.log(color.dim(' legacy fit is off for this host — islands mount directly, there is no legacy skin to fit.'));
471
+ }
472
+ console.log('Next: ' + color.bold('motu archipelago init <id> --page <file>') + color.dim(' then ') + color.bold('motu island create <name>'));
473
+ }