@motujs/cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/bundled/github/agents/archipelago-build.agent.md +298 -0
- package/bundled/github/agents/island-create.agent.md +261 -0
- package/bundled/github/agents/island-extract.agent.md +360 -0
- package/bundled/github/agents/island-locate.agent.md +208 -0
- package/bundled/github/agents/motu-checks.agent.md +158 -0
- package/bundled/github/agents/motu-evidence.agent.md +267 -0
- package/bundled/github/agents/motu-regions.agent.md +304 -0
- package/bundled/github/host-rules.md +43 -0
- package/bundled/github/prompts/archipelago-build.prompt.md +19 -0
- package/bundled/github/prompts/island-create.prompt.md +37 -0
- package/bundled/github/prompts/island-extract.prompt.md +59 -0
- package/bundled/github/prompts/island-locate.prompt.md +43 -0
- package/bundled/packages/adapters/angularjs/vite.mjs +43 -0
- package/bundled/packages/adapters/next/lagoon/next-stubs.tsx +108 -0
- package/bundled/packages/adapters/next/vite.mjs +121 -0
- package/bundled/packages/adapters/vite/vite.mjs +179 -0
- package/package.json +64 -3
- package/src/alias-loader.mjs +63 -0
- package/src/alias-register.mjs +28 -0
- package/src/asset-stub.cjs +3 -0
- package/src/asset-stub.mjs +5 -0
- package/src/cli.mjs +78 -0
- package/src/commands/adopt-root.mjs +194 -0
- package/src/commands/archipelago-init.mjs +314 -0
- package/src/commands/archipelago.mjs +138 -0
- package/src/commands/assurance.mjs +40 -0
- package/src/commands/check.mjs +346 -0
- package/src/commands/codegen.mjs +29 -0
- package/src/commands/coverage.mjs +358 -0
- package/src/commands/create.mjs +277 -0
- package/src/commands/defaults.mjs +129 -0
- package/src/commands/diff.mjs +595 -0
- package/src/commands/fixtures.mjs +153 -0
- package/src/commands/init.mjs +473 -0
- package/src/commands/integrate.mjs +208 -0
- package/src/commands/integration.mjs +1349 -0
- package/src/commands/lagoon-try.mjs +272 -0
- package/src/commands/lagoon.mjs +1419 -0
- package/src/commands/link.mjs +110 -0
- package/src/commands/region-coverage.mjs +604 -0
- package/src/commands/removal-check.mjs +878 -0
- package/src/commands/skills.mjs +156 -0
- package/src/commands/snapshot.mjs +456 -0
- package/src/commands/sync.mjs +38 -0
- package/src/commands/verify/island-runtime.mjs +581 -0
- package/src/commands/verify/island-static.mjs +586 -0
- package/src/commands/verify/island.mjs +135 -0
- package/src/commands/verify/reach.mjs +426 -0
- package/src/commands/verify/region-composition.mjs +391 -0
- package/src/commands/verify/region-config.mjs +330 -0
- package/src/commands/verify/region-flows.mjs +760 -0
- package/src/commands/verify/region-sources.mjs +516 -0
- package/src/commands/verify/region.mjs +271 -0
- package/src/commands/verify/report.mjs +338 -0
- package/src/commands/verify/screens-runtime.mjs +123 -0
- package/src/commands/verify.mjs +18 -0
- package/src/lagoon-build.mjs +25 -0
- package/src/lagoon-dev.mjs +32 -0
- package/src/lib/alias-core.mjs +49 -0
- package/src/lib/archipelagos.mjs +111 -0
- package/src/lib/assurance.mjs +144 -0
- package/src/lib/baselines.mjs +107 -0
- package/src/lib/bundlability.mjs +123 -0
- package/src/lib/changed.mjs +157 -0
- package/src/lib/component-props.mjs +145 -0
- package/src/lib/config.mjs +451 -0
- package/src/lib/contracts.mjs +244 -0
- package/src/lib/data-flow.mjs +47 -0
- package/src/lib/declared-sources.mjs +101 -0
- package/src/lib/dock-inject.mjs +47 -0
- package/src/lib/effects.mjs +97 -0
- package/src/lib/eject.mjs +715 -0
- package/src/lib/evidence-json.mjs +74 -0
- package/src/lib/expect-calls.mjs +53 -0
- package/src/lib/finding-memory.mjs +0 -0
- package/src/lib/flow-calls.mjs +53 -0
- package/src/lib/framework.mjs +98 -0
- package/src/lib/host-rules.mjs +55 -0
- package/src/lib/host-sources.mjs +121 -0
- package/src/lib/island-placement.mjs +64 -0
- package/src/lib/island-render.mjs +105 -0
- package/src/lib/islands.mjs +112 -0
- package/src/lib/lagoon-bundle.mjs +138 -0
- package/src/lib/lagoon-declares.mjs +553 -0
- package/src/lib/lagoon-materialize.mjs +217 -0
- package/src/lib/lagoon-vite.mjs +788 -0
- package/src/lib/live-url.mjs +53 -0
- package/src/lib/node-aliases.mjs +73 -0
- package/src/lib/operations.mjs +145 -0
- package/src/lib/provenance-plugin.mjs +74 -0
- package/src/lib/region-refs.mjs +40 -0
- package/src/lib/remote.mjs +171 -0
- package/src/lib/scaffold.mjs +606 -0
- package/src/lib/screens.mjs +266 -0
- package/src/lib/snapshots.mjs +85 -0
- package/src/lib/stubs.mjs +114 -0
- package/src/lib/ts-project.mjs +42 -0
- package/src/lib/util.mjs +626 -0
- package/src/lib/worlds.mjs +236 -0
- package/src/lib/write-if-changed.mjs +35 -0
- package/src/lib/writes-coverage.mjs +32 -0
- package/src/playwright-lagoon.mjs +2305 -0
- package/src/run.mjs +385 -0
- package/src/runtime-harness.mjs +225 -0
- package/README.md +0 -3
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
// `motu coverage init | doctor | pull` — production coverage that reaches the host, on any stack.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS. Coverage is the one part of motu that runs in production, and on the first project
|
|
4
|
+
// that switched it on the host never stored a single corpus. Nothing said so: setup took four values
|
|
5
|
+
// in a production environment, a token minted by hand on the host, two route files and two meta tags,
|
|
6
|
+
// and any one of them missing gave silence. These three verbs are the setup, the proof, and the way
|
|
7
|
+
// round a production server that cannot reach the host at all.
|
|
8
|
+
//
|
|
9
|
+
// init write the route for this host stack, mint the ingest token over the host's admin API, and
|
|
10
|
+
// print the ONE remaining step in that platform's terms
|
|
11
|
+
// doctor send a synthetic report through the DEPLOYED route and name the hop that fails
|
|
12
|
+
// pull fold `motu-coverage {…}` lines from a log (the route's `log` mode) and upload them with
|
|
13
|
+
// this machine's credentials — for a server with no outbound network
|
|
14
|
+
//
|
|
15
|
+
// An agent runs all three: `init` during integration, `doctor` after every deploy, `pull` from CI.
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { dirname, resolve } from 'node:path';
|
|
19
|
+
import { mergeCorpora, keysHash } from '@motujs/coverage';
|
|
20
|
+
import { color, paths, REPO_ROOT } from '../lib/util.mjs';
|
|
21
|
+
import { loadMotuConfig } from '../lib/config.mjs';
|
|
22
|
+
import { readRegions } from '../lib/eject.mjs';
|
|
23
|
+
import { gitIdentity, loadHostConfig } from '../lib/remote.mjs';
|
|
24
|
+
|
|
25
|
+
/** The full config: the curated `paths` export carries neither the host stack nor its root. */
|
|
26
|
+
const full = loadMotuConfig();
|
|
27
|
+
|
|
28
|
+
const DEFAULT_ENDPOINT = '/api/motu/coverage';
|
|
29
|
+
/** Where the Java servlet in `dev.motu:endpoint` answers, relative to the application's context. */
|
|
30
|
+
const JAVA_ENDPOINT = '/motu/coverage';
|
|
31
|
+
|
|
32
|
+
export async function coverageCommand(sub, argv) {
|
|
33
|
+
if (sub === 'doctor') return doctor(argv);
|
|
34
|
+
if (sub === 'pull') return pull(argv);
|
|
35
|
+
if (sub === 'init') return init(argv);
|
|
36
|
+
console.error(color.red(`unknown: motu coverage ${sub ?? ''}`) + color.dim(' — init | doctor | pull'));
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hostTarget(argv) {
|
|
41
|
+
const cfg = loadHostConfig();
|
|
42
|
+
const url = (typeof argv.host === 'string' ? argv.host : process.env.MOTU_HOST_URL || cfg.url || '').replace(/\/+$/, '');
|
|
43
|
+
const token = (typeof argv.token === 'string' ? argv.token : process.env.MOTU_HOST_TOKEN || cfg.token) || null;
|
|
44
|
+
return { url, token };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function repoName() {
|
|
48
|
+
return paths.publishAs?.repo ?? gitIdentity(REPO_ROOT).repo;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function regionIds() {
|
|
52
|
+
return readRegions(paths.archipelagosDir).map((r) => r.id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── doctor ──────────────────────────────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Send one synthetic report through the deployed route, and say which hop failed.
|
|
59
|
+
*
|
|
60
|
+
* The report is marked (`x-motu-doctor: 1`): the route forwards it as a DRY RUN, which the host
|
|
61
|
+
* checks (token, repo) and does not store, or logs it under a different prefix, which `pull` skips.
|
|
62
|
+
* So running this against production changes nothing a person will later read.
|
|
63
|
+
*
|
|
64
|
+
* EXIT CODES carry the usual meaning: 1 is a finding about the deployment (repair it), 2 is that the
|
|
65
|
+
* application could not be reached from here at all (retry, or run it from somewhere that can).
|
|
66
|
+
*/
|
|
67
|
+
async function doctor(argv) {
|
|
68
|
+
const json = argv.json === true;
|
|
69
|
+
const base = typeof argv.url === 'string' ? argv.url.replace(/\/+$/, '') : '';
|
|
70
|
+
if (!/^https?:\/\//.test(base)) {
|
|
71
|
+
console.error(color.red('✗ --url <the application’s origin> is required, e.g. --url https://app.example.com'));
|
|
72
|
+
return 2;
|
|
73
|
+
}
|
|
74
|
+
const endpoint = typeof argv.endpoint === 'string' ? argv.endpoint : full.host === 'angularjs' ? JAVA_ENDPOINT : DEFAULT_ENDPOINT;
|
|
75
|
+
const region = typeof argv.region === 'string' ? argv.region : regionIds()[0];
|
|
76
|
+
if (!region) {
|
|
77
|
+
console.error(color.red('✗ this project declares no region to name in the report — pass --region <id>'));
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
const target = `${base}${endpoint}`;
|
|
81
|
+
const report = { v: 1, regionId: region, keys: [], entries: [] };
|
|
82
|
+
const say = (ok, hop, detail, code) => {
|
|
83
|
+
if (json) console.log(JSON.stringify({ ok, hop, detail, target, region }, null, 2));
|
|
84
|
+
else {
|
|
85
|
+
console.log(`${ok ? color.green('✓') : color.red('✗')} ${color.bold(hop)} ${color.dim('·')} ${detail}`);
|
|
86
|
+
console.log(color.dim(` ${target} (region ${region})`));
|
|
87
|
+
}
|
|
88
|
+
return code;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(target, {
|
|
94
|
+
method: 'POST',
|
|
95
|
+
headers: { 'content-type': 'application/json', 'x-motu-doctor': '1' },
|
|
96
|
+
body: JSON.stringify(report),
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return say(false, 'application unreachable', `could not reach ${base} from this machine (${err?.cause?.code ?? err?.message ?? err})`, 2);
|
|
100
|
+
}
|
|
101
|
+
const took = res.headers.get('x-motu-coverage');
|
|
102
|
+
let body = null;
|
|
103
|
+
try {
|
|
104
|
+
body = await res.json();
|
|
105
|
+
} catch {
|
|
106
|
+
body = null;
|
|
107
|
+
}
|
|
108
|
+
const error = body?.error ?? '';
|
|
109
|
+
if (res.status === 404) return say(false, 'route missing', `nothing answers at ${endpoint} — \`motu coverage init\` writes the route`, 1);
|
|
110
|
+
if (res.status === 405) return say(false, 'route refuses POST', `${endpoint} exists but answers 405 to a POST — is it the coverage route?`, 1);
|
|
111
|
+
if (res.status === 503) return say(false, 'route not configured', error || 'the route answered 503', 1);
|
|
112
|
+
if (res.status === 400) return say(false, 'report refused by the route', error || 'the route answered 400', 1);
|
|
113
|
+
if (res.status === 502) {
|
|
114
|
+
const hop = /401|token/.test(error) ? 'host refused the token' : /unreachable/.test(error) ? 'host unreachable from the application' : 'host answered an error';
|
|
115
|
+
return say(false, hop, error || 'the route answered 502', 1);
|
|
116
|
+
}
|
|
117
|
+
if (res.ok && took === 'forwarded') return say(true, 'forwarded', 'the route reached the motu host, which accepted the token (dry run — nothing stored)', 0);
|
|
118
|
+
if (res.ok && took === 'logged') {
|
|
119
|
+
return say(true, 'logged', 'the route is in log mode: look for one `motu-coverage-doctor {…}` line in that server’s log, then collect real lines with `motu coverage pull`', 0);
|
|
120
|
+
}
|
|
121
|
+
if (res.ok) {
|
|
122
|
+
return say(false, 'route too old to say', `the route answered ${res.status} without \`x-motu-coverage\` — update @motujs/coverage (or the Java endpoint) so this can tell forwarded from dropped`, 1);
|
|
123
|
+
}
|
|
124
|
+
return say(false, 'unexpected answer', `the route answered ${res.status}${error ? `: ${error}` : ''}`, 1);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── pull ────────────────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
const LINE = /(?:^|\s)motu-coverage (\{.*\})\s*$/;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fold `motu-coverage {…}` lines out of a log and upload them to the host.
|
|
133
|
+
*
|
|
134
|
+
* For a server in `log` mode: it cannot reach the host (a JBoss behind a datacentre's egress
|
|
135
|
+
* firewall) or should not hold a secret. Its log is collected somewhere already — a file, a CI
|
|
136
|
+
* artifact, an ELK or Splunk export — and this runs wherever that is, with this machine's own host
|
|
137
|
+
* credentials.
|
|
138
|
+
*
|
|
139
|
+
* PULLED LINES ARE REMEMBERED (by hash, in `.motu/cache/coverage-pulled.json`) so pulling the same log
|
|
140
|
+
* twice does not count its states twice. Pulling it from two machines would; pull from one place.
|
|
141
|
+
*/
|
|
142
|
+
async function pull(argv) {
|
|
143
|
+
const json = argv.json === true;
|
|
144
|
+
const from = typeof argv.from === 'string' ? argv.from : null;
|
|
145
|
+
if (!from) {
|
|
146
|
+
console.error(color.red('✗ --from <log file> is required (`-` reads stdin: `kubectl logs … | motu coverage pull --from -`)'));
|
|
147
|
+
return 2;
|
|
148
|
+
}
|
|
149
|
+
let text;
|
|
150
|
+
try {
|
|
151
|
+
text = from === '-' ? readFileSync(0, 'utf8') : readFileSync(resolve(process.cwd(), from), 'utf8');
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.error(color.red(`✗ could not read ${from}: ${err.message}`));
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const seenFile = process.env.MOTU_COVERAGE_PULL_STATE || resolve(full.cacheDir ?? resolve(REPO_ROOT, '.motu/cache'), 'coverage-pulled.json');
|
|
158
|
+
let seen = new Set();
|
|
159
|
+
try {
|
|
160
|
+
seen = new Set(JSON.parse(readFileSync(seenFile, 'utf8')));
|
|
161
|
+
} catch {
|
|
162
|
+
/* first pull */
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const groups = new Map();
|
|
166
|
+
let lines = 0;
|
|
167
|
+
let malformed = 0;
|
|
168
|
+
let already = 0;
|
|
169
|
+
const fresh = [];
|
|
170
|
+
for (const raw of text.split('\n')) {
|
|
171
|
+
const m = LINE.exec(raw);
|
|
172
|
+
if (!m) continue;
|
|
173
|
+
lines++;
|
|
174
|
+
const h = createHash('sha256').update(m[1]).digest('hex').slice(0, 32);
|
|
175
|
+
if (seen.has(h)) {
|
|
176
|
+
already++;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
let corpus;
|
|
180
|
+
try {
|
|
181
|
+
corpus = JSON.parse(m[1]);
|
|
182
|
+
} catch {
|
|
183
|
+
malformed++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (!corpus || typeof corpus.regionId !== 'string' || !Array.isArray(corpus.keys) || !Array.isArray(corpus.entries)) {
|
|
187
|
+
malformed++;
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
// Grouped by DECLARATION, not just region: two key sets are not comparable, and `mergeCorpora`
|
|
191
|
+
// refuses to fold them rather than produce nonsense.
|
|
192
|
+
const k = `${corpus.regionId}\u0000${corpus.keysHash ?? keysHash(corpus.keys)}`;
|
|
193
|
+
if (!groups.has(k)) groups.set(k, []);
|
|
194
|
+
groups.get(k).push(corpus);
|
|
195
|
+
fresh.push(h);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!lines) {
|
|
199
|
+
// A PULL THAT FOUND NOTHING is almost always the wrong file, or a route not in log mode — never a
|
|
200
|
+
// success to report quietly.
|
|
201
|
+
const msg = `no \`motu-coverage {…}\` lines in ${from} — is the route in log mode, and is this that server’s log?`;
|
|
202
|
+
if (json) console.log(JSON.stringify({ ok: false, error: msg, lines: 0 }, null, 2));
|
|
203
|
+
else console.error(color.red(`✗ ${msg}`));
|
|
204
|
+
return 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const host = hostTarget(argv);
|
|
208
|
+
const repo = repoName();
|
|
209
|
+
const dry = argv['dry-run'] === true;
|
|
210
|
+
if (!dry && (!host.url || !host.token)) {
|
|
211
|
+
console.error(color.red('✗ no lagoon host to upload to — set MOTU_HOST_URL / MOTU_HOST_TOKEN or ~/.config/motu/host.json (or pass --dry-run)'));
|
|
212
|
+
return 2;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const list of groups.values()) {
|
|
217
|
+
const merged = mergeCorpora(list);
|
|
218
|
+
const entry = { region: merged.regionId, reports: list.length, states: merged.entries.length };
|
|
219
|
+
if (!dry) {
|
|
220
|
+
const res = await fetch(`${host.url}/api/coverage?repo=${encodeURIComponent(repo)}®ion=${encodeURIComponent(merged.regionId)}`, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: { authorization: `Bearer ${host.token}`, 'content-type': 'application/json' },
|
|
223
|
+
body: JSON.stringify(merged),
|
|
224
|
+
}).catch((err) => ({ ok: false, status: 0, statusText: String(err?.cause?.code ?? err?.message ?? err) }));
|
|
225
|
+
entry.uploaded = !!res.ok;
|
|
226
|
+
if (!res.ok) entry.error = `host answered ${res.status || res.statusText}`;
|
|
227
|
+
}
|
|
228
|
+
results.push(entry);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const failed = results.filter((r) => r.uploaded === false);
|
|
232
|
+
if (!dry && !failed.length) {
|
|
233
|
+
mkdirSync(dirname(seenFile), { recursive: true });
|
|
234
|
+
// Bounded: the newest 50 000 line hashes are plenty to stop a re-pull of last week's logs.
|
|
235
|
+
writeFileSync(seenFile, JSON.stringify([...seen, ...fresh].slice(-50_000)));
|
|
236
|
+
}
|
|
237
|
+
if (json) {
|
|
238
|
+
console.log(JSON.stringify({ ok: !failed.length, repo, lines, already, malformed, regions: results, dryRun: dry }, null, 2));
|
|
239
|
+
} else {
|
|
240
|
+
for (const r of results) {
|
|
241
|
+
const mark = r.uploaded === false ? color.red('✗') : color.green('✓');
|
|
242
|
+
console.log(`${mark} ${r.region} ${r.states} state(s) from ${r.reports} report(s)${r.error ? color.red(` — ${r.error}`) : dry ? color.dim(' (dry run)') : ''}`);
|
|
243
|
+
}
|
|
244
|
+
console.log(color.dim(` ${lines} line(s) read · ${already} already pulled · ${malformed} malformed · repo ${repo}`));
|
|
245
|
+
}
|
|
246
|
+
return failed.length ? 1 : 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── init ────────────────────────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
const NEXT_ROUTE = (host, mode) => `// Where a motu coverage beacon lands. Written by \`motu coverage init\`; see @motujs/coverage/server.
|
|
252
|
+
//
|
|
253
|
+
// ${mode === 'log' ? 'LOG MODE: each report becomes one `motu-coverage {…}` line in this server\'s log, and\n// `motu coverage pull --from <log>` uploads them. No secret, no outbound network.' : 'FORWARD MODE: each report goes to the motu host with MOTU_COVERAGE_TOKEN, the one secret this\n// needs. The host URL is not a secret, so it is written here; the repo is read from the token.'}
|
|
254
|
+
import { createCoverageRoute } from '@motujs/coverage/server/next';
|
|
255
|
+
|
|
256
|
+
export const dynamic = 'force-dynamic';
|
|
257
|
+
|
|
258
|
+
export const POST = createCoverageRoute({ ${mode === 'log' ? "mode: 'log'" : `host: ${JSON.stringify(host)}`} });
|
|
259
|
+
`;
|
|
260
|
+
|
|
261
|
+
const NEXT_KNOWN = (host) => `// The states somebody accepted, served back so the beacon stays quiet about them. Written by
|
|
262
|
+
// \`motu coverage init\`. The ingest token reads this set of its own repo; nothing else is needed.
|
|
263
|
+
import { createKnownRoute } from '@motujs/coverage/server/next/known';
|
|
264
|
+
|
|
265
|
+
export const dynamic = 'force-dynamic';
|
|
266
|
+
|
|
267
|
+
export const GET = createKnownRoute({ host: ${JSON.stringify(host)} });
|
|
268
|
+
`;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Wire coverage for this project's host stack, as far as files in this repository can.
|
|
272
|
+
*
|
|
273
|
+
* What it writes is code (the route). What it cannot write is the production environment, so for
|
|
274
|
+
* that it prints exactly one step in the platform's own terms — a Vercel variable, a JBoss system
|
|
275
|
+
* property — and stops. `doctor` is how that step is proved afterwards.
|
|
276
|
+
*/
|
|
277
|
+
async function init(argv) {
|
|
278
|
+
const json = argv.json === true;
|
|
279
|
+
const mode = argv.mode === 'log' ? 'log' : 'forward';
|
|
280
|
+
const host = hostTarget(argv);
|
|
281
|
+
const repo = repoName();
|
|
282
|
+
const cfg = full;
|
|
283
|
+
const steps = [];
|
|
284
|
+
const wrote = [];
|
|
285
|
+
|
|
286
|
+
if (!cfg.coverage?.enabled) steps.push('switch the fold on: `"coverage": { "enabled": true }` in motu.config.json, then `motu sync`');
|
|
287
|
+
|
|
288
|
+
// 1. THE ROUTE, for the stacks motu knows.
|
|
289
|
+
let endpoint = DEFAULT_ENDPOINT;
|
|
290
|
+
if (full.host === 'next') {
|
|
291
|
+
const appDir = ['app', 'src/app'].map((d) => resolve(full.hostRoot, d)).find((d) => existsSync(d));
|
|
292
|
+
if (!appDir) {
|
|
293
|
+
steps.push(`no Next app router found under ${paths.rel(full.hostRoot)} — add a POST route at ${DEFAULT_ENDPOINT} exporting createCoverageRoute from @motujs/coverage/server/next`);
|
|
294
|
+
} else {
|
|
295
|
+
const routeFile = resolve(appDir, 'api/motu/coverage/route.ts');
|
|
296
|
+
const knownFile = resolve(appDir, 'api/motu/coverage/known/route.ts');
|
|
297
|
+
for (const [file, body] of [
|
|
298
|
+
[routeFile, NEXT_ROUTE(host.url, mode)],
|
|
299
|
+
...(mode === 'forward' ? [[knownFile, NEXT_KNOWN(host.url)]] : []),
|
|
300
|
+
]) {
|
|
301
|
+
if (existsSync(file) && argv.force !== true) {
|
|
302
|
+
steps.push(`${paths.rel(file)} already exists — kept. \`--force\` rewrites it to the one-secret form`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
306
|
+
writeFileSync(file, body);
|
|
307
|
+
wrote.push(paths.rel(file));
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
steps.push(`the root layout renders <meta name="motu-coverage-endpoint" content="${DEFAULT_ENDPOINT}" />${mode === 'forward' ? ` and <meta name="motu-coverage-known" content="${DEFAULT_ENDPOINT}/known" />` : ''} (skip if present)`);
|
|
311
|
+
} else if (full.host === 'angularjs') {
|
|
312
|
+
endpoint = JAVA_ENDPOINT;
|
|
313
|
+
steps.push('depend on dev.motu:endpoint in the WAR: its servlet registers itself at /motu/coverage through web-fragment.xml (no web.xml edit)');
|
|
314
|
+
steps.push(`the page renders <meta name="motu-coverage-endpoint" content="<context>${JAVA_ENDPOINT}" />`);
|
|
315
|
+
steps.push(mode === 'log' ? 'set the system property -Dmotu.coverage.mode=log (or MOTU_COVERAGE_MODE=log)' : `set -Dmotu.coverage.host=${host.url || '<host url>'} (not a secret)`);
|
|
316
|
+
} else {
|
|
317
|
+
steps.push(`this host (${full.host}) has no server motu writes for: deploy a POST route at ${DEFAULT_ENDPOINT} with @motujs/coverage/server's handleCoverage (Web-standard Request/Response — any JS runtime), or the Java servlet`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 2. THE ONE SECRET, minted over the admin API.
|
|
321
|
+
let token = null;
|
|
322
|
+
if (mode === 'forward') {
|
|
323
|
+
if (!host.url || !host.token) {
|
|
324
|
+
steps.push('mint an ingest token: no admin credentials here (~/.config/motu/host.json) — on the host, `motu-host access --repo ' + repo + ' --ingest`');
|
|
325
|
+
} else {
|
|
326
|
+
const res = await fetch(`${host.url}/api/access/ingest?repo=${encodeURIComponent(repo)}`, {
|
|
327
|
+
method: 'POST',
|
|
328
|
+
headers: { authorization: `Bearer ${host.token}` },
|
|
329
|
+
}).catch((err) => ({ ok: false, status: 0, json: async () => ({ error: String(err?.cause?.code ?? err) }) }));
|
|
330
|
+
const body = await res.json().catch(() => ({}));
|
|
331
|
+
if (!res.ok || !body.token) {
|
|
332
|
+
steps.push(`could not mint an ingest token (${res.status || 'unreachable'}: ${body.error ?? 'no detail'}) — on the host, \`motu-host access --repo ${repo} --ingest\``);
|
|
333
|
+
} else token = body.token;
|
|
334
|
+
}
|
|
335
|
+
const where = existsSync(resolve(full.hostRoot, 'vercel.json'))
|
|
336
|
+
? 'vercel env add MOTU_COVERAGE_TOKEN production (paste the token)'
|
|
337
|
+
: full.host === 'angularjs'
|
|
338
|
+
? '-Dmotu.coverage.token=<token> in the server’s JVM options (or MOTU_COVERAGE_TOKEN in its environment)'
|
|
339
|
+
: 'MOTU_COVERAGE_TOKEN=<token> in the production environment';
|
|
340
|
+
steps.push(`the ONE production step: ${where}`);
|
|
341
|
+
}
|
|
342
|
+
steps.push(`then prove it: motu coverage doctor --url <production origin>${endpoint !== DEFAULT_ENDPOINT ? ` --endpoint <context>${endpoint}` : ''}`);
|
|
343
|
+
|
|
344
|
+
if (json) {
|
|
345
|
+
console.log(JSON.stringify({ ok: true, mode, repo, host: host.url || null, wrote, token, steps }, null, 2));
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
console.log(`${color.green('✓')} coverage for ${color.bold(repo)} — ${mode} mode${host.url && mode === 'forward' ? color.dim(` → ${host.url}`) : ''}`);
|
|
349
|
+
for (const f of wrote) console.log(color.dim(` wrote ${f}`));
|
|
350
|
+
if (token) {
|
|
351
|
+
console.log('');
|
|
352
|
+
console.log(` ${color.bold('MOTU_COVERAGE_TOKEN')}=${token}`);
|
|
353
|
+
console.log(color.dim(' shown once; it can only WRITE coverage for this repo (and read back its accepted states).'));
|
|
354
|
+
}
|
|
355
|
+
console.log('');
|
|
356
|
+
steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`));
|
|
357
|
+
return 0;
|
|
358
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// `motu island create <name>` — scaffolds an island as ONE file, plus its ui component if motu owns it:
|
|
2
|
+
// ui/<kebab>/<Pascal>.tsx — the plain, mode-agnostic component (only when not wrapping the app's)
|
|
3
|
+
// islands/<kebab>.island.ts — the island: tag, component, and its declared boundary
|
|
4
|
+
// then regenerates the registry from what is on disk.
|
|
5
|
+
//
|
|
6
|
+
// It does NOT scaffold an evidence file. A `fixtures.mock.ts` full of `TODO(motu:fixtures)` is worse
|
|
7
|
+
// than no file: it looks like coverage, invites a hand-written response shape, and rots — six of them
|
|
8
|
+
// sat empty in the reference adopter for months. Evidence appears when `motu fixtures record` produces
|
|
9
|
+
// it, or when you write a scenario because you need one.
|
|
10
|
+
//
|
|
11
|
+
// The component lives OUTSIDE islands/ so mount points can never import each other. TODO(motu:*)
|
|
12
|
+
// markers are left for the extraction skill.
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, resolve } from 'node:path';
|
|
15
|
+
import { Project, QuoteKind, SyntaxKind } from 'ts-morph';
|
|
16
|
+
import { paths, names, color, FMT, resolveModuleSpecifier, LEGACY_FIT, islandComponentPath, islandComponentExport } from '../lib/util.mjs';
|
|
17
|
+
import { syncRegistry } from '../lib/islands.mjs';
|
|
18
|
+
import { syncContracts } from '../lib/contracts.mjs';
|
|
19
|
+
import { readComponentContract } from '../lib/component-props.mjs';
|
|
20
|
+
|
|
21
|
+
function componentSource(pascal, kebab) {
|
|
22
|
+
return `export interface ${pascal}Props {
|
|
23
|
+
/** TODO(motu:props): declare inputs. Every prop MUST have a default so the island renders in the
|
|
24
|
+
* lagoon from its defaults alone (verified by \`motu island verify\`). */
|
|
25
|
+
title?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ${pascal} — TODO(motu:body): implement this island.
|
|
30
|
+
*
|
|
31
|
+
* Rules enforced by \`motu island verify\` (see README "The rules that make islands verifiable"):
|
|
32
|
+
* - Mode-agnostic: no bare fetch, no history/pushState, no document reach-out into the host.
|
|
33
|
+
* - All server I/O goes through the generated @motujs/contract, never a raw transport.
|
|
34
|
+
* - Renders correctly from default props alone (the lagoon mounts it with none).
|
|
35
|
+
*/
|
|
36
|
+
export function ${pascal}({ title = '${pascal}' }: ${pascal}Props = {}) {
|
|
37
|
+
return (
|
|
38
|
+
<section className="motu-${kebab}">
|
|
39
|
+
<h2>{title}</h2>
|
|
40
|
+
<p className="motu-note">TODO(motu:body): build the ${kebab} island.</p>
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function elementSource(pascal, camel, tag, kebab, from, exportName, contract) {
|
|
48
|
+
// Only where the host HAS a legacy skin. Under a modern host it is not optional but absent —
|
|
49
|
+
// declaring it would be a field nothing reads (see `motu island verify`'s legacy-strategy rule).
|
|
50
|
+
const legacy = LEGACY_FIT ? "\n legacy: 'fill'," : '';
|
|
51
|
+
// `from` = wrapping a component the app already owns; otherwise the scaffolded ui/ component.
|
|
52
|
+
// One directory shallower than the old folder layout: the island file sits IN islands/.
|
|
53
|
+
const spec = from ?? `../ui/${kebab}/${pascal}.js`;
|
|
54
|
+
const header = from
|
|
55
|
+
? `// Mount point for ${pascal}: it wraps the application's OWN component rather than a copy, so the
|
|
56
|
+
// island cannot drift from what the app already ships. The component stays where it is and keeps
|
|
57
|
+
// being used directly elsewhere; this file only declares how it is mounted as an island.
|
|
58
|
+
`
|
|
59
|
+
: '';
|
|
60
|
+
|
|
61
|
+
// READ from the component when there is one to read. Every line below is a fact already in its
|
|
62
|
+
// source — transcribing it by hand is how an island's contract drifts from the component on its
|
|
63
|
+
// first day. What is left for a human is the part that is a decision: the event NAMES (renamed here
|
|
64
|
+
// to the region's vocabulary if it has a better word) and, in the archipelago, what they bind to.
|
|
65
|
+
const lines = [];
|
|
66
|
+
if (contract?.effects?.length) {
|
|
67
|
+
lines.push(' // AMBIENT — host capabilities this island reaches for without being handed them.');
|
|
68
|
+
lines.push(' effects: [');
|
|
69
|
+
for (const a of contract.effects) lines.push(` '${a}',`);
|
|
70
|
+
lines.push(' ],');
|
|
71
|
+
}
|
|
72
|
+
if (contract?.input?.length) {
|
|
73
|
+
lines.push(' input: [');
|
|
74
|
+
for (const i of contract.input) lines.push(` '${i}',`);
|
|
75
|
+
lines.push(' ],');
|
|
76
|
+
} else if (from) {
|
|
77
|
+
lines.push(" // TODO(motu:contract): this component's props could not be read — list them here.");
|
|
78
|
+
lines.push(' input: [],');
|
|
79
|
+
} else {
|
|
80
|
+
lines.push(" input: ['title'],");
|
|
81
|
+
}
|
|
82
|
+
if (contract?.output?.length) {
|
|
83
|
+
lines.push(' // Event names are the REGION\'s vocabulary — rename them if it has a better word.');
|
|
84
|
+
lines.push(' output: {');
|
|
85
|
+
for (const o of contract.output) lines.push(` ${o.prop}: '${o.event}',`);
|
|
86
|
+
lines.push(' },');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const imported = exportName && exportName !== pascal ? `${exportName} as ${pascal}` : pascal;
|
|
90
|
+
return `${header}import { islandElement } from '@motujs/react';
|
|
91
|
+
import { ${imported} } from '${spec}';
|
|
92
|
+
|
|
93
|
+
export const element = islandElement({
|
|
94
|
+
tag: '${tag}',
|
|
95
|
+
component: ${pascal},
|
|
96
|
+
options: {
|
|
97
|
+
// The island's boundary in one place — input (props), output (events), effects (what it reaches).
|
|
98
|
+
contract: {
|
|
99
|
+
${lines.join('\n')}
|
|
100
|
+
},${legacy}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function indexSource(camel) {
|
|
107
|
+
return `export { ${camel}Element } from './element';\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function fixturesSource(kebab, contractHint) {
|
|
111
|
+
return `// Lagoon fixtures for x-${kebab}: replayed by MockTransport so the island renders offline (no backend,
|
|
112
|
+
// no login). Fill these in with the shapes the island's @motujs/contract calls return.
|
|
113
|
+
//
|
|
114
|
+
// Contract methods available to mock (from @motujs/contract):
|
|
115
|
+
${contractHint}
|
|
116
|
+
import type { Fixture, Scenario } from '@motujs/runtime/mock';
|
|
117
|
+
|
|
118
|
+
export const fixtures: Fixture[] = [
|
|
119
|
+
// TODO(motu:fixtures): e.g.
|
|
120
|
+
// { service: 'SomeService', method: 'search', roles: ['CAN_...'], response: { /* ... */ } },
|
|
121
|
+
// Request-keyed (return different data per input) — 'match' deep-equals the call args, with
|
|
122
|
+
// 'undefined' as a wildcard slot and a plain object matched as a subset:
|
|
123
|
+
// { service: 'SomeService', method: 'search', match: [undefined, { login: 'brice' }], response: { /* ... */ } },
|
|
124
|
+
];
|
|
125
|
+
|
|
126
|
+
/** Roles the mock caller holds — must satisfy the fixtures' role gates. */
|
|
127
|
+
export const roles: string[] = [];
|
|
128
|
+
|
|
129
|
+
/** Optional data-flow cases: two+ seeds whose DISTINCT output proves inputs reach the render
|
|
130
|
+
* (\`motu island verify\` mounts each and asserts they differ). Pair with request-keyed fixtures. */
|
|
131
|
+
export const scenarios: Scenario[] = [
|
|
132
|
+
// { name: 'empty', seed: { criteria: {} } },
|
|
133
|
+
// { name: 'filtered', seed: { criteria: { login: 'brice' } } },
|
|
134
|
+
];
|
|
135
|
+
`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** A comment block listing contract services/methods so the fixtures author knows what to mock. */
|
|
139
|
+
function readContractHint() {
|
|
140
|
+
try {
|
|
141
|
+
const text = readFileSync(paths.contract, 'utf8');
|
|
142
|
+
const services = [...text.matchAll(/export const (\w+) = \{([\s\S]*?)\n\};/g)];
|
|
143
|
+
const lines = [];
|
|
144
|
+
for (const [, name, body] of services) {
|
|
145
|
+
for (const [, method] of body.matchAll(/^\s{2}(\w+):/gm)) {
|
|
146
|
+
lines.push(`// - ${name}.${method}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return lines.length ? lines.join('\n') : '// (none found — regenerate @motujs/contract)';
|
|
150
|
+
} catch {
|
|
151
|
+
return '// (@motujs/contract not found)';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Add the import + ELEMENT_REGISTRY entry to the islands registry via AST. */
|
|
156
|
+
function addRegistryEntry(project, camel, kebab) {
|
|
157
|
+
const sf = project.addSourceFileAtPath(paths.islandsRegistry);
|
|
158
|
+
const spec = `./${kebab}/element`;
|
|
159
|
+
const constName = `${camel}Element`;
|
|
160
|
+
|
|
161
|
+
if (!sf.getImportDeclarations().some((d) => d.getModuleSpecifierValue() === spec)) {
|
|
162
|
+
sf.addImportDeclaration({ moduleSpecifier: spec, namedImports: [constName] });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const registry = sf.getVariableDeclarationOrThrow('ELEMENT_REGISTRY');
|
|
166
|
+
const arr = registry.getInitializerIfKindOrThrow(SyntaxKind.ArrayLiteralExpression);
|
|
167
|
+
if (!arr.getElements().some((e) => e.getText().trim() === constName)) {
|
|
168
|
+
arr.addElement(constName);
|
|
169
|
+
}
|
|
170
|
+
return sf;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export async function createCommand(argv) {
|
|
174
|
+
const name = argv._[0];
|
|
175
|
+
if (!name) {
|
|
176
|
+
console.error('usage: motu island create <name>');
|
|
177
|
+
process.exit(2);
|
|
178
|
+
}
|
|
179
|
+
const { pascal, camel, kebab, tag } = names(name);
|
|
180
|
+
// `--from <specifier>` wraps a component the application already owns (the React-host case: there is
|
|
181
|
+
// nothing to write, only something to mount). Without it, scaffold a new component under ui/ — the
|
|
182
|
+
// extraction case, where the original is not React and a component has to be authored.
|
|
183
|
+
let from = typeof argv.from === 'string' ? argv.from : null;
|
|
184
|
+
// `--export` names the component INSIDE the module, when it is not the island's Pascal name.
|
|
185
|
+
let exportName = typeof argv.export === 'string' ? argv.export : null;
|
|
186
|
+
|
|
187
|
+
// `--from <module>:<Export>` IS ACCEPTED, because it is what people actually type.
|
|
188
|
+
//
|
|
189
|
+
// Measured on three cold-start adoptions: two independent agents, on two different applications,
|
|
190
|
+
// invented exactly this form, spent three invocations each on it, and only recovered via the docs
|
|
191
|
+
// or `--help`. When two strangers independently guess the same syntax, the syntax is not the
|
|
192
|
+
// problem — refusing it is. `--export` remains the canonical spelling and still wins if both are
|
|
193
|
+
// given; this only stops the guess from being a dead end.
|
|
194
|
+
//
|
|
195
|
+
// Split on the LAST colon, and only when the tail looks like an export name, so a Windows drive
|
|
196
|
+
// letter or a `node:`/`virtual:` style specifier is untouched.
|
|
197
|
+
if (from && !exportName) {
|
|
198
|
+
const m = from.match(/^(.*[^:]):([A-Za-z_$][\w$]*)$/);
|
|
199
|
+
if (m && !/^[a-z]+:$/.test(m[1] + ':')) {
|
|
200
|
+
from = m[1];
|
|
201
|
+
exportName = m[2];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const componentPath = paths.componentFile(kebab, pascal);
|
|
205
|
+
const fixturesPath = paths.fixturesFile(kebab);
|
|
206
|
+
|
|
207
|
+
if (!from && existsSync(componentPath) && !argv.force) {
|
|
208
|
+
console.error(color.red(`✗ ${componentPath} already exists (use --force to overwrite)`));
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
// RESOLVE FROM WHERE THE ISLAND FILE WILL ACTUALLY SIT.
|
|
212
|
+
//
|
|
213
|
+
// This resolved against `islandDir(kebab)` — the FOLDER layout, `src/islands/<kebab>/` — while
|
|
214
|
+
// create always writes the FLAT one, `src/islands/<kebab>.island.ts`, one level up. So a relative
|
|
215
|
+
// `--from` was broken in both directions: the specifier that is correct for the emitted file
|
|
216
|
+
// (`../ui/x`) was rejected, and the one that satisfied this check (`../../ui/x`) was written
|
|
217
|
+
// verbatim into a file where it resolves one level too high — a broken import that passed the
|
|
218
|
+
// CLI's own validation. Only the alias form worked, because aliases resolve from the host root,
|
|
219
|
+
// which is why the React hosts never hit it.
|
|
220
|
+
const islandDir = paths.islandsDir;
|
|
221
|
+
if (from && !resolveModuleSpecifier(from, islandDir)) {
|
|
222
|
+
// SHOW A CORRECT CALL. The old message said what was wrong and never what to type, so an agent
|
|
223
|
+
// that had guessed the syntax had nothing to correct toward — it guessed twice more, then read
|
|
224
|
+
// the docs. A message that ends in an example is the difference between three invocations and one.
|
|
225
|
+
const guessedExt = /\.(tsx?|jsx?)$/.test(from);
|
|
226
|
+
console.error(
|
|
227
|
+
color.red(`✗ --from '${from}' does not resolve to a file from ${paths.rel(islandDir)}`) +
|
|
228
|
+
(guessedExt ? color.dim('\n Drop the file extension — this is a module specifier, not a path.') : '') +
|
|
229
|
+
color.dim('\n Use the specifier the APP itself uses, and name the export with --export:') +
|
|
230
|
+
color.dim(`\n motu island create ${kebab} --from @/components/${kebab} --export ${pascal}`) +
|
|
231
|
+
color.dim(`\n motu island create ${kebab} --from ../components/${kebab} --export ${pascal} (relative to ${paths.rel(islandDir)})`) +
|
|
232
|
+
color.dim('\n `--from <module>:<Export>` is also accepted.'),
|
|
233
|
+
);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (!from) mkdirSync(dirname(componentPath), { recursive: true }); // ui/<kebab>/
|
|
238
|
+
mkdirSync(paths.islandsDir, { recursive: true });
|
|
239
|
+
|
|
240
|
+
// Component in ui/ (unless wrapping one the app owns), then the island itself — one file.
|
|
241
|
+
if (!from) writeFileSync(componentPath, componentSource(pascal, kebab));
|
|
242
|
+
const islandFile = resolve(paths.islandsDir, `${kebab}.island.ts`);
|
|
243
|
+
// The component's own source answers most of the contract; read it rather than asking for it again.
|
|
244
|
+
const contract = from ? readComponentContract(resolveModuleSpecifier(from, islandDir), exportName ?? pascal) : null;
|
|
245
|
+
writeFileSync(islandFile, elementSource(pascal, camel, tag, kebab, from, exportName, contract));
|
|
246
|
+
|
|
247
|
+
// The registry is GENERATED from what is on disk, not edited: adding an island is a file operation,
|
|
248
|
+
// and reconciling is what keeps a deleted or renamed one from lingering in it.
|
|
249
|
+
syncRegistry(paths.islandsDir, paths.isolation);
|
|
250
|
+
// AND THE CONTRACTS, in the same breath. Creating an island left `contracts.generated.ts` missing,
|
|
251
|
+
// so the very first `motu check` a new project runs — straight after the next-step this command
|
|
252
|
+
// prints — opened with `✗ generated contracts.generated.ts is missing`. The scaffolder failing its
|
|
253
|
+
// own check is the first thing a stranger sees, and the fix was a command they had not been told
|
|
254
|
+
// about yet.
|
|
255
|
+
syncContracts(paths.islandsDir, { islandComponentPath, islandComponentExport, names });
|
|
256
|
+
console.log(color.green(`✓ created island ${color.bold(kebab)} (component ${pascal}, tag ${tag})`));
|
|
257
|
+
if (contract) {
|
|
258
|
+
console.log(
|
|
259
|
+
' ' +
|
|
260
|
+
color.dim(
|
|
261
|
+
`read from the component: ${contract.input.length} input(s), ${contract.output.length} output(s)` +
|
|
262
|
+
`${contract.effects.length ? `, ${contract.effects.length} effect(s)` : ''}`,
|
|
263
|
+
),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
if (from) console.log(' ' + color.dim(`wraps ${from} (the app's own component — not copied)`));
|
|
267
|
+
else console.log(' ' + color.dim(`${paths.rel(paths.componentFile(kebab, pascal))} (component)`));
|
|
268
|
+
console.log(' ' + color.dim(`${paths.rel(islandFile)} (the island)`));
|
|
269
|
+
console.log(' ' + color.dim(`${paths.rel(paths.islandsRegistry)} (regenerated)`));
|
|
270
|
+
console.log('');
|
|
271
|
+
console.log('Next: fill the TODO(motu:*) markers, then ' + color.bold(`motu island verify ${kebab}`));
|
|
272
|
+
console.log(
|
|
273
|
+
color.dim(' Evidence (fixtures/scenarios) goes in ') +
|
|
274
|
+
color.dim(color.bold(`${kebab}.evidence.ts`)) +
|
|
275
|
+
color.dim(' — write one when you need it, or record it with `motu fixtures record`.'),
|
|
276
|
+
);
|
|
277
|
+
}
|