diff --git a/.gitignore b/.gitignore index 1fffd27..3ecae7f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # Dependencies node_modules/ +.cache/ +.npm-cache/ # Build output dist/ diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..c638254 --- /dev/null +++ b/.npmrc @@ -0,0 +1,6 @@ +cache=.cache/npm +prefer-offline=true +fetch-retries=5 +fetch-retry-factor=2 +fetch-retry-mintimeout=1000 +fetch-retry-maxtimeout=60000 diff --git a/RUNBOOK.md b/RUNBOOK.md new file mode 100644 index 0000000..2592252 --- /dev/null +++ b/RUNBOOK.md @@ -0,0 +1,49 @@ +# AgendaPro: local setup and failure recovery + +## Recommended install + +Use the checked-in lockfile and a project-local npm cache: + +```text +npm run setup +npm run dev +``` + +On PowerShell machines where `npm.ps1` is blocked by execution policy, use `npm.cmd`: + +```text +npm.cmd run setup +npm.cmd run dev +``` + +The setup script uses `npm ci`; it does not mutate dependency versions. The local cache is ignored by Git and avoids failures caused by an unwritable global npm cache. + +## What was audited + +- `SEC_E_NO_CREDENTIALS` during clone: Git was using the Windows Schannel TLS backend and the host could not acquire TLS credentials. This is a machine-level Git/TLS issue, not an application error. Retry the clone with `git -c http.sslBackend=openssl clone `. +- `npm.ps1` execution-policy error: PowerShell blocked the npm wrapper. `npm.cmd` is the same npm installation without the blocked PowerShell wrapper. +- npm `EPERM` in `AppData\Local\npm-cache`: the global cache was not writable. `.npmrc` and `scripts/setup.mjs` route the cache to `.cache/npm` inside the project. +- `tsx` `uv_os_get_passwd`/`os.userInfo()` error: the host returned an OS error while tsx selected its temporary directory. `scripts/run-tsx.mjs` keeps the native call when it works and supplies a safe username fallback when it fails. +- Vite/esbuild config loading: the original config used `__dirname` in an ESM project. `vite.config.ts` now resolves `src` from `import.meta.url`, binds the dev server deterministically to `127.0.0.1`, supports `VITE_PORT`, and fails clearly when the port is already occupied. +- Git `dubious ownership`: some managed workspaces are created by a different Windows identity. If needed, authorize only this checkout with `git config --global --add safe.directory `. + +## Preflight and recovery + +The project validates Node.js >= 22.5 and `node:sqlite` before development, build, and production start. If a port is busy, inspect it with: + +```text +netstat -ano | findstr :3000 +netstat -ano | findstr :5173 +``` + +Change ports without editing source: + +```text +PORT=3001 VITE_PORT=5174 API_URL=http://127.0.0.1:3001 npm run dev +``` + +PowerShell equivalent: + +```text +$env:PORT='3001'; $env:VITE_PORT='5174'; $env:API_URL='http://127.0.0.1:3001'; npm.cmd run dev +``` diff --git a/package-lock.json b/package-lock.json index c334a5d..316ad82 100644 --- a/package-lock.json +++ b/package-lock.json @@ -91,6 +91,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -368,6 +369,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -995,6 +997,7 @@ "resolved": "https://registry.npmjs.org/@fullcalendar/core/-/core-6.1.21.tgz", "integrity": "sha512-t3u/+sqh3Iq7TWtUnVLcGDUE6OWZh0UD3c04bI/l7lSLAgAKr3kngBmhHiQD1QXpwC8ZN5iNqG7a7gOVixhSKQ==", "license": "MIT", + "peer": true, "dependencies": { "preact": "~10.12.1" } @@ -1327,9 +1330,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1344,9 +1344,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1361,9 +1358,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1378,9 +1372,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1395,9 +1386,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1412,9 +1400,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1429,9 +1414,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1446,9 +1428,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1463,9 +1442,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1480,9 +1456,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1497,9 +1470,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1514,9 +1484,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1531,9 +1498,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1880,6 +1844,7 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1968,6 +1933,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2222,6 +2188,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", @@ -2927,6 +2894,7 @@ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3681,6 +3649,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -4299,6 +4268,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", @@ -4568,6 +4538,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -4580,6 +4551,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -5288,6 +5260,7 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5346,6 +5319,7 @@ "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.28.0" }, @@ -5509,6 +5483,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index 538af01..839c03d 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,19 @@ "type": "module", "description": "Visual scheduling and business management app for owners and employees", "scripts": { + "setup": "node scripts/setup.mjs", + "dev:background": "node scripts/launch-dev.mjs", + "predev": "node scripts/preflight.mjs --ports=3000,5173", "dev": "concurrently -k -n SERVER,WEB -c blue,magenta \"npm:dev:server\" \"npm:dev:web\"", - "dev:server": "tsx watch server/index.ts", + "dev:server": "node scripts/run-tsx.mjs watch server/index.ts", "dev:web": "vite", + "prebuild": "node scripts/preflight.mjs", "build": "tsc -b && vite build", - "start": "cross-env NODE_ENV=production tsx server/index.ts", + "prestart": "node scripts/preflight.mjs --ports=3000", + "start": "cross-env NODE_ENV=production node scripts/run-tsx.mjs server/index.ts", "typecheck": "tsc -b --noEmit", "lint": "eslint . --ext ts,tsx", - "seed": "tsx server/scripts/seed.ts", + "seed": "node scripts/run-tsx.mjs server/scripts/seed.ts", "test:e2e": "node e2e-test.mjs", "test:admin": "node admin-test.mjs", "test:unit": "node --import tsx --test server/lib/scheduling.test.ts server/lib/time.test.ts server/lib/metrics.test.ts", diff --git a/scripts/launch-dev.mjs b/scripts/launch-dev.mjs new file mode 100644 index 0000000..e0db23e --- /dev/null +++ b/scripts/launch-dev.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs"; +import path from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runtimeDir = path.join(projectDir, ".cache", "runtime"); +fs.mkdirSync(runtimeDir, { recursive: true }); + +const preflight = spawnSync(process.execPath, [path.join(projectDir, "scripts", "preflight.mjs"), "--ports=3000,5173"], { + cwd: projectDir, + stdio: "inherit", + shell: false, +}); +if (preflight.status !== 0) process.exit(preflight.status ?? 1); + +const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); +const stdout = fs.openSync(path.join(runtimeDir, "dev.stdout.log"), "a"); +const stderr = fs.openSync(path.join(runtimeDir, "dev.stderr.log"), "a"); +const child = spawn(process.execPath, [npmCli, "run", "dev"], { + cwd: projectDir, + detached: true, + stdio: ["ignore", stdout, stderr], + shell: false, + windowsHide: true, +}); + +child.unref(); +console.log(`AgendaPro dev iniciado en segundo plano (PID ${child.pid}). Logs: ${path.join(runtimeDir, "dev.stdout.log")}`); diff --git a/scripts/preflight.mjs b/scripts/preflight.mjs new file mode 100644 index 0000000..21fbd83 --- /dev/null +++ b/scripts/preflight.mjs @@ -0,0 +1,57 @@ +import net from "node:net"; + +const MIN_NODE = [22, 5, 0]; +const actualNode = process.versions.node.split(".").map(Number); +const isVersionAtLeast = actualNode[0] > MIN_NODE[0] + || actualNode[0] === MIN_NODE[0] && (actualNode[1] > MIN_NODE[1] + || actualNode[1] === MIN_NODE[1] && actualNode[2] >= MIN_NODE[2]); + +if (!isVersionAtLeast) { + console.error(`AgendaPro requiere Node.js >= ${MIN_NODE.join(".")}; detectado ${process.version}.`); + process.exit(1); +} + +try { + await import("node:sqlite"); +} catch (error) { + console.error("La instalación de Node no incluye node:sqlite, requerido por AgendaPro."); + console.error(error); + process.exit(1); +} + +const portsArg = process.argv.find((arg) => arg.startsWith("--ports="))?.split("=", 2)[1]; +const configuredPorts = portsArg ? portsArg.split(",").map(Number).filter(Boolean) : []; +const ports = configuredPorts.map((port, index) => { + if (index === 0 && process.env.PORT) return Number(process.env.PORT) || port; + if (index === 1 && process.env.VITE_PORT) return Number(process.env.VITE_PORT) || port; + return port; +}); + +function isPortBusy(port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host: "127.0.0.1", port }); + let settled = false; + const finish = (busy) => { + if (settled) return; + settled = true; + socket.destroy(); + resolve(busy); + }; + socket.once("connect", () => finish(true)); + socket.once("error", () => finish(false)); + socket.setTimeout(500, () => finish(false)); + }); +} + +const busyPorts = []; +for (const port of ports) { + if (await isPortBusy(port)) busyPorts.push(port); +} + +if (busyPorts.length) { + console.error(`No se puede iniciar AgendaPro: los puertos ${busyPorts.join(", ")} ya están ocupados.`); + console.error("Cierra el proceso anterior o cambia PORT/VITE_PORT; en Windows puedes localizarlo con: netstat -ano | findstr :"); + process.exit(1); +} + +console.log(`Preflight OK: Node ${process.version}${ports.length ? `; puertos ${ports.join(", ")} disponibles` : ""}.`); diff --git a/scripts/run-tsx.mjs b/scripts/run-tsx.mjs new file mode 100644 index 0000000..4469daa --- /dev/null +++ b/scripts/run-tsx.mjs @@ -0,0 +1,34 @@ +import os from "node:os"; + +// tsx creates a temporary directory using os.userInfo(). On some Windows +// runners that syscall fails even though the rest of Node is healthy. +// Keep the normal implementation when it works and provide a safe fallback. +const nativeUserInfo = os.userInfo.bind(os); +const safeUserInfo = (...args) => { + try { + return nativeUserInfo(...args); + } catch { + const username = (process.env.USERNAME || process.env.USER || "agendapro").replace(/[<>:"/\\|?*]/g, "-"); + return { + uid: -1, + gid: -1, + username, + homedir: os.homedir(), + shell: null, + }; + } +}; + +try { + os.userInfo = safeUserInfo; +} catch { + Object.defineProperty(os, "userInfo", { configurable: true, writable: true, value: safeUserInfo }); +} + +try { + await import("tsx/cli"); +} catch (error) { + console.error("No se pudo iniciar tsx. Ejecuta primero npm run setup."); + console.error(error); + process.exitCode = 1; +} diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..ef355f8 --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,17 @@ +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const cacheDir = path.resolve(".cache", "npm"); +const npmCli = process.env.npm_execpath || path.join(path.dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js"); +const result = spawnSync(process.execPath, [npmCli, "ci", "--cache", cacheDir], { + stdio: "inherit", + // Invoke npm-cli.js through node so paths with spaces need no shell quoting. + shell: false, +}); + +if (result.error) { + console.error(`No se pudo ejecutar npm: ${result.error.message}`); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/vite.config.ts b/vite.config.ts index 4c2c0c6..2c20eb3 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,19 +1,25 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; -import path from "node:path"; +import { fileURLToPath, URL } from "node:url"; + +const srcDir = fileURLToPath(new URL("./src", import.meta.url)); +const apiTarget = process.env.API_URL || "http://127.0.0.1:3000"; +const webPort = Number(process.env.VITE_PORT) || 5173; export default defineConfig({ plugins: [react()], resolve: { alias: { - "@": path.resolve(__dirname, "src"), + "@": srcDir, }, }, server: { - port: 5173, + host: process.env.VITE_HOST || "127.0.0.1", + port: webPort, + strictPort: true, proxy: { "/api": { - target: "http://localhost:3000", + target: apiTarget, changeOrigin: true, }, },