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.
- package/lib/cases/runner.d.ts +2 -0
- package/lib/cases/runner.js +13 -6
- package/lib/cli.js +2 -2
- package/lib/commands/cases.js +53 -7
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/headless.js +123 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/report.js +2 -2
- package/lib/config.d.ts +16 -0
- package/lib/config.js +30 -0
- package/lib/device/driver.js +2 -2
- package/lib/mcp/server.js +31 -2
- package/package.json +3 -3
- package/src/cases/runner.ts +14 -6
- package/src/cli.ts +2 -2
- package/src/commands/cases.ts +55 -6
- package/src/commands/doctor.ts +9 -2
- package/src/commands/headless.ts +134 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/report.ts +2 -2
- package/src/config.ts +33 -0
- package/src/device/driver.ts +2 -2
- package/src/mcp/server.ts +31 -2
package/src/commands/init.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
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
5
|
|
|
4
|
-
import { packageName } from '../config.js';
|
|
6
|
+
import { ensureToken, packageName, readToken } from '../config.js';
|
|
5
7
|
|
|
6
8
|
const APP_CANDIDATES = ['App.tsx', 'App.jsx', 'App.js', 'app/App.tsx', 'src/App.tsx', 'src/App.jsx', 'src/App.js'];
|
|
7
9
|
const INDEX_CANDIDATES = ['index.js', 'index.tsx', 'index.ts'];
|
|
@@ -17,6 +19,16 @@ export type InitOptions = {
|
|
|
17
19
|
|
|
18
20
|
const findFirst = (root: string, candidates: string[]): string | undefined => candidates.find((c) => fs.existsSync(path.join(root, c)));
|
|
19
21
|
|
|
22
|
+
/** The file that already mounts the agent: the entry, or the App component of an app that renders `<Lynkeus>` itself. */
|
|
23
|
+
const mountedIn = (root: string): string | undefined =>
|
|
24
|
+
[...INDEX_CANDIDATES, ...APP_CANDIDATES].find((file) => {
|
|
25
|
+
try {
|
|
26
|
+
return /from ['"]lynkeus-agent(\/auto)?['"]/.test(fs.readFileSync(path.join(root, file), 'utf8'));
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
20
32
|
const relImport = (fromFile: string, toFile: string): string => {
|
|
21
33
|
const rel = path
|
|
22
34
|
.relative(path.dirname(fromFile), toFile)
|
|
@@ -25,7 +37,44 @@ const relImport = (fromFile: string, toFile: string): string => {
|
|
|
25
37
|
return rel.startsWith('.') ? rel : `./${rel}`;
|
|
26
38
|
};
|
|
27
39
|
|
|
28
|
-
|
|
40
|
+
/** The folder of an Expo Router app's routes, which is the app when there is no root component to import. */
|
|
41
|
+
const routesOf = (root: string): string | undefined => {
|
|
42
|
+
try {
|
|
43
|
+
createRequire(path.join(root, 'package.json')).resolve('expo-router/package.json');
|
|
44
|
+
} catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
return ['src/app', 'app'].find(
|
|
48
|
+
(dir) =>
|
|
49
|
+
fs.existsSync(path.join(root, dir, '_layout.tsx')) ||
|
|
50
|
+
fs.existsSync(path.join(root, dir, '_layout.js')) ||
|
|
51
|
+
fs.existsSync(path.join(root, dir, 'index.tsx')),
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** How the entry gets the App: the root component, or, for Expo Router, the router over the app's route files. */
|
|
56
|
+
type AppSource = { component: string } | { routes: string; dir: string };
|
|
57
|
+
|
|
58
|
+
const appBlock = (source: AppSource): string =>
|
|
59
|
+
'component' in source
|
|
60
|
+
? `// Whatever the app's index runs before rendering (polyfills, i18n, services):
|
|
61
|
+
// require it here too, then the App.
|
|
62
|
+
const { default: App } = require('${source.component}')`
|
|
63
|
+
: `// An Expo Router app has no root component: the router is the app, over the route
|
|
64
|
+
// files in ${source.dir}, read here as the bundler would read them. Whatever the
|
|
65
|
+
// app's root layout does not run before rendering (polyfills, i18n), require it first.
|
|
66
|
+
process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'
|
|
67
|
+
// Its testing library first: it stands in for what has no native side here (expo-linking's
|
|
68
|
+
// scheme, Reanimated), and the router must load after it.
|
|
69
|
+
const { getMockContext } = require('expo-router/testing-library')
|
|
70
|
+
const React = require('react')
|
|
71
|
+
const { ExpoRoot } = require('expo-router')
|
|
72
|
+
const routes = getMockContext(require('path').resolve(__dirname, '${source.routes}'))
|
|
73
|
+
// Mounted as a device build mounts it, so the agent finds the router's navigation by itself.
|
|
74
|
+
require('lynkeus-agent/auto').auto({ token: process.env.LYNKEUS_TOKEN })
|
|
75
|
+
const App = () => React.createElement(ExpoRoot, { context: routes })`;
|
|
76
|
+
|
|
77
|
+
const entryTemplate = (source: AppSource, apiEnv: string, appName: string, locale?: InitOptions['locale']): string => {
|
|
29
78
|
const LOCALE =
|
|
30
79
|
locale?.languageTag || locale?.country || locale?.language
|
|
31
80
|
? `\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 })},`
|
|
@@ -51,9 +100,7 @@ installDefaultMocks({
|
|
|
51
100
|
// lists the native modules the app references that nothing covers yet:
|
|
52
101
|
// jest.mock('some-native-package', () => ({ method: () => undefined }))
|
|
53
102
|
|
|
54
|
-
|
|
55
|
-
// require it here too, then the App.
|
|
56
|
-
const { default: App } = require('${appImport}')
|
|
103
|
+
${appBlock(source)}
|
|
57
104
|
|
|
58
105
|
export { App }
|
|
59
106
|
`;
|
|
@@ -87,14 +134,30 @@ const addTokenPlugin = (root: string): string => {
|
|
|
87
134
|
if (text.includes(TOKEN_PLUGIN)) return `${file} already has ${TOKEN_PLUGIN}`;
|
|
88
135
|
const plugins = /\bplugins\s*:\s*\[/.exec(text);
|
|
89
136
|
const presets = /^([ \t]*)presets\s*:/m.exec(text);
|
|
90
|
-
|
|
137
|
+
// A config written on one line has its presets after the brace, not at the start of a line.
|
|
138
|
+
const inline = plugins || presets ? null : /\bpresets\s*:/.exec(text);
|
|
139
|
+
if (!plugins && !presets && !inline) return byHand;
|
|
91
140
|
const next = plugins
|
|
92
141
|
? `${text.slice(0, plugins.index + plugins[0].length)}'${TOKEN_PLUGIN}', ${text.slice(plugins.index + plugins[0].length)}`
|
|
93
|
-
:
|
|
142
|
+
: presets
|
|
143
|
+
? `${text.slice(0, presets.index)}${presets[1]}plugins: ['${TOKEN_PLUGIN}'],\n${text.slice(presets.index)}`
|
|
144
|
+
: `${text.slice(0, inline!.index)}plugins: ['${TOKEN_PLUGIN}'], ${text.slice(inline!.index)}`;
|
|
94
145
|
fs.writeFileSync(path.join(root, file), next);
|
|
95
146
|
return `added ${TOKEN_PLUGIN} to ${file}`;
|
|
96
147
|
};
|
|
97
148
|
|
|
149
|
+
const EXPO_ENV = '.env.local';
|
|
150
|
+
const EXPO_TOKEN = 'EXPO_PUBLIC_LYNKEUS_TOKEN';
|
|
151
|
+
|
|
152
|
+
/** Expo passes the app only variables that start with EXPO_PUBLIC_, and reads them from `.env.local`, which its template keeps out of git. */
|
|
153
|
+
const giveExpoTheToken = (root: string, token: string): string => {
|
|
154
|
+
const file = path.join(root, EXPO_ENV);
|
|
155
|
+
const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
156
|
+
if (new RegExp(`^${EXPO_TOKEN}=`, 'm').test(text)) return `${EXPO_ENV} already sets ${EXPO_TOKEN}`;
|
|
157
|
+
fs.writeFileSync(file, `${text}${text && !text.endsWith('\n') ? '\n' : ''}${EXPO_TOKEN}=${token}\n`);
|
|
158
|
+
return `wrote ${EXPO_TOKEN} to ${EXPO_ENV} (check it is in .gitignore: Expo's template ignores .env*.local)`;
|
|
159
|
+
};
|
|
160
|
+
|
|
98
161
|
/**
|
|
99
162
|
* Mounts the agent from the app's entry, before anything registers the root component. An Expo Router app
|
|
100
163
|
* has no entry of its own (`"main": "expo-router/entry"`), so it gets one that mounts the agent and then
|
|
@@ -106,7 +169,10 @@ export const mountAgent = (root: string): string[] => {
|
|
|
106
169
|
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
107
170
|
const index = findFirst(root, INDEX_CANDIDATES);
|
|
108
171
|
const routed = manifest.main === 'expo-router/entry';
|
|
109
|
-
|
|
172
|
+
const already = mountedIn(root);
|
|
173
|
+
if (already) {
|
|
174
|
+
notes.push(`${already} already mounts the agent`);
|
|
175
|
+
} else if (routed && !index) {
|
|
110
176
|
fs.writeFileSync(path.join(root, 'index.js'), `${mountLines(true)}\nimport 'expo-router/entry'\n`);
|
|
111
177
|
fs.writeFileSync(
|
|
112
178
|
path.join(root, 'package.json'),
|
|
@@ -119,25 +185,22 @@ export const mountAgent = (root: string): string[] => {
|
|
|
119
185
|
} else {
|
|
120
186
|
const indexPath = path.join(root, index);
|
|
121
187
|
const text = fs.readFileSync(indexPath, 'utf8');
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const at = imports.length ? imports[imports.length - 1]!.index! + imports[imports.length - 1]![0].length + 1 : 0;
|
|
128
|
-
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
129
|
-
notes.push(`added auto() to ${index}`);
|
|
130
|
-
}
|
|
188
|
+
// After the last import, so the app's own polyfills still run first.
|
|
189
|
+
const imports = [...text.matchAll(/^import .*$/gm)];
|
|
190
|
+
const at = imports.length ? imports[imports.length - 1]!.index! + imports[imports.length - 1]![0].length + 1 : 0;
|
|
191
|
+
fs.writeFileSync(indexPath, text.slice(0, at) + mountLines(expo) + text.slice(at));
|
|
192
|
+
notes.push(`added auto() to ${index}`);
|
|
131
193
|
}
|
|
194
|
+
const { token, made } = ensureToken(root);
|
|
195
|
+
if (made) 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');
|
|
132
196
|
if (!expo) {
|
|
133
197
|
notes.push(addTokenPlugin(root));
|
|
134
|
-
notes.push('
|
|
198
|
+
notes.push('restart Metro with --reset-cache so the bundle picks the token up; LYNKEUS_TOKEN in the environment still wins over the file');
|
|
135
199
|
}
|
|
136
200
|
if (expo) {
|
|
201
|
+
notes.push(giveExpoTheToken(root, token));
|
|
137
202
|
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');
|
|
138
|
-
notes.push(
|
|
139
|
-
'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',
|
|
140
|
-
);
|
|
203
|
+
notes.push('Expo: restart the dev server so it reads .env.local; EXPO_PUBLIC_LYNKEUS_TOKEN in the environment still wins over the file');
|
|
141
204
|
}
|
|
142
205
|
return notes;
|
|
143
206
|
};
|
|
@@ -146,17 +209,198 @@ export const init = (root: string, opts: InitOptions): string[] => {
|
|
|
146
209
|
const notes: string[] = [];
|
|
147
210
|
const entryPath = path.join(root, opts.entry);
|
|
148
211
|
const app = opts.app ?? findFirst(root, APP_CANDIDATES);
|
|
149
|
-
|
|
212
|
+
const routes = app ? undefined : routesOf(root);
|
|
213
|
+
if (!app && !routes) throw new Error(`no App component found (${APP_CANDIDATES.join(', ')}) and no Expo Router routes; pass --component <path>`);
|
|
150
214
|
if (fs.existsSync(entryPath) && !opts.force) {
|
|
151
215
|
notes.push(`${opts.entry} exists; left as is (--force overwrites)`);
|
|
152
216
|
} else {
|
|
153
217
|
fs.mkdirSync(path.dirname(entryPath), { recursive: true });
|
|
154
|
-
|
|
155
|
-
|
|
218
|
+
const source: AppSource = app
|
|
219
|
+
? { component: relImport(entryPath, path.join(root, app)) }
|
|
220
|
+
: { routes: path.relative(path.dirname(entryPath), path.join(root, routes!)).replace(/\\/g, '/'), dir: routes! };
|
|
221
|
+
fs.writeFileSync(entryPath, entryTemplate(source, opts.apiEnv, packageName(root), opts.locale));
|
|
222
|
+
notes.push(`wrote ${opts.entry} (${app ? `app: ${app}` : `Expo Router, routes in ${routes}`})`);
|
|
156
223
|
}
|
|
157
224
|
if (opts.auto) notes.push(...mountAgent(root));
|
|
158
|
-
|
|
159
|
-
notes.push(
|
|
225
|
+
// `headless start` refuses to run without one, and an app tested only headless never runs `lynkeus init`.
|
|
226
|
+
else if (ensureToken(root).made) notes.push('made a token for this project in .lynkeus/token (kept out of git); the CLI and the MCP server read it');
|
|
227
|
+
notes.push(
|
|
228
|
+
"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",
|
|
229
|
+
);
|
|
230
|
+
notes.push(
|
|
231
|
+
`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)`,
|
|
232
|
+
);
|
|
160
233
|
notes.push(' then, in another shell: lynkeus screen (or lynkeus headless doctor to see what still needs a mock)');
|
|
161
234
|
return notes;
|
|
162
235
|
};
|
|
236
|
+
|
|
237
|
+
const AGENT = 'lynkeus-agent';
|
|
238
|
+
|
|
239
|
+
/** How this project adds a package: Expo's own installer picks the version its SDK wants; otherwise the manager whose lockfile is there. */
|
|
240
|
+
type Manager = 'bun' | 'pnpm' | 'yarn' | 'npm';
|
|
241
|
+
|
|
242
|
+
/** The package manager whose lockfile is in the app's folder or above it: an app in a monorepo has it at the top. */
|
|
243
|
+
const managerOf = (root: string): Manager => {
|
|
244
|
+
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
|
245
|
+
if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
|
|
246
|
+
if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
|
|
247
|
+
if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
|
|
248
|
+
if (fs.existsSync(path.join(dir, 'package-lock.json')) || fs.existsSync(path.join(dir, '.git')) || path.dirname(dir) === dir) return 'npm';
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const installCommand = (root: string, expo: boolean): string[] => {
|
|
253
|
+
if (expo) return ['npx', 'expo', 'install', AGENT];
|
|
254
|
+
const manager = managerOf(root);
|
|
255
|
+
return manager === 'npm' ? ['npm', 'install', AGENT] : [manager, 'add', AGENT];
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What a failed install said, short enough for one line. npm ends its errors with where its logs are, so
|
|
260
|
+
* the last lines alone told nothing; the reason (a peer range that does not match, say) comes before them.
|
|
261
|
+
*/
|
|
262
|
+
export const installFailure = (ran: { stderr?: string | null; error?: Error }): string =>
|
|
263
|
+
`${ran.stderr ?? ''}\n${ran.error?.message ?? ''}`
|
|
264
|
+
.split('\n')
|
|
265
|
+
.map((line) => line.replace(/^(npm (error|ERR!)|error|ERR_PNPM_\w+|YN\d{4}:)\s*/, '').trim())
|
|
266
|
+
.filter(
|
|
267
|
+
(line) => line && !/^code \w+$|^node_modules\/|_logs|complete log|full report|^Fix the upstream|^this command with|^to accept an incorrect/i.test(line),
|
|
268
|
+
)
|
|
269
|
+
.slice(0, 8)
|
|
270
|
+
.join('; ');
|
|
271
|
+
|
|
272
|
+
const hasAgent = (manifest: Manifest): boolean => !!(manifest.dependencies?.[AGENT] ?? manifest.devDependencies?.[AGENT]);
|
|
273
|
+
|
|
274
|
+
/** Adds lynkeus-agent to the app when it is not a dependency yet. It has native code, so the app is built again after. */
|
|
275
|
+
export const installAgent = (root: string): string => {
|
|
276
|
+
const manifest = readManifest(root);
|
|
277
|
+
if (hasAgent(manifest)) return `${AGENT} is already a dependency`;
|
|
278
|
+
const command = installCommand(root, !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo));
|
|
279
|
+
const ran = spawnSync(command[0]!, command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
280
|
+
if (ran.status === 0) return `installed ${AGENT} (${command.join(' ')}); it has native code, so build the app again`;
|
|
281
|
+
const said = installFailure(ran);
|
|
282
|
+
return `could not install ${AGENT} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself, then \`lynkeus init\` again`;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
export type SetupCheck = { what: string; ok: boolean; fix?: string };
|
|
286
|
+
|
|
287
|
+
/** What a project has of lynkeus and what it still lacks, each with the one thing that fixes it. Reads files only. */
|
|
288
|
+
export const setupChecks = (root: string): SetupCheck[] => {
|
|
289
|
+
const manifest = readManifest(root);
|
|
290
|
+
if (!manifest.dependencies && !manifest.devDependencies)
|
|
291
|
+
return [{ what: 'a package.json here', ok: false, fix: 'run this from the app\u2019s folder (or pass --root)' }];
|
|
292
|
+
const expo = !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
293
|
+
const checks: SetupCheck[] = [];
|
|
294
|
+
const depends = hasAgent(manifest);
|
|
295
|
+
checks.push({ what: `${AGENT} is a dependency`, ok: depends, fix: '`lynkeus init` installs it' });
|
|
296
|
+
if (depends)
|
|
297
|
+
checks.push({
|
|
298
|
+
what: `${AGENT} is installed`,
|
|
299
|
+
ok: fs.existsSync(path.join(root, 'node_modules', AGENT, 'package.json')),
|
|
300
|
+
fix: 'install the project\u2019s dependencies',
|
|
301
|
+
});
|
|
302
|
+
const mounted = mountedIn(root);
|
|
303
|
+
checks.push({ what: mounted ? `${mounted} mounts the agent` : 'the app mounts the agent', ok: !!mounted, fix: '`lynkeus init` adds it to the entry' });
|
|
304
|
+
if (!expo) {
|
|
305
|
+
const babel = findFirst(root, BABEL_CANDIDATES);
|
|
306
|
+
checks.push({
|
|
307
|
+
what: 'babel.config.js has lynkeus-agent/babel',
|
|
308
|
+
ok: !!babel && fs.readFileSync(path.join(root, babel), 'utf8').includes(TOKEN_PLUGIN),
|
|
309
|
+
fix: '`lynkeus init` adds it',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
const token = readToken(root);
|
|
313
|
+
checks.push({ what: 'the project has a token', ok: !!token, fix: '`lynkeus init` makes one in .lynkeus/token' });
|
|
314
|
+
if (expo && token) {
|
|
315
|
+
const env = path.join(root, EXPO_ENV);
|
|
316
|
+
const given = !!process.env[EXPO_TOKEN] || (fs.existsSync(env) && new RegExp(`^${EXPO_TOKEN}=`, 'm').test(fs.readFileSync(env, 'utf8')));
|
|
317
|
+
checks.push({ what: `Expo is given the token (${EXPO_TOKEN})`, ok: given, fix: '`lynkeus init` writes it to .env.local' });
|
|
318
|
+
}
|
|
319
|
+
return checks;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
/** What `lynkeus headless start` runs on: jest hosts the app, Testing Library renders it, lynkeus-headless serves the agent. */
|
|
323
|
+
const HEADLESS_PACKAGES = ['jest', '@testing-library/react-native', 'lynkeus-headless'];
|
|
324
|
+
|
|
325
|
+
const resolvable = (root: string, name: string): boolean => {
|
|
326
|
+
try {
|
|
327
|
+
createRequire(path.join(root, 'package.json')).resolve(`${name}/package.json`);
|
|
328
|
+
return true;
|
|
329
|
+
} catch (error) {
|
|
330
|
+
// A package whose exports leave package.json out is still there.
|
|
331
|
+
return (error as { code?: string }).code === 'ERR_PACKAGE_PATH_NOT_EXPORTED';
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
const JEST_PRESET = '@react-native/jest-preset';
|
|
336
|
+
|
|
337
|
+
/** The app's React Native version, when it is installed. */
|
|
338
|
+
const reactNativeVersion = (root: string): string | undefined => {
|
|
339
|
+
try {
|
|
340
|
+
const file = createRequire(path.join(root, 'package.json')).resolve('react-native/package.json');
|
|
341
|
+
return (JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: string }).version;
|
|
342
|
+
} catch {
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
// React Native 0.86 moved its jest preset out of `react-native`, which now throws when jest asks it for one.
|
|
348
|
+
const presetMoved = (version: string | undefined): boolean => {
|
|
349
|
+
const [major, minor] = (version ?? '').split('.').map(Number);
|
|
350
|
+
return major === 0 ? (minor ?? 0) >= 86 : (major ?? 0) > 0;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const JEST_EXPO = 'jest-expo';
|
|
354
|
+
|
|
355
|
+
const isExpo = (root: string): boolean => {
|
|
356
|
+
const manifest = readManifest(root);
|
|
357
|
+
return !!(manifest.dependencies?.expo ?? manifest.devDependencies?.expo);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The jest preset the app needs and lacks: an Expo app's is jest-expo, which gives Expo's modules the native
|
|
362
|
+
* side they look for (\`globalThis.expo\`); React Native 0.86 and later keep theirs out of \`react-native\`.
|
|
363
|
+
*/
|
|
364
|
+
const presetNeeded = (root: string): string | undefined => (isExpo(root) ? JEST_EXPO : presetMoved(reactNativeVersion(root)) ? JEST_PRESET : undefined);
|
|
365
|
+
|
|
366
|
+
/** The packages the headless host needs that this project cannot load yet. */
|
|
367
|
+
export const headlessMissing = (root: string): string[] =>
|
|
368
|
+
[...HEADLESS_PACKAGES, ...[presetNeeded(root)].filter((p): p is string => !!p)].filter((name) => !resolvable(root, name));
|
|
369
|
+
|
|
370
|
+
/** The preset \`headless start\` gives jest when the app's own configuration names none. */
|
|
371
|
+
export const jestPreset = (root: string): string => (resolvable(root, JEST_EXPO) ? JEST_EXPO : resolvable(root, JEST_PRESET) ? JEST_PRESET : 'react-native');
|
|
372
|
+
|
|
373
|
+
const devInstallCommand = (root: string, names: string[]): string[] =>
|
|
374
|
+
({ bun: ['bun', 'add', '-d'], pnpm: ['pnpm', 'add', '-D'], yarn: ['yarn', 'add', '-D'], npm: ['npm', 'install', '--save-dev'] })[managerOf(root)].concat(
|
|
375
|
+
names,
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
const install = (root: string, command: string[], names: string[]): string => {
|
|
379
|
+
const ran = spawnSync(command[0]!, command.slice(1), { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
380
|
+
if (ran.status === 0) return `installed ${names.join(', ')} (${command.join(' ')})`;
|
|
381
|
+
const said = installFailure(ran);
|
|
382
|
+
return `could not install ${names.join(', ')} with \`${command.join(' ')}\`${said ? `: ${said}` : ''}; run it yourself`;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
/** Adds what the headless host needs as development dependencies; a line saying what happened. */
|
|
386
|
+
export const installHeadless = (root: string): string => {
|
|
387
|
+
const missing = headlessMissing(root);
|
|
388
|
+
if (!missing.length) return `jest, Testing Library, lynkeus-headless and the ${jestPreset(root)} preset are installed`;
|
|
389
|
+
const lines: string[] = [];
|
|
390
|
+
const plain = missing.filter((name) => name !== JEST_EXPO);
|
|
391
|
+
// The React Native preset is released with React Native, version for version.
|
|
392
|
+
if (plain.length)
|
|
393
|
+
lines.push(
|
|
394
|
+
install(
|
|
395
|
+
root,
|
|
396
|
+
devInstallCommand(
|
|
397
|
+
root,
|
|
398
|
+
plain.map((name) => (name === JEST_PRESET ? `${name}@${reactNativeVersion(root)}` : name)),
|
|
399
|
+
),
|
|
400
|
+
plain,
|
|
401
|
+
),
|
|
402
|
+
);
|
|
403
|
+
// Expo's installer picks the jest-expo of the app's SDK.
|
|
404
|
+
if (missing.includes(JEST_EXPO)) lines.push(install(root, ['npx', 'expo', 'install', JEST_EXPO, '--', '--save-dev'], [JEST_EXPO]));
|
|
405
|
+
return lines.join('\n');
|
|
406
|
+
};
|
package/src/commands/report.ts
CHANGED
|
@@ -118,9 +118,9 @@ export const plan = async (o: Service & { go?: 'upgrade' | 'manage' }) => {
|
|
|
118
118
|
};
|
|
119
119
|
|
|
120
120
|
export const service = (): Service | string => {
|
|
121
|
-
const url = process.env.LYNKEUS_REPORTS_URL;
|
|
121
|
+
const url = process.env.LYNKEUS_REPORTS_URL || 'https://lynkeus.io';
|
|
122
122
|
const key = process.env.LYNKEUS_REPORTS_KEY;
|
|
123
|
-
return
|
|
123
|
+
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';
|
|
124
124
|
};
|
|
125
125
|
|
|
126
126
|
export const reportCommands = [
|
package/src/config.ts
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
|
|
|
@@ -10,6 +11,33 @@ export const graphFile = (root?: string) => path.join(lynkeusDir(root), 'graph.j
|
|
|
10
11
|
export const edgesFile = (root?: string) => path.join(lynkeusDir(root), 'edges.jsonl');
|
|
11
12
|
export const frontierFile = (root?: string) => path.join(lynkeusDir(root), 'frontier.jsonl');
|
|
12
13
|
export const runsDir = (root?: string) => path.join(lynkeusDir(root), 'runs');
|
|
14
|
+
export const tokenFile = (root?: string) => path.join(lynkeusDir(root), 'token');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The secret the app and its driver share. LYNKEUS_TOKEN wins; otherwise it is the one `lynkeus init` wrote to
|
|
18
|
+
* `.lynkeus/token`, which the app's bundle reads too, so nobody has to set a variable in two terminals.
|
|
19
|
+
*/
|
|
20
|
+
export const readToken = (root?: string): string | undefined => {
|
|
21
|
+
if (process.env.LYNKEUS_TOKEN) return process.env.LYNKEUS_TOKEN;
|
|
22
|
+
try {
|
|
23
|
+
return fs.readFileSync(tokenFile(root), 'utf8').trim() || undefined;
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Makes the project's token if it has none, and keeps it out of git: `.lynkeus` also holds what a team commits. */
|
|
30
|
+
export const ensureToken = (root?: string): { token: string; made: boolean } => {
|
|
31
|
+
const had = readToken(root);
|
|
32
|
+
if (had) return { token: had, made: false };
|
|
33
|
+
const token = crypto.randomBytes(24).toString('hex');
|
|
34
|
+
fs.mkdirSync(lynkeusDir(root), { recursive: true });
|
|
35
|
+
fs.writeFileSync(tokenFile(root), `${token}\n`, { mode: 0o600 });
|
|
36
|
+
const ignore = path.join(lynkeusDir(root), '.gitignore');
|
|
37
|
+
const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
38
|
+
if (!ignored.split('\n').includes('token')) fs.writeFileSync(ignore, `${ignored}${ignored && !ignored.endsWith('\n') ? '\n' : ''}token\n`);
|
|
39
|
+
return { token, made: true };
|
|
40
|
+
};
|
|
13
41
|
|
|
14
42
|
export const loadGraph = (root?: string): ScreenGraph | null => {
|
|
15
43
|
const file = graphFile(root);
|
|
@@ -27,6 +55,11 @@ export type LynkeusConfig = {
|
|
|
27
55
|
entry?: string;
|
|
28
56
|
/** The environment variable the app reads its API base from; the headless entry refuses one that is not local. */
|
|
29
57
|
apiEnv?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The local backend `headless start` hands the app under `apiEnv` when the environment sets none: an agent
|
|
60
|
+
* starting the host over MCP cannot export a variable.
|
|
61
|
+
*/
|
|
62
|
+
apiUrl?: string;
|
|
30
63
|
/** The driver port the app dials. */
|
|
31
64
|
port?: number;
|
|
32
65
|
/** Simulator udid or name. */
|
package/src/device/driver.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { AgentServer } from 'lynkeus-client';
|
|
7
7
|
import type { CoreMethods, Screen, TraceEvent } from 'lynkeus-protocol';
|
|
8
8
|
|
|
9
|
-
import { portFromEnv } from '../config.js';
|
|
9
|
+
import { portFromEnv, readToken } from '../config.js';
|
|
10
10
|
import { withoutHost } from '../knowledge/problems.js';
|
|
11
11
|
import type { DeviceInfo } from './devices.js';
|
|
12
12
|
import { lifecycleFor, type PlatformLifecycle } from './platform.js';
|
|
@@ -23,7 +23,7 @@ export class Device {
|
|
|
23
23
|
this.info = info;
|
|
24
24
|
this.#resolving = options.resolving;
|
|
25
25
|
// The app serves commands only to a driver that presents its token; without one, anything on the loopback could drive a dev build.
|
|
26
|
-
this.server = options.server ?? new AgentServer({ token:
|
|
26
|
+
this.server = options.server ?? new AgentServer({ token: readToken(), port: portFromEnv() });
|
|
27
27
|
this.#lifecycle = lifecycleFor(info.platform);
|
|
28
28
|
this.#appId = options.appId ?? null;
|
|
29
29
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -60,9 +60,23 @@ const argvFor = (c: Command, input: Record<string, unknown>): string[] => {
|
|
|
60
60
|
return av;
|
|
61
61
|
};
|
|
62
62
|
|
|
63
|
+
// What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
|
|
64
|
+
// a test for this" need nothing more from the person asking.
|
|
65
|
+
const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
|
|
66
|
+
|
|
67
|
+
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.
|
|
68
|
+
|
|
69
|
+
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.
|
|
70
|
+
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
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.`;
|
|
76
|
+
|
|
63
77
|
export const serve = async (session: Session, options: { full?: boolean } = {}) => {
|
|
64
78
|
const { version } = createRequire(import.meta.url)('../../package.json') as { version: string };
|
|
65
|
-
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
|
|
79
|
+
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
66
80
|
const served = commands().filter((c) => overMcp(c, !!options.full));
|
|
67
81
|
const byName = new Map(served.map((c) => [identifier(c.name), c]));
|
|
68
82
|
// So a model reading the app's screens knows their language before it starts guessing.
|
|
@@ -105,5 +119,20 @@ export const serve = async (session: Session, options: { full?: boolean } = {})
|
|
|
105
119
|
if (r.error) return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
|
|
106
120
|
return text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
|
|
107
121
|
});
|
|
108
|
-
|
|
122
|
+
// The client going away ends the server: the session holds the driver's port open, and a server left behind
|
|
123
|
+
// keeps the next one (or a case run) from binding it.
|
|
124
|
+
const transport = new StdioServerTransport();
|
|
125
|
+
let stopping = false;
|
|
126
|
+
const stop = () => {
|
|
127
|
+
if (stopping) return;
|
|
128
|
+
stopping = true;
|
|
129
|
+
void session
|
|
130
|
+
.stop()
|
|
131
|
+
.catch(() => undefined)
|
|
132
|
+
.finally(() => process.exit(0));
|
|
133
|
+
};
|
|
134
|
+
process.stdin.once('end', stop);
|
|
135
|
+
process.stdin.once('close', stop);
|
|
136
|
+
transport.onclose = stop;
|
|
137
|
+
await server.connect(transport);
|
|
109
138
|
};
|