@danieltmn/tamnora 0.7.1 → 0.8.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/CHANGELOG.md +12 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +2 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/server/public/app.js +367 -61
- package/harness/packages/server/public/index.html +32 -1
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/styles.css +54 -18
- package/harness/packages/server/src/routes/chat.js +3 -0
- package/harness/packages/server/src/routes/git.js +43 -0
- package/harness/packages/server/src/routes/procs.js +14 -2
- package/harness/packages/server/src/routes/shell.js +23 -0
- package/harness/packages/server/src/server.js +4 -0
- package/harness/packages/server/test/server.test.js +106 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
|
|
|
5
5
|
El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
|
|
6
6
|
y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
|
|
7
7
|
|
|
8
|
+
## [0.8.0] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
### Documentacion
|
|
11
|
+
|
|
12
|
+
- **Revision de docs y sitio**: se audito cada afirmacion contra el codigo y se
|
|
13
|
+
corrigieron varias inexactas (las API keys **no** se cifran —van en texto plano
|
|
14
|
+
en SQLite—; `--max-steps` no tiene default `8`; "nada se sube a terceros" es
|
|
15
|
+
falso si se elige un proveedor remoto). Se sumaron al sitio y a la guia las
|
|
16
|
+
features de 0.7.1 (tablas markdown, saltos de scroll, agente autonomo y MCP
|
|
17
|
+
con entorno confinado/redaccion). Actualiza `README.md`, `document.md`,
|
|
18
|
+
`AGENTS.md`, `SECURITY.md`, `website/` y la landing.
|
|
19
|
+
|
|
8
20
|
## [0.7.1] - 2026-10-06
|
|
9
21
|
|
|
10
22
|
### Agregado
|
package/README.md
CHANGED
|
@@ -84,6 +84,10 @@ menu "mas" (los tres puntitos). En el chat:
|
|
|
84
84
|
- **Actividad colapsable**: las tools de lectura (`read`, `list`, `glob`, `grep`,
|
|
85
85
|
`fetch_url`, `git_*`) salen **plegadas** y al hacer click se ve el detalle
|
|
86
86
|
(args + resultado); el volcado completo se pide bajo demanda.
|
|
87
|
+
- **Markdown con tablas**: las respuestas renderizan tablas (`|`, con alineacion
|
|
88
|
+
y celdas saneadas) y botones flotantes para saltar al inicio/final del turno.
|
|
89
|
+
- **Acciones por mensaje**: copiar, editar, regenerar y borrar se agrupan en un
|
|
90
|
+
menu `⋮`; el indicador de "pensando/trabajando" vive en el pie (`#sb-activity`).
|
|
87
91
|
- **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
|
|
88
92
|
volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
|
|
89
93
|
el boton de detener (o `POST /api/sessions/:id/stop`).
|
|
@@ -149,6 +153,11 @@ stdio. Las tools se exponen como `mcp__<servidor>__<tool>` y, si el servidor los
|
|
|
149
153
|
anuncia, tambien sus **recursos** y **prompts** (`list_resources`,
|
|
150
154
|
`read_resource`, `list_prompts`, `get_prompt`).
|
|
151
155
|
|
|
156
|
+
Los MCP stdio **no heredan `process.env`**: reciben un baseline minimo mas el
|
|
157
|
+
`env` declarado (`"KEY": "env:VAR"`), y sus resultados pasan por `redactSecrets`
|
|
158
|
+
(ver [`SECURITY.md`](SECURITY.md)). Para un servidor de confianza que necesite el
|
|
159
|
+
entorno completo, `"inheritEnv": true`.
|
|
160
|
+
|
|
152
161
|
## Tools
|
|
153
162
|
|
|
154
163
|
Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
|
package/SECURITY.md
CHANGED
|
@@ -15,12 +15,17 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
|
|
|
15
15
|
- **Local (loopback)**: quien ya tiene tu sesion de usuario ya puede correr
|
|
16
16
|
comandos; el server no agrega una barrera adicional mas alla del login.
|
|
17
17
|
- **Acceso remoto por tunel (tunnelmole)**: la URL publica es accesible desde
|
|
18
|
-
Internet.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
Internet. Configura un **usuario** (asi toda `/api/*` pide sesion) con una
|
|
19
|
+
**contrasena fuerte**; nunca expongas un server sin login. El tunnel reenvia
|
|
20
|
+
por loopback, de modo que una vez autenticado se alcanza toda la consola
|
|
21
|
+
(incluidos el explorador de archivos y el selector de carpetas): tratalo como
|
|
22
|
+
acceso completo a tu maquina. Cierra el tunnel (`tamnora bridge --stop`) cuando
|
|
23
|
+
no lo uses.
|
|
24
|
+
- **Credenciales**: las API keys de proveedores se guardan en la base SQLite de
|
|
25
|
+
la casa local (fuera del repo) **en texto plano**; protege el archivo con los
|
|
26
|
+
permisos del sistema. Para no persistirlas, usa `"apiKey": "env:VAR"` y
|
|
27
|
+
define la variable en el entorno. Nunca se commitean y se devuelven
|
|
28
|
+
enmascaradas al cliente (`apiKeyHint`).
|
|
24
29
|
- **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
|
|
25
30
|
`process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
|
|
26
31
|
declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
|
|
@@ -160,7 +160,7 @@ Opciones:
|
|
|
160
160
|
--session <id> Continuar una sesión existente
|
|
161
161
|
-c, --continue Continuar la última sesión
|
|
162
162
|
--system <texto> Instrucciones extra de sistema
|
|
163
|
-
--max-steps <n>
|
|
163
|
+
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
164
164
|
--tools <lista> Tools habilitadas (coma), ej. read,list,grep
|
|
165
165
|
--no-tools Deshabilitar todas las tools
|
|
166
166
|
-y, --yes Aprobar automáticamente los permisos "ask"
|
|
@@ -113,7 +113,7 @@ Opciones:
|
|
|
113
113
|
--model <id> Modelo (por defecto: el activo)
|
|
114
114
|
--mode <id> Modo de agente: agent | plan
|
|
115
115
|
--dir <path> Directorio del proyecto
|
|
116
|
-
--max-steps <n>
|
|
116
|
+
--max-steps <n> Techo de rondas de herramientas (0 = sin límite, default)
|
|
117
117
|
--tools <lista> Tools habilitadas (coma) --no-tools
|
|
118
118
|
-y, --yes Auto-aprobar tools con permiso "ask"`);
|
|
119
119
|
}
|
|
@@ -150,6 +150,9 @@ export async function runAgent({
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
const tokens = accumulateUsage(result.usage ?? null);
|
|
153
|
+
// Uso de la ronda para el medidor de contexto en vivo (last_prompt_tokens
|
|
154
|
+
// es el tamano real de contexto de esta ronda).
|
|
155
|
+
if (result.usage) onEvent?.({ type: 'usage', usage: { ...usageTotal } });
|
|
153
156
|
|
|
154
157
|
const calls = result.toolCalls ?? [];
|
|
155
158
|
if (calls.length === 0) {
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import fsp from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const GIT_TIMEOUT_MS = 8000;
|
|
7
|
+
const MAX_DIFF_CHARS = 200_000;
|
|
8
|
+
const MAX_STDERR_CHARS = 20_000;
|
|
9
|
+
|
|
10
|
+
/** Run `git <args>` in `root`. Never rejects; resolves `{ code, stdout, stderr }`. */
|
|
11
|
+
function runGit(root, args, { timeout = GIT_TIMEOUT_MS } = {}) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
let child;
|
|
14
|
+
try {
|
|
15
|
+
child = spawn('git', args, { cwd: root, windowsHide: true });
|
|
16
|
+
} catch (error) {
|
|
17
|
+
resolve({ code: -1, stdout: '', stderr: `Error ejecutando git: ${error.message}` });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
let stdout = '';
|
|
21
|
+
let stderr = '';
|
|
22
|
+
let settled = false;
|
|
23
|
+
const finish = (result) => {
|
|
24
|
+
if (settled) return;
|
|
25
|
+
settled = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve(result);
|
|
28
|
+
};
|
|
29
|
+
const timer = setTimeout(() => {
|
|
30
|
+
try {
|
|
31
|
+
child.kill();
|
|
32
|
+
} catch {
|
|
33
|
+
// already gone
|
|
34
|
+
}
|
|
35
|
+
finish({ code: -1, stdout, stderr: stderr || 'git: tiempo de espera agotado' });
|
|
36
|
+
}, timeout);
|
|
37
|
+
child.stdout?.on('data', (chunk) => {
|
|
38
|
+
if (stdout.length < MAX_DIFF_CHARS) stdout += chunk.toString();
|
|
39
|
+
});
|
|
40
|
+
child.stderr?.on('data', (chunk) => {
|
|
41
|
+
if (stderr.length < MAX_STDERR_CHARS) stderr += chunk.toString();
|
|
42
|
+
});
|
|
43
|
+
child.on('error', (error) => {
|
|
44
|
+
finish({ code: -1, stdout, stderr: `Error ejecutando git: ${error.message}` });
|
|
45
|
+
});
|
|
46
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout, stderr }));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fast synchronous check for a `.git` entry in the workspace root. */
|
|
51
|
+
export function isGitRepo(root) {
|
|
52
|
+
return Boolean(root) && fs.existsSync(path.join(root, '.git'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseBranch(text) {
|
|
56
|
+
let value = String(text ?? '').trim();
|
|
57
|
+
let ahead = 0;
|
|
58
|
+
let behind = 0;
|
|
59
|
+
const flags = value.match(/\[(.+?)\]\s*$/);
|
|
60
|
+
if (flags) {
|
|
61
|
+
const a = flags[1].match(/ahead (\d+)/);
|
|
62
|
+
const b = flags[1].match(/behind (\d+)/);
|
|
63
|
+
if (a) ahead = Number(a[1]);
|
|
64
|
+
if (b) behind = Number(b[1]);
|
|
65
|
+
value = value.slice(0, flags.index).trim();
|
|
66
|
+
}
|
|
67
|
+
const dots = value.indexOf('...');
|
|
68
|
+
if (dots !== -1) value = value.slice(0, dots);
|
|
69
|
+
value = value.replace(/^No commits yet on /, '').trim();
|
|
70
|
+
return { branch: value || '(sin rama)', ahead, behind };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function classify(index, worktree) {
|
|
74
|
+
const pair = `${index}${worktree}`;
|
|
75
|
+
if (pair === '??') return 'untracked';
|
|
76
|
+
if (pair === '!!') return 'ignored';
|
|
77
|
+
if (index === 'U' || worktree === 'U' || pair === 'AA' || pair === 'DD') return 'conflicted';
|
|
78
|
+
if (index === 'R' || worktree === 'R') return 'renamed';
|
|
79
|
+
if (index === 'A' || worktree === 'A') return 'added';
|
|
80
|
+
if (index === 'D' || worktree === 'D') return 'deleted';
|
|
81
|
+
if (index === 'M' || worktree === 'M') return 'modified';
|
|
82
|
+
return 'modified';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Parse `git status --porcelain=v1 -z --branch` output. */
|
|
86
|
+
function parseStatus(raw) {
|
|
87
|
+
const tokens = String(raw ?? '').split('\0').filter((token) => token.length > 0);
|
|
88
|
+
let branch = null;
|
|
89
|
+
let ahead = 0;
|
|
90
|
+
let behind = 0;
|
|
91
|
+
const entries = [];
|
|
92
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
93
|
+
const token = tokens[i];
|
|
94
|
+
if (token.startsWith('## ')) {
|
|
95
|
+
({ branch, ahead, behind } = parseBranch(token.slice(3)));
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const index = token[0] ?? ' ';
|
|
99
|
+
const worktree = token[1] ?? ' ';
|
|
100
|
+
let origPath = null;
|
|
101
|
+
if (index === 'R' || worktree === 'R') {
|
|
102
|
+
origPath = tokens[i + 1] ?? null;
|
|
103
|
+
i++;
|
|
104
|
+
}
|
|
105
|
+
entries.push({
|
|
106
|
+
path: token.slice(3),
|
|
107
|
+
origPath,
|
|
108
|
+
index,
|
|
109
|
+
worktree,
|
|
110
|
+
status: classify(index, worktree),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return { isRepo: true, branch, ahead, behind, entries };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Working-tree status as structured data.
|
|
118
|
+
* @returns {Promise<{isRepo:boolean,branch:string|null,ahead:number,behind:number,entries:Array}>}
|
|
119
|
+
*/
|
|
120
|
+
export async function gitStatus(root) {
|
|
121
|
+
const result = await runGit(root, ['status', '--porcelain=v1', '-z', '--branch']);
|
|
122
|
+
if (result.code !== 0) {
|
|
123
|
+
return { isRepo: false, branch: null, ahead: 0, behind: 0, entries: [] };
|
|
124
|
+
}
|
|
125
|
+
return parseStatus(result.stdout);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build a `new file` unified diff for an untracked path (no `--no-index` needed). */
|
|
129
|
+
async function untrackedDiff(root, target) {
|
|
130
|
+
let stat;
|
|
131
|
+
try {
|
|
132
|
+
stat = await fsp.stat(path.resolve(root, target));
|
|
133
|
+
} catch {
|
|
134
|
+
return `# ${target}: no se pudo leer el archivo.\n`;
|
|
135
|
+
}
|
|
136
|
+
if (stat.isDirectory()) return `# ${target}: carpeta sin seguimiento (sin diff)\n`;
|
|
137
|
+
if (stat.size > MAX_DIFF_CHARS) return `# ${target}: archivo demasiado grande para mostrar el diff.\n`;
|
|
138
|
+
let buffer;
|
|
139
|
+
try {
|
|
140
|
+
buffer = await fsp.readFile(path.resolve(root, target));
|
|
141
|
+
} catch (error) {
|
|
142
|
+
return `# ${target}: ${error.message}\n`;
|
|
143
|
+
}
|
|
144
|
+
if (buffer.includes(0)) return `# ${target}: archivo binario (sin diff).\n`;
|
|
145
|
+
const lines = buffer.toString('utf8').replace(/\r\n/g, '\n').split('\n');
|
|
146
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
147
|
+
const header =
|
|
148
|
+
`diff --git a/${target} b/${target}\n` +
|
|
149
|
+
`new file mode 100644\n--- /dev/null\n+++ b/${target}\n` +
|
|
150
|
+
`@@ -0,0 +1,${lines.length} @@\n`;
|
|
151
|
+
return header + lines.map((line) => `+${line}`).join('\n') + '\n';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Unified diff for a single path (or the whole tree if `path` is empty).
|
|
156
|
+
* @returns {Promise<{ok:boolean,diff:string,staged:boolean,untracked:boolean,error?:string}>}
|
|
157
|
+
*/
|
|
158
|
+
export async function gitDiff(root, { path: filePath, staged = false } = {}) {
|
|
159
|
+
const target = String(filePath ?? '').trim();
|
|
160
|
+
const args = ['diff', '--no-color'];
|
|
161
|
+
if (staged) args.push('--staged');
|
|
162
|
+
if (target) {
|
|
163
|
+
const status = await gitStatus(root);
|
|
164
|
+
if (!status.isRepo) return { ok: false, error: 'No es un repositorio git.' };
|
|
165
|
+
const entry = status.entries.find((e) => e.path === target || e.origPath === target);
|
|
166
|
+
if (entry?.status === 'untracked') {
|
|
167
|
+
const diff = (await untrackedDiff(root, target)).slice(0, MAX_DIFF_CHARS);
|
|
168
|
+
return { ok: true, diff, staged: false, untracked: true };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (target) args.push('--', target);
|
|
172
|
+
const result = await runGit(root, args);
|
|
173
|
+
if (result.code !== 0 && !result.stdout) {
|
|
174
|
+
return { ok: false, error: result.stderr.trim() || 'git diff falló.' };
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, diff: result.stdout.slice(0, MAX_DIFF_CHARS), staged, untracked: false };
|
|
177
|
+
}
|
|
@@ -13,6 +13,7 @@ export { ensureUtf8Console } from './console.js';
|
|
|
13
13
|
export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
|
|
14
14
|
export { estimateTokens, trimContext } from './agent/context.js';
|
|
15
15
|
export { diffLines } from './tools/diff.js';
|
|
16
|
+
export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
|
|
16
17
|
export { summarizeToolCall, previewToolResult } from './tools/labels.js';
|
|
17
18
|
export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
|
|
18
19
|
export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
|
|
@@ -94,4 +95,5 @@ export {
|
|
|
94
95
|
} from './mcp/registry.js';
|
|
95
96
|
export { parseTunnelmoleUrls, startTunnelmole, startTunnel } from './tunnel.js';
|
|
96
97
|
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
|
|
98
|
+
export { killPort } from './procs/kill.js';
|
|
97
99
|
export { readPackageVersion } from './version.js';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const RUN_TIMEOUT_MS = 8000;
|
|
4
|
+
|
|
5
|
+
/** Run a command, resolving `{ code, stdout }` (never rejects). */
|
|
6
|
+
function run(command, args, { timeout = RUN_TIMEOUT_MS } = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let child;
|
|
9
|
+
try {
|
|
10
|
+
child = spawn(command, args, { windowsHide: true });
|
|
11
|
+
} catch {
|
|
12
|
+
resolve({ code: -1, stdout: '' });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let settled = false;
|
|
17
|
+
const finish = (result) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true;
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve(result);
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
try {
|
|
25
|
+
child.kill();
|
|
26
|
+
} catch {
|
|
27
|
+
// already gone
|
|
28
|
+
}
|
|
29
|
+
finish({ code: -1, stdout });
|
|
30
|
+
}, timeout);
|
|
31
|
+
child.stdout?.on('data', (chunk) => {
|
|
32
|
+
if (stdout.length < 20000) stdout += chunk.toString();
|
|
33
|
+
});
|
|
34
|
+
child.on('error', () => finish({ code: -1, stdout }));
|
|
35
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout }));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function uniquePids(values) {
|
|
40
|
+
return [...new Set(values.map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Find PIDs listening on a TCP/UDP port (Windows `netstat`). */
|
|
44
|
+
async function pidsOnWindows(port) {
|
|
45
|
+
const { stdout } = await run('netstat', ['-ano']);
|
|
46
|
+
const pids = [];
|
|
47
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
48
|
+
const fields = line.trim().split(/\s+/);
|
|
49
|
+
if (fields.length < 4) continue;
|
|
50
|
+
if (fields[0].toUpperCase() !== 'TCP' && fields[0].toUpperCase() !== 'UDP') continue;
|
|
51
|
+
if (!fields[1].endsWith(`:${port}`)) continue;
|
|
52
|
+
pids.push(fields[fields.length - 1]);
|
|
53
|
+
}
|
|
54
|
+
return uniquePids(pids);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Find PIDs listening on a port (Unix: lsof, fuser or ss). */
|
|
58
|
+
async function pidsOnUnix(port) {
|
|
59
|
+
const lsof = await run('lsof', ['-ti', `tcp:${port}`]);
|
|
60
|
+
if (lsof.code === 0 && lsof.stdout.trim()) {
|
|
61
|
+
return uniquePids(lsof.stdout.split(/\s+/));
|
|
62
|
+
}
|
|
63
|
+
const fuser = await run('fuser', [`${port}/tcp`]);
|
|
64
|
+
if (fuser.code === 0 && fuser.stdout.trim()) {
|
|
65
|
+
return uniquePids(fuser.stdout.match(/\d+/g) ?? []);
|
|
66
|
+
}
|
|
67
|
+
const ss = await run('ss', ['-ltnp', `sport = :${port}`]);
|
|
68
|
+
if (ss.code === 0 && ss.stdout.trim()) {
|
|
69
|
+
return uniquePids([...ss.stdout.matchAll(/pid=(\d+)/g)].map((match) => match[1]));
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Kill every process listening on `port`.
|
|
76
|
+
* @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function killPort(port) {
|
|
79
|
+
const value = Number(port);
|
|
80
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
81
|
+
throw new Error('Puerto inválido (usá 1-65535).');
|
|
82
|
+
}
|
|
83
|
+
const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
|
|
84
|
+
const killed = [];
|
|
85
|
+
const errors = [];
|
|
86
|
+
for (const pid of pids) {
|
|
87
|
+
const result =
|
|
88
|
+
process.platform === 'win32'
|
|
89
|
+
? await run('taskkill', ['/PID', String(pid), '/T', '/F'])
|
|
90
|
+
: await run('kill', ['-9', String(pid)]);
|
|
91
|
+
if (result.code === 0) killed.push(pid);
|
|
92
|
+
else errors.push(String(pid));
|
|
93
|
+
}
|
|
94
|
+
return { port: value, pids, killed, errors };
|
|
95
|
+
}
|
|
@@ -1,9 +1,59 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import net from 'node:net';
|
|
3
5
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
6
|
import { tmpdir } from 'node:os';
|
|
5
7
|
import path from 'node:path';
|
|
6
8
|
import { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW } from '../src/procs/detect.js';
|
|
9
|
+
import { killPort } from '../src/procs/kill.js';
|
|
10
|
+
|
|
11
|
+
async function waitForPort(port, attempts = 50) {
|
|
12
|
+
for (let i = 0; i < attempts; i++) {
|
|
13
|
+
const ok = await new Promise((resolve) => {
|
|
14
|
+
const socket = net.connect({ port, host: '127.0.0.1' });
|
|
15
|
+
socket.once('connect', () => {
|
|
16
|
+
socket.destroy();
|
|
17
|
+
resolve(true);
|
|
18
|
+
});
|
|
19
|
+
socket.once('error', () => resolve(false));
|
|
20
|
+
});
|
|
21
|
+
if (ok) return;
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
23
|
+
}
|
|
24
|
+
throw new Error('el puerto no abrió');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
test('killPort rejects invalid ports', async () => {
|
|
28
|
+
await assert.rejects(() => killPort(0), /Puerto inválido/);
|
|
29
|
+
await assert.rejects(() => killPort(70000), /Puerto inválido/);
|
|
30
|
+
await assert.rejects(() => killPort('abc'), /Puerto inválido/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('killPort terminates the process listening on a port', async (t) => {
|
|
34
|
+
const port = 47000 + Math.floor(Math.random() * 1000);
|
|
35
|
+
const script = `const http=require('node:http');http.createServer((q,r)=>r.end('ok')).listen(${port},'127.0.0.1');setInterval(()=>{},1000);`;
|
|
36
|
+
const child = spawn(process.execPath, ['-e', script], { stdio: 'ignore', windowsHide: true });
|
|
37
|
+
t.after(() => {
|
|
38
|
+
try {
|
|
39
|
+
child.kill('SIGKILL');
|
|
40
|
+
} catch {
|
|
41
|
+
// already gone
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
await waitForPort(port);
|
|
45
|
+
|
|
46
|
+
const result = await killPort(port);
|
|
47
|
+
assert.ok(result.pids.length >= 1, 'debería encontrar el PID');
|
|
48
|
+
assert.ok(result.killed.length >= 1, 'debería matar el proceso');
|
|
49
|
+
|
|
50
|
+
await new Promise((resolve) => {
|
|
51
|
+
if (child.exitCode !== null || child.signalCode) return resolve();
|
|
52
|
+
child.once('exit', resolve);
|
|
53
|
+
setTimeout(resolve, 3000);
|
|
54
|
+
});
|
|
55
|
+
assert.ok(child.exitCode !== null || child.signalCode, 'el hijo debería haber terminado');
|
|
56
|
+
});
|
|
7
57
|
|
|
8
58
|
test('detectProcs finds node dev scripts with the right package manager', async () => {
|
|
9
59
|
const dir = await mkdtemp(path.join(tmpdir(), 'tamnora-proc-'));
|