lynkeus 0.3.0 → 0.5.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 (89) hide show
  1. package/lib/cases/coverage.d.ts +31 -0
  2. package/lib/cases/coverage.js +82 -0
  3. package/lib/cases/draft.d.ts +21 -0
  4. package/lib/cases/draft.js +53 -0
  5. package/lib/cases/fixtures.d.ts +11 -0
  6. package/lib/cases/fixtures.js +59 -8
  7. package/lib/cases/frames.d.ts +6 -0
  8. package/lib/cases/frames.js +28 -0
  9. package/lib/cases/nativeLogs.d.ts +14 -0
  10. package/lib/cases/nativeLogs.js +111 -0
  11. package/lib/cases/runDirs.d.ts +13 -0
  12. package/lib/cases/runDirs.js +48 -0
  13. package/lib/cases/runfile.d.ts +5 -1
  14. package/lib/cases/runfile.js +67 -4
  15. package/lib/cases/runner.d.ts +58 -1
  16. package/lib/cases/runner.js +322 -39
  17. package/lib/cases/templates.d.ts +2 -2
  18. package/lib/cases/templates.js +20 -2
  19. package/lib/cases/traces.js +4 -1
  20. package/lib/cli.js +27 -4
  21. package/lib/commands/cases.d.ts +8 -0
  22. package/lib/commands/cases.js +154 -25
  23. package/lib/commands/ci.d.ts +13 -0
  24. package/lib/commands/ci.js +184 -0
  25. package/lib/commands/coverage.d.ts +1 -0
  26. package/lib/commands/coverage.js +151 -0
  27. package/lib/commands/device.js +32 -2
  28. package/lib/commands/doctor.js +8 -2
  29. package/lib/commands/drive.js +3 -1
  30. package/lib/commands/fixtures.d.ts +7 -0
  31. package/lib/commands/fixtures.js +94 -0
  32. package/lib/commands/headless.d.ts +11 -0
  33. package/lib/commands/headless.js +189 -9
  34. package/lib/commands/init.d.ts +23 -0
  35. package/lib/commands/init.js +231 -26
  36. package/lib/commands/map.d.ts +2 -0
  37. package/lib/commands/map.js +18 -14
  38. package/lib/commands/os.js +43 -10
  39. package/lib/commands/report.d.ts +4 -0
  40. package/lib/commands/report.js +156 -20
  41. package/lib/config.d.ts +41 -0
  42. package/lib/config.js +30 -0
  43. package/lib/device/agentVersion.d.ts +7 -0
  44. package/lib/device/agentVersion.js +14 -0
  45. package/lib/device/devices.js +6 -0
  46. package/lib/device/driver.js +2 -2
  47. package/lib/knowledge/analyticsTraffic.d.ts +10 -0
  48. package/lib/knowledge/analyticsTraffic.js +109 -0
  49. package/lib/knowledge/errors.js +6 -0
  50. package/lib/knowledge/events.d.ts +2 -0
  51. package/lib/knowledge/why.d.ts +2 -0
  52. package/lib/knowledge/why.js +1 -1
  53. package/lib/mcp/server.js +45 -4
  54. package/lib/runtime.d.ts +9 -1
  55. package/lib/runtime.js +25 -3
  56. package/package.json +7 -4
  57. package/src/cases/coverage.ts +99 -0
  58. package/src/cases/draft.ts +75 -0
  59. package/src/cases/fixtures.ts +57 -8
  60. package/src/cases/frames.ts +30 -0
  61. package/src/cases/nativeLogs.ts +112 -0
  62. package/src/cases/runDirs.ts +48 -0
  63. package/src/cases/runfile.ts +64 -4
  64. package/src/cases/runner.ts +372 -42
  65. package/src/cases/templates.ts +20 -2
  66. package/src/cases/traces.ts +3 -1
  67. package/src/cli.ts +27 -4
  68. package/src/commands/cases.ts +165 -23
  69. package/src/commands/ci.ts +189 -0
  70. package/src/commands/coverage.ts +147 -0
  71. package/src/commands/device.ts +27 -2
  72. package/src/commands/doctor.ts +9 -2
  73. package/src/commands/drive.ts +3 -1
  74. package/src/commands/fixtures.ts +102 -0
  75. package/src/commands/headless.ts +197 -9
  76. package/src/commands/init.ts +270 -26
  77. package/src/commands/map.ts +20 -15
  78. package/src/commands/os.ts +38 -10
  79. package/src/commands/report.ts +162 -18
  80. package/src/config.ts +48 -0
  81. package/src/device/agentVersion.ts +17 -0
  82. package/src/device/devices.ts +5 -0
  83. package/src/device/driver.ts +2 -2
  84. package/src/knowledge/analyticsTraffic.ts +142 -0
  85. package/src/knowledge/errors.ts +6 -0
  86. package/src/knowledge/events.ts +2 -1
  87. package/src/knowledge/why.ts +3 -1
  88. package/src/mcp/server.ts +45 -4
  89. package/src/runtime.ts +24 -3
@@ -1,11 +1,13 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import fs from 'node:fs';
4
+ import { createRequire } from 'node:module';
3
5
  import path from 'node:path';
4
- import { lynkeusDir, portFromEnv } from '../config.js';
6
+ import { lynkeusDir, portFromEnv, readToken } from '../config.js';
5
7
  import { describeExplanations, explainError } from '../knowledge/errors.js';
6
8
  import { define } from '../registry.js';
7
9
  import { doctor } from './doctor.js';
8
- import { init, mountAgent } from './init.js';
10
+ import { headlessMissing, init, installAgent, installHeadless, jestPreset, mountAgent, setupChecks } from './init.js';
9
11
  const ENTRY_FLAG = { entry: { type: 'string', help: 'the headless entry (or `entry` in lynkeus.config.json)', value: 'file' } };
10
12
  const entryOf = (ctx) => ctx.flags.entry ?? ctx.config.entry ?? 'qa/headless.tsx';
11
13
  /** The jest test that hosts the app. The port is written in, not read from the environment there: babel would inline the first run's value into jest's transform cache. */
@@ -27,34 +29,176 @@ import * as entry from '${entryImport}';
27
29
  hostApp(() => <entry.App />, { ${options.join(', ')} });
28
30
  `;
29
31
  };
32
+ const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'jest.config.ts', 'jest.config.json'];
33
+ // React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
34
+ const apiFromConfig = (config) => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
35
+ /**
36
+ * A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
37
+ * keeps that output, so a host started with another API address kept calling the first one it was started with.
38
+ */
39
+ export const cacheFor = (root, env, apiEnv = 'API_URL') => {
40
+ const key = createHash('sha256')
41
+ .update(`${apiEnv}=${env[apiEnv] ?? ''}`)
42
+ .digest('hex')
43
+ .slice(0, 12);
44
+ return path.join(lynkeusDir(root), 'jest-cache', key);
45
+ };
46
+ const TRANSFORM_PACKAGES = '((jest-)?react-native[^/]*|@react-native[^/]*|@react-navigation|react-navigation|expo(nent)?|@expo(nent)?/.*|expo-.*|@unimodules|unimodules|@shopify|@gorhom|@sentry/react-native|lynkeus-[^/]+|yoga-layout|@testing-library|immer|@reduxjs/toolkit|react-redux|redux|reselect|@expo-google-fonts|native-base|standard-navigation)';
47
+ /**
48
+ * The import aliases in tsconfig.json's `paths`, as jest mappings, the longest first: \`@/assets/*\` has to be
49
+ * tried before \`@/*\`, which jest-expo does not do. The bundler reads them; jest knows nothing of them.
50
+ */
51
+ const aliasesOf = (root) => {
52
+ let options;
53
+ try {
54
+ const text = fs.readFileSync(path.join(root, 'tsconfig.json'), 'utf8').replace(/^\s*\/\/.*$/gm, '');
55
+ options = JSON.parse(text).compilerOptions;
56
+ }
57
+ catch {
58
+ return {};
59
+ }
60
+ const base = path.join(root, options?.baseUrl ?? '.');
61
+ const literal = (text) => text.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
62
+ return Object.fromEntries(Object.entries(options?.paths ?? {})
63
+ .filter(([alias, [target]]) => alias.endsWith('/*') && target?.endsWith('/*'))
64
+ .sort(([a], [b]) => b.length - a.length)
65
+ .map(([alias, [target]]) => [`^${literal(alias.slice(0, -1))}(.*)$`, `${path.join(base, target.slice(0, -1))}$1`]));
66
+ };
67
+ /**
68
+ * What jest is told when the app has not said it itself, so an app with no jest set up runs headless as it is:
69
+ * the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
70
+ */
71
+ /** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
72
+ export const duplicateReact = (root) => {
73
+ const require = createRequire(import.meta.url);
74
+ try {
75
+ // Its exports leave out package.json: from its entry up to the folder that holds one.
76
+ let headless = path.dirname(fs.realpathSync(require.resolve('lynkeus-headless', { paths: [root] })));
77
+ while (!fs.existsSync(path.join(headless, 'package.json')) && path.dirname(headless) !== headless)
78
+ headless = path.dirname(headless);
79
+ const ours = path.dirname(require.resolve('react/package.json', { paths: [root] }));
80
+ const theirs = path.dirname(require.resolve('react/package.json', { paths: [headless] }));
81
+ return fs.realpathSync(ours) === fs.realpathSync(theirs) ? undefined : ours;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ };
87
+ const jestDefaults = (root) => {
88
+ const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
89
+ let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
90
+ if (!file) {
91
+ try {
92
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
93
+ config = manifest.jest ? JSON.stringify(manifest.jest) : '';
94
+ }
95
+ catch { }
96
+ }
97
+ const flags = [];
98
+ const react = duplicateReact(root);
99
+ // lynkeus-headless with a React of its own (a linked package, a workspace, pnpm) breaks every hook: the app and
100
+ // the renderer must share the app's copy.
101
+ const sameReact = react ? { '^react$': react, '^react/(.*)$': `${react}/$1` } : {};
102
+ if (!config) {
103
+ // A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
104
+ // behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
105
+ const stub = path.join(lynkeusDir(root), 'style-stub.js');
106
+ fs.mkdirSync(path.dirname(stub), { recursive: true });
107
+ fs.writeFileSync(stub, 'module.exports = {};\n');
108
+ flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root), ...sameReact })}`);
109
+ }
110
+ else if (react) {
111
+ if (config.includes('moduleNameMapper'))
112
+ process.stderr.write(`lynkeus: lynkeus-headless finds another copy of react than the app's; add to moduleNameMapper in the app's jest config: '^react$': '${react}', '^react/(.*)$': '${react}/$1'\n`);
113
+ else
114
+ flags.push(`--moduleNameMapper=${JSON.stringify(sameReact)}`);
115
+ }
116
+ // Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
117
+ if (!config.includes('transformIgnorePatterns'))
118
+ flags.push(`--transformIgnorePatterns=node_modules/(?!${TRANSFORM_PACKAGES}/)`, '--transformIgnorePatterns=/node_modules/react-native-reanimated/plugin/', '--transformIgnorePatterns=/node_modules/@react-native/babel-preset/');
119
+ return flags;
120
+ };
30
121
  export const headlessCommands = [
31
122
  define({
32
123
  name: 'init',
33
124
  group: 'drive',
34
- summary: "Mount the agent from the app's entry: React Native or Expo (Expo Router gets an entry of its own)",
125
+ summary: "Set lynkeus up in this app: install the agent, mount it from the app's entry, make the project's token",
126
+ details: 'React Native or Expo (an Expo Router app gets an entry of its own). Safe to run again: it changes only what is missing. The agent has native code, so the app is built again afterwards (`npx react-native run-ios`, `npx expo run:ios`) and Metro restarted with `--reset-cache`; `lynkeus status` says what is left.',
127
+ flags: { 'no-install': { type: 'boolean', help: 'do not add lynkeus-agent to the app; mount it only' } },
35
128
  needs: 'nothing',
129
+ mcp: 'core',
36
130
  run: async (ctx) => {
37
- const lines = mountAgent(ctx.root);
131
+ const lines = [...(ctx.flags['no-install'] ? [] : [installAgent(ctx.root)]), ...mountAgent(ctx.root)];
132
+ lines.push('next: build the app again and open it, then `lynkeus status` (over MCP: the status tool) to see that it attaches');
38
133
  return { text: lines.join('\n'), json: lines };
39
134
  },
40
135
  }),
136
+ define({
137
+ name: 'status',
138
+ group: 'drive',
139
+ summary: 'What this project has of lynkeus and what is missing: the agent, the token, a running app, cases, the reports key',
140
+ details: 'The first thing to call in a project you do not know: each line that fails says what fixes it. `--offline` skips waiting for the app.',
141
+ flags: { offline: { type: 'boolean', help: 'check the files only; do not wait for a running app' } },
142
+ needs: 'nothing',
143
+ mcp: 'core',
144
+ run: async (ctx) => {
145
+ const checks = setupChecks(ctx.root);
146
+ const ready = checks.every((c) => c.ok);
147
+ if (ready && !ctx.flags.offline) {
148
+ try {
149
+ const device = await ctx.device();
150
+ checks.push({ what: `an app is attached (${device.info.name})`, ok: true });
151
+ }
152
+ catch {
153
+ checks.push({
154
+ what: 'an app is attached',
155
+ ok: false,
156
+ fix: 'open a development build made after `lynkeus init`, with Metro restarted (`--reset-cache`); or `lynkeus headless start` for no simulator',
157
+ });
158
+ }
159
+ }
160
+ const unloadable = headlessMissing(ctx.root);
161
+ checks.push({
162
+ what: unloadable.length ? 'the app can run headless, in Node with no simulator' : 'the app can run headless (jest, Testing Library, lynkeus-headless)',
163
+ ok: !unloadable.length && fs.existsSync(path.join(ctx.root, entryOf(ctx))),
164
+ fix: '`lynkeus headless init` installs what it needs and writes its entry; optional',
165
+ });
166
+ const dir = path.resolve(ctx.config.cases?.base ?? ctx.root, ctx.config.cases?.dir ?? 'cases');
167
+ const cases = fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !/^(README|FORMAT|CATALOG)\.md$/i.test(f)).length : 0;
168
+ checks.push({
169
+ what: cases ? `${cases} case${cases === 1 ? '' : 's'} in ${path.relative(ctx.root, dir) || '.'}` : 'cases to run',
170
+ ok: cases > 0,
171
+ fix: '`lynkeus case init` writes an example and the format',
172
+ });
173
+ checks.push({
174
+ what: 'runs can be pushed to the reports service',
175
+ ok: !!process.env.LYNKEUS_REPORTS_KEY,
176
+ fix: 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); optional',
177
+ });
178
+ const text = checks.map((c) => `${c.ok ? '✓' : '✗'} ${c.what}${c.ok || !c.fix ? '' : ` — ${c.fix}`}`).join('\n');
179
+ return { text, json: checks };
180
+ },
181
+ }),
41
182
  define({
42
183
  name: 'headless start',
43
184
  group: 'headless',
44
185
  summary: 'Host the app in Node (jest + RNTL) and wait for a driver; same protocol, no simulator',
45
- details: 'Needs LYNKEUS_TOKEN in the environment (the value the app reads for its token). LYNKEUS_PORT moves the host and its driver together; LYNKEUS_HOST says where the driver is listening, for a host that is not in the same place as its driver (a container dialling out, say). Arguments after `--` go to jest. When the host crashes, the last lines say what the error usually means.',
186
+ details: "Needs the project's token: the one `lynkeus init` wrote to .lynkeus/token, or LYNKEUS_TOKEN in the environment. The app's API base comes from the environment variable `apiEnv` names, or else from `apiUrl` in lynkeus.config.json. LYNKEUS_PORT moves the host and its driver together; LYNKEUS_HOST says where the driver is listening, for a host that is not in the same place as its driver (a container dialling out, say). Arguments after `--` go to jest. When the host crashes, the last lines say what the error usually means.",
46
187
  flags: ENTRY_FLAG,
47
188
  aliases: ['headless'],
48
189
  needs: 'nothing',
49
190
  session: false,
50
191
  mcp: false,
51
192
  run: async (ctx) => {
52
- const token = process.env.LYNKEUS_TOKEN;
193
+ const token = readToken(ctx.root);
53
194
  if (!token)
54
195
  return {
55
- text: 'lynkeus headless needs LYNKEUS_TOKEN in the environment (the same value the app reads for its token): a hosted app talks to a real backend, and without one any local process could drive it.',
196
+ text: 'lynkeus headless needs a token (`lynkeus init` makes one in .lynkeus/token, or set LYNKEUS_TOKEN): a hosted app talks to a real backend, and without one any local process could drive it.',
56
197
  code: 1,
57
198
  };
199
+ const missing = headlessMissing(ctx.root);
200
+ if (missing.length)
201
+ return { text: `lynkeus headless runs the app under jest and needs ${missing.join(', ')}; \`lynkeus headless init\` installs them.`, code: 1 };
58
202
  const entry = entryOf(ctx);
59
203
  if (!fs.existsSync(path.join(ctx.root, entry)))
60
204
  return { text: `No ${entry} in ${ctx.root}. \`lynkeus headless init\` writes one; it must export \`App\` and, optionally, \`navigation\`.`, code: 1 };
@@ -70,17 +214,44 @@ export const headlessCommands = [
70
214
  fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
71
215
  // Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
72
216
  const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
73
- const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...ctx.passthrough], {
217
+ // LYNKEUS_HEADLESS tells the app's jest setup a host is starting, not its unit tests: a setup that stubs a native
218
+ // SDK whole can leave it real there, so the call reaches the stand-in `nativeMocks` shows.
219
+ const env = { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, LYNKEUS_HEADLESS: '1', CI: '1', NODE_OPTIONS: nodeOptions };
220
+ const cache = `--cacheDirectory=${cacheFor(ctx.root, env, ctx.config.apiEnv)}`;
221
+ const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), cache, ...ctx.passthrough], {
74
222
  cwd: ctx.root,
75
223
  stdio: ['inherit', 'inherit', 'pipe'],
76
- env: { ...process.env, CI: '1', NODE_OPTIONS: nodeOptions },
224
+ env,
225
+ // Its own process group: npx, jest and the host it runs end together. Stopping only npx left the
226
+ // host dialing the port, where a later run's driver took it for the app it meant to drive.
227
+ detached: true,
228
+ });
229
+ const stop = (signal) => {
230
+ try {
231
+ if (child.pid)
232
+ process.kill(-child.pid, signal);
233
+ }
234
+ catch {
235
+ // already gone
236
+ }
237
+ };
238
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP'];
239
+ const forward = signals.map((signal) => {
240
+ const handler = () => stop(signal);
241
+ process.on(signal, handler);
242
+ return () => process.off(signal, handler);
77
243
  });
244
+ const onExit = () => stop('SIGTERM');
245
+ process.on('exit', onExit);
78
246
  let tail = '';
79
247
  child.stderr?.on('data', (chunk) => {
80
248
  process.stderr.write(chunk);
81
249
  tail = (tail + chunk.toString()).slice(-20000);
82
250
  });
83
251
  const code = await new Promise((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
252
+ for (const off of forward)
253
+ off();
254
+ process.off('exit', onExit);
84
255
  if (code) {
85
256
  const found = explainError(tail);
86
257
  if (found.length)
@@ -99,8 +270,12 @@ export const headlessCommands = [
99
270
  'api-env': { type: 'string', help: 'the env var the app reads its API base from', value: 'NAME' },
100
271
  auto: { type: 'boolean', help: 'also mount the agent from index.js with auto()' },
101
272
  force: { type: 'boolean', help: 'overwrite an existing entry' },
273
+ 'no-install': { type: 'boolean', help: 'do not add jest, Testing Library and lynkeus-headless to the project' },
102
274
  },
103
275
  needs: 'nothing',
276
+ mcp: 'core',
277
+ mcpOmit: ['entry', 'auto', 'force'],
278
+ mcpDetails: 'Prepares the app to run in Node, no simulator: installs jest, Testing Library and lynkeus-headless, writes the entry. Then `case_run` with `host`.',
104
279
  run: async (ctx) => {
105
280
  const lines = init(ctx.root, {
106
281
  entry: entryOf(ctx),
@@ -110,6 +285,8 @@ export const headlessCommands = [
110
285
  force: !!ctx.flags.force,
111
286
  locale: { ...ctx.config.locale, language: ctx.config.language },
112
287
  });
288
+ if (!ctx.flags['no-install'])
289
+ lines.unshift(installHeadless(ctx.root));
113
290
  return { text: lines.join('\n'), json: lines };
114
291
  },
115
292
  }),
@@ -123,6 +300,9 @@ export const headlessCommands = [
123
300
  explain: { type: 'string', help: 'turn a jest error into its usual cause and fix', value: 'text' },
124
301
  },
125
302
  needs: 'nothing',
303
+ mcp: 'core',
304
+ mcpOmit: ['entry'],
305
+ mcpDetails: 'Which native modules the app uses that nothing mocks yet, so the headless host would fail on them; `fix` writes stubs for them into the entry. `explain` turns the first line of a jest error into its cause.',
126
306
  run: async (ctx) => {
127
307
  if (ctx.flags.explain) {
128
308
  const found = explainError(ctx.flags.explain);
@@ -17,3 +17,26 @@ export type InitOptions = {
17
17
  */
18
18
  export declare const mountAgent: (root: string) => string[];
19
19
  export declare const init: (root: string, opts: InitOptions) => string[];
20
+ /**
21
+ * What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
22
+ * the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
23
+ */
24
+ export declare const installFailure: (ran: {
25
+ stderr?: string | null;
26
+ error?: Error;
27
+ }) => string;
28
+ /** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
29
+ export declare const installAgent: (root: string) => string;
30
+ export type SetupCheck = {
31
+ what: string;
32
+ ok: boolean;
33
+ fix?: string;
34
+ };
35
+ /** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
36
+ export declare const setupChecks: (root: string) => SetupCheck[];
37
+ /** The packages the headless host needs that this project cannot load yet. */
38
+ export declare const headlessMissing: (root: string) => string[];
39
+ /** The preset \`headless start\` gives jest when the app's own configuration names none. */
40
+ export declare const jestPreset: (root: string) => string;
41
+ /** Adds what the headless host needs as development dependencies; a line saying what happened. */
42
+ export declare const installHeadless: (root: string) => string;
@@ -1,9 +1,20 @@
1
+ import { spawnSync } from 'node:child_process';
1
2
  import fs from 'node:fs';
3
+ import { createRequire } from 'node:module';
2
4
  import path from 'node:path';
3
- import { packageName } from '../config.js';
5
+ import { ensureToken, packageName, readToken } from '../config.js';
4
6
  const APP_CANDIDATES = ['App.tsx', 'App.jsx', 'App.js', 'app/App.tsx', 'src/App.tsx', 'src/App.jsx', 'src/App.js'];
5
7
  const INDEX_CANDIDATES = ['index.js', 'index.tsx', 'index.ts'];
6
8
  const findFirst = (root, candidates) => candidates.find((c) => fs.existsSync(path.join(root, c)));
9
+ /** The file that already mounts the agent: the entry, or the App component of an app that renders `<Lynkeus>` itself. */
10
+ const mountedIn = (root) => [...INDEX_CANDIDATES, ...APP_CANDIDATES].find((file) => {
11
+ try {
12
+ return /from ['"]lynkeus-agent(\/auto)?['"]/.test(fs.readFileSync(path.join(root, file), 'utf8'));
13
+ }
14
+ catch {
15
+ return false;
16
+ }
17
+ });
7
18
  const relImport = (fromFile, toFile) => {
8
19
  const rel = path
9
20
  .relative(path.dirname(fromFile), toFile)
@@ -11,7 +22,36 @@ const relImport = (fromFile, toFile) => {
11
22
  .replace(/\.(tsx|ts|jsx|js)$/, '');
12
23
  return rel.startsWith('.') ? rel : `./${rel}`;
13
24
  };
14
- const entryTemplate = (appImport, apiEnv, appName, locale) => {
25
+ /** The folder of an Expo Router app's routes, which is the app when there is no root component to import. */
26
+ const routesOf = (root) => {
27
+ try {
28
+ createRequire(path.join(root, 'package.json')).resolve('expo-router/package.json');
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ return ['src/app', 'app'].find((dir) => fs.existsSync(path.join(root, dir, '_layout.tsx')) ||
34
+ fs.existsSync(path.join(root, dir, '_layout.js')) ||
35
+ fs.existsSync(path.join(root, dir, 'index.tsx')));
36
+ };
37
+ const appBlock = (source) => 'component' in source
38
+ ? `// Whatever the app's index runs before rendering (polyfills, i18n, services):
39
+ // require it here too, then the App.
40
+ const { default: App } = require('${source.component}')`
41
+ : `// An Expo Router app has no root component: the router is the app, over the route
42
+ // files in ${source.dir}, read here as the bundler would read them. Whatever the
43
+ // app's root layout does not run before rendering (polyfills, i18n), require it first.
44
+ process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'
45
+ // Its testing library first: it stands in for what has no native side here (expo-linking's
46
+ // scheme, Reanimated), and the router must load after it.
47
+ const { getMockContext } = require('expo-router/testing-library')
48
+ const React = require('react')
49
+ const { ExpoRoot } = require('expo-router')
50
+ const routes = getMockContext(require('path').resolve(__dirname, '${source.routes}'))
51
+ // Mounted as a device build mounts it, so the agent finds the router's navigation by itself.
52
+ require('lynkeus-agent/auto').auto({ token: process.env.LYNKEUS_TOKEN })
53
+ const App = () => React.createElement(ExpoRoot, { context: routes })`;
54
+ const entryTemplate = (source, apiEnv, appName, locale) => {
15
55
  const LOCALE = locale?.languageTag || locale?.country || locale?.language
16
56
  ? `\n // The locale this app is built for, from lynkeus.config.json.\n locale: ${JSON.stringify({ country: locale.country, languageTag: locale.languageTag, languageCode: locale.language })},`
17
57
  : "\n // locale: { country: 'DE', languageTag: 'de-DE', languageCode: 'de' },";
@@ -36,9 +76,7 @@ installDefaultMocks({
36
76
  // lists the native modules the app references that nothing covers yet:
37
77
  // jest.mock('some-native-package', () => ({ method: () => undefined }))
38
78
 
39
- // Whatever the app's index runs before rendering (polyfills, i18n, services):
40
- // require it here too, then the App.
41
- const { default: App } = require('${appImport}')
79
+ ${appBlock(source)}
42
80
 
43
81
  export { App }
44
82
  `;
@@ -67,14 +105,29 @@ const addTokenPlugin = (root) => {
67
105
  return `${file} already has ${TOKEN_PLUGIN}`;
68
106
  const plugins = /\bplugins\s*:\s*\[/.exec(text);
69
107
  const presets = /^([ \t]*)presets\s*:/m.exec(text);
70
- if (!plugins && !presets)
108
+ // A config written on one line has its presets after the brace, not at the start of a line.
109
+ const inline = plugins || presets ? null : /\bpresets\s*:/.exec(text);
110
+ if (!plugins && !presets && !inline)
71
111
  return byHand;
72
112
  const next = plugins
73
113
  ? `${text.slice(0, plugins.index + plugins[0].length)}'${TOKEN_PLUGIN}', ${text.slice(plugins.index + plugins[0].length)}`
74
- : `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`;
114
+ : presets
115
+ ? `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`
116
+ : `${text.slice(0, inline.index)}plugins: ['${TOKEN_PLUGIN}'], ${text.slice(inline.index)}`;
75
117
  fs.writeFileSync(path.join(root, file), next);
76
118
  return `added ${TOKEN_PLUGIN} to ${file}`;
77
119
  };
120
+ const EXPO_ENV = '.env.local';
121
+ const EXPO_TOKEN = 'EXPO_PUBLIC_LYNKEUS_TOKEN';
122
+ /** Expo passes the app only variables that start with EXPO_PUBLIC_, and reads them from `.env.local`, which its template keeps out of git. */
123
+ const giveExpoTheToken = (root, token) => {
124
+ const file = path.join(root, EXPO_ENV);
125
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
126
+ if (new RegExp(`^${EXPO_TOKEN}=`, 'm').test(text))
127
+ return `${EXPO_ENV} already sets ${EXPO_TOKEN}`;
128
+ fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${EXPO_TOKEN}=${token}\n`);
129
+ return `wrote ${EXPO_TOKEN} to ${EXPO_ENV} (check it is in .gitignore: Expo's template ignores .env*.local)`;
130
+ };
78
131
  /**
79
132
  * Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
80
133
  * has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
@@ -86,7 +139,11 @@ export const mountAgent = (root) => {
86
139
  const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
87
140
  const index = findFirst(root, INDEX_CANDIDATES);
88
141
  const routed = manifest.main === 'expo-router/entry';
89
- if (routed && !index) {
142
+ const already = mountedIn(root);
143
+ if (already) {
144
+ notes.push(`${already} already mounts the agent`);
145
+ }
146
+ else if (routed && !index) {
90
147
  fs.writeFileSync(path.join(root, 'index.js'), `${mountLines(true)}\nimport 'expo-router/entry'\n`);
91
148
  fs.writeFileSync(path.join(root, 'package.json'), `${JSON.stringify({ ...JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')), main: 'index.js' }, null, 2)}\n`);
92
149
  notes.push("wrote index.js (mounts the agent, then loads expo-router) and pointed package.json's main at it");
@@ -98,24 +155,23 @@ export const mountAgent = (root) => {
98
155
  else {
99
156
  const indexPath = path.join(root, index);
100
157
  const text = fs.readFileSync(indexPath, 'utf8');
101
- if (text.includes('lynkeus-agent/auto')) {
102
- notes.push(`${index} already mounts the agent`);
103
- }
104
- else {
105
- // After the last import, so the app's own polyfills still run first.
106
- const imports = [...text.matchAll(/^import .*$/gm)];
107
- const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
108
- fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
109
- notes.push(`added auto() to ${index}`);
110
- }
158
+ // After the last import, so the app's own polyfills still run first.
159
+ const imports = [...text.matchAll(/^import .*$/gm)];
160
+ const at = imports.length ? imports[imports.length - 1].index + imports[imports.length - 1][0].length + 1 : 0;
161
+ fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
162
+ notes.push(`added auto() to ${index}`);
111
163
  }
164
+ const { token, made } = ensureToken(root);
165
+ if (made)
166
+ notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI, the MCP server and the app all read it');
112
167
  if (!expo) {
113
168
  notes.push(addTokenPlugin(root));
114
- notes.push('start Metro with LYNKEUS_TOKEN set to the value the CLI reads; after changing it, start Metro with --reset-cache');
169
+ notes.push('restart Metro with --reset-cache so the bundle picks the token up; LYNKEUS_TOKEN in the environment still wins over the file');
115
170
  }
116
171
  if (expo) {
172
+ notes.push(giveExpoTheToken(root, token));
117
173
  notes.push('Expo: the agent has native code, so it runs in a development build (npx expo run:ios, npx expo run:android), not in Expo Go');
118
- notes.push('Expo: start the dev server with EXPO_PUBLIC_LYNKEUS_TOKEN set to the same value as LYNKEUS_TOKEN; Expo passes the app only variables with that prefix');
174
+ notes.push('Expo: restart the dev server so it reads .env.local; EXPO_PUBLIC_LYNKEUS_TOKEN in the environment still wins over the file');
119
175
  }
120
176
  return notes;
121
177
  };
@@ -123,20 +179,169 @@ export const init = (root, opts) => {
123
179
  const notes = [];
124
180
  const entryPath = path.join(root, opts.entry);
125
181
  const app = opts.app ?? findFirst(root, APP_CANDIDATES);
126
- if (!app)
127
- throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}); pass --component <path>`);
182
+ const routes = app ? undefined : routesOf(root);
183
+ if (!app && !routes)
184
+ throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}) and no Expo Router routes; pass --component <path>`);
128
185
  if (fs.existsSync(entryPath) && !opts.force) {
129
186
  notes.push(`${opts.entry} exists; left as is (--force overwrites)`);
130
187
  }
131
188
  else {
132
189
  fs.mkdirSync(path.dirname(entryPath), { recursive: true });
133
- fs.writeFileSync(entryPath, entryTemplate(relImport(entryPath, path.join(root, app)), opts.apiEnv, packageName(root), opts.locale));
134
- notes.push(`wrote ${opts.entry} (app: ${app})`);
190
+ const source = app
191
+ ? { component: relImport(entryPath, path.join(root, app)) }
192
+ : { routes: path.relative(path.dirname(entryPath), path.join(root, routes)).replace(/\\/g, '/'), dir: routes };
193
+ fs.writeFileSync(entryPath, entryTemplate(source, opts.apiEnv, packageName(root), opts.locale));
194
+ notes.push(`wrote ${opts.entry} (${app ? `app: ${app}` : `Expo Router, routes in ${routes}`})`);
135
195
  }
136
196
  if (opts.auto)
137
197
  notes.push(...mountAgent(root));
138
- notes.push("jest: make sure transformIgnorePatterns lets 'lynkeus-[^/]+' through, next to react-native (lynkeus headless doctor checks it)");
139
- notes.push(`next: LYNKEUS_TOKEN=<token> ${opts.apiEnv}=http://127.0.0.1:3000/ lynkeus headless start`);
198
+ // `headless start` refuses to run without one, and an app tested only headless never runs `lynkeus init`.
199
+ else if (ensureToken(root).made)
200
+ notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI and the MCP server read it');
201
+ notes.push("jest: when the app has no jest configuration, `headless start` passes the preset it needs (jest-expo for Expo, React Native's otherwise) and the packages to transpile; an app that sets its own lets 'lynkeus-[^/]+' through, next to react-native");
202
+ notes.push(`next: ${opts.apiEnv}=http://127.0.0.1:3000/ lynkeus headless start (or put "apiUrl": "http://127.0.0.1:3000/" in lynkeus.config.json; the token is the project's, in .lynkeus/token)`);
140
203
  notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
141
204
  return notes;
142
205
  };
206
+ const AGENT = 'lynkeus-agent';
207
+ /** The package manager whose lockfile is in the app's folder or above it: an app in a monorepo has it at the top. */
208
+ const managerOf = (root) => {
209
+ for (let dir = path.resolve(root);; dir = path.dirname(dir)) {
210
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock')))
211
+ return 'bun';
212
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml')))
213
+ return 'pnpm';
214
+ if (fs.existsSync(path.join(dir, 'yarn.lock')))
215
+ return 'yarn';
216
+ if (fs.existsSync(path.join(dir, 'package-lock.json')) || fs.existsSync(path.join(dir, '.git')) || path.dirname(dir) === dir)
217
+ return 'npm';
218
+ }
219
+ };
220
+ const installCommand = (root, expo) => {
221
+ if (expo)
222
+ return ['npx', 'expo', 'install', AGENT];
223
+ const manager = managerOf(root);
224
+ return manager === 'npm' ? ['npm', 'install', AGENT] : [manager, 'add', AGENT];
225
+ };
226
+ /**
227
+ * What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
228
+ * the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
229
+ */
230
+ export const installFailure = (ran) => `${ran.stderr ?? ''}\n${ran.error?.message ?? ''}`
231
+ .split('\n')
232
+ .map((line) => line.replace(/^(npm (error|ERR!)|error|ERR_PNPM_\w+|YN\d{4}:)\s*/, '').trim())
233
+ .filter((line) => line && !/^code \w+$|^node_modules\/|_logs|complete log|full report|^Fix the upstream|^this command with|^to accept an incorrect/i.test(line))
234
+ .slice(0, 8)
235
+ .join('; ');
236
+ const hasAgent = (manifest) => !!(manifest.dependencies?.[AGENT] ?? manifest.devDependencies?.[AGENT]);
237
+ /** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
238
+ export const installAgent = (root) => {
239
+ const manifest = readManifest(root);
240
+ if (hasAgent(manifest))
241
+ return `${AGENT} is already a dependency`;
242
+ const command = installCommand(root, !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo));
243
+ const ran = spawnSync(command[0], command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
244
+ if (ran.status === 0)
245
+ return `installed ${AGENT} (${command.join(' ')}); it has native code, so build the app again`;
246
+ const said = installFailure(ran);
247
+ return `could not install ${AGENT} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself, then \`lynkeus init\` again`;
248
+ };
249
+ /** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
250
+ export const setupChecks = (root) => {
251
+ const manifest = readManifest(root);
252
+ if (!manifest.dependencies && !manifest.devDependencies)
253
+ return [{ what: 'a package.json here', ok: false, fix: 'run this from the app\u2019s folder (or pass --root)' }];
254
+ const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
255
+ const checks = [];
256
+ const depends = hasAgent(manifest);
257
+ checks.push({ what: `${AGENT} is a dependency`, ok: depends, fix: '`lynkeus init` installs it' });
258
+ if (depends)
259
+ checks.push({
260
+ what: `${AGENT} is installed`,
261
+ ok: fs.existsSync(path.join(root, 'node_modules', AGENT, 'package.json')),
262
+ fix: 'install the project\u2019s dependencies',
263
+ });
264
+ const mounted = mountedIn(root);
265
+ checks.push({ what: mounted ? `${mounted} mounts the agent` : 'the app mounts the agent', ok: !!mounted, fix: '`lynkeus init` adds it to the entry' });
266
+ if (!expo) {
267
+ const babel = findFirst(root, BABEL_CANDIDATES);
268
+ checks.push({
269
+ what: 'babel.config.js has lynkeus-agent/babel',
270
+ ok: !!babel && fs.readFileSync(path.join(root, babel), 'utf8').includes(TOKEN_PLUGIN),
271
+ fix: '`lynkeus init` adds it',
272
+ });
273
+ }
274
+ const token = readToken(root);
275
+ checks.push({ what: 'the project has a token', ok: !!token, fix: '`lynkeus init` makes one in .lynkeus/token' });
276
+ if (expo && token) {
277
+ const env = path.join(root, EXPO_ENV);
278
+ const given = !!process.env[EXPO_TOKEN] || (fs.existsSync(env) && new RegExp(`^${EXPO_TOKEN}=`, 'm').test(fs.readFileSync(env, 'utf8')));
279
+ checks.push({ what: `Expo is given the token (${EXPO_TOKEN})`, ok: given, fix: '`lynkeus init` writes it to .env.local' });
280
+ }
281
+ return checks;
282
+ };
283
+ /** What `lynkeus headless start` runs on: jest hosts the app, Testing Library renders it, lynkeus-headless serves the agent. */
284
+ const HEADLESS_PACKAGES = ['jest', '@testing-library/react-native', 'lynkeus-headless'];
285
+ const resolvable = (root, name) => {
286
+ try {
287
+ createRequire(path.join(root, 'package.json')).resolve(`${name}/package.json`);
288
+ return true;
289
+ }
290
+ catch (error) {
291
+ // A package whose exports leave package.json out is still there.
292
+ return error.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED';
293
+ }
294
+ };
295
+ const JEST_PRESET = '@react-native/jest-preset';
296
+ /** The app's React Native version, when it is installed. */
297
+ const reactNativeVersion = (root) => {
298
+ try {
299
+ const file = createRequire(path.join(root, 'package.json')).resolve('react-native/package.json');
300
+ return JSON.parse(fs.readFileSync(file, 'utf8')).version;
301
+ }
302
+ catch {
303
+ return undefined;
304
+ }
305
+ };
306
+ // React Native 0.86 moved its jest preset out of `react-native`, which now throws when jest asks it for one.
307
+ const presetMoved = (version) => {
308
+ const [major, minor] = (version ?? '').split('.').map(Number);
309
+ return major === 0 ? (minor ?? 0) >= 86 : (major ?? 0) > 0;
310
+ };
311
+ const JEST_EXPO = 'jest-expo';
312
+ const isExpo = (root) => {
313
+ const manifest = readManifest(root);
314
+ return !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
315
+ };
316
+ /**
317
+ * The jest preset the app needs and lacks: an Expo app's is jest-expo, which gives Expo's modules the native
318
+ * side they look for (\`globalThis.expo\`); React Native 0.86 and later keep theirs out of \`react-native\`.
319
+ */
320
+ const presetNeeded = (root) => (isExpo(root) ? JEST_EXPO : presetMoved(reactNativeVersion(root)) ? JEST_PRESET : undefined);
321
+ /** The packages the headless host needs that this project cannot load yet. */
322
+ export const headlessMissing = (root) => [...HEADLESS_PACKAGES, ...[presetNeeded(root)].filter((p) => !!p)].filter((name) => !resolvable(root, name));
323
+ /** The preset \`headless start\` gives jest when the app's own configuration names none. */
324
+ export const jestPreset = (root) => (resolvable(root, JEST_EXPO) ? JEST_EXPO : resolvable(root, JEST_PRESET) ? JEST_PRESET : 'react-native');
325
+ const devInstallCommand = (root, names) => ({ bun: ['bun', 'add', '-d'], pnpm: ['pnpm', 'add', '-D'], yarn: ['yarn', 'add', '-D'], npm: ['npm', 'install', '--save-dev'] })[managerOf(root)].concat(names);
326
+ const install = (root, command, names) => {
327
+ const ran = spawnSync(command[0], command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
328
+ if (ran.status === 0)
329
+ return `installed ${names.join(', ')} (${command.join(' ')})`;
330
+ const said = installFailure(ran);
331
+ return `could not install ${names.join(', ')} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself`;
332
+ };
333
+ /** Adds what the headless host needs as development dependencies; a line saying what happened. */
334
+ export const installHeadless = (root) => {
335
+ const missing = headlessMissing(root);
336
+ if (!missing.length)
337
+ return `jest, Testing Library, lynkeus-headless and the ${jestPreset(root)} preset are installed`;
338
+ const lines = [];
339
+ const plain = missing.filter((name) => name !== JEST_EXPO);
340
+ // The React Native preset is released with React Native, version for version.
341
+ if (plain.length)
342
+ lines.push(install(root, devInstallCommand(root, plain.map((name) => (name === JEST_PRESET ? `${name}@${reactNativeVersion(root)}` : name))), plain));
343
+ // Expo's installer picks the jest-expo of the app's SDK.
344
+ if (missing.includes(JEST_EXPO))
345
+ lines.push(install(root, ['npx', 'expo', 'install', JEST_EXPO, '--', '--save-dev'], [JEST_EXPO]));
346
+ return lines.join('\n');
347
+ };
@@ -1,2 +1,4 @@
1
+ /** Source files a branch changed against `base`, committed or not. */
2
+ export declare const changedSources: (root: string, base: string, err: (line: string) => void) => string[];
1
3
  export declare const mapCommands: import("../registry.js").Command[];
2
4
  export declare const mapCheckWorkflow: (depth: number) => string;