codetac 0.1.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/src/cli.mjs ADDED
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env node
2
+ // Comando `codetac` (Fase 5): numa pasta de projeto, descobre como arrancá-lo,
3
+ // arranca o painel e a app com a captura e diz onde abrir. Se a app não
4
+ // arrancar com a instrumentação fina, arranca-a de novo em modo mínimo.
5
+ // codetac [pasta] [opções] [-- comando de arranque]
6
+ // codetac diagnostico [pasta]
7
+ import { spawn, spawnSync } from 'node:child_process';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
9
+ import { arch, homedir, release } from 'node:os';
10
+ import http from 'node:http';
11
+ import { basename, join, resolve } from 'node:path';
12
+ import readline from 'node:readline/promises';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { detectProject, describeStart, foreignRuntime } from './detect.mjs';
15
+ import { createSummary, follow } from './recording.mjs';
16
+
17
+ import { dataDirectory, install as workspace } from './home.mjs';
18
+
19
+ const recordings = dataDirectory();
20
+ const register = pathToFileURL(join(workspace, 'src', 'register.mjs')).href;
21
+ const started = Date.now();
22
+ const seconds = () => `${Math.round((Date.now() - started) / 1000)} s`;
23
+ const say = text => process.stdout.write(`${text}\n`);
24
+ const HELP = `Uso:
25
+ codetac [pasta] [opções] [-- comando] arranca a app da pasta (por omissão, a atual) com o CodeTAC
26
+ codetac diagnostico [pasta] explica o que está e o que não está a funcionar
27
+ codetac relatorio [pasta] guarda o diagnóstico num ficheiro para enviar com uma incidência
28
+ codetac --version versão instalada
29
+
30
+ Opções:
31
+ --script <nome> script do package.json a usar (por omissão: dev, start…)
32
+ --parte <pasta> num projeto com várias partes, qual arrancar (pode repetir)
33
+ --porta <n> porta da app, se não for descoberta sozinha
34
+ --painel <n> porta do painel (por omissão 4000)
35
+ --minimo não seguir as funções do projeto (só pedidos e fronteiras)
36
+ --sim responder «sim» às perguntas (instalar dependências…)
37
+ --nao-abrir não abrir o browser
38
+ -- <comando> comando de arranque, quando não for descoberto (ex.: -- node server.js)`;
39
+
40
+ function parseArgs(argv) {
41
+ const options = { parts: [], command: null, folder: null, sub: null };
42
+ for (let index = 0; index < argv.length; index++) {
43
+ const arg = argv[index];
44
+ const value = () => argv[++index];
45
+ if (arg === '--') { options.command = argv.slice(index + 1); break; }
46
+ else if (arg === '--script') options.script = value();
47
+ else if (arg === '--parte') options.parts.push(value());
48
+ else if (arg === '--porta') options.port = Number(value());
49
+ else if (arg === '--painel') options.panelPort = Number(value());
50
+ else if (arg === '--minimo') options.minimal = true;
51
+ else if (arg === '--sim') options.yes = true;
52
+ else if (arg === '--nao-abrir') options.noOpen = true;
53
+ else if (arg === '-h' || arg === '--help' || arg === 'ajuda') options.sub = 'ajuda';
54
+ else if (!options.sub && !options.folder && ['diagnostico', 'diagnóstico'].includes(arg)) options.sub = 'diagnostico';
55
+ else if (!options.sub && !options.folder && ['relatorio', 'relatório'].includes(arg)) options.sub = 'relatorio';
56
+ else if (arg === '-v' || arg === '--version' || arg === 'versao') options.sub = 'versao';
57
+ else if (!options.folder && !arg.startsWith('-')) options.folder = arg;
58
+ else { say(`Opção desconhecida: ${arg}\n\n${HELP}`); process.exit(2); }
59
+ }
60
+ return options;
61
+ }
62
+
63
+ const interactive = process.stdin.isTTY && process.stdout.isTTY;
64
+ async function ask(question, choices) {
65
+ if (!interactive) return null;
66
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
67
+ try {
68
+ if (choices) {
69
+ choices.forEach((choice, index) => say(` ${index + 1}. ${choice}`));
70
+ const answer = (await rl.question(`${question} `)).trim();
71
+ return answer;
72
+ }
73
+ return (await rl.question(`${question} `)).trim();
74
+ } finally { rl.close(); }
75
+ }
76
+ async function confirm(question, options) {
77
+ if (options.yes) return true;
78
+ const answer = await ask(`${question} [S/n]`);
79
+ if (answer === null) return null;
80
+ return !/^n/i.test(answer);
81
+ }
82
+
83
+ function hasBinary(name) {
84
+ return spawnSync(process.platform === 'win32' ? 'where' : 'which', [name], { stdio: 'ignore' }).status === 0;
85
+ }
86
+ // A package manager that is not installed runs through npx.
87
+ function runnable(command) {
88
+ const [bin, ...rest] = command;
89
+ if (['pnpm', 'yarn'].includes(bin) && !hasBinary(bin)) return ['npx', '--yes', bin, ...rest];
90
+ return command;
91
+ }
92
+
93
+ function get(port, path, { host = '127.0.0.1', timeout = 3000, headers = {} } = {}) {
94
+ return new Promise(done => {
95
+ const request = http.get({ host, port, path, timeout, headers: { host: `127.0.0.1:${port}`, ...headers } }, response => {
96
+ let body = '';
97
+ response.setEncoding('utf8');
98
+ response.on('data', chunk => { if (body.length < 4096) body += chunk; });
99
+ response.on('end', () => done({ status: response.statusCode, type: String(response.headers['content-type'] ?? ''), body }));
100
+ });
101
+ request.on('timeout', () => { request.destroy(); done(null); });
102
+ request.on('error', () => done(null));
103
+ });
104
+ }
105
+ const wait = ms => new Promise(done => setTimeout(done, ms));
106
+
107
+ // The panel: reuses one already running, or starts it.
108
+ async function ensurePanel(port, avoid) {
109
+ for (let candidate = port; candidate < port + 20; candidate++) {
110
+ if (avoid.has(candidate)) continue;
111
+ const ping = await get(candidate, '/api/ping');
112
+ if (ping?.status === 200 && ping.body.includes('"ok":true')) return { port: candidate, reused: true };
113
+ if (ping) continue; // another program answers on this port
114
+ const child = spawn(process.execPath, [join(workspace, 'src', 'panel.mjs'), '--port', String(candidate)],
115
+ { cwd: workspace, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env } });
116
+ let first = '';
117
+ child.stdout.on('data', chunk => { first += chunk; });
118
+ child.stderr.on('data', chunk => { first += chunk; });
119
+ for (let tries = 0; tries < 60; tries++) {
120
+ await wait(250);
121
+ if (child.exitCode !== null) break;
122
+ const ok = await get(candidate, '/api/ping');
123
+ if (ok?.status === 200) return { port: candidate, child, line: first.split('\n')[0] };
124
+ }
125
+ child.kill();
126
+ if (/EADDRINUSE/.test(first)) continue;
127
+ throw new Error(`O painel não arrancou: ${first.trim().split('\n').slice(-3).join(' ')}`);
128
+ }
129
+ throw new Error('Não encontrei uma porta livre para o painel.');
130
+ }
131
+
132
+ function recordingName(name) {
133
+ const slug = String(name).toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'projeto';
134
+ const stamp = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
135
+ return `${slug}-${stamp}`;
136
+ }
137
+
138
+ function openBrowser(url) {
139
+ const opener = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
140
+ try { spawn(opener[0], opener[1], { stdio: 'ignore', detached: true }).unref(); } catch {}
141
+ }
142
+
143
+ const ANSI = /\u001b\[[0-9;?]*[A-Za-z]/g;
144
+ const URL_IN_OUTPUT = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]|[\w-]+\.local)(?::(\d{2,5}))?/g;
145
+
146
+ // Starts the parts of the application with the capture at a level; resolves
147
+ // when one of them serves HTTP, or when they all stopped.
148
+ const CRASHED = /\[nodemon\] app crashed|Failed running ['"]|waiting for (?:file )?changes before restart/i;
149
+ const BUSY = /EADDRINUSE[^\n]*?:(\d{2,5})\b|[Pp]ort (\d{2,5}) is (?:already )?in use()/;
150
+
151
+ function startApp(parts, { run, panelPort, minimal, reason, root, extraEnv = {} }) {
152
+ const children = [];
153
+ const ports = [];
154
+ const tail = [];
155
+ const state = { busy: null };
156
+ const env = {
157
+ ...process.env, CODETAC_ROOT: root, CODETAC_RUN: run, CODETAC_PANEL_PORT: String(panelPort),
158
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${register}`].filter(Boolean).join(' '),
159
+ };
160
+ // The application's output goes through a pipe: its colours stay when the terminal has them.
161
+ if (process.stdout.isTTY && env.FORCE_COLOR === undefined) env.FORCE_COLOR = '1';
162
+ Object.assign(env, extraEnv);
163
+ if (minimal) Object.assign(env, { CODETAC_LEVEL: 'minimo', CODETAC_MINIMO_MOTIVO: reason });
164
+ for (const part of parts) {
165
+ const [bin, ...args] = runnable(part.command);
166
+ const child = spawn(bin, args, { cwd: part.folder, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32',
167
+ shell: process.platform === 'win32' });
168
+ const label = parts.length > 1 ? `[${part.part}] ` : '';
169
+ const show = chunk => {
170
+ for (const line of String(chunk).split(/\r?\n/)) {
171
+ if (!line.trim()) continue;
172
+ process.stdout.write(` │ ${label}${line}\n`);
173
+ const plain = line.replace(ANSI, '');
174
+ tail.push(`${label}${plain}`);
175
+ if (tail.length > 40) tail.shift();
176
+ for (const match of plain.matchAll(URL_IN_OUTPUT)) if (match[1]) ports.push(Number(match[1]));
177
+ // Watch modes (node --watch, nodemon, tsx watch) keep running after a crash.
178
+ if (CRASHED.test(plain)) child.crashed = true;
179
+ const busy = plain.match(BUSY);
180
+ if (busy) state.busy = Number(busy[1] ?? busy[2] ?? busy[3]);
181
+ }
182
+ };
183
+ child.stdout.on('data', show);
184
+ child.stderr.on('data', show);
185
+ child.part = part;
186
+ children.push(child);
187
+ }
188
+ return { children, ports, tail, state };
189
+ }
190
+
191
+ // The program holding a port, to name it.
192
+ function holder(port) {
193
+ const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fc'], { encoding: 'utf8' });
194
+ return result.stdout?.split('\n').find(line => line.startsWith('c'))?.slice(1) ?? null;
195
+ }
196
+ function freePort() {
197
+ return new Promise(done => {
198
+ const server = http.createServer().listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => done(port)); });
199
+ });
200
+ }
201
+
202
+ function stopAll(children, signal = 'SIGTERM') {
203
+ for (const child of children) {
204
+ if (child.exitCode !== null || child.signalCode !== null) continue;
205
+ try { process.platform === 'win32' ? child.kill(signal) : process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch {} }
206
+ }
207
+ }
208
+ const running = children => children.filter(child => child.exitCode === null && child.signalCode === null);
209
+
210
+ // The processes of the application: its children and their descendants.
211
+ function processTree(children) {
212
+ const result = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], { encoding: 'utf8' });
213
+ if (result.status !== 0) return null;
214
+ const rows = result.stdout.trim().split('\n').map(line => line.trim().split(/\s+/).map(Number));
215
+ const ours = new Set(children.map(child => child.pid));
216
+ for (let grew = true; grew;) {
217
+ grew = false;
218
+ for (const [pid, ppid, pgid] of rows) if (!ours.has(pid) && (ours.has(ppid) || ours.has(pgid))) { ours.add(pid); grew = true; }
219
+ }
220
+ return ours;
221
+ }
222
+ // Who listens on a port (pid and address), by lsof; null where lsof is missing.
223
+ function listeners(port) {
224
+ const result = spawnSync('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpcn'], { encoding: 'utf8' });
225
+ if (result.error) return null;
226
+ const found = [];
227
+ let current = null;
228
+ for (const line of (result.stdout ?? '').split('\n')) {
229
+ if (line.startsWith('p')) found.push(current = { pid: Number(line.slice(1)), names: [] });
230
+ else if (line.startsWith('c') && current) current.command = line.slice(1);
231
+ else if (line.startsWith('n') && current) current.names.push(line.slice(1));
232
+ }
233
+ return found;
234
+ }
235
+
236
+ // The page of the application: the first candidate port that answers HTML,
237
+ // else the first that answers at all (an API without pages). Only ports
238
+ // opened by the application's own processes count: another program on the
239
+ // same port (in the other IP family) would otherwise answer in its place.
240
+ async function findApp(candidates, panelPort, { children, addresses, warn }) {
241
+ let api = null;
242
+ const ours = processTree(children);
243
+ for (const port of [...new Set(candidates)].filter(port => port && port !== panelPort)) {
244
+ const owners = listeners(port);
245
+ let host = 'localhost';
246
+ if (owners && ours) {
247
+ const mine = owners.filter(owner => ours.has(owner.pid));
248
+ const others = owners.filter(owner => !ours.has(owner.pid));
249
+ if (!mine.length) continue;
250
+ if (others.length) {
251
+ // Reach the application by the exact address it bound.
252
+ const bound = addresses.get(port) ?? (mine[0].names.find(name => /^127\.0\.0\.1:|^\[::1\]:/.test(name)) ?? mine[0].names[0] ?? '').replace(/:\d+$/, '');
253
+ host = /^\[?::1\]?$/.test(bound) ? '::1' : '127.0.0.1';
254
+ warn(port, others[0].command ?? 'outro programa', host);
255
+ }
256
+ }
257
+ // The internal header keeps these probes out of the recording.
258
+ const headers = { accept: 'text/html', 'x-codetac-internal': '1', host: `${host === '::1' ? '[::1]' : host}:${port}` };
259
+ const answer = await get(port, '/', { host, timeout: 90_000, headers })
260
+ ?? (host === 'localhost' ? await get(port, '/', { host: '127.0.0.1', timeout: 90_000, headers: { ...headers, host: `127.0.0.1:${port}` } }) : null);
261
+ if (!answer) continue;
262
+ // A 404 in HTML is an API's "Cannot GET /"; a 500 is a page that fails,
263
+ // shown with a warning.
264
+ const url = `http://${host === '::1' ? '[::1]' : host}:${port}/`;
265
+ if (/text\/html/.test(answer.type) && answer.status !== 404) return { port, url, page: true, status: answer.status };
266
+ api ??= { port, url, page: false, status: answer.status };
267
+ }
268
+ return api;
269
+ }
270
+
271
+ async function main() {
272
+ const options = parseArgs(process.argv.slice(2));
273
+ if (options.sub === 'ajuda') { say(HELP); return; }
274
+ if (options.sub === 'versao') { say(JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')).version); return; }
275
+ const folder = resolve(options.folder ?? process.cwd());
276
+ if (!existsSync(folder)) { say(`A pasta ${folder} não existe.`); process.exit(2); }
277
+ const root = realpathSync(folder);
278
+ if (options.sub === 'relatorio') {
279
+ // The diagnosis in a file to send with an incident: no code, no values;
280
+ // the home folder is shortened to ~.
281
+ const { diagnose } = await import('./diagnose.mjs');
282
+ const lines = [];
283
+ const version = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf8')).version;
284
+ lines.push(`codeTAC ${version} · Node ${process.version} · ${process.platform} ${arch()} · ${release()}`, '');
285
+ await diagnose(root, { panelPort: options.panelPort ?? Number(process.env.CODETAC_PANEL_PORT || 4000), out: text => lines.push(text) });
286
+ const text = lines.join('\n').split(homedir()).join('~') + '\n';
287
+ mkdirSync(recordings, { recursive: true });
288
+ const file = join(recordings, `relatorio-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`);
289
+ writeFileSync(file, text);
290
+ say(text);
291
+ say(`Relatório guardado em ${file.split(homedir()).join('~')}`);
292
+ say('Não contém código nem valores da app: só o que está acima. Envie-o com a descrição do problema em');
293
+ say(' https://github.com/deltaxmodules/codetacvibe/issues/new');
294
+ return;
295
+ }
296
+ if (options.sub === 'diagnostico') {
297
+ const { diagnose } = await import('./diagnose.mjs');
298
+ process.exitCode = await diagnose(root, { panelPort: options.panelPort ?? Number(process.env.CODETAC_PANEL_PORT || 4000) });
299
+ return;
300
+ }
301
+
302
+ const [major] = process.versions.node.split('.').map(Number);
303
+ if (major < 24) say(`! Este Node é o ${process.version}; o CodeTAC foi feito para o Node 24 ou superior. Vou tentar na mesma.`);
304
+
305
+ // 1. How to start the project.
306
+ const project = detectProject(root);
307
+ let parts = project.start;
308
+ if (options.command?.length) {
309
+ parts = [{ folder: root, part: '.', name: project.name, command: options.command, script: null, stack: null, port: null, installed: true, foreign: foreignRuntime(options.command.join(' ')) }];
310
+ } else if (options.parts.length) {
311
+ parts = options.parts.map(name => project.parts.find(part => part.part === name || basename(part.folder) === name) ?? (name === '.' ? project.top : null)).filter(Boolean);
312
+ if (!parts.length) { say(`Não encontrei a parte ${options.parts.join(', ')}. Partes: ${project.parts.map(part => part.part).join(', ') || 'nenhuma'}.`); process.exit(2); }
313
+ } else if (options.script) {
314
+ const base = project.top ?? project.start[0];
315
+ if (!base) { say('Não há package.json nesta pasta para escolher um script.'); process.exit(2); }
316
+ parts = [{ ...base, script: { name: options.script, command: '' }, command: [base.runner, 'run', options.script] }];
317
+ }
318
+ say(`CodeTAC · ${project.name} (${root})`);
319
+ if (!parts.length && project.missing.includes('part')) {
320
+ say('Este projeto tem várias partes que se podem arrancar:');
321
+ const answer = await ask('Quais arranco? (números separados por vírgulas, ou Enter para todas)', project.parts.map(describeStart));
322
+ if (answer === null) parts = project.parts;
323
+ else parts = answer ? answer.split(/[\s,]+/).map(n => project.parts[Number(n) - 1]).filter(Boolean) : project.parts;
324
+ }
325
+ if (!parts.length) {
326
+ say(project.missing.includes('package')
327
+ ? 'Não encontrei um package.json nem um ficheiro de servidor (server.js, index.js…) nesta pasta.'
328
+ : 'Não encontrei um script de arranque (dev, start…) no package.json.');
329
+ const answer = await ask('Que comando arranca a app? (ex.: node server.js, ou Enter para sair)');
330
+ if (!answer) { say('Indique o comando depois de --, por exemplo: codetac . -- node server.js'); process.exit(2); }
331
+ parts = [{ folder: root, part: '.', name: project.name, command: answer.split(/\s+/), script: null, stack: null, port: null, installed: true, foreign: foreignRuntime(answer) }];
332
+ }
333
+ for (const part of parts) say(` Arranque: ${describeStart(part)}`);
334
+ for (const part of parts.filter(part => part.foreign)) {
335
+ say(`! [${part.part}] O script usa ${part.foreign}, que não é o Node: essa parte corre, mas o CodeTAC não a consegue observar por dentro.`);
336
+ }
337
+
338
+ // 2. Dependencies.
339
+ for (const part of parts.filter(part => !part.installed)) {
340
+ const manager = part.manager === 'bun' ? 'npm' : part.manager;
341
+ const where = part.installFolder ?? (part.manager === 'pnpm' || part.workspaces ? root : part.folder);
342
+ const yes = await confirm(`As dependências de ${part.part === '.' ? 'o projeto' : part.part} não estão instaladas. Instalar agora com «${manager} install»?`, options);
343
+ if (yes === null) { say(`As dependências não estão instaladas. Corra «${manager} install» na pasta ou use --sim.`); process.exit(2); }
344
+ if (!yes) continue;
345
+ const [bin, ...args] = runnable([manager, 'install']);
346
+ say(` A instalar (${bin} ${args.join(' ')})…`);
347
+ const result = spawnSync(bin, args, { cwd: where, stdio: 'inherit', shell: process.platform === 'win32' });
348
+ if (result.status !== 0) { say(`✗ A instalação falhou (código ${result.status}). Veja as mensagens acima.`); process.exit(1); }
349
+ for (const other of parts) if ((other.installFolder ?? other.folder) === where || (where === root && !other.installFolder)) other.installed = true;
350
+ }
351
+
352
+ // 3. The panel, on a port the application does not use.
353
+ const predicted = new Set(parts.map(part => part.port?.value).filter(Boolean));
354
+ if (options.port) predicted.add(options.port);
355
+ const panel = await ensurePanel(options.panelPort ?? Number(process.env.CODETAC_PANEL_PORT || 4000), predicted);
356
+ const panelUrl = `http://127.0.0.1:${panel.port}`;
357
+ say(panel.reused ? ` Painel: ${panelUrl} (já estava a correr)` : ` Painel: ${panelUrl}${panel.line ? ` · ${panel.line}` : ''}`);
358
+
359
+ // 4. The application, with the fine capture; minimal mode if it does not start.
360
+ let minimal = Boolean(options.minimal);
361
+ let reason = minimal ? 'pedido com --minimo' : null;
362
+ let run = recordingName(project.name) + (minimal ? '-minimo' : '');
363
+ let app = null;
364
+ let found = null;
365
+ const cleanup = () => { stopAll(app?.children ?? []); panel.child?.kill(); };
366
+ let stopping = false;
367
+ const stop = () => {
368
+ if (stopping) return;
369
+ stopping = true;
370
+ say('\nA terminar a app e o painel…');
371
+ cleanup();
372
+ setTimeout(() => { stopAll(app?.children ?? [], 'SIGKILL'); process.exit(0); }, 4000).unref();
373
+ const check = setInterval(() => { if (!running(app?.children ?? []).length) { clearInterval(check); panel.child?.kill(); process.exit(0); } }, 200);
374
+ };
375
+ process.on('SIGINT', stop);
376
+ process.on('SIGTERM', stop);
377
+
378
+ const extraEnv = {};
379
+ const busyTried = new Set();
380
+ const warnedShared = new Set();
381
+ const sharedWarning = (port, command, host) => {
382
+ if (warnedShared.has(port)) return;
383
+ warnedShared.add(port);
384
+ say(`! A porta ${port} também está a ser usada por outro programa (${command}). Para não o abrir por engano, uso o endereço ${host === '::1' ? '[::1]' : host}.`);
385
+ };
386
+ for (let attempt = 0; attempt < 4 && !found; attempt++) {
387
+ say(` Gravação: ${run}${minimal ? ' (modo mínimo)' : ''}`);
388
+ say(' A arrancar a app…');
389
+ app = startApp(parts, { run, panelPort: panel.port, minimal, reason, root, extraEnv });
390
+ const summary = createSummary();
391
+ const recording = follow(join(recordings, run), summary.add);
392
+ const deadline = Date.now() + 180_000;
393
+ let warned = false;
394
+ let api = null;
395
+ // Before it is ready, any part that stops (or crashes in a watch mode) is a failed start.
396
+ const failed = () => app.children.filter(child => child.exitCode !== null || child.signalCode !== null || child.crashed);
397
+ while (!found && Date.now() < deadline) {
398
+ await wait(500);
399
+ recording.poll();
400
+ // Ports the application really opened (seen by the capture, or written
401
+ // in its output); the predicted ones only decide the order.
402
+ const opened = [...summary.ports, ...app.ports];
403
+ // Pages first: the ports of the parts that serve the browser (Vite, Next…).
404
+ const front = parts.filter(part => /Vite|Next|Nuxt|Astro|Svelte|Remix|React|Angular/.test(part.stack ?? '')).map(part => part.port?.value);
405
+ const candidates = [options.port, ...[...front, ...predicted].filter(port => opened.includes(port)), ...opened];
406
+ const answer = candidates.some(Boolean) ? await findApp(candidates, panel.port, { children: app.children, addresses: summary.addresses, warn: sharedWarning }) : null;
407
+ if (answer?.page) found = answer;
408
+ else if (answer) api ??= { ...answer, at: Date.now() };
409
+ // An API without pages is accepted when nothing else serves pages for a while.
410
+ // Right away only for a known API stack; otherwise pages may still come (Vite after the API…).
411
+ if (!found && api && (Date.now() - api.at > 20_000 || (parts.length === 1 && /^(Express|Fastify|Koa|Hono|NestJS)$/.test(parts[0].stack ?? '')))) found = api;
412
+ if (found) {
413
+ // The other parts may still be failing (a port taken…): a moment to see it.
414
+ await wait(2000);
415
+ if (failed().length) found = null;
416
+ break;
417
+ }
418
+ // A taken port only matters when a part fails because of it: some
419
+ // tools (Next.js) move to the next free port by themselves.
420
+ if (failed().length) break;
421
+ if (!warned && Date.now() - started > 60_000) {
422
+ warned = true;
423
+ say(` … a app ainda não respondeu (${seconds()}). Algumas demoram na primeira vez.`);
424
+ }
425
+ }
426
+ if (found) { app.recording = recording; app.summary = summary; break; }
427
+ const stopped = failed();
428
+ stopAll(app.children);
429
+ await wait(800);
430
+ stopAll(app.children, 'SIGKILL');
431
+ // A port already taken by another program: not a problem of the capture.
432
+ // Some servers do not say it (they bind one IP family and stop): the
433
+ // expected port of a part that stopped, held by someone else, says it.
434
+ if (!app.state.busy) {
435
+ for (const child of stopped) {
436
+ const expected = child.part.port?.value;
437
+ if (expected && listeners(expected)?.length) { app.state.busy = expected; break; }
438
+ }
439
+ }
440
+ const busy = stopped.length ? app.state.busy : null;
441
+ // Several parts point at each other's ports (a Vite proxy to the API…):
442
+ // moving one would send its requests to the other program. Only a single
443
+ // part is moved.
444
+ if (busy && parts.length > 1) {
445
+ const who = holder(busy);
446
+ say(`✗ A porta ${busy} já está ocupada${who ? ` por outro programa (${who})` : ''}, e uma parte da app precisa dela.`);
447
+ say(' Não a mudo sozinho: as outras partes da app contam com essa porta. Feche esse programa e volte a correr o comando.');
448
+ cleanup();
449
+ process.exit(1);
450
+ }
451
+ if (busy && !busyTried.has(busy)) {
452
+ busyTried.add(busy);
453
+ const who = holder(busy);
454
+ const port = await freePort();
455
+ extraEnv.PORT = String(port);
456
+ predicted.add(port);
457
+ say(`! A porta ${busy} já está ocupada${who ? ` por outro programa (${who})` : ''}. Vou arrancar a app na porta ${port} (variável PORT).`);
458
+ run = recordingName(project.name) + (minimal ? '-minimo' : '');
459
+ continue;
460
+ }
461
+ if (busy) {
462
+ say(`✗ A porta ${busy} continua ocupada${holder(busy) ? ` (${holder(busy)})` : ''} e a app não aceita outra pela variável PORT.`);
463
+ say(' Feche o programa que usa essa porta e volte a correr o comando.');
464
+ cleanup();
465
+ process.exit(1);
466
+ }
467
+ if (!stopped.length) {
468
+ say(`✗ A app não abriu nenhuma porta em ${seconds()}.`);
469
+ say(' Se a app usa uma porta que não escreve no terminal, indique-a: codetac . --porta <n>');
470
+ cleanup();
471
+ process.exit(1);
472
+ }
473
+ const codes = stopped.map(child => `${child.part.part}: ${child.crashed ? 'falhou' : `código ${child.exitCode ?? child.signalCode}`}`).join(', ');
474
+ if (minimal) {
475
+ say(`✗ A app falhou também em modo mínimo (${codes}).`);
476
+ say(' O problema parece ser da própria app, não da captura. Últimas linhas:');
477
+ for (const line of app.tail.slice(-12)) say(` ${line}`);
478
+ say(' Dica: faltam dependências ou variáveis de ambiente (.env)? «codetac diagnostico» ajuda a ver.');
479
+ cleanup();
480
+ process.exit(1);
481
+ }
482
+ minimal = true;
483
+ reason = `a app falhou ao arrancar com a instrumentação fina (${codes})`;
484
+ run = `${run}-minimo`;
485
+ say(`! A app falhou durante o arranque com a captura completa (${codes}).`);
486
+ say(' Vou arrancá-la de novo em modo mínimo: pedidos, fronteiras e browser, sem seguir as funções do projeto.');
487
+ }
488
+ if (!found) { say('✗ Não foi possível arrancar a app.'); cleanup(); process.exit(1); }
489
+
490
+ // 5. Ready: where to go, and the first dossier.
491
+ const appUrl = found.url ?? `http://localhost:${found.port}/`;
492
+ say('');
493
+ say(`✓ App pronta em ${appUrl} (${seconds()})${minimal ? ' · modo mínimo' : ''}`);
494
+ if (found.status >= 500) say(`! A página inicial respondeu com erro ${found.status}. Veja as mensagens da app acima (faltam variáveis de ambiente?). O dossiê desse pedido mostra onde falhou.`);
495
+ if (found.page) {
496
+ say(' Abra-a no browser e use-a: a barra do CodeTAC aparece no canto inferior direito.');
497
+ say(' Cada ação (clique, formulário…) fica com um dossiê; a barra abre-o.');
498
+ if (!options.noOpen && interactive) openBrowser(appUrl);
499
+ } else {
500
+ say(' Esta app responde sem páginas HTML (uma API). Faça pedidos como de costume; cada pedido fica com um dossiê no painel.');
501
+ }
502
+ say(` Painel: ${panelUrl} · Terminar: Ctrl+C`);
503
+ say('');
504
+ let firstShown = false;
505
+ let silentWarned = false;
506
+ const seenActions = new Set();
507
+ const { recording, summary } = app;
508
+ const watcher = setInterval(() => {
509
+ recording.poll();
510
+ for (const id of summary.actions) {
511
+ if (seenActions.has(id)) continue;
512
+ seenActions.add(id);
513
+ const link = `${panelUrl}/?action=${encodeURIComponent(id)}`;
514
+ if (!firstShown) { firstShown = true; say(`✓ Primeiro dossiê (${seconds()} desde o comando): ${link}`); }
515
+ else say(`• Ação gravada: ${link}`);
516
+ }
517
+ // A page that loads but where nothing is clicked yet (or that fails to
518
+ // render): the dossier of its own load.
519
+ if (!firstShown && summary.firstPage && Date.now() - summary.firstPage.seenAt > 8000) {
520
+ firstShown = true;
521
+ say(`✓ Primeiro dossiê (${seconds()} desde o comando), o do carregamento da página: ${panelUrl}/?request=${encodeURIComponent(summary.firstPage.requestId)}`);
522
+ say(' Cada clique na app terá o seu próprio dossiê.');
523
+ }
524
+ if (!firstShown && summary.firstRequest && !found.page) {
525
+ firstShown = true;
526
+ say(`✓ Primeiro dossiê (${seconds()} desde o comando): ${panelUrl}/?request=${encodeURIComponent(summary.firstRequest.requestId)}`);
527
+ }
528
+ if (!silentWarned && !minimal && summary.requests >= 3 && !summary.withFunctions.size && summary.functions === 0) {
529
+ silentWarned = true;
530
+ say('! Já chegaram pedidos, mas nenhuma função do projeto foi observada. Os dossiês mostram pedidos e fronteiras.');
531
+ say(' «codetac diagnostico» explica porquê.');
532
+ }
533
+ // A part that fails later with a taken port: its requests could reach the
534
+ // other program (a proxy to that port). Everything stops.
535
+ const down = app.children.filter(child => child.exitCode !== null || child.signalCode !== null || child.crashed);
536
+ if (app.state.busy && down.length && parts.length > 1 && !stopping) {
537
+ clearInterval(watcher);
538
+ const who = holder(app.state.busy);
539
+ say(`✗ Uma parte da app falhou: a porta ${app.state.busy} está ocupada${who ? ` por outro programa (${who})` : ''}.`);
540
+ say(' Paro a app, para que os pedidos dela não cheguem a esse programa. Feche-o e volte a correr o comando.');
541
+ stop();
542
+ return;
543
+ }
544
+ if (!running(app.children).length && !stopping) {
545
+ clearInterval(watcher);
546
+ say('A app terminou.');
547
+ panel.child?.kill();
548
+ process.exit(0);
549
+ }
550
+ }, 700);
551
+ }
552
+
553
+ main().catch(error => {
554
+ say(`✗ ${error.message}`);
555
+ process.exit(1);
556
+ });