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/package.json +10 -5
- package/readme.md +81 -14
- package/src/ai.mjs +4 -2
- package/src/boundaries.mjs +12 -9
- package/src/browser/bar.js +5 -3
- package/src/cli.mjs +251 -33
- package/src/detect-python.mjs +286 -0
- package/src/detect.mjs +21 -6
- package/src/diagnose.mjs +35 -5
- package/src/digest.mjs +18 -4
- package/src/page.mjs +19 -7
- package/src/panel.mjs +56 -14
- package/src/python/codetac_py/__init__.py +88 -0
- package/src/python/codetac_py/boundaries.py +435 -0
- package/src/python/codetac_py/capture.py +351 -0
- package/src/python/codetac_py/context.py +50 -0
- package/src/python/codetac_py/detail.py +333 -0
- package/src/python/codetac_py/files.py +135 -0
- package/src/python/codetac_py/frameworks.py +72 -0
- package/src/python/codetac_py/hooks.py +72 -0
- package/src/python/codetac_py/jinja_map.py +132 -0
- package/src/python/codetac_py/network.py +704 -0
- package/src/python/codetac_py/page.py +513 -0
- package/src/python/codetac_py/project.py +60 -0
- package/src/python/codetac_py/redact.py +104 -0
- package/src/python/codetac_py/servers.py +514 -0
- package/src/python/codetac_py/sitecustomize.py +62 -0
- package/src/python/codetac_py/writer.py +237 -0
- package/src/python/probe.py +99 -0
- package/src/recording.mjs +22 -4
- package/src/runtime.mjs +13 -1
- package/src/sentences.mjs +4 -0
- package/src/store.mjs +61 -9
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,
|
|
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
|
-
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
343
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
522
|
-
say(' Cada clique na app terá o seu próprio
|
|
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
|
|
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
|
|
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
|