codetac 0.1.0 → 0.2.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 CHANGED
@@ -5,24 +5,27 @@
5
5
  // codetac [pasta] [opções] [-- comando de arranque]
6
6
  // codetac diagnostico [pasta]
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
9
9
  import { arch, homedir, release } from 'node:os';
10
10
  import http from 'node:http';
11
11
  import { basename, join, resolve } from 'node:path';
12
12
  import readline from 'node:readline/promises';
13
13
  import { pathToFileURL } from 'node:url';
14
14
  import { detectProject, describeStart, foreignRuntime } from './detect.mjs';
15
+ import { MINIMUM, commandFor, describePythonFolder, environmentOf, interpreters, moveTo, probe, proxyOf, pythonPort, versionBelow } from './detect-python.mjs';
15
16
  import { createSummary, follow } from './recording.mjs';
16
17
 
17
18
  import { dataDirectory, install as workspace } from './home.mjs';
18
19
 
19
20
  const recordings = dataDirectory();
20
21
  const register = pathToFileURL(join(workspace, 'src', 'register.mjs')).href;
22
+ // The Python captor: its sitecustomize.py runs in every Python process of the app.
23
+ const captor = join(workspace, 'src', 'python', 'codetac_py');
21
24
  const started = Date.now();
22
25
  const seconds = () => `${Math.round((Date.now() - started) / 1000)} s`;
23
26
  const say = text => process.stdout.write(`${text}\n`);
24
27
  const HELP = `Uso:
25
- codetac [pasta] [opções] [-- comando] arranca a app da pasta (por omissão, a atual) com o CodeTAC
28
+ codetac [pasta] [opções] [-- comando] arranca a app da pasta (por omissão, a atual) com o CodeTAC: Node ou Python (FastAPI, Flask)
26
29
  codetac diagnostico [pasta] explica o que está e o que não está a funcionar
27
30
  codetac relatorio [pasta] guarda o diagnóstico num ficheiro para enviar com uma incidência
28
31
  codetac --version versão instalada
@@ -33,7 +36,7 @@ Opções:
33
36
  --porta <n> porta da app, se não for descoberta sozinha
34
37
  --painel <n> porta do painel (por omissão 4000)
35
38
  --minimo não seguir as funções do projeto (só pedidos e fronteiras)
36
- --sim responder «sim» às perguntas (instalar dependências…)
39
+ --sim responder «sim» às perguntas (instalar dependências, criar o .venv…)
37
40
  --nao-abrir não abrir o browser
38
41
  -- <comando> comando de arranque, quando não for descoberto (ex.: -- node server.js)`;
39
42
 
