lynkeus 0.3.0 → 0.4.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.
@@ -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
+ };
@@ -75,9 +75,9 @@ export const plan = async (o) => {
75
75
  return { ...now, url: String((await ask(o, 'POST', route, o.go === 'upgrade' ? 'opening checkout' : 'opening the billing portal')).url) };
76
76
  };
77
77
  export const service = () => {
78
- const url = process.env.LYNKEUS_REPORTS_URL;
78
+ const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
79
79
  const key = process.env.LYNKEUS_REPORTS_KEY;
80
- return url && key ? { url, key } : 'set LYNKEUS_REPORTS_URL and LYNKEUS_REPORTS_KEY (the organization’s API key)';
80
+ return key ? { url, key } : 'set LYNKEUS_REPORTS_KEY to an API key of your organization (lynkeus.io, API keys); LYNKEUS_REPORTS_URL names another service';
81
81
  };
82
82
  export const reportCommands = [
83
83
  define({
package/lib/config.d.ts CHANGED
@@ -5,6 +5,17 @@ export declare const graphFile: (root?: string) => string;
5
5
  export declare const edgesFile: (root?: string) => string;
6
6
  export declare const frontierFile: (root?: string) => string;
7
7
  export declare const runsDir: (root?: string) => string;
8
+ export declare const tokenFile: (root?: string) => string;
9
+ /**
10
+ * The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
11
+ * `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
12
+ */
13
+ export declare const readToken: (root?: string) => string | undefined;
14
+ /** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
15
+ export declare const ensureToken: (root?: string) => {
16
+ token: string;
17
+ made: boolean;
18
+ };
8
19
  export declare const loadGraph: (root?: string) => ScreenGraph | null;
9
20
  /**
10
21
  * `lynkeus.config.json` in the app's folder: what would otherwise be repeated
@@ -17,6 +28,11 @@ export type LynkeusConfig = {
17
28
  entry?: string;
18
29
  /** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
19
30
  apiEnv?: string;
31
+ /**
32
+ * The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
33
+ * starting the host over MCP cannot export a variable.
34
+ */
35
+ apiUrl?: string;
20
36
  /** The driver port the app dials. */
21
37
  port?: number;
22
38
  /** Simulator udid or name. */
package/lib/config.js CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { readJson } from './files.js';
@@ -6,6 +7,35 @@ export const graphFile = (root) => path.join(lynkeusDir(root), 'graph.json');
6
7
  export const edgesFile = (root) => path.join(lynkeusDir(root), 'edges.jsonl');
7
8
  export const frontierFile = (root) => path.join(lynkeusDir(root), 'frontier.jsonl');
8
9
  export const runsDir = (root) => path.join(lynkeusDir(root), 'runs');
10
+ export const tokenFile = (root) => path.join(lynkeusDir(root), 'token');
11
+ /**
12
+ * The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
13
+ * `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
14
+ */
15
+ export const readToken = (root) => {
16
+ if (process.env.LYNKEUS_TOKEN)
17
+ return process.env.LYNKEUS_TOKEN;
18
+ try {
19
+ return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ };
25
+ /** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
26
+ export const ensureToken = (root) => {
27
+ const had = readToken(root);
28
+ if (had)
29
+ return { token: had, made: false };
30
+ const token = crypto.randomBytes(24).toString('hex');
31
+ fs.mkdirSync(lynkeusDir(root), { recursive: true });
32
+ fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
33
+ const ignore = path.join(lynkeusDir(root), '.gitignore');
34
+ const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
35
+ if (!ignored.split('\n').includes('token'))
36
+ fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
37
+ return { token, made: true };
38
+ };
9
39
  export const loadGraph = (root) => {
10
40
  const file = graphFile(root);
11
41
  return fs.existsSync(file) ? readJson(file) : null;
@@ -4,7 +4,7 @@
4
4
  * only launching, screenshots and the status bar go through simctl or adb.
5
5
  */
6
6
  import { AgentServer } from 'lynkeus-client';
7
- import { portFromEnv } from '../config.js';
7
+ import { portFromEnv, readToken } from '../config.js';
8
8
  import { withoutHost } from '../knowledge/problems.js';
9
9
  import { lifecycleFor } from './platform.js';
10
10
  export class Device {
@@ -17,7 +17,7 @@ export class Device {
17
17
  this.info = info;
18
18
  this.#resolving = options.resolving;
19
19
  // The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
20
- this.server = options.server ?? new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: portFromEnv() });
20
+ this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
21
21
  this.#lifecycle = lifecycleFor(info.platform);
22
22
  this.#appId = options.appId ?? null;
23
23
  }
package/lib/mcp/server.js CHANGED
@@ -59,9 +59,22 @@ const argvFor = (c, input) => {
59
59
  }
60
60
  return av;
61
61
  };
62
+ // What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
63
+ // a test for this" need nothing more from the person asking.
64
+ const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
65
+
66
+ In a project you do not know, call status first: it lists what is set up and, for what is not, the one thing that fixes it.
67
+
68
+ To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
69
+
70
+ To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there; prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
71
+
72
+ To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
73
+
74
+ To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.`;
62
75
  export const serve = async (session, options = {}) => {
63
76
  const { version } = createRequire(import.meta.url)('../../package.json');
64
- const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
77
+ const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
65
78
  const served = commands().filter((c) => overMcp(c, !!options.full));
66
79
  const byName = new Map(served.map((c) => [identifier(c.name), c]));
67
80
  // So a model reading the app's screens knows their language before it starts guessing.
@@ -107,5 +120,21 @@ export const serve = async (session, options = {}) => {
107
120
  return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
108
121
  return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
109
122
  });
110
- await server.connect(new StdioServerTransport());
123
+ // The client going away ends the server: the session holds the driver's port open, and a server left behind
124
+ // keeps the next one (or a case run) from binding it.
125
+ const transport = new StdioServerTransport();
126
+ let stopping = false;
127
+ const stop = () => {
128
+ if (stopping)
129
+ return;
130
+ stopping = true;
131
+ void session
132
+ .stop()
133
+ .catch(() => undefined)
134
+ .finally(() => process.exit(0));
135
+ };
136
+ process.stdin.once('end', stop);
137
+ process.stdin.once('close', stop);
138
+ transport.onclose = stop;
139
+ await server.connect(transport);
111
140
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lynkeus",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Drive a React Native app running lynkeus-agent from the terminal, a test runner or an MCP client: screens, flows, smoke, crawl, and the app's screen graph.",
5
5
  "type": "module",
6
6
  "bin": "./lib/cli.js",
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@modelcontextprotocol/sdk": "^1.30.0",
38
- "lynkeus-client": "^0.3.0",
39
- "lynkeus-protocol": "^0.3.0",
38
+ "lynkeus-client": "^0.4.0",
39
+ "lynkeus-protocol": "^0.4.0",
40
40
  "pngjs": "^7.0.0",
41
41
  "ts-morph": "^24.0.0",
42
42
  "yaml": "^2"
@@ -91,7 +91,7 @@ export type CaseReport = {
91
91
  };
92
92
 
93
93
  /** Where a case keeps what it can show of itself: a frame per step, a film. Each is taken only where the host can. */
94
- export type Evidence = { dir: string; screenshots?: boolean; video?: { fps: number } };
94
+ export type Evidence = { dir: string; screenshots?: boolean; settleMs?: number; video?: { fps: number; scale?: number } };
95
95
 
96
96
  export type RunOptions = {
97
97
  base: Base;
@@ -449,7 +449,12 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
449
449
  if (looks === 1) await d.waitFor({ target: what, timeoutMs: 1500 }).catch(() => undefined);
450
450
  else if (looks === 2) {
451
451
  if (await scrollTo(async () => (await seen(params)) === undefined)) return;
452
- } else await d.waitFor({ target: what, timeoutMs: 20_000 }).catch(() => undefined);
452
+ } else {
453
+ // A step that names its own timeout gets no more than what is left of it; one that does not may
454
+ // wait for a carousel to come round.
455
+ const long = typeof params.timeoutMs === 'number' ? Math.max(0, deadline - Date.now()) : 20_000;
456
+ await d.waitFor({ target: what, timeoutMs: long }).catch(() => undefined);
457
+ }
453
458
  continue;
454
459
  }
455
460
  if (Date.now() > deadline && Date.now() < hard && (await (await device()).screen()).busy) {
@@ -769,7 +774,8 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
769
774
  let filming = false;
770
775
  if (film && evidence?.video) {
771
776
  const d = await device();
772
- filming = await d.command('record', { path: film, fps: evidence.video.fps }).then(
777
+ // A host that films at one size only ignores the scale.
778
+ filming = await d.command('record', { path: film, fps: evidence.video.fps, ...(evidence.video.scale ? { scale: evidence.video.scale } : {}) }).then(
773
779
  () => true,
774
780
  () => false,
775
781
  );
@@ -796,9 +802,11 @@ export const runCase = async (c: Case, options: RunOptions): Promise<CaseReport>
796
802
  if (evidence?.screenshots) {
797
803
  // A step may end mid-transition (a screen fading in, the splash fading out): the frame is of where the
798
804
  // step left the app, so two runs' frames of the same step show the same screen.
799
- // Long enough for what arrives after a short delay; a screen that never settles (an animation that
800
- // keeps going) is noted as such, and its frame is of a moment, not of a state.
801
- const settled = await d.idle({ quietMs: 700, timeoutMs: 3000 }).then(
805
+ // A screen that never settles (an animation that keeps going) is noted as such, and its frame is of a
806
+ // moment, not of a state. The wait is paid on every step: short for a report, and long enough for what
807
+ // arrives after a short delay (700 ms) where two runs' frames are compared pixel by pixel.
808
+ const quietMs = evidence.settleMs ?? 150;
809
+ const settled = await d.idle({ quietMs, timeoutMs: Math.max(1000, quietMs * 4) }).then(
802
810
  (r) => r.idle,
803
811
  () => false,
804
812
  );
package/src/cli.ts CHANGED
@@ -13,7 +13,7 @@ import { mapCommands } from './commands/map.js';
13
13
  import { osCommands } from './commands/os.js';
14
14
  import { reportCommands } from './commands/report.js';
15
15
  import { runsCommands } from './commands/runs.js';
16
- import { graphFile, loadConfig, loadGraph, portFromEnv } from './config.js';
16
+ import { graphFile, loadConfig, loadGraph, portFromEnv, readToken } from './config.js';
17
17
  import { agentMismatch } from './device/agentVersion.js';
18
18
  import { HEADLESS_HOST, pickAgentDevice, pickAndroidDevice } from './device/devices.js';
19
19
  import { Device } from './device/driver.js';
@@ -56,7 +56,7 @@ const contextFor = (argv: string[], attached = false, port?: number, ownHost = f
56
56
  const appId = (g.app as string | undefined) ?? process.env.LYNKEUS_APP_ID ?? config.app;
57
57
  const started = performance.now();
58
58
  const attachedIn = (d: Device) => err(`attached in ${Math.round(performance.now() - started)}ms (${d.info.name})`);
59
- const server = new AgentServer({ token: process.env.LYNKEUS_TOKEN, port: port ?? portFromEnv(config) });
59
+ const server = new AgentServer({ token: readToken(root), port: port ?? portFromEnv(config) });
60
60
  server.on('hello', (hello: HelloEvent) => {
61
61
  const told = mismatchTold ? undefined : agentMismatch(hello);
62
62
  if (!told) return;