diff --git a/.gitignore b/.gitignore index bce37d22..ba9c1018 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ backend/public/ # Auto-generated files backend/cookies.txt +backend/src/generated/version.ts # Local-only projects (separate repos) demo-video/ diff --git a/CHANGELOG.md b/CHANGELOG.md index a2927c9f..03116ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -330,7 +330,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **licensing:** license card now shows "Duration: Lifetime" for lifetime licenses instead of an empty renewal date * **fleet:** remote node capability detection now works reliably; `/api/meta` and `/api/health` are exempt from the global rate limiter so they are never blocked by proxied traffic, and a backend-side cache with stale-while-revalidate prevents transient failures from disabling capability-gated features (Auto-Update, Schedules, Audit, Console) on remote nodes * **fleet:** failed capability fetches now retry after 30 seconds instead of being cached for the full 5-minute TTL -* **fleet:** `getSenchoVersion()` now resolves `package.json` via `__dirname` walk instead of a hardcoded relative path; the previous `require('../../../package.json')` resolved correctly in dev but crashed with a 500 in Docker (compiled path `dist/services/` has a different depth than source `src/services/`), causing `/api/meta` to always fail on containerized instances and all capability-gated features to appear unsupported on remote nodes +* **fleet:** `getSenchoVersion()` no longer crashes in Docker containers; version is now baked in at build time via a prebuild script that reads the root `package.json` and generates a TypeScript constant, with a filesystem walk fallback for dev environments where the prebuild hook may not have run ### Changed diff --git a/backend/package.json b/backend/package.json index 51925e09..8d873aaa 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,8 +4,11 @@ "description": "", "main": "index.js", "scripts": { + "postinstall": "test -f scripts/generate-version.js && node scripts/generate-version.js || true", + "prebuild": "node scripts/generate-version.js", "build": "tsc", "start": "node dist/index.js", + "predev": "node scripts/generate-version.js", "dev": "nodemon --watch src --ext ts,json --exec ts-node src/index.ts", "test": "vitest run", "lint": "eslint src" diff --git a/backend/scripts/generate-version.js b/backend/scripts/generate-version.js new file mode 100644 index 00000000..93dd9198 --- /dev/null +++ b/backend/scripts/generate-version.js @@ -0,0 +1,27 @@ +#!/usr/bin/env node +// Reads the version from the root package.json and writes it into +// src/generated/version.ts so it's available as a compiled constant. +// Invoked automatically by prebuild/predev/postinstall npm hooks. + +const fs = require('fs'); +const path = require('path'); + +const rootPkg = path.resolve(__dirname, '..', '..', 'package.json'); +const outFile = path.resolve(__dirname, '..', 'src', 'generated', 'version.ts'); + +let version = '0.0.0-dev'; +try { + const pkg = JSON.parse(fs.readFileSync(rootPkg, 'utf8')); + if (pkg.version) version = pkg.version; +} catch { + console.warn('[generate-version] Could not read root package.json, using fallback version'); +} + +const content = `// Auto-generated by prebuild script. Do not edit manually. +// Regenerated on every \`npm run build\` from the root package.json. +export const SENCHO_VERSION: string = ${JSON.stringify(version)}; +`; + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, content, 'utf8'); +console.log(`[generate-version] Wrote ${version} to src/generated/version.ts`); diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 9266b591..240c24eb 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import path from 'path'; import fs from 'fs'; +import { SENCHO_VERSION } from '../generated/version'; /** * Static registry of capabilities supported by THIS Sencho instance. @@ -34,24 +35,29 @@ export const CAPABILITIES = [ export type Capability = (typeof CAPABILITIES)[number]; -export function getSenchoVersion(): string { - // Walk up from __dirname to find the root package.json (name === 'sencho'). - // In dev this is 3 levels up (src/services/ -> root), in Docker it's 2 - // (dist/services/ -> /app/). Skips intermediate package.json files like - // backend/package.json which has a different name and version. +// Resolved once per process at import time, then cached. +function resolveVersion(): string { + if (SENCHO_VERSION !== '0.0.0-dev') return SENCHO_VERSION; + + // Fallback for manual ts-node runs without the predev hook. let dir = __dirname; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, 'package.json'); - if (fs.existsSync(candidate)) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const pkg = require(candidate); + try { + const pkg = JSON.parse(fs.readFileSync(candidate, 'utf8')); if (pkg.name === 'sencho') return pkg.version; - } + } catch { /* not found, keep walking */ } dir = path.dirname(dir); } return 'unknown'; } +const cachedVersion = resolveVersion(); + +export function getSenchoVersion(): string { + return cachedVersion; +} + export interface RemoteMeta { version: string | null; capabilities: string[];