agent-device-lab 0.0.0-stage → 0.3.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/LICENSE +202 -0
- package/NOTICE +10 -0
- package/README.md +142 -2
- package/assets/dashboard/app.css +238 -0
- package/assets/dashboard/app.js +976 -0
- package/assets/dashboard/index.html +120 -0
- package/bin/agentlab.js +8 -0
- package/dist/cli/ci.js +443 -0
- package/dist/cli/flow.js +127 -0
- package/dist/cli/main.js +855 -0
- package/dist/cli/signals.js +38 -0
- package/dist/core/action-log.js +125 -0
- package/dist/core/auth.js +175 -0
- package/dist/core/bundle.js +432 -0
- package/dist/core/checks.js +137 -0
- package/dist/core/ci.js +389 -0
- package/dist/core/commands.js +366 -0
- package/dist/core/control.js +217 -0
- package/dist/core/detectors.js +392 -0
- package/dist/core/devices.js +83 -0
- package/dist/core/doctor.js +203 -0
- package/dist/core/explore-safety.js +110 -0
- package/dist/core/extract.js +1445 -0
- package/dist/core/feed.js +493 -0
- package/dist/core/findings.js +179 -0
- package/dist/core/format.js +311 -0
- package/dist/core/init.js +607 -0
- package/dist/core/lab.js +1979 -0
- package/dist/core/migrate.js +35 -0
- package/dist/core/observation.js +161 -0
- package/dist/core/ownership.js +142 -0
- package/dist/core/process-group.js +206 -0
- package/dist/core/process-identity.js +56 -0
- package/dist/core/profile.js +816 -0
- package/dist/core/project-runner.js +359 -0
- package/dist/core/replay.js +303 -0
- package/dist/core/reproduction.js +36 -0
- package/dist/core/scan-policy.js +156 -0
- package/dist/core/scan-report.js +145 -0
- package/dist/core/scan.js +455 -0
- package/dist/core/schema.js +39 -0
- package/dist/core/services.js +134 -0
- package/dist/core/steps.js +80 -0
- package/dist/core/sweep.js +138 -0
- package/dist/core/trace-sanitize.js +641 -0
- package/dist/core/url-redact.js +25 -0
- package/dist/core/versions.js +29 -0
- package/dist/core/zip.js +264 -0
- package/dist/daemon/main.js +160 -0
- package/dist/daemon/state.js +93 -0
- package/dist/dashboard/open.js +25 -0
- package/dist/dashboard/server.js +366 -0
- package/dist/mcp/server.js +89 -0
- package/package.json +58 -4
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { appendFile, readdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { LabError } from './schema.js';
|
|
4
|
+
import { PROFILE_FILE, parseProfile } from './profile.js';
|
|
5
|
+
/** Dev servers by dependency and the command word that runs them. More specific first (several use vite). */
|
|
6
|
+
const FRAMEWORKS = [
|
|
7
|
+
{ dep: '@sveltejs/kit', bins: [], port: 5173 },
|
|
8
|
+
{ dep: 'next', bins: ['next'], port: 3000, timeoutMs: 120_000 },
|
|
9
|
+
{ dep: 'nuxt', bins: ['nuxt', 'nuxi'], port: 3000, timeoutMs: 120_000 },
|
|
10
|
+
{ dep: 'astro', bins: ['astro'], port: 4321 },
|
|
11
|
+
{ dep: 'gatsby', bins: ['gatsby'], port: 8000 },
|
|
12
|
+
{ dep: '@angular/cli', bins: ['ng'], port: 4200 },
|
|
13
|
+
{ dep: 'react-scripts', bins: ['react-scripts'], port: 3000 },
|
|
14
|
+
{ dep: '@vue/cli-service', bins: ['vue-cli-service'], port: 8080 },
|
|
15
|
+
{ dep: 'parcel', bins: ['parcel'], port: 1234 },
|
|
16
|
+
{ dep: 'webpack-dev-server', bins: ['webpack-dev-server'], port: 8080 },
|
|
17
|
+
{ dep: 'vite', bins: ['vite'], port: 5173 },
|
|
18
|
+
];
|
|
19
|
+
const API_DEPS = ['express', 'fastify', 'koa', 'hono', '@nestjs/core', '@hapi/hapi'];
|
|
20
|
+
const NAMES = {
|
|
21
|
+
web: ['web', 'frontend', 'client', 'app', 'ui', 'site'],
|
|
22
|
+
api: ['api', 'server', 'backend'],
|
|
23
|
+
worker: ['worker', 'jobs', 'queue'],
|
|
24
|
+
};
|
|
25
|
+
const LOCKFILES = [
|
|
26
|
+
['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
|
|
27
|
+
];
|
|
28
|
+
const RUN_SCRIPTS = ['dev', 'serve', 'start', 'preview'];
|
|
29
|
+
const SERVICE_DIRS = ['web', 'frontend', 'client', 'app', 'ui', 'site', 'api', 'server', 'backend', 'worker', 'jobs'];
|
|
30
|
+
const GROUP_DIRS = ['apps', 'packages', 'services'];
|
|
31
|
+
const ROOT_SCRIPT_NAMES = ['api', 'server', 'backend', 'web', 'frontend', 'client', 'worker', 'jobs'];
|
|
32
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build']);
|
|
33
|
+
const ENV_EXAMPLES = ['.env.example', '.env.sample', '.env.template'];
|
|
34
|
+
const PLAYWRIGHT_CONFIGS = ['ts', 'js', 'mjs', 'cjs'].map((e) => `playwright.config.${e}`);
|
|
35
|
+
const COMPOSE_FILES = ['compose.yaml', 'compose.yml', 'docker-compose.yml', 'docker-compose.yaml'];
|
|
36
|
+
/** Published ports that are databases, caches or brokers rather than a browsable app. */
|
|
37
|
+
const NON_HTTP_PORTS = new Set([5432, 3306, 6379, 27017, 9200, 5672, 11211]);
|
|
38
|
+
const MAX_ENTRY_BYTES = 200 * 1024;
|
|
39
|
+
const GITIGNORE_BLOCK = '\n# Agent Device Lab: local runs, logs and saved sign-in state\n.agentlab/\n';
|
|
40
|
+
const IGNORE_LINES = new Set(['.agentlab', '.agentlab/', '/.agentlab', '/.agentlab/']);
|
|
41
|
+
const ORDER = ['compose', 'api', 'worker', 'web'];
|
|
42
|
+
/**
|
|
43
|
+
* Inspect a project directory read-only and propose an agentlab.json. Never runs project commands,
|
|
44
|
+
* never writes, and never opens `.env`: only example env files, and only their names.
|
|
45
|
+
*/
|
|
46
|
+
export async function proposeProfile(dir) {
|
|
47
|
+
const root = resolve(dir);
|
|
48
|
+
const detections = [];
|
|
49
|
+
const warnings = [];
|
|
50
|
+
const found = (kind, path, detail) => detections.push({ kind, path: path || '.', detail });
|
|
51
|
+
const existing = await isFile(join(root, PROFILE_FILE));
|
|
52
|
+
if (existing) {
|
|
53
|
+
found('existing-profile', PROFILE_FILE, 'already present');
|
|
54
|
+
warnings.push(`${PROFILE_FILE} already exists; writing this proposal replaces it only when forced.`);
|
|
55
|
+
}
|
|
56
|
+
// Packages: the root, then workspaces, then conventional service directories.
|
|
57
|
+
const rootPkg = await readPackage(root, '', found, warnings);
|
|
58
|
+
const subPkgs = [];
|
|
59
|
+
const seen = new Set(['']);
|
|
60
|
+
for (const rel of await candidateDirs(root, rootPkg)) {
|
|
61
|
+
if (seen.has(rel))
|
|
62
|
+
continue;
|
|
63
|
+
seen.add(rel);
|
|
64
|
+
const pkg = await readPackage(root, rel, found, warnings);
|
|
65
|
+
if (pkg)
|
|
66
|
+
subPkgs.push(pkg);
|
|
67
|
+
}
|
|
68
|
+
const envCache = new Map();
|
|
69
|
+
const env = async (rel) => {
|
|
70
|
+
if (!envCache.has(rel))
|
|
71
|
+
envCache.set(rel, await readEnvNames(root, rel, found));
|
|
72
|
+
return envCache.get(rel);
|
|
73
|
+
};
|
|
74
|
+
const drafts = [];
|
|
75
|
+
const pm = async (rel) => packageManager(root, rel, found);
|
|
76
|
+
for (const pkg of subPkgs) {
|
|
77
|
+
const script = RUN_SCRIPTS.find((s) => pkg.scripts[s]);
|
|
78
|
+
if (!script)
|
|
79
|
+
continue;
|
|
80
|
+
const text = pkg.scripts[script];
|
|
81
|
+
found('package-script', join(pkg.rel, 'package.json'), `scripts.${script} = ${JSON.stringify(text)}`);
|
|
82
|
+
const category = classify(basename(pkg.rel), pkg.deps);
|
|
83
|
+
drafts.push({
|
|
84
|
+
base: category === 'other' ? serviceName(basename(pkg.rel)) : category, category,
|
|
85
|
+
command: `${await pm(pkg.rel)} run ${script}`, cwd: pkg.rel, scriptText: text, pkg,
|
|
86
|
+
requiredEnv: unique([...await env(pkg.rel), ...await env('')]),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (rootPkg) {
|
|
90
|
+
// Root scripts named after a service (api, dev:web, start:worker, ...).
|
|
91
|
+
const fromSubs = new Set(drafts.map((d) => d.base));
|
|
92
|
+
const rootDrafts = [];
|
|
93
|
+
for (const n of ROOT_SCRIPT_NAMES) {
|
|
94
|
+
const script = [`dev:${n}`, n, `start:${n}`].find((s) => rootPkg.scripts[s]);
|
|
95
|
+
if (!script)
|
|
96
|
+
continue;
|
|
97
|
+
const text = rootPkg.scripts[script];
|
|
98
|
+
const category = classify(n, new Set());
|
|
99
|
+
if (fromSubs.has(category)) {
|
|
100
|
+
found('root-script', 'package.json', `scripts.${script} skipped: the ${category} package already provides it`);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
found('root-script', 'package.json', `scripts.${script} = ${JSON.stringify(text)}`);
|
|
104
|
+
rootDrafts.push({ base: category, category, command: `${await pm('')} run ${script}`, cwd: '', scriptText: text, pkg: rootPkg, requiredEnv: await env('') });
|
|
105
|
+
}
|
|
106
|
+
// The root's own dev script, unless it only orchestrates services found above.
|
|
107
|
+
const script = RUN_SCRIPTS.find((s) => rootPkg.scripts[s]);
|
|
108
|
+
if (script) {
|
|
109
|
+
const text = rootPkg.scripts[script];
|
|
110
|
+
const orchestrates = drafts.length > 0 || rootDrafts.some((d) => d.category === 'web');
|
|
111
|
+
found('package-script', 'package.json', `scripts.${script} = ${JSON.stringify(text)}${orchestrates ? ' (not a service: it runs the packages above)' : ''}`);
|
|
112
|
+
if (!orchestrates) {
|
|
113
|
+
const byDeps = classify('', rootPkg.deps);
|
|
114
|
+
const category = byDeps === 'other' ? 'web' : byDeps;
|
|
115
|
+
rootDrafts.unshift({ base: category, category, command: `${await pm('')} run ${script}`, cwd: '', scriptText: text, pkg: rootPkg, requiredEnv: await env('') });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
drafts.push(...rootDrafts);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
await env('');
|
|
122
|
+
}
|
|
123
|
+
// Ports and frameworks.
|
|
124
|
+
for (const d of drafts) {
|
|
125
|
+
if (d.category === 'worker')
|
|
126
|
+
continue;
|
|
127
|
+
d.framework = frameworkFor(d.scriptText, d.pkg.deps, d.category);
|
|
128
|
+
const at = join(d.cwd, 'package.json');
|
|
129
|
+
const explicit = scriptPort(d.scriptText);
|
|
130
|
+
if (explicit) {
|
|
131
|
+
d.port = explicit;
|
|
132
|
+
found('port', at, `${explicit} from the script text`);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const entry = await entryPort(root, d.cwd, d.scriptText);
|
|
136
|
+
if (entry) {
|
|
137
|
+
d.port = entry.port;
|
|
138
|
+
found('port', entry.path, `${entry.port} from the entry file`);
|
|
139
|
+
if (entry.health) {
|
|
140
|
+
d.health = entry.health;
|
|
141
|
+
found('readiness', entry.path, `route ${entry.health} in the entry file`);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const envPort = await exampleEnvPort(root, d.cwd);
|
|
146
|
+
if (envPort) {
|
|
147
|
+
d.port = envPort.port;
|
|
148
|
+
found('port', envPort.path, `${envPort.port} from PORT in the example env file`);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (d.framework) {
|
|
152
|
+
d.port = /\bvite\s+preview\b/.test(d.scriptText) ? 4173 : d.framework.port;
|
|
153
|
+
found('framework', at, `${d.framework.dep} (default port ${d.port})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// Names: clashes get a numeric suffix.
|
|
157
|
+
const services = {};
|
|
158
|
+
const named = [];
|
|
159
|
+
for (const d of drafts) {
|
|
160
|
+
let name = d.base;
|
|
161
|
+
for (let i = 2; named.some((n) => n.name === name) || name === 'compose'; i++)
|
|
162
|
+
name = `${d.base}-${i}`;
|
|
163
|
+
named.push({ name, d });
|
|
164
|
+
}
|
|
165
|
+
// Playwright config: its webServer is the most reliable way the project already starts the app.
|
|
166
|
+
const pw = await readPlaywright(root, found);
|
|
167
|
+
const web = named.find((n) => n.name === 'web');
|
|
168
|
+
if (pw && web) {
|
|
169
|
+
const origin = originOf(pw.webServerUrl) ?? originOf(pw.baseURL);
|
|
170
|
+
if (pw.webServerCommand) {
|
|
171
|
+
web.d.command = pw.webServerCommand;
|
|
172
|
+
web.d.cwd = pw.cwd;
|
|
173
|
+
}
|
|
174
|
+
if (origin)
|
|
175
|
+
web.d.url = origin;
|
|
176
|
+
}
|
|
177
|
+
const compose = await readCompose(root, found);
|
|
178
|
+
for (const { name, d } of named) {
|
|
179
|
+
const svc = { command: d.command };
|
|
180
|
+
if (d.cwd && d.cwd !== '.')
|
|
181
|
+
svc.cwd = d.cwd.split(sep).join('/');
|
|
182
|
+
const url = d.category === 'worker' ? undefined : d.url ?? (d.port ? `http://localhost:${d.port}` : undefined);
|
|
183
|
+
if (url) {
|
|
184
|
+
svc.url = url;
|
|
185
|
+
svc.readiness = { path: d.health ?? '/', timeoutMs: d.framework?.timeoutMs ?? 60_000 };
|
|
186
|
+
}
|
|
187
|
+
else if (d.category === 'worker') {
|
|
188
|
+
svc.readiness = { alive: 2000 };
|
|
189
|
+
warnings.push(`services.${name}: readiness only checks that it is still running after 2 s; if it prints a line when ready, use {"log": "<that line>"} instead.`);
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
svc.readiness = { alive: 2000 };
|
|
193
|
+
warnings.push(`services.${name}: no port found; set its "url" (or a {"tcp": "host:port"} / {"log": "..."} readiness) so the lab knows when it is ready.`);
|
|
194
|
+
}
|
|
195
|
+
if (d.requiredEnv.length)
|
|
196
|
+
svc.requiredEnv = d.requiredEnv;
|
|
197
|
+
services[name] = svc;
|
|
198
|
+
}
|
|
199
|
+
if (compose) {
|
|
200
|
+
const first = compose.ports[0];
|
|
201
|
+
services.compose = {
|
|
202
|
+
command: 'docker compose up -d', mode: 'oneshot',
|
|
203
|
+
readiness: first ? { tcp: `127.0.0.1:${first}`, timeoutMs: 120_000 } : { timeoutMs: 120_000 },
|
|
204
|
+
shutdown: { command: 'docker compose stop' },
|
|
205
|
+
};
|
|
206
|
+
if (!first)
|
|
207
|
+
warnings.push(`services.compose: ${compose.file} publishes no ports, so readiness only waits for \`docker compose up -d\` to exit; add a {"tcp": "127.0.0.1:<port>"} check if a service must be reachable.`);
|
|
208
|
+
}
|
|
209
|
+
// Dependencies: compose first, then api.
|
|
210
|
+
for (const [name, svc] of Object.entries(services)) {
|
|
211
|
+
if (name === 'compose')
|
|
212
|
+
continue;
|
|
213
|
+
const deps = [];
|
|
214
|
+
if (services.compose)
|
|
215
|
+
deps.push('compose');
|
|
216
|
+
const category = named.find((n) => n.name === name)?.d.category;
|
|
217
|
+
if (services.api && name !== 'api' && (category === 'web' || category === 'worker'))
|
|
218
|
+
deps.push('api');
|
|
219
|
+
if (deps.length)
|
|
220
|
+
svc.dependsOn = deps;
|
|
221
|
+
}
|
|
222
|
+
// A Django project (manage.py at the root) when nothing in the Node ecosystem was found. Its python
|
|
223
|
+
// is a virtualenv next to it when there is one; readiness is a TCP check because many Django projects
|
|
224
|
+
// answer 404 at "/" (an admin-only site, for example).
|
|
225
|
+
if (!Object.keys(services).length && await isFile(join(root, 'manage.py'))) {
|
|
226
|
+
const python = await virtualenvPython(root);
|
|
227
|
+
found('django', 'manage.py', python ? `python from ${python.split('/').slice(0, -2).join('/')}` : 'no virtualenv found next to it');
|
|
228
|
+
services.web = {
|
|
229
|
+
command: `${python ?? 'python3'} manage.py runserver 127.0.0.1:8000 --noreload`,
|
|
230
|
+
url: 'http://127.0.0.1:8000',
|
|
231
|
+
readiness: { tcp: '127.0.0.1:8000', timeoutMs: 60_000 },
|
|
232
|
+
};
|
|
233
|
+
if (!python)
|
|
234
|
+
warnings.push('services.web: no virtualenv was found next to manage.py, so the command uses python3; point it at the project\'s environment if Django is installed there.');
|
|
235
|
+
warnings.push('services.web: Django was detected; set "startPath" to a page that exists (e.g. /admin/), and run migrations first (agentlab does not run project commands during init).');
|
|
236
|
+
}
|
|
237
|
+
const placeholder = Object.keys(services).length === 0;
|
|
238
|
+
if (placeholder) {
|
|
239
|
+
services.web = { command: 'npm run dev', url: 'http://localhost:3000', readiness: { path: '/', timeoutMs: 60_000 } };
|
|
240
|
+
warnings.unshift('NOTHING RUNNABLE WAS FOUND: services.web is a placeholder. Replace its "command" and "url" with how this project really starts.');
|
|
241
|
+
}
|
|
242
|
+
const ordered = {};
|
|
243
|
+
const rank = (n) => (ORDER.includes(n) ? ORDER.indexOf(n) : ORDER.length);
|
|
244
|
+
for (const n of Object.keys(services).sort((a, b) => rank(a) - rank(b)))
|
|
245
|
+
ordered[n] = services[n];
|
|
246
|
+
const profile = {
|
|
247
|
+
schemaVersion: 2,
|
|
248
|
+
name: typeof rootPkg?.json.name === 'string' && rootPkg.json.name ? rootPkg.json.name : basename(root),
|
|
249
|
+
services: ordered,
|
|
250
|
+
};
|
|
251
|
+
if (ordered.web?.url)
|
|
252
|
+
profile.app = { service: 'web' };
|
|
253
|
+
else if (compose?.httpPort)
|
|
254
|
+
profile.app = { url: `http://localhost:${compose.httpPort}` };
|
|
255
|
+
profile.startPath = '/';
|
|
256
|
+
profile.device = 'mobile-390';
|
|
257
|
+
const envNames = unique(Object.values(ordered).flatMap((s) => s.requiredEnv ?? []));
|
|
258
|
+
if (envNames.length)
|
|
259
|
+
warnings.push(`Export ${envNames.join(', ')} in your shell before \`agentlab start\`; the lab checks they are set and never reads .env files.`);
|
|
260
|
+
try {
|
|
261
|
+
parseProfile(profile, join(root, PROFILE_FILE));
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const problems = err.details?.problems;
|
|
265
|
+
if (Array.isArray(problems))
|
|
266
|
+
for (const p of problems)
|
|
267
|
+
warnings.push(`Profile check: ${String(p)}`);
|
|
268
|
+
else
|
|
269
|
+
warnings.push(`Profile check: ${err.message}`);
|
|
270
|
+
}
|
|
271
|
+
const gitignoreFile = join(root, '.gitignore');
|
|
272
|
+
const gitignoreText = await readFile(gitignoreFile, 'utf8').catch(() => undefined);
|
|
273
|
+
const ignoresState = gitignoreText !== undefined && ignores(gitignoreText);
|
|
274
|
+
found('gitignore', '.gitignore', gitignoreText === undefined ? 'absent' : ignoresState ? 'ignores .agentlab/' : 'does not ignore .agentlab/');
|
|
275
|
+
return { dir: root, profile, detections, warnings, gitignore: { file: gitignoreFile, exists: gitignoreText !== undefined, ignoresState }, existing };
|
|
276
|
+
}
|
|
277
|
+
/** Write agentlab.json and make sure .gitignore ignores `.agentlab/`. Idempotent for .gitignore. */
|
|
278
|
+
export async function writeProposal(p, opts = {}) {
|
|
279
|
+
const profilePath = join(p.dir, PROFILE_FILE);
|
|
280
|
+
if (!opts.force && await isFile(profilePath)) {
|
|
281
|
+
throw new LabError('invalid_request', `${profilePath} already exists`, { hint: 'Review the proposal, then force the write to replace it.' });
|
|
282
|
+
}
|
|
283
|
+
await writeFile(profilePath, JSON.stringify(p.profile, null, 2) + '\n');
|
|
284
|
+
const file = join(p.dir, '.gitignore');
|
|
285
|
+
const text = await readFile(file, 'utf8').catch(() => undefined);
|
|
286
|
+
if (text !== undefined && ignores(text))
|
|
287
|
+
return { profilePath, gitignoreUpdated: false };
|
|
288
|
+
if (text === undefined)
|
|
289
|
+
await writeFile(file, GITIGNORE_BLOCK.slice(1));
|
|
290
|
+
else
|
|
291
|
+
await appendFile(file, (text === '' || text.endsWith('\n') ? '' : '\n') + GITIGNORE_BLOCK);
|
|
292
|
+
return { profilePath, gitignoreUpdated: true };
|
|
293
|
+
}
|
|
294
|
+
/** Human-readable proposal: what was found, the JSON, what to review, and what to do next. */
|
|
295
|
+
export function formatProposal(p) {
|
|
296
|
+
const lines = [`Proposed ${PROFILE_FILE} for ${p.dir}`, '', 'Found:'];
|
|
297
|
+
for (const d of p.detections)
|
|
298
|
+
lines.push(` ${d.path}: ${d.detail}`);
|
|
299
|
+
if (!p.detections.length)
|
|
300
|
+
lines.push(' nothing');
|
|
301
|
+
lines.push('', `${PROFILE_FILE}:`, JSON.stringify(p.profile, null, 2));
|
|
302
|
+
if (p.warnings.length) {
|
|
303
|
+
lines.push('', 'Review before starting:');
|
|
304
|
+
for (const w of p.warnings)
|
|
305
|
+
lines.push(` - ${w}`);
|
|
306
|
+
}
|
|
307
|
+
lines.push('', 'Nothing has been written.');
|
|
308
|
+
lines.push(p.existing
|
|
309
|
+
? `${PROFILE_FILE} already exists; saving this proposal replaces it only when forced.`
|
|
310
|
+
: `Saving writes ${PROFILE_FILE}${p.gitignore.ignoresState ? '' : ' and adds .agentlab/ to .gitignore'}.`);
|
|
311
|
+
lines.push('Next: edit the file if needed, then run `agentlab start`.');
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
}
|
|
314
|
+
async function readPackage(root, rel, found, warnings) {
|
|
315
|
+
const path = join(rel, 'package.json');
|
|
316
|
+
const text = await readFile(join(root, path), 'utf8').catch(() => undefined);
|
|
317
|
+
if (text === undefined)
|
|
318
|
+
return undefined;
|
|
319
|
+
let json;
|
|
320
|
+
try {
|
|
321
|
+
json = JSON.parse(text);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
warnings.push(`${path} is not valid JSON; it was skipped.`);
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
if (!isRecord(json))
|
|
328
|
+
return undefined;
|
|
329
|
+
const scripts = {};
|
|
330
|
+
if (isRecord(json.scripts))
|
|
331
|
+
for (const [k, v] of Object.entries(json.scripts))
|
|
332
|
+
if (typeof v === 'string')
|
|
333
|
+
scripts[k] = v;
|
|
334
|
+
const deps = new Set();
|
|
335
|
+
for (const key of ['dependencies', 'devDependencies'])
|
|
336
|
+
if (isRecord(json[key]))
|
|
337
|
+
for (const d of Object.keys(json[key]))
|
|
338
|
+
deps.add(d);
|
|
339
|
+
found('package', path, typeof json.name === 'string' ? json.name : '(unnamed)');
|
|
340
|
+
return { rel, json, scripts, deps };
|
|
341
|
+
}
|
|
342
|
+
/** Sub-package directories (depth ≤ 2): workspaces globs, conventional names, apps/*, packages/*, services/*. */
|
|
343
|
+
async function candidateDirs(root, rootPkg) {
|
|
344
|
+
const out = [];
|
|
345
|
+
const ws = rootPkg?.json.workspaces;
|
|
346
|
+
const patterns = Array.isArray(ws) ? ws : isRecord(ws) && Array.isArray(ws.packages) ? ws.packages : [];
|
|
347
|
+
for (const pat of patterns) {
|
|
348
|
+
if (typeof pat !== 'string')
|
|
349
|
+
continue;
|
|
350
|
+
const clean = pat.replace(/^\.\//, '').replace(/\/+$/, '');
|
|
351
|
+
const parts = clean.split('/');
|
|
352
|
+
if (parts.length === 2 && parts[1] === '*' && usable(parts[0]))
|
|
353
|
+
out.push(...(await subdirs(root, parts[0])).map((d) => `${parts[0]}/${d}`));
|
|
354
|
+
else if (parts.length <= 2 && parts.every(usable))
|
|
355
|
+
out.push(clean);
|
|
356
|
+
}
|
|
357
|
+
const top = await subdirs(root, '');
|
|
358
|
+
for (const name of SERVICE_DIRS)
|
|
359
|
+
if (top.includes(name))
|
|
360
|
+
out.push(name);
|
|
361
|
+
for (const group of GROUP_DIRS)
|
|
362
|
+
if (top.includes(group))
|
|
363
|
+
out.push(...(await subdirs(root, group)).map((d) => `${group}/${d}`));
|
|
364
|
+
return out.map((r) => r.split('/').join(sep));
|
|
365
|
+
}
|
|
366
|
+
function usable(name) {
|
|
367
|
+
return !!name && !name.startsWith('.') && !SKIP_DIRS.has(name) && !/[*?{[]/.test(name);
|
|
368
|
+
}
|
|
369
|
+
async function subdirs(root, rel) {
|
|
370
|
+
const entries = await readdir(join(root, rel), { withFileTypes: true }).catch(() => []);
|
|
371
|
+
return entries.filter((e) => e.isDirectory() && usable(e.name)).map((e) => e.name).sort();
|
|
372
|
+
}
|
|
373
|
+
async function packageManager(root, rel, found) {
|
|
374
|
+
for (const dir of rel ? [rel, ''] : ['']) {
|
|
375
|
+
for (const [file, pm] of LOCKFILES) {
|
|
376
|
+
if (await isFile(join(root, dir, file))) {
|
|
377
|
+
found('lockfile', join(dir, file), pm);
|
|
378
|
+
return pm;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
return 'npm';
|
|
383
|
+
}
|
|
384
|
+
function classify(name, deps) {
|
|
385
|
+
const n = name.toLowerCase();
|
|
386
|
+
for (const c of ['web', 'api', 'worker'])
|
|
387
|
+
if (NAMES[c].includes(n))
|
|
388
|
+
return c;
|
|
389
|
+
if (FRAMEWORKS.some((f) => deps.has(f.dep)))
|
|
390
|
+
return 'web';
|
|
391
|
+
if (API_DEPS.some((d) => deps.has(d)))
|
|
392
|
+
return 'api';
|
|
393
|
+
return 'other';
|
|
394
|
+
}
|
|
395
|
+
function serviceName(dirName) {
|
|
396
|
+
const s = dirName.replace(/[^A-Za-z0-9_-]/g, '-').replace(/^[-_]+/, '');
|
|
397
|
+
return s || 'service';
|
|
398
|
+
}
|
|
399
|
+
/** The framework the script runs: its command words first, then (for a web service) the dependencies. */
|
|
400
|
+
function frameworkFor(script, deps, category) {
|
|
401
|
+
const words = script.split(/[\s;&|()]+/);
|
|
402
|
+
const byBin = FRAMEWORKS.find((f) => f.bins.some((b) => words.includes(b)));
|
|
403
|
+
if (byBin)
|
|
404
|
+
return byBin;
|
|
405
|
+
return category === 'web' ? FRAMEWORKS.find((f) => deps.has(f.dep)) : undefined;
|
|
406
|
+
}
|
|
407
|
+
function scriptPort(script) {
|
|
408
|
+
for (const re of [/--port[=\s]+(\d{2,5})\b/, /(?:^|\s)-p[=\s]*(\d{2,5})\b/, /\bPORT=(\d{2,5})\b/]) {
|
|
409
|
+
const m = re.exec(script);
|
|
410
|
+
if (m)
|
|
411
|
+
return Number(m[1]);
|
|
412
|
+
}
|
|
413
|
+
return undefined;
|
|
414
|
+
}
|
|
415
|
+
/** For `node <file>` scripts, read that one file (≤ 200 KB, inside the project) for its default port. */
|
|
416
|
+
async function entryPort(root, cwd, script) {
|
|
417
|
+
const words = script.trim().split(/\s+/);
|
|
418
|
+
if (words[0] !== 'node')
|
|
419
|
+
return undefined;
|
|
420
|
+
let file;
|
|
421
|
+
for (let i = 1; i < words.length; i++) {
|
|
422
|
+
const w = words[i];
|
|
423
|
+
if (['-r', '--require', '--import', '--loader'].includes(w)) {
|
|
424
|
+
i++;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (w.startsWith('-'))
|
|
428
|
+
continue;
|
|
429
|
+
file = w;
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
if (!file)
|
|
433
|
+
return undefined;
|
|
434
|
+
const abs = resolve(root, cwd, file);
|
|
435
|
+
const rel = relative(root, abs);
|
|
436
|
+
if (rel.startsWith('..') || resolve(rel) === rel)
|
|
437
|
+
return undefined;
|
|
438
|
+
const info = await stat(abs).catch(() => undefined);
|
|
439
|
+
if (!info?.isFile() || info.size > MAX_ENTRY_BYTES)
|
|
440
|
+
return undefined;
|
|
441
|
+
const text = await readFile(abs, 'utf8');
|
|
442
|
+
const m = /process\.env\.PORT\s*(?:\?\?|\|\|)\s*['"]?(\d{2,5})\b/.exec(text) ?? /\.listen\(\s*(\d{2,5})\b/.exec(text);
|
|
443
|
+
// A health route in the server code is a better readiness check than "/", which many APIs 404.
|
|
444
|
+
const health = /['"`](\/(?:api\/)?(?:health|healthz|livez|readyz))['"`]/.exec(text)?.[1];
|
|
445
|
+
return m ? { port: Number(m[1]), path: rel, ...(health ? { health } : {}) } : undefined;
|
|
446
|
+
}
|
|
447
|
+
async function firstEnvExample(root, rel) {
|
|
448
|
+
for (const name of ENV_EXAMPLES) {
|
|
449
|
+
const path = join(rel, name);
|
|
450
|
+
const text = await readFile(join(root, path), 'utf8').catch(() => undefined);
|
|
451
|
+
if (text === undefined)
|
|
452
|
+
continue;
|
|
453
|
+
const lines = [];
|
|
454
|
+
for (const line of text.split(/\r?\n/)) {
|
|
455
|
+
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
456
|
+
if (!m)
|
|
457
|
+
continue;
|
|
458
|
+
let value = m[2].trim();
|
|
459
|
+
if (!/^["']/.test(value))
|
|
460
|
+
value = value.replace(/\s+#.*$/, '').replace(/^#.*$/, '');
|
|
461
|
+
lines.push({ name: m[1], value });
|
|
462
|
+
}
|
|
463
|
+
return { path, lines };
|
|
464
|
+
}
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
/** Names with an empty example value are required. Values are never recorded. */
|
|
468
|
+
async function readEnvNames(root, rel, found) {
|
|
469
|
+
const env = await firstEnvExample(root, rel);
|
|
470
|
+
if (!env)
|
|
471
|
+
return [];
|
|
472
|
+
const required = [];
|
|
473
|
+
for (const { name, value } of env.lines) {
|
|
474
|
+
if (value === '' || value === '""' || value === "''") {
|
|
475
|
+
required.push(name);
|
|
476
|
+
found('env-name', env.path, `${name} (empty in the example: required)`);
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
found('env-name', env.path, `${name} has example default`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return unique(required);
|
|
483
|
+
}
|
|
484
|
+
async function exampleEnvPort(root, rel) {
|
|
485
|
+
const env = await firstEnvExample(root, rel);
|
|
486
|
+
const line = env?.lines.find((l) => l.name === 'PORT' && /^["']?\d{2,5}["']?$/.test(l.value));
|
|
487
|
+
return env && line ? { port: Number(line.value.replace(/["']/g, '')), path: env.path } : undefined;
|
|
488
|
+
}
|
|
489
|
+
async function readPlaywright(root, found) {
|
|
490
|
+
for (const file of PLAYWRIGHT_CONFIGS) {
|
|
491
|
+
const text = await readFile(join(root, file), 'utf8').catch(() => undefined);
|
|
492
|
+
if (text === undefined)
|
|
493
|
+
continue;
|
|
494
|
+
const str = (re, from) => re.exec(from)?.[1];
|
|
495
|
+
const info = { cwd: dirname(file) === '.' ? '' : dirname(file) };
|
|
496
|
+
info.baseURL = str(/baseURL\s*:\s*['"`]([^'"`]+)['"`]/, text);
|
|
497
|
+
const ws = /webServer\s*:\s*[{[]/.exec(text);
|
|
498
|
+
if (ws) {
|
|
499
|
+
const block = text.slice(ws.index, ws.index + 2000);
|
|
500
|
+
info.webServerCommand = str(/command\s*:\s*['"`]([^'"`]+)['"`]/, block);
|
|
501
|
+
info.webServerUrl = str(/\burl\s*:\s*['"`]([^'"`]+)['"`]/, block);
|
|
502
|
+
}
|
|
503
|
+
const parts = [
|
|
504
|
+
info.baseURL && `baseURL ${info.baseURL}`,
|
|
505
|
+
info.webServerCommand && `webServer.command ${JSON.stringify(info.webServerCommand)}`,
|
|
506
|
+
info.webServerUrl && `webServer.url ${info.webServerUrl}`,
|
|
507
|
+
].filter(Boolean);
|
|
508
|
+
found('playwright-config', file, parts.length ? parts.join(', ') : 'no baseURL or webServer found');
|
|
509
|
+
return info;
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
/** Line-based scan of top-level `services:` names and their published ports. No YAML parser. */
|
|
514
|
+
async function readCompose(root, found) {
|
|
515
|
+
for (const file of COMPOSE_FILES) {
|
|
516
|
+
const text = await readFile(join(root, file), 'utf8').catch(() => undefined);
|
|
517
|
+
if (text === undefined)
|
|
518
|
+
continue;
|
|
519
|
+
const services = [];
|
|
520
|
+
let inServices = false;
|
|
521
|
+
let serviceIndent = -1;
|
|
522
|
+
let inPorts = false;
|
|
523
|
+
let portsIndent = -1;
|
|
524
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
525
|
+
const line = raw.replace(/\s+#.*$/, '');
|
|
526
|
+
if (!line.trim() || line.trim().startsWith('#'))
|
|
527
|
+
continue;
|
|
528
|
+
const indent = line.length - line.trimStart().length;
|
|
529
|
+
const body = line.trim();
|
|
530
|
+
if (indent === 0) {
|
|
531
|
+
inServices = body === 'services:';
|
|
532
|
+
serviceIndent = -1;
|
|
533
|
+
inPorts = false;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
if (!inServices)
|
|
537
|
+
continue;
|
|
538
|
+
if (serviceIndent < 0)
|
|
539
|
+
serviceIndent = indent;
|
|
540
|
+
if (indent === serviceIndent) {
|
|
541
|
+
const m = /^["']?([A-Za-z0-9._-]+)["']?:\s*$/.exec(body);
|
|
542
|
+
if (m)
|
|
543
|
+
services.push({ name: m[1], ports: [] });
|
|
544
|
+
inPorts = false;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
const current = services.at(-1);
|
|
548
|
+
if (!current)
|
|
549
|
+
continue;
|
|
550
|
+
if (/^ports:\s*$/.test(body)) {
|
|
551
|
+
inPorts = true;
|
|
552
|
+
portsIndent = indent;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (inPorts && indent <= portsIndent && !body.startsWith('-'))
|
|
556
|
+
inPorts = false;
|
|
557
|
+
if (!inPorts)
|
|
558
|
+
continue;
|
|
559
|
+
const published = /^(?:-\s*)?published:\s*["']?(\d+)/.exec(body);
|
|
560
|
+
if (published) {
|
|
561
|
+
current.ports.push(Number(published[1]));
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
const short = /^-\s*["']?([^"'\s]+)["']?\s*$/.exec(body);
|
|
565
|
+
if (short && !short[1].includes(': ') && !/^[a-z_]+:/.test(short[1])) {
|
|
566
|
+
const parts = short[1].replace(/\/(tcp|udp)$/, '').split(':');
|
|
567
|
+
const host = parts.length === 3 ? parts[1] : parts.length === 2 ? parts[0] : undefined;
|
|
568
|
+
const port = host ? Number(/^\d+/.exec(host)?.[0]) : NaN;
|
|
569
|
+
if (port > 0 && port < 65536)
|
|
570
|
+
current.ports.push(port);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const s of services)
|
|
574
|
+
found('compose-service', file, `${s.name}${s.ports.length ? ` publishes ${s.ports.join(', ')}` : ' publishes no ports'}`);
|
|
575
|
+
const ports = services.flatMap((s) => s.ports);
|
|
576
|
+
return { file, ports, httpPort: ports.find((p) => !NON_HTTP_PORTS.has(p)) };
|
|
577
|
+
}
|
|
578
|
+
return undefined;
|
|
579
|
+
}
|
|
580
|
+
function originOf(url) {
|
|
581
|
+
if (!url || !URL.canParse(url))
|
|
582
|
+
return undefined;
|
|
583
|
+
return new URL(url).origin;
|
|
584
|
+
}
|
|
585
|
+
function ignores(gitignore) {
|
|
586
|
+
return gitignore.split(/\r?\n/).some((l) => IGNORE_LINES.has(l.trim()));
|
|
587
|
+
}
|
|
588
|
+
/** `<dir>/bin/python` of a virtualenv directly inside the project (a directory with pyvenv.cfg), as a relative path. */
|
|
589
|
+
async function virtualenvPython(root) {
|
|
590
|
+
const names = ['.venv', 'venv', 'env', ...(await readdir(root, { withFileTypes: true }).catch(() => []))
|
|
591
|
+
.filter((e) => e.isDirectory() && !SKIP_DIRS.has(e.name)).map((e) => e.name)];
|
|
592
|
+
for (const name of unique(names)) {
|
|
593
|
+
if (await isFile(join(root, name, 'pyvenv.cfg')) && await isFile(join(root, name, 'bin', 'python')))
|
|
594
|
+
return `${name}/bin/python`;
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
async function isFile(path) {
|
|
599
|
+
return (await stat(path).catch(() => undefined))?.isFile() ?? false;
|
|
600
|
+
}
|
|
601
|
+
function unique(items) {
|
|
602
|
+
return [...new Set(items)];
|
|
603
|
+
}
|
|
604
|
+
function isRecord(v) {
|
|
605
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
606
|
+
}
|
|
607
|
+
//# sourceMappingURL=init.js.map
|