quick-outerbase 0.4.0 → 0.5.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/launcher.mjs CHANGED
@@ -1,286 +1,343 @@
1
- #!/usr/bin/env node
2
- // Launcher fino de quick-outerbase (publicado en npm como `quick-outerbase`).
3
- // NO buildea ni trae dependencias pesadas: detecta tu plataforma, baja UNA vez
4
- // el bundle `standalone` precompilado desde GitHub Releases, lo cachea y corre
5
- // `node server.js`. Primer run: ~descarga + arranque (segundos). Siguientes:
6
- // instantáneo (cacheado por versión+plataforma).
7
- //
8
- // Uso: npx quick-outerbase --url "postgresql://user:pass@host:5432/db?schema=public"
9
- // (o posicional, o env DATABASE_URL). Flags: --port, --no-open.
10
- //
11
- // Override para testing/offline: QUICK_OUTERBASE_BUNDLE=/ruta/a/bundle.tar.gz
12
- import { spawn, spawnSync } from "node:child_process";
13
- import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from "node:fs";
14
- import { Readable } from "node:stream";
15
- import { pipeline } from "node:stream/promises";
16
- import http from "node:http";
17
- import os from "node:os";
18
- import path from "node:path";
19
- import process from "node:process";
20
- import { fileURLToPath } from "node:url";
21
- import { createRequire } from "node:module";
22
-
23
- const require = createRequire(import.meta.url);
24
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
- const pkg = require("./package.json");
26
- const VERSION = pkg.version;
27
- const REPO = "joajo13/quick-outerbase";
28
- const isWin = process.platform === "win32";
29
-
30
- function fail(msg) {
31
- console.error("\x1b[31m" + msg + "\x1b[0m");
32
- process.exit(1);
33
- }
34
-
35
- // --- Guard de versión de Node (el server.js standalone necesita Node 20.9+) ---
36
- {
37
- const [maj, min] = process.versions.node.split(".").map(Number);
38
- if (maj < 20 || (maj === 20 && min < 9)) {
39
- fail(`Necesitás Node 20.9+ (tenés ${process.versions.node}). Actualizá Node y reintentá.`);
40
- }
41
- }
42
-
43
- // --- Parseo de argumentos (mismo contrato que el comando original) ---
44
- function getArg(name) {
45
- const i = process.argv.indexOf(name);
46
- return i >= 0 ? process.argv[i + 1] : undefined;
47
- }
48
- function positionalUrl() {
49
- return process.argv
50
- .slice(2)
51
- .find((a) =>
52
- /^(postgres|postgresql|mysql|mariadb|sqlite|file|libsql|dynamodb):/i.test(a)
53
- );
54
- }
55
- const url = getArg("--url") || positionalUrl() || process.env.DATABASE_URL;
56
- const port = getArg("--port") || process.env.PORT || "3008";
57
- const noOpen = process.argv.includes("--no-open");
58
-
59
- if (!url) {
60
- fail(
61
- "Falta DATABASE_URL. Pasalo con --url <connection-string> o por la env DATABASE_URL.\n" +
62
- 'Ej: npx quick-outerbase --url "postgresql://user:pass@localhost:5432/db?schema=public"'
63
- );
64
- }
65
-
66
- // --- Validación del motor por el scheme ---
67
- const scheme = (url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1]?.toLowerCase();
68
- const SUPPORTED = new Set([
69
- "postgres",
70
- "postgresql",
71
- "mysql",
72
- "mariadb",
73
- "sqlite",
74
- "file",
75
- "libsql",
76
- "dynamodb",
77
- ]);
78
- if (!scheme || !SUPPORTED.has(scheme)) {
79
- fail(
80
- `Scheme no reconocido: "${scheme || "(ninguno)"}". ` +
81
- "Motores soportados: postgres://, postgresql://, mysql://, sqlite:/file:, libsql://, dynamodb://<region>"
82
- );
83
- }
84
-
85
- // DynamoDB: la URL lleva SOLO región (+endpoint opcional). Las credenciales NO van
86
- // en la URL: las resuelve el server standalone desde la cadena estándar de AWS
87
- // (env AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN, ~/.aws/credentials o IAM role),
88
- // heredadas vía process.env al spawnear server.js más abajo.
89
- if (scheme === "dynamodb") {
90
- const hasEnvCreds =
91
- process.env.AWS_ACCESS_KEY_ID ||
92
- process.env.AWS_PROFILE ||
93
- process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
94
- process.env.AWS_WEB_IDENTITY_TOKEN_FILE;
95
- if (!hasEnvCreds) {
96
- console.warn(
97
- "\x1b[33m⚠ DynamoDB: no detecté credenciales AWS en el entorno. Si no usás un perfil " +
98
- "(~/.aws/credentials) ni un IAM role, seteá AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY antes de correr.\x1b[0m"
99
- );
100
- }
101
- }
102
-
103
- // --- SQLite: resolver path relativo contra el cwd del USUARIO (no el cache) y
104
- // pasar a libsql una URL file: absoluta (anda en Windows). ---
105
- const userCwd = process.cwd();
106
- function normalizeDbUrl(raw) {
107
- const m = raw.match(/^(sqlite|file):(.*)$/i);
108
- if (!m) return raw;
109
- const p = m[2].replace(/^\/\//, "");
110
- if (!p) return raw;
111
- const isAbs = path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p);
112
- const abs = isAbs ? p : path.resolve(userCwd, p);
113
- return "file:" + abs.split("\\").join("/");
114
- }
115
- const runUrl = normalizeDbUrl(url);
116
- const redacted = url.replace(/\/\/([^:/@]+):([^@]+)@/, "//$1:***@");
117
- console.log(`▶ quick-outerbase v${VERSION} → ${scheme} (${redacted})`);
118
-
119
- // --- Plataforma → nombre del asset del release ---
120
- const ARCH = process.arch; // x64 | arm64
121
- const PLAT = process.platform; // win32 | linux | darwin
122
- // Bundles precompilados: solo Linux y Windows. macOS quedó fuera a propósito
123
- // (los runners de macOS alargan mucho el CI). En Mac, corré desde el código.
124
- const SUPPORTED_TARGETS = new Set(["win32-x64", "linux-x64"]);
125
- const target = `${PLAT}-${ARCH}`;
126
- if (!SUPPORTED_TARGETS.has(target)) {
127
- fail(
128
- `No hay bundle precompilado para tu plataforma (${target}).\n` +
129
- "Soportadas: win32-x64, linux-x64.\n" +
130
- "Alternativa: corré desde el código con `npx github:" + REPO + "`."
131
- );
132
- }
133
- const assetName = `quick-outerbase-${target}.tar.gz`;
134
- const assetUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${assetName}`;
135
-
136
- // --- Cache por versión+plataforma ---
137
- const cacheRoot =
138
- process.env.QUICK_OUTERBASE_CACHE ||
139
- path.join(os.homedir() || os.tmpdir(), ".cache", "quick-outerbase");
140
- const bundleDir = path.join(cacheRoot, `${VERSION}-${target}`);
141
- const serverJs = path.join(bundleDir, "server.js");
142
-
143
- async function ensureBundle() {
144
- if (existsSync(serverJs)) return; // ya cacheado
145
- mkdirSync(bundleDir, { recursive: true });
146
- // El .tgz va ADENTRO del dir destino y extraemos con cwd + basename: así
147
- // tar nunca recibe una ruta con ':' (GNU tar la tomaría como host remoto).
148
- const innerTgz = path.join(bundleDir, "_bundle.tar.gz");
149
-
150
- const localOverride = process.env.QUICK_OUTERBASE_BUNDLE;
151
- if (localOverride) {
152
- if (!existsSync(localOverride)) fail(`QUICK_OUTERBASE_BUNDLE no existe: ${localOverride}`);
153
- copyFileSync(localOverride, innerTgz);
154
- } else {
155
- await download(assetUrl, innerTgz);
156
- }
157
- extractInDir(bundleDir, "_bundle.tar.gz");
158
- try {
159
- rmSync(innerTgz, { force: true });
160
- } catch {
161
- /* noop */
162
- }
163
- }
164
-
165
- async function download(fromUrl, toFile) {
166
- let res;
167
- try {
168
- res = await fetch(fromUrl, { redirect: "follow" });
169
- } catch (e) {
170
- fail(`No pude descargar el runtime (${fromUrl}): ${e.message}`);
171
- }
172
- if (!res.ok) {
173
- fail(
174
- `No pude descargar el runtime (HTTP ${res.status}) de:\n ${fromUrl}\n` +
175
- `¿Existe el release v${VERSION} con el asset ${assetName}?`
176
- );
177
- }
178
- await pipeline(Readable.fromWeb(res.body), createWriteStream(toFile));
179
- }
180
-
181
- function extractInDir(dir, fname) {
182
- // tar disponible en Windows 10+ (tar.exe/bsdtar), macOS y Linux. Corremos con
183
- // cwd=dir y solo el basename → sin rutas con ':' que rompan GNU tar.
184
- const r = spawnSync("tar", ["-xzf", fname], { cwd: dir, stdio: "inherit" });
185
- if (r.error || r.status !== 0) {
186
- fail(
187
- "Falló la extracción con `tar`. Asegurate de tener `tar` en el PATH " +
188
- "(Windows 10+ lo trae como tar.exe)."
189
- );
190
- }
191
- }
192
-
193
- // --- Arranque del server standalone + teardown limpio ---
194
- async function main() {
195
- await ensureBundle();
196
- if (!existsSync(serverJs)) fail(`No encontré server.js en ${bundleDir} tras extraer.`);
197
-
198
- const child = spawn(process.execPath, [serverJs], {
199
- cwd: bundleDir,
200
- stdio: "inherit",
201
- env: {
202
- ...process.env,
203
- DATABASE_URL: runUrl,
204
- PORT: String(port),
205
- HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
206
- FORK_LOCAL: "1",
207
- },
208
- });
209
-
210
- let tearingDown = false;
211
- function teardown(code = 0) {
212
- if (tearingDown) return;
213
- tearingDown = true;
214
- console.log("\n• Cerrando…");
215
- try {
216
- if (isWin) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
217
- else process.kill(child.pid, "SIGTERM");
218
- } catch {
219
- /* ya muerto */
220
- }
221
- if (isWin) {
222
- try {
223
- const out = spawnSync("netstat", ["-ano"], { encoding: "utf8" }).stdout || "";
224
- const pids = new Set();
225
- for (const line of out.split(/\r?\n/)) {
226
- if (line.includes(":" + port + " ") && /LISTENING/i.test(line)) {
227
- const pid = line.trim().split(/\s+/).pop();
228
- if (pid && /^\d+$/.test(pid)) pids.add(pid);
229
- }
230
- }
231
- for (const pid of pids) spawnSync("taskkill", ["/PID", pid, "/T", "/F"], { stdio: "ignore" });
232
- } catch {
233
- /* best-effort */
234
- }
235
- }
236
- console.log("• Listo. Puerto liberado, sin procesos zombie.");
237
- process.exit(code);
238
- }
239
- process.on("SIGINT", () => teardown(0));
240
- process.on("SIGTERM", () => teardown(0));
241
- child.on("exit", (code) => {
242
- if (!tearingDown) teardown(code ?? 0);
243
- });
244
-
245
- if (!noOpen) {
246
- const targetUrl = `http://localhost:${port}/env`;
247
- const started = Date.now();
248
- // Puede haber varios requests del poll en vuelo a la vez (el server tarda más
249
- // que el intervalo en responder el primero). Sin este guard, dos respuestas OK
250
- // abrían el navegador dos veces. `opened` asegura una sola apertura.
251
- let opened = false;
252
- const poll = setInterval(() => {
253
- if (tearingDown) return clearInterval(poll);
254
- const req = http.get(
255
- { host: "localhost", port: Number(port), path: "/env", timeout: 2000 },
256
- (res) => {
257
- res.destroy();
258
- clearInterval(poll);
259
- if (opened) return;
260
- opened = true;
261
- console.log(`✔ Listo en ${targetUrl}`);
262
- openBrowser(targetUrl);
263
- }
264
- );
265
- req.on("error", () => {
266
- if (Date.now() - started > 60000) {
267
- clearInterval(poll);
268
- console.warn("No confirmé el arranque; abrí manualmente " + targetUrl);
269
- }
270
- });
271
- req.on("timeout", () => req.destroy());
272
- }, 800);
273
- }
274
- }
275
-
276
- function openBrowser(u) {
277
- try {
278
- if (isWin) spawn("cmd", ["/c", "start", "", u], { stdio: "ignore", detached: true });
279
- else if (process.platform === "darwin") spawn("open", [u], { stdio: "ignore", detached: true });
280
- else spawn("xdg-open", [u], { stdio: "ignore", detached: true });
281
- } catch {
282
- /* sin browser, no es fatal */
283
- }
284
- }
285
-
286
- main().catch((e) => fail("Error: " + (e?.message || e)));
1
+ #!/usr/bin/env node
2
+ // Launcher fino de quick-outerbase (publicado en npm como `quick-outerbase`).
3
+ // NO buildea ni trae dependencias pesadas: detecta tu plataforma, baja UNA vez
4
+ // el bundle `standalone` precompilado desde GitHub Releases, lo cachea y corre
5
+ // `node server.js`. Primer run: ~descarga + arranque (segundos). Siguientes:
6
+ // instantáneo (cacheado por versión+plataforma).
7
+ //
8
+ // Uso: npx quick-outerbase --url "postgresql://user:pass@host:5432/db?schema=public"
9
+ // (o posicional, o env DATABASE_URL). Flags: --port, --no-open.
10
+ //
11
+ // Override para testing/offline: QUICK_OUTERBASE_BUNDLE=/ruta/a/bundle.tar.gz
12
+ import { spawn, spawnSync } from "node:child_process";
13
+ import { copyFileSync, createWriteStream, existsSync, mkdirSync, rmSync } from "node:fs";
14
+ import { Readable } from "node:stream";
15
+ import { pipeline } from "node:stream/promises";
16
+ import http from "node:http";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import process from "node:process";
20
+ import { fileURLToPath } from "node:url";
21
+ import { createRequire } from "node:module";
22
+ import { loadExpected, verifyBundleChecksum } from "./checksum.mjs";
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
26
+ const pkg = require("./package.json");
27
+ const VERSION = pkg.version;
28
+ const REPO = "joajo13/quick-outerbase";
29
+ const isWin = process.platform === "win32";
30
+
31
+ function fail(msg) {
32
+ console.error("\x1b[31m" + msg + "\x1b[0m");
33
+ process.exit(1);
34
+ }
35
+
36
+ // --- Guard de versión de Node (el server.js standalone necesita Node 20.9+) ---
37
+ {
38
+ const [maj, min] = process.versions.node.split(".").map(Number);
39
+ if (maj < 20 || (maj === 20 && min < 9)) {
40
+ fail(`Necesitás Node 20.9+ (tenés ${process.versions.node}). Actualizá Node y reintentá.`);
41
+ }
42
+ }
43
+
44
+ // --- Parseo de argumentos (mismo contrato que el comando original) ---
45
+ function getArg(name) {
46
+ const i = process.argv.indexOf(name);
47
+ return i >= 0 ? process.argv[i + 1] : undefined;
48
+ }
49
+ function positionalUrl() {
50
+ return process.argv
51
+ .slice(2)
52
+ .find((a) =>
53
+ /^(postgres|postgresql|mysql|mariadb|sqlite|file|libsql|dynamodb):/i.test(a)
54
+ );
55
+ }
56
+ const url = getArg("--url") || positionalUrl() || process.env.DATABASE_URL;
57
+ const port = getArg("--port") || process.env.PORT || "3008";
58
+ const noOpen = process.argv.includes("--no-open");
59
+
60
+ if (!url) {
61
+ fail(
62
+ "Falta DATABASE_URL. Pasalo con --url <connection-string> o por la env DATABASE_URL.\n" +
63
+ 'Ej: npx quick-outerbase --url "postgresql://user:pass@localhost:5432/db?schema=public"'
64
+ );
65
+ }
66
+
67
+ // --- Validación del motor por el scheme ---
68
+ const scheme = (url.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/) || [])[1]?.toLowerCase();
69
+ const SUPPORTED = new Set([
70
+ "postgres",
71
+ "postgresql",
72
+ "mysql",
73
+ "mariadb",
74
+ "sqlite",
75
+ "file",
76
+ "libsql",
77
+ "dynamodb",
78
+ ]);
79
+ if (!scheme || !SUPPORTED.has(scheme)) {
80
+ fail(
81
+ `Scheme no reconocido: "${scheme || "(ninguno)"}". ` +
82
+ "Motores soportados: postgres://, postgresql://, mysql://, sqlite:/file:, libsql://, dynamodb://<region>"
83
+ );
84
+ }
85
+
86
+ // DynamoDB: la URL lleva SOLO región (+endpoint opcional). Las credenciales NO van
87
+ // en la URL: las resuelve el server standalone desde la cadena estándar de AWS
88
+ // (env AWS_ACCESS_KEY_ID/SECRET/SESSION_TOKEN, ~/.aws/credentials o IAM role),
89
+ // que el launcher deja pasar al server vía la whitelist AWS_* (ver buildChildEnv).
90
+ if (scheme === "dynamodb") {
91
+ const hasEnvCreds =
92
+ process.env.AWS_ACCESS_KEY_ID ||
93
+ process.env.AWS_PROFILE ||
94
+ process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI ||
95
+ process.env.AWS_WEB_IDENTITY_TOKEN_FILE;
96
+ if (!hasEnvCreds) {
97
+ console.warn(
98
+ "\x1b[33m⚠ DynamoDB: no detecté credenciales AWS en el entorno. Si no usás un perfil " +
99
+ "(~/.aws/credentials) ni un IAM role, seteá AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY antes de correr.\x1b[0m"
100
+ );
101
+ }
102
+ }
103
+
104
+ // --- SQLite: resolver path relativo contra el cwd del USUARIO (no el cache) y
105
+ // pasar a libsql una URL file: absoluta (anda en Windows). ---
106
+ const userCwd = process.cwd();
107
+ function normalizeDbUrl(raw) {
108
+ const m = raw.match(/^(sqlite|file):(.*)$/i);
109
+ if (!m) return raw;
110
+ const p = m[2].replace(/^\/\//, "");
111
+ if (!p) return raw;
112
+ const isAbs = path.isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p);
113
+ const abs = isAbs ? p : path.resolve(userCwd, p);
114
+ return "file:" + abs.split("\\").join("/");
115
+ }
116
+ const runUrl = normalizeDbUrl(url);
117
+ const redacted = url.replace(/\/\/([^:/@]+):([^@]+)@/, "//$1:***@");
118
+ console.log(`▶ quick-outerbase v${VERSION} → ${scheme} (${redacted})`);
119
+
120
+ // --- Plataforma nombre del asset del release ---
121
+ const ARCH = process.arch; // x64 | arm64
122
+ const PLAT = process.platform; // win32 | linux | darwin
123
+ // Bundles precompilados: solo Linux y Windows. macOS quedó fuera a propósito
124
+ // (los runners de macOS alargan mucho el CI). En Mac, corré desde el código.
125
+ const SUPPORTED_TARGETS = new Set(["win32-x64", "linux-x64"]);
126
+ const target = `${PLAT}-${ARCH}`;
127
+ if (!SUPPORTED_TARGETS.has(target)) {
128
+ fail(
129
+ `No hay bundle precompilado para tu plataforma (${target}).\n` +
130
+ "Soportadas: win32-x64, linux-x64.\n" +
131
+ "Alternativa: corré desde el código con `npx github:" + REPO + "`."
132
+ );
133
+ }
134
+ const assetName = `quick-outerbase-${target}.tar.gz`;
135
+ const assetUrl = `https://github.com/${REPO}/releases/download/v${VERSION}/${assetName}`;
136
+
137
+ // --- Cache por versión+plataforma ---
138
+ const cacheRoot =
139
+ process.env.QUICK_OUTERBASE_CACHE ||
140
+ path.join(os.homedir() || os.tmpdir(), ".cache", "quick-outerbase");
141
+ const bundleDir = path.join(cacheRoot, `${VERSION}-${target}`);
142
+ const serverJs = path.join(bundleDir, "server.js");
143
+
144
+ async function ensureBundle() {
145
+ if (existsSync(serverJs)) return; // ya cacheado
146
+ mkdirSync(bundleDir, { recursive: true });
147
+ // El .tgz va ADENTRO del dir destino y extraemos con cwd + basename: así
148
+ // tar nunca recibe una ruta con ':' (GNU tar la tomaría como host remoto).
149
+ const innerTgz = path.join(bundleDir, "_bundle.tar.gz");
150
+
151
+ const localOverride = process.env.QUICK_OUTERBASE_BUNDLE;
152
+ if (localOverride) {
153
+ // Canal de testing/offline: confiás en TU bundle local, se saltea la verificación.
154
+ if (!existsSync(localOverride)) fail(`QUICK_OUTERBASE_BUNDLE no existe: ${localOverride}`);
155
+ copyFileSync(localOverride, innerTgz);
156
+ } else {
157
+ await download(assetUrl, innerTgz);
158
+ // --- Cadena de confianza (C1): verificar el sha256 del bundle ANTES de extraer.
159
+ // checksums.json viaja DENTRO del paquete npm firmado; el bundle viene de un
160
+ // Release sin firmar. Si no matchea, abortamos y borramos el tgz corrupto.
161
+ let expected;
162
+ try {
163
+ expected = loadExpected(path.join(__dirname, "checksums.json"));
164
+ } catch {
165
+ rmSync(innerTgz, { force: true });
166
+ fail(
167
+ "No encontré checksums.json en el paquete: no puedo verificar la integridad del runtime.\n" +
168
+ "Reinstalá quick-outerbase desde npm (npm i -g quick-outerbase@latest o npx -y quick-outerbase@latest)."
169
+ );
170
+ }
171
+ try {
172
+ verifyBundleChecksum(innerTgz, target, expected);
173
+ } catch (e) {
174
+ rmSync(innerTgz, { force: true });
175
+ fail(e.message);
176
+ }
177
+ }
178
+ extractInDir(bundleDir, "_bundle.tar.gz");
179
+ try {
180
+ rmSync(innerTgz, { force: true });
181
+ } catch {
182
+ /* noop */
183
+ }
184
+ }
185
+
186
+ async function download(fromUrl, toFile) {
187
+ let res;
188
+ try {
189
+ res = await fetch(fromUrl, { redirect: "follow" });
190
+ } catch (e) {
191
+ fail(`No pude descargar el runtime (${fromUrl}): ${e.message}`);
192
+ }
193
+ if (!res.ok) {
194
+ fail(
195
+ `No pude descargar el runtime (HTTP ${res.status}) de:\n ${fromUrl}\n` +
196
+ `¿Existe el release v${VERSION} con el asset ${assetName}?`
197
+ );
198
+ }
199
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(toFile));
200
+ }
201
+
202
+ function extractInDir(dir, fname) {
203
+ // tar disponible en Windows 10+ (tar.exe/bsdtar), macOS y Linux. Corremos con
204
+ // cwd=dir y solo el basename → sin rutas con ':' que rompan GNU tar.
205
+ const r = spawnSync("tar", ["-xzf", fname], { cwd: dir, stdio: "inherit" });
206
+ if (r.error || r.status !== 0) {
207
+ fail(
208
+ "Falló la extracción con `tar`. Asegurate de tener `tar` en el PATH " +
209
+ "(Windows 10+ lo trae como tar.exe)."
210
+ );
211
+ }
212
+ }
213
+
214
+ // --- Subset whitelisteado de env para el server (A2: defensa en profundidad) ---
215
+ // El runtime corre en otro proceso; no tiene por qué heredar TODO process.env (que
216
+ // puede traer tokens de CI, claves de otros servicios, etc.). Pasamos solo lo que el
217
+ // server standalone, Node/Next y el AWS SDK necesitan. OJO: NO movemos la resolución
218
+ // de credenciales acá; solo dejamos pasar las AWS_* para que el server (proxy) las
219
+ // resuelva server-side como hasta ahora (autoconnect de dynamodb:// intacto).
220
+ function buildChildEnv() {
221
+ // Match case-insensitive: en Windows las claves vienen con case impredecible.
222
+ const PREFIXES = ["AWS_", "NODE_", "NEXT_", "NPM_", "UV_"];
223
+ const EXACT = new Set([
224
+ // PATH / home / temp — Node y cualquier spawn del runtime los necesitan
225
+ "PATH", "HOME", "TMPDIR", "TMP", "TEMP", "PWD",
226
+ // locale / timezone
227
+ "LANG", "LC_ALL", "LC_CTYPE", "TZ",
228
+ // Windows: imprescindibles para que Node y las DLLs del sistema resuelvan
229
+ "SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATHEXT",
230
+ "APPDATA", "LOCALAPPDATA", "PROGRAMDATA", "ALLUSERSPROFILE",
231
+ "PROGRAMFILES", "PROGRAMFILES(X86)", "PROGRAMW6432", "COMMONPROGRAMFILES",
232
+ "USERPROFILE", "USERNAME", "USERDOMAIN", "HOMEDRIVE", "HOMEPATH",
233
+ "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "OS",
234
+ // red / proxy / TLS — para que el AWS SDK salga a internet (incluido detrás de proxy)
235
+ "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
236
+ "SSL_CERT_FILE", "SSL_CERT_DIR",
237
+ // app: lo que el server standalone lee de su propio entorno
238
+ "DATABASE_AUTH_TOKEN", "BASE_URL",
239
+ ]);
240
+ const out = {};
241
+ for (const [k, v] of Object.entries(process.env)) {
242
+ if (v === undefined) continue;
243
+ const up = k.toUpperCase();
244
+ if (EXACT.has(up) || PREFIXES.some((p) => up.startsWith(p))) out[k] = v;
245
+ }
246
+ return out;
247
+ }
248
+
249
+ // --- Arranque del server standalone + teardown limpio ---
250
+ async function main() {
251
+ await ensureBundle();
252
+ if (!existsSync(serverJs)) fail(`No encontré server.js en ${bundleDir} tras extraer.`);
253
+
254
+ const child = spawn(process.execPath, [serverJs], {
255
+ cwd: bundleDir,
256
+ stdio: "inherit",
257
+ env: {
258
+ ...buildChildEnv(),
259
+ // El launcher fija estos explícitamente (pisan cualquier valor heredado):
260
+ DATABASE_URL: runUrl,
261
+ PORT: String(port),
262
+ HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
263
+ FORK_LOCAL: "1",
264
+ },
265
+ });
266
+
267
+ let tearingDown = false;
268
+ function teardown(code = 0) {
269
+ if (tearingDown) return;
270
+ tearingDown = true;
271
+ console.log("\n• Cerrando…");
272
+ try {
273
+ if (isWin) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
274
+ else process.kill(child.pid, "SIGTERM");
275
+ } catch {
276
+ /* ya muerto */
277
+ }
278
+ if (isWin) {
279
+ try {
280
+ const out = spawnSync("netstat", ["-ano"], { encoding: "utf8" }).stdout || "";
281
+ const pids = new Set();
282
+ for (const line of out.split(/\r?\n/)) {
283
+ if (line.includes(":" + port + " ") && /LISTENING/i.test(line)) {
284
+ const pid = line.trim().split(/\s+/).pop();
285
+ if (pid && /^\d+$/.test(pid)) pids.add(pid);
286
+ }
287
+ }
288
+ for (const pid of pids) spawnSync("taskkill", ["/PID", pid, "/T", "/F"], { stdio: "ignore" });
289
+ } catch {
290
+ /* best-effort */
291
+ }
292
+ }
293
+ console.log("• Listo. Puerto liberado, sin procesos zombie.");
294
+ process.exit(code);
295
+ }
296
+ process.on("SIGINT", () => teardown(0));
297
+ process.on("SIGTERM", () => teardown(0));
298
+ child.on("exit", (code) => {
299
+ if (!tearingDown) teardown(code ?? 0);
300
+ });
301
+
302
+ if (!noOpen) {
303
+ const targetUrl = `http://localhost:${port}/env`;
304
+ const started = Date.now();
305
+ // Puede haber varios requests del poll en vuelo a la vez (el server tarda más
306
+ // que el intervalo en responder el primero). Sin este guard, dos respuestas OK
307
+ // abrían el navegador dos veces. `opened` asegura una sola apertura.
308
+ let opened = false;
309
+ const poll = setInterval(() => {
310
+ if (tearingDown) return clearInterval(poll);
311
+ const req = http.get(
312
+ { host: "localhost", port: Number(port), path: "/env", timeout: 2000 },
313
+ (res) => {
314
+ res.destroy();
315
+ clearInterval(poll);
316
+ if (opened) return;
317
+ opened = true;
318
+ console.log(`✔ Listo en ${targetUrl}`);
319
+ openBrowser(targetUrl);
320
+ }
321
+ );
322
+ req.on("error", () => {
323
+ if (Date.now() - started > 60000) {
324
+ clearInterval(poll);
325
+ console.warn("No confirmé el arranque; abrí manualmente " + targetUrl);
326
+ }
327
+ });
328
+ req.on("timeout", () => req.destroy());
329
+ }, 800);
330
+ }
331
+ }
332
+
333
+ function openBrowser(u) {
334
+ try {
335
+ if (isWin) spawn("cmd", ["/c", "start", "", u], { stdio: "ignore", detached: true });
336
+ else if (process.platform === "darwin") spawn("open", [u], { stdio: "ignore", detached: true });
337
+ else spawn("xdg-open", [u], { stdio: "ignore", detached: true });
338
+ } catch {
339
+ /* sin browser, no es fatal */
340
+ }
341
+ }
342
+
343
+ main().catch((e) => fail("Error: " + (e?.message || e)));