@@ -156,14 +159,20 @@ function startApp(parts, { run, panelPort, minimal, reason, root, extraEnv = {}
156
159
  const env = {
157
160
  ...process.env, CODETAC_ROOT: root, CODETAC_RUN: run, CODETAC_PANEL_PORT: String(panelPort),
158
161
  NODE_OPTIONS: [process.env.NODE_OPTIONS, `--import=${register}`].filter(Boolean).join(' '),
162
+ PYTHONPATH: [captor, process.env.PYTHONPATH].filter(Boolean).join(process.platform === 'win32' ? ';' : ':'),
159
163
  };
164
+ // Python writes to a pipe in blocks: without this, the app's prints would show late.
165
+ env.PYTHONUNBUFFERED ??= '1';
166
+ // Python's bytecode (__pycache__) goes to CodeTAC's folder, not into the project:
167
+ // some projects even keep .pyc files in Git.
168
+ env.PYTHONPYCACHEPREFIX ??= join(recordings, 'pycache');
160
169
  // The application's output goes through a pipe: its colours stay when the terminal has them.
161
170
  if (process.stdout.isTTY && env.FORCE_COLOR === undefined) env.FORCE_COLOR = '1';
162
171
  Object.assign(env, extraEnv);
163
172
  if (minimal) Object.assign(env, { CODETAC_LEVEL: 'minimo', CODETAC_MINIMO_MOTIVO: reason });
164
173
  for (const part of parts) {
165
174
  const [bin, ...args] = runnable(part.command);
166
- const child = spawn(bin, args, { cwd: part.folder, env, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32',
175
+ const child = spawn(bin, args, { cwd: part.folder, env: { ...env, ...part.env, ...(part.language === 'python' ? environmentOf(part) : {}) }, stdio: ['ignore', 'pipe', 'pipe'], detached: process.platform !== 'win32',
167
176
  shell: process.platform === 'win32' });
168
177
  const label = parts.length > 1 ? `[${part.part}] ` : '';
169
178
  const show = chunk => {
@@ -176,6 +185,9 @@ function startApp(parts, { run, panelPort, minimal, reason, root, extraEnv = {}
176
185
  for (const match of plain.matchAll(URL_IN_OUTPUT)) if (match[1]) ports.push(Number(match[1]));
177
186
  // Watch modes (node --watch, nodemon, tsx watch) keep running after a crash.
178
187
  if (CRASHED.test(plain)) child.crashed = true;
188
+ // A Python app that fails while importing, under a reloader (uvicorn --reload,
189
+ // fastapi dev, flask --debug): the reloader stays, waiting for changes.
190
+ if (child.part?.language === 'python' && /^\s*Traceback \(most recent call last\):/.test(plain)) child.tracebackAt ??= Date.now();
179
191
  const busy = plain.match(BUSY);
180
192
  if (busy) state.busy = Number(busy[1] ?? busy[2] ?? busy[3]);
181
193
  }
@@ -199,9 +211,11 @@ function freePort() {
199
211
  });
200
212
  }
201
213
 
202
- function stopAll(children, signal = 'SIGTERM') {
214
+ function stopAll(children, force = null) {
203
215
  for (const child of children) {
204
216
  if (child.exitCode !== null || child.signalCode !== null) continue;
217
+ // Python apps stop as with Ctrl+C: SIGTERM makes some of them warn (leaked semaphores).
218
+ const signal = force ?? (child.part?.language === 'python' ? 'SIGINT' : 'SIGTERM');
205
219
  try { process.platform === 'win32' ? child.kill(signal) : process.kill(-child.pid, signal); } catch { try { child.kill(signal); } catch {} }
206
220
  }
207
221
  }
@@ -250,7 +264,8 @@ async function findApp(candidates, panelPort, { children, addresses, warn }) {
250
264
  if (others.length) {
251
265
  // Reach the application by the exact address it bound.
252
266
  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';
267
+ // '::' (every IPv6 address) too: the other program holds the IPv4 one.
268
+ host = /^\[?::1?\]?$/.test(bound) ? '::1' : '127.0.0.1';
254
269
  warn(port, others[0].command ?? 'outro programa', host);
255
270
  }
256
271
  }
@@ -268,6 +283,131 @@ async function findApp(candidates, panelPort, { children, addresses, warn }) {
268
283
  return api;
269
284
  }
270
285
 
286
+ // DP5: a Python project without an environment, or without its dependencies.
287
+ // Always asked first (--sim answers yes); the environment is .venv in the project.
288
+ function requirementFiles(folder) {
289
+ let names = [];
290
+ try { names = readdirSync(folder); } catch {}
291
+ const files = names.filter(name => /^requirements.*\.txt$/.test(name) && !/(dev|test|lint|doc)/.test(name));
292
+ return files.includes('requirements.txt') ? ['requirements.txt'] : files.slice(0, 1);
293
+ }
294
+ function pythonSteps(part, { create, skip = [] }) {
295
+ const { folder, python } = part;
296
+ const uv = hasBinary('uv');
297
+ const venvPython = python.interpreter ?? join(folder, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
298
+ const steps = [];
299
+ let chosen = null;
300
+ if (part.manager === 'uv' && uv) return { steps: [['uv', ['sync']]], chosen, env: {} };
301
+ if (part.manager === 'poetry' || part.manager === 'pipenv') {
302
+ const tool = part.manager;
303
+ if (!hasBinary(tool)) return { problem: `O projeto usa o ${tool}, que não está instalado neste computador. Instale-o (por exemplo, pipx install ${tool}) e volte a correr o comando.` };
304
+ // Both keep the environment in the project when asked to: the .venv of DP5.
305
+ const env = tool === 'poetry' ? { POETRY_VIRTUALENVS_IN_PROJECT: 'true' } : { PIPENV_VENV_IN_PROJECT: '1' };
306
+ return { steps: [tool === 'poetry' ? ['poetry', ['install', '--no-root']] : ['pipenv', ['install', '--dev']]], chosen, env };
307
+ }
308
+ if (create) {
309
+ const found = interpreters();
310
+ chosen = found.find(item => !versionBelow(item.version) && !skip.includes(item.executable)) ?? null;
311
+ // uv fetches a Python 3.12 when there is none on the computer.
312
+ if (uv) steps.push(['uv', ['venv', '--python', chosen?.executable ?? MINIMUM.join('.'), '.venv']]);
313
+ else {
314
+ chosen ??= found[0] ?? null;
315
+ if (!chosen) return { problem: 'Não encontrei nenhum Python neste computador. Instale o Python 3.12 ou mais recente (python.org ou brew install python) e volte a correr o comando.' };
316
+ steps.push([chosen.executable, ['-m', 'venv', '.venv']]);
317
+ }
318
+ }
319
+ const files = requirementFiles(folder);
320
+ const install = uv ? ['uv', ['pip', 'install', '--python', venvPython]] : [venvPython, ['-m', 'pip', 'install']];
321
+ if (files.length) steps.push([install[0], [...install[1], '-r', files[0]]]);
322
+ else if (existsSync(join(folder, 'pyproject.toml'))) {
323
+ if (uv) steps.push([install[0], [...install[1], '-r', 'pyproject.toml']]);
324
+ else steps.push({ pyproject: true, install });
325
+ }
326
+ return { steps, chosen, env: {} };
327
+ }
328
+ // A command as the user would type it in the project's folder.
329
+ const shown = ([bin, args], folder) => [bin, ...args].map(token => token.startsWith(`${folder}/`) ? token.slice(folder.length + 1) : token)
330
+ .map(token => token.includes(' ') ? `"${token}"` : token).join(' ');
331
+
332
+ async function preparePython(part, root, options, skip = []) {
333
+ const label = part.part === '.' ? 'O projeto' : `A parte ${part.part}`;
334
+ const create = !part.python.interpreter;
335
+ const plan = pythonSteps(part, { create, skip });
336
+ if (plan.problem) { say(`✗ ${label} não tem ambiente Python. ${plan.problem}`); process.exit(2); }
337
+ const lines = plan.steps.map(step => step.pyproject ? `${shown(step.install, part.folder)} <dependências do pyproject.toml>` : shown(step, part.folder));
338
+ const why = create
339
+ ? `${label} não tem ambiente Python (.venv).`
340
+ : `Faltam dependências no ambiente ${part.python.source} (${part.python.missing.slice(0, 6).join(', ')}${part.python.missing.length > 6 ? '…' : ''}).`;
341
+ say(`${why} Para arrancar, é preciso correr na pasta ${part.folder}:`);
342
+ for (const line of lines) say(` ${line}`);
343
+ if (create && plan.chosen) say(` (com o Python ${plan.chosen.version}${skip.length ? '' : ', o mais recente deste computador'})`);
344
+ else if (create && hasBinary('uv') && part.manager !== 'uv') say(` (o uv descarrega o Python ${MINIMUM.join('.')}: não há nenhum ${MINIMUM.join('.')} ou mais recente neste computador)`);
345
+ if (!lines.length) return part;
346
+ // A retry with an older Python was already agreed to.
347
+ const yes = skip.length ? true : await confirm(create ? 'Criar o .venv e instalar as dependências agora?' : 'Instalar agora?', options);
348
+ if (yes === null) { say('Corra esses comandos, ou use --sim para o CodeTAC os correr.'); process.exit(2); }
349
+ if (!yes) { say('Sem as dependências, a app não arranca. Corra esses comandos e volte a correr o codetac.'); process.exit(2); }
350
+ for (const step of plan.steps) {
351
+ let command = step;
352
+ if (step.pyproject) {
353
+ const venvPython = join(part.folder, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
354
+ const declared = probe(existsSync(venvPython) ? venvPython : step.install[0], part.folder)?.pyproject ?? [];
355
+ if (!declared.length) continue;
356
+ command = [step.install[0], [...step.install[1], ...declared]];
357
+ }
358
+ say(` A correr: ${shown(command, part.folder)}`);
359
+ // Not the environment of whoever runs codetac: the project's.
360
+ const env = { ...process.env, ...plan.env };
361
+ delete env.VIRTUAL_ENV;
362
+ const result = spawnSync(command[0], command[1], { cwd: part.folder, stdio: 'inherit', env });
363
+ if (result.status !== 0) {
364
+ // Pinned dependencies may not exist yet for the newest Python (no wheel, and the
365
+ // build fails): the new .venv is made again with the next older Python ≥ the minimum.
366
+ const older = create && plan.chosen ? interpreters().find(item => !versionBelow(item.version) && item.executable !== plan.chosen.executable
367
+ && ![...skip, plan.chosen.executable].includes(item.executable) && item.version.split('.').slice(0, 2).join('.') !== plan.chosen.version.split('.').slice(0, 2).join('.')) : null;
368
+ if (older) {
369
+ say(`! A instalação falhou com o Python ${plan.chosen.version} (algumas dependências fixadas ainda não existem para ele). Vou tentar com o Python ${older.version}.`);
370
+ rmSync(join(part.folder, '.venv'), { recursive: true, force: true });
371
+ return preparePython(part, root, options, [...skip, plan.chosen.executable]);
372
+ }
373
+ say(`✗ Falhou (código ${result.status}). Veja as mensagens acima.`);
374
+ process.exit(1);
375
+ }
376
+ }
377
+ if (create && existsSync(join(part.folder, '.venv'))) {
378
+ const inGit = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: part.folder, encoding: 'utf8' }).stdout?.trim() === 'true';
379
+ // Git shows the .venv (as untracked) only when nothing ignores it; uv puts a .gitignore inside it.
380
+ if (inGit && spawnSync('git', ['status', '--porcelain', '--', '.venv'], { cwd: part.folder, encoding: 'utf8' }).stdout.trim()) {
381
+ say('! O .venv não está no .gitignore. Acrescente a linha «.venv/» ao .gitignore, para não o enviar para o repositório.');
382
+ }
383
+ }
384
+ const again = describePythonFolder(part.folder, root);
385
+ if (!again?.command) { say('✗ Depois da instalação, continuo sem saber como arrancar a app.'); process.exit(1); }
386
+ if (again.python.missing.length) say(`! Continuam em falta: ${again.python.missing.join(', ')}. Vou tentar arrancar na mesma.`);
387
+ return { ...again, part: part.part };
388
+ }
389
+
390
+ // A command given by the user. In a Python project it runs with the project's
391
+ // environment (uvicorn, flask… as python -m with its interpreter).
392
+ function manualPart(project, root, command) {
393
+ const top = project.top?.language === 'python' && project.top.folder === root ? project.top : null;
394
+ if (!top) return { folder: root, part: '.', name: project.name, command, script: null, stack: null, port: null, installed: true, foreign: foreignRuntime(command.join(' ')) };
395
+ const text = command.join(' ');
396
+ const part = { ...top, installed: true, notes: [], python: { ...top.python, declared: text, declaredSource: 'o comando indicado' },
397
+ script: { name: 'o comando indicado', command: text } };
398
+ part.port = pythonPort(root, { command: text, app: top.python.app });
399
+ part.command = commandFor(part, part.port?.value ?? 8000);
400
+ return part;
401
+ }
402
+
403
+ // The macOS AirPlay Receiver listens on 5000 (and 7000): Flask's default port.
404
+ function portTakenBy(port, who) {
405
+ if (process.platform === 'darwin' && port === 5000 && /ControlCe|AirPlay/i.test(who ?? '')) {
406
+ return 'pelo Recetor AirPlay do macOS (ControlCenter). Para voltar a usar a 5000, desligue o «Recetor AirPlay» em Definições do Sistema › Geral › AirDrop e Handoff';
407
+ }
408
+ return `por outro programa (${who ?? 'desconhecido'})`;
409
+ }
410
+
271
411
  async function main() {
272
412
  const options = parseArgs(process.argv.slice(2));
273
413
  if (options.sub === 'ajuda') { say(HELP); return; }
@@ -306,7 +446,7 @@ async function main() {
306
446
  const project = detectProject(root);
307
447
  let parts = project.start;
308
448
  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(' ')) }];
449
+ parts = [manualPart(project, root, options.command)];
310
450
  } else if (options.parts.length) {
311
451
  parts = options.parts.map(name => project.parts.find(part => part.part === name || basename(part.folder) === name) ?? (name === '.' ? project.top : null)).filter(Boolean);
312
452
  if (!parts.length) { say(`Não encontrei a parte ${options.parts.join(', ')}. Partes: ${project.parts.map(part => part.part).join(', ') || 'nenhuma'}.`); process.exit(2); }
@@ -322,33 +462,89 @@ async function main() {
322
462
  if (answer === null) parts = project.parts;
323
463
  else parts = answer ? answer.split(/[\s,]+/).map(n => project.parts[Number(n) - 1]).filter(Boolean) : project.parts;
324
464
  }
465
+ if (!parts.length && project.top?.language === 'python') {
466
+ say(project.top.python.django
467
+ ? 'Este projeto usa o Django, que o CodeTAC ainda não suporta (só FastAPI e Flask).'
468
+ : 'Encontrei um projeto Python, mas não a app: procurei FastAPI(...) e Flask(...) nos ficheiros .py e comandos no Procfile, Makefile e README.');
469
+ }
325
470
  if (!parts.length) {
326
- say(project.missing.includes('package')
471
+ const python = project.top?.language === 'python';
472
+ if (!python) say(project.missing.includes('package')
327
473
  ? 'Não encontrei um package.json nem um ficheiro de servidor (server.js, index.js…) nesta pasta.'
328
474
  : '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) }];
475
+ const example = python ? 'uvicorn main:app --reload' : 'node server.js';
476
+ const answer = await ask(`Que comando arranca a app? (ex.: ${example}, ou Enter para sair)`);
477
+ if (!answer) { say(`Indique o comando depois de --, por exemplo: codetac . -- ${example}`); process.exit(2); }
478
+ parts = [manualPart(project, root, answer.split(/\s+/))];
332
479
  }
333
- for (const part of parts) say(` Arranque: ${describeStart(part)}`);
334
480
  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.`);
481
+ // Python started from a Node script inherits the Python captor (PYTHONPATH).
482
+ if (/^(python3?|uvicorn)$/.test(part.foreign)) say(`! [${part.part}] O script arranca Python: o CodeTAC observa-o se esse Python for o ${MINIMUM.join('.')} ou mais recente.`);
483
+ else 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
484
  }
337
485
 
338
- // 2. Dependencies.
339
- for (const part of parts.filter(part => !part.installed)) {
486
+ // 2. Dependencies. Python (DP5): an environment in the project, always asked first.
487
+ for (const [index, part] of parts.entries()) {
488
+ if (part.language === 'python' && !part.installed) parts[index] = await preparePython(part, root, options);
489
+ }
490
+ for (const part of parts) say(` Arranque: ${describeStart(part)}`);
491
+ for (const part of parts) for (const note of part.notes ?? []) say(`! ${parts.length > 1 ? `[${part.part}] ` : ''}${note}`);
492
+ for (const part of parts.filter(part => part.language !== 'python' && !part.installed)) {
340
493
  const manager = part.manager === 'bun' ? 'npm' : part.manager;
341
494
  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); }
495
+ // npm: the project's files stay as they are. With a lockfile, `npm ci` installs exactly it;
496
+ // without one (or when it is out of date), `npm install --no-package-lock` writes none.
497
+ const locked = manager === 'npm' && existsSync(join(where, 'package-lock.json'));
498
+ const commands = manager !== 'npm' ? [[manager, 'install']] : locked ? [['npm', 'ci'], ['npm', 'install', '--no-package-lock']] : [['npm', 'install', '--no-package-lock']];
499
+ const yes = await confirm(`As dependências de ${part.part === '.' ? 'o projeto' : part.part} não estão instaladas. Instalar agora com «${commands[0].join(' ')}»?`, options);
500
+ if (yes === null) { say(`As dependências não estão instaladas. Corra «${commands[0].join(' ')}» na pasta ou use --sim.`); process.exit(2); }
344
501
  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' });
502
+ let result = null;
503
+ for (const [index, command] of commands.entries()) {
504
+ const [bin, ...args] = runnable(command);
505
+ if (index) say(`! O «${commands[index - 1].join(' ')}» falhou (o package-lock.json não corresponde ao package.json?). Tento sem o lockfile, sem o alterar.`);
506
+ say(` A instalar (${bin} ${args.join(' ')})…`);
507
+ result = spawnSync(bin, args, { cwd: where, stdio: 'inherit', shell: process.platform === 'win32' });
508
+ if (result.status === 0) break;
509
+ }
348
510
  if (result.status !== 0) { say(`✗ A instalação falhou (código ${result.status}). Veja as mensagens acima.`); process.exit(1); }
349
511
  for (const other of parts) if ((other.installFolder ?? other.folder) === where || (where === root && !other.installFolder)) other.installed = true;
350
512
  }
351
513
 
514
+ // A Python app gets its port on the command line: a port another program
515
+ // holds (AirPlay on 5000, another API on 8000) is replaced before starting.
516
+ // A Vite proxy that reads the API's port from a variable follows it.
517
+ const extraEnv = {};
518
+ for (const [index, part] of parts.entries()) {
519
+ const old = part.port?.value;
520
+ if (part.language !== 'python' || !old || options.command?.length) continue;
521
+ const owners = listeners(old);
522
+ if (!owners?.length) continue;
523
+ const who = owners[0].command;
524
+ const proxies = parts.filter(other => other.language !== 'python').map(other => ({ other, proxy: proxyOf(other.folder, other.script?.command) })).filter(item => item.proxy);
525
+ const fixed = proxies.find(item => item.proxy.literal.includes(old) && !item.proxy.variables.some(variable => variable.port === old));
526
+ if (fixed) {
527
+ say(`✗ A porta ${old}, da API, já está ocupada ${portTakenBy(old, who)}.`);
528
+ say(` O proxy do Vite em ${fixed.other.part} envia os pedidos para essa porta, escrita no vite.config: se eu mudasse a API de porta, os pedidos iriam para esse programa.`);
529
+ say(' Feche esse programa, ou mude a porta no vite.config e no arranque da API, e volte a correr o comando.');
530
+ process.exit(1);
531
+ }
532
+ const port = await freePort();
533
+ const variable = proxies.flatMap(item => item.proxy.variables).find(item => item.port === old);
534
+ parts[index] = moveTo(part, port);
535
+ if (variable) extraEnv[variable.variable] = String(port);
536
+ say(`! A porta ${old} já está ocupada ${portTakenBy(old, who)}.`);
537
+ say(` Vou arrancar ${parts.length > 1 ? `a parte ${part.part}` : 'a app'} na porta ${port}${variable ? `, e o proxy do Vite segue-a (variável ${variable.variable})` : ''}.`);
538
+ say(` Arranque: ${describeStart(parts[index])}`);
539
+ }
540
+ // Frontend and Python API without a proxy: requests go to another origin (DP3).
541
+ if (parts.some(part => part.language === 'python')) {
542
+ for (const part of parts.filter(part => part.stack === 'Vite' && !proxyOf(part.folder, part.script?.command))) {
543
+ say(`! [${part.part}] O Vite não tem proxy para a API: os pedidos do browser à API vão para outra origem, e o CodeTAC liga-os por aproximação («ligação provável»).`);
544
+ say(' Para uma ligação exata, configure server.proxy no vite.config.');
545
+ }
546
+ }
547
+
352
548
  // 3. The panel, on a port the application does not use.
353
549
  const predicted = new Set(parts.map(part => part.port?.value).filter(Boolean));
354
550
  if (options.port) predicted.add(options.port);
@@ -375,7 +571,6 @@ async function main() {
375
571
  process.on('SIGINT', stop);
376
572
  process.on('SIGTERM', stop);
377
573
 
378
- const extraEnv = {};
379
574
  const busyTried = new Set();
380
575
  const warnedShared = new Set();
381
576
  const sharedWarning = (port, command, host) => {
@@ -393,7 +588,9 @@ async function main() {
393
588
  let warned = false;
394
589
  let api = null;
395
590
  // 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);
591
+ // A traceback before the app answers is a failed start once nothing answers for 5 s.
592
+ const failed = () => app.children.filter(child => child.exitCode !== null || child.signalCode !== null || child.crashed
593
+ || (child.tracebackAt && Date.now() - child.tracebackAt > 5000));
397
594
  while (!found && Date.now() < deadline) {
398
595
  await wait(500);
399
596
  recording.poll();
@@ -408,7 +605,7 @@ async function main() {
408
605
  else if (answer) api ??= { ...answer, at: Date.now() };
409
606
  // An API without pages is accepted when nothing else serves pages for a while.
410
607
  // 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;
608
+ if (!found && api && (Date.now() - api.at > 20_000 || (parts.length === 1 && /^(Express|Fastify|Koa|Hono|NestJS|FastAPI)$/.test(parts[0].stack ?? '')))) found = api;
412
609
  if (found) {
413
610
  // The other parts may still be failing (a port taken…): a moment to see it.
414
611
  await wait(2000);
@@ -452,9 +649,14 @@ async function main() {
452
649
  busyTried.add(busy);
453
650
  const who = holder(busy);
454
651
  const port = await freePort();
455
- extraEnv.PORT = String(port);
456
652
  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).`);
653
+ if (parts[0].language === 'python') {
654
+ parts[0] = moveTo(parts[0], port);
655
+ say(`! A porta ${busy} já está ocupada ${portTakenBy(busy, who)}. Vou arrancar a app na porta ${port}.`);
656
+ } else {
657
+ extraEnv.PORT = String(port);
658
+ say(`! A porta ${busy} já está ocupada${who ? ` por outro programa (${who})` : ''}. Vou arrancar a app na porta ${port} (variável PORT).`);
659
+ }
458
660
  run = recordingName(project.name) + (minimal ? '-minimo' : '');
459
661
  continue;
460
662
  }
@@ -491,13 +693,29 @@ async function main() {
491
693
  const appUrl = found.url ?? `http://localhost:${found.port}/`;
492
694
  say('');
493
695
  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.`);
696
+ // The capture may have chosen the minimal mode by itself (Python below the minimum…).
697
+ // (Unless it was announced before starting: a Python below the minimum.)
698
+ if (!minimal && !parts.some(part => part.python?.version && versionBelow(part.python.version))) {
699
+ const own = app.summary.starts.find(start => start.level === 'minimo');
700
+ if (own) say(`! Modo mínimo: ${own.reason}. Os dossiers mostram pedidos, fronteiras e browser, sem as funções do projeto.`);
701
+ }
702
+ 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 dossier desse pedido mostra onde falhou.`);
495
703
  if (found.page) {
496
704
  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.');
705
+ say(' Cada ação (clique, formulário…) fica com um dossier; a barra abre-o.');
498
706
  if (!options.noOpen && interactive) openBrowser(appUrl);
707
+ } else if (found.status === 404 && parts.some(part => /Vite|Next|Nuxt|Astro|Svelte|Remix|React|Angular/.test(part.stack ?? ''))) {
708
+ // A frontend that answers 404 on its home page is not an API: it did not find its page.
709
+ say(`! A página inicial respondeu 404. O frontend não encontrou a página (falta o index.html, ou a raiz no vite.config aponta para outra pasta?).`);
710
+ say(' O dossier desse pedido mostra-o. Os pedidos à API continuam a ser gravados.');
711
+ } else if (parts.length === 1 && parts[0].stack === 'FastAPI') {
712
+ // The interactive documentation is a page: the bar appears there, and each «Try it out» is an action.
713
+ const docs = `${appUrl}docs`;
714
+ say(' Esta app é uma API. Cada pedido fica com um dossier no painel.');
715
+ say(` Para a experimentar no browser, com a barra do CodeTAC: ${docs} («Try it out» em cada rota).`);
716
+ if (!options.noOpen && interactive) openBrowser(docs);
499
717
  } 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.');
718
+ say(' Esta app responde sem páginas HTML (uma API). Faça pedidos como de costume; cada pedido fica com um dossier no painel.');
501
719
  }
502
720
  say(` Painel: ${panelUrl} · Terminar: Ctrl+C`);
503
721
  say('');
@@ -511,23 +729,23 @@ async function main() {
511
729
  if (seenActions.has(id)) continue;
512
730
  seenActions.add(id);
513
731
  const link = `${panelUrl}/?action=${encodeURIComponent(id)}`;
514
- if (!firstShown) { firstShown = true; say(`✓ Primeiro dossiê (${seconds()} desde o comando): ${link}`); }
732
+ if (!firstShown) { firstShown = true; say(`✓ Primeiro dossier (${seconds()} desde o comando): ${link}`); }
515
733
  else say(`• Ação gravada: ${link}`);
516
734
  }
517
735
  // A page that loads but where nothing is clicked yet (or that fails to
518
736
  // render): the dossier of its own load.
519
737
  if (!firstShown && summary.firstPage && Date.now() - summary.firstPage.seenAt > 8000) {
520
738
  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ê.');
739
+ say(`✓ Primeiro dossier (${seconds()} desde o comando), o do carregamento da página: ${panelUrl}/?request=${encodeURIComponent(summary.firstPage.requestId)}`);
740
+ say(' Cada clique na app terá o seu próprio dossier.');
523
741
  }
524
742
  if (!firstShown && summary.firstRequest && !found.page) {
525
743
  firstShown = true;
526
- say(`✓ Primeiro dossiê (${seconds()} desde o comando): ${panelUrl}/?request=${encodeURIComponent(summary.firstRequest.requestId)}`);
744
+ say(`✓ Primeiro dossier (${seconds()} desde o comando): ${panelUrl}/?request=${encodeURIComponent(summary.firstRequest.requestId)}`);
527
745
  }
528
746
  if (!silentWarned && !minimal && summary.requests >= 3 && !summary.withFunctions.size && summary.functions === 0) {
529
747
  silentWarned = true;
530
- say('! Já chegaram pedidos, mas nenhuma função do projeto foi observada. Os dossiês mostram pedidos e fronteiras.');
748
+ say('! Já chegaram pedidos, mas nenhuma função do projeto foi observada. Os dossiers mostram pedidos e fronteiras.');
531
749
  say(' «codetac diagnostico» explica porquê.');
532
750
  }
533
751
  // A part that fails later with a taken port: its requests could reach the