import { spawnSync } from 'child_process'; import crypto from 'crypto'; import fs from 'fs'; import os from 'os'; import path from 'path'; import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const PACKAGE_NAME = '@openchamber/web'; const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}`; const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber/main/CHANGELOG.md'; let cachedDetectedPm = null; function getSpawnSyncBaseOptions() { return process.platform === 'win32' ? { windowsHide: true } : {}; } const UPDATE_CHECK_URL = process.env.OPENCHAMBER_UPDATE_API_URL || 'https://api.openchamber.dev/v1/update/check'; function getOpenChamberConfigDir() { if (process.platform === 'win32') { const appData = process.env.APPDATA; if (appData) return path.join(appData, 'openchamber'); } return path.join(os.homedir(), '.config', 'openchamber'); } function sanitizeInstallScope(scope) { if (scope === 'desktop-tauri' || scope === 'vscode' || scope === 'web') return scope; return 'web'; } function getOrCreateInstallId(scope = 'web') { const configDir = getOpenChamberConfigDir(); const normalizedScope = sanitizeInstallScope(scope); const idPath = path.join(configDir, `install-id-${normalizedScope}`); try { const existing = fs.readFileSync(idPath, 'utf8').trim(); if (existing) return existing; } catch { // Generate new id. } const installId = crypto.randomUUID(); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(idPath, `${installId}\n`, { encoding: 'utf8', mode: 0o600 }); return installId; } function mapPlatform(value) { if (value === 'darwin') return 'macos'; if (value === 'win32') return 'windows'; if (value === 'linux') return 'linux'; return 'web'; } function mapArch(value) { if (value === 'arm64' || value === 'aarch64') return 'arm64'; if (value === 'x64' || value === 'amd64') return 'x64'; return 'unknown'; } function normalizeAppType(value) { if (value === 'web' || value === 'desktop-tauri' || value === 'vscode') return value; return 'web'; } function normalizeDeviceClass(value) { if (value === 'mobile' || value === 'tablet' || value === 'desktop' || value === 'unknown') return value; return 'unknown'; } function normalizePlatform(value) { if (value === 'macos' || value === 'windows' || value === 'linux' || value === 'web') return value; return mapPlatform(process.platform); } function normalizeArch(value) { if (value === 'arm64' || value === 'x64' || value === 'unknown') return value; return mapArch(process.arch); } async function checkForUpdatesFromApi(currentVersion, options = {}) { try { const appType = normalizeAppType(options.appType); const hostPlatform = mapPlatform(process.platform); const hostArch = mapArch(process.arch); const platform = appType === 'vscode' ? normalizePlatform(options.platform) : hostPlatform; const arch = appType === 'vscode' ? normalizeArch(options.arch) : hostArch; const payload = { appType, deviceClass: normalizeDeviceClass(options.deviceClass), platform, arch, channel: 'stable', currentVersion, installId: getOrCreateInstallId(appType), instanceMode: options.instanceMode || 'unknown', reportUsage: options.reportUsage !== false, }; const response = await fetch(UPDATE_CHECK_URL, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(payload), signal: AbortSignal.timeout(10000), }); if (!response.ok) return null; const data = await response.json(); if (typeof data?.latestVersion !== 'string') return null; return { available: Boolean(data.updateAvailable), version: data.latestVersion, currentVersion, body: typeof data.releaseNotes === 'string' ? data.releaseNotes : undefined, nextSuggestedCheckInSec: typeof data.nextSuggestedCheckInSec === 'number' && Number.isFinite(data.nextSuggestedCheckInSec) ? data.nextSuggestedCheckInSec : undefined, }; } catch { return null; } } /** * Detect which package manager was used to install this package. * Strategy: * 1. Check npm_config_user_agent (set during npm/pnpm/yarn/bun install) * 2. Check npm_execpath for PM binary path * 3. Analyze package location path for PM-specific patterns * 4. Fall back to npm */ export function detectPackageManager() { if (cachedDetectedPm) { return cachedDetectedPm; } const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim(); if (forcedPm && ['npm', 'pnpm', 'yarn', 'bun'].includes(forcedPm)) { const forcedPmCommand = resolvePackageManagerCommand(forcedPm); if (isCommandAvailable(forcedPmCommand)) { cachedDetectedPm = forcedPm; return cachedDetectedPm; } } // Strategy 1: Detect from runtime executable path (reliable for server-side updates) const runtimePm = detectPackageManagerFromRuntimePath(process.execPath); if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) { cachedDetectedPm = runtimePm; return cachedDetectedPm; } // Strategy 2: Check user agent (most reliable during install) const userAgent = process.env.npm_config_user_agent || ''; let hintedPm = null; if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm'; else if (userAgent.startsWith('yarn')) hintedPm = 'yarn'; else if (userAgent.startsWith('bun')) hintedPm = 'bun'; else if (userAgent.startsWith('npm')) hintedPm = 'npm'; // Strategy 3: Check execpath const execPath = process.env.npm_execpath || ''; if (!hintedPm) { if (execPath.includes('pnpm')) hintedPm = 'pnpm'; else if (execPath.includes('yarn')) hintedPm = 'yarn'; else if (execPath.includes('bun')) hintedPm = 'bun'; else if (execPath.includes('npm')) hintedPm = 'npm'; } // Strategy 4: Detect from invoked binary path (works for bun global symlink installs) const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]); if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) { cachedDetectedPm = invokedPm; return cachedDetectedPm; } if (!hintedPm) { hintedPm = invokedPm; } // Strategy 5: Analyze package location for PM-specific patterns try { const pkgPath = path.resolve(__dirname, '..', '..'); const pmFromPath = detectPackageManagerFromInstallPath(pkgPath); if (pmFromPath && isCommandAvailable(resolvePackageManagerCommand(pmFromPath))) { cachedDetectedPm = pmFromPath; return cachedDetectedPm; } if (!hintedPm) { hintedPm = pmFromPath; } } catch { // Ignore path resolution errors } // Validate the hinted PM actually owns the global install. // This avoids false positives (for example running via bunx while installed with npm). if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) { cachedDetectedPm = hintedPm; return cachedDetectedPm; } // Strategy 6: Check which PM binaries are available and preferred const pmChecks = [ { name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) }, { name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) }, { name: 'bun', check: () => isCommandAvailable(resolvePackageManagerCommand('bun')) }, { name: 'npm', check: () => isCommandAvailable(resolvePackageManagerCommand('npm')) }, ]; for (const { name, check } of pmChecks) { if (check()) { // Verify this PM actually has the package installed globally if (isPackageInstalledWith(name)) { cachedDetectedPm = name; return cachedDetectedPm; } } } cachedDetectedPm = 'npm'; return cachedDetectedPm; } function detectPackageManagerFromInstallPath(pkgPath) { if (!pkgPath) return null; const normalized = pkgPath.replace(/\\/g, '/').toLowerCase(); if (normalized.includes('/.pnpm/') || normalized.includes('/pnpm/')) return 'pnpm'; if (normalized.includes('/.yarn/')) return 'yarn'; if (normalized.includes('/.bun/') || normalized.includes('/bun/install/')) return 'bun'; if (normalized.includes('/node_modules/')) return 'npm'; return null; } function detectPackageManagerFromRuntimePath(runtimePath) { if (!runtimePath || typeof runtimePath !== 'string') return null; const normalized = runtimePath.replace(/\\/g, '/').toLowerCase(); if (normalized.includes('/.bun/bin/bun') || normalized.endsWith('/bun') || normalized.endsWith('/bun.exe')) { return 'bun'; } if (normalized.includes('/pnpm/')) return 'pnpm'; if (normalized.includes('/yarn/')) return 'yarn'; if (normalized.includes('/node') || normalized.endsWith('/node.exe')) return 'npm'; return null; } function detectPackageManagerFromInvocationPath(invokedPath) { if (!invokedPath || typeof invokedPath !== 'string') return null; const normalized = invokedPath.replace(/\\/g, '/').toLowerCase(); if (normalized.includes('/.bun/bin/')) return 'bun'; if (normalized.includes('/.pnpm/')) return 'pnpm'; if (normalized.includes('/.yarn/')) return 'yarn'; return null; } function getPackageManagerCommandCandidates(pm) { const candidates = []; if (pm === 'bun') { const bunExecutable = process.platform === 'win32' ? 'bun.exe' : 'bun'; if (process.env.BUN_INSTALL) { candidates.push(path.join(process.env.BUN_INSTALL, 'bin', bunExecutable)); } if (process.env.HOME) { candidates.push(path.join(process.env.HOME, '.bun', 'bin', bunExecutable)); } if (process.env.USERPROFILE) { candidates.push(path.join(process.env.USERPROFILE, '.bun', 'bin', bunExecutable)); } } candidates.push(pm); return [...new Set(candidates.filter(Boolean))]; } function resolvePackageManagerCommand(pm) { const candidates = getPackageManagerCommandCandidates(pm); for (const candidate of candidates) { if (isCommandAvailable(candidate)) { return candidate; } } return pm; } function quoteCommand(command) { if (!command) return command; if (!/\s/.test(command)) return command; if (process.platform === 'win32') { return `"${command.replace(/"/g, '""')}"`; } return `'${command.replace(/'/g, "'\\''")}'`; } function isCommandAvailable(command) { try { const result = spawnSync(command, ['--version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000, ...getSpawnSyncBaseOptions(), }); return result.status === 0; } catch { return false; } } function isPackageInstalledWith(pm) { try { const pmCommand = resolvePackageManagerCommand(pm); let args; switch (pm) { case 'pnpm': args = ['list', '-g', '--depth=0', PACKAGE_NAME]; break; case 'yarn': args = ['global', 'list', '--depth=0']; break; case 'bun': args = ['pm', 'ls', '-g']; break; default: args = ['list', '-g', '--depth=0', PACKAGE_NAME]; } const result = spawnSync(pmCommand, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, ...getSpawnSyncBaseOptions(), }); if (result.status !== 0) return false; return result.stdout.includes(PACKAGE_NAME) || result.stdout.includes('openchamber'); } catch { return false; } } /** * Get the update command for the detected package manager */ export function getUpdateCommand(pm = detectPackageManager()) { const pmCommand = quoteCommand(resolvePackageManagerCommand(pm)); switch (pm) { case 'pnpm': return `${pmCommand} add -g ${PACKAGE_NAME}@latest`; case 'yarn': return `${pmCommand} global add ${PACKAGE_NAME}@latest`; case 'bun': return `${pmCommand} add -g ${PACKAGE_NAME}@latest`; default: return `${pmCommand} install -g ${PACKAGE_NAME}@latest`; } } /** * Get current installed version from package.json */ export function getCurrentVersion() { try { const pkgPath = path.resolve(__dirname, '..', '..', 'package.json'); const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); return pkg.version || 'unknown'; } catch { return 'unknown'; } } /** * Fetch latest version from npm registry */ export async function getLatestVersion() { try { const response = await fetch(NPM_REGISTRY_URL, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!response.ok) { throw new Error(`Registry responded with ${response.status}`); } const data = await response.json(); return data['dist-tags']?.latest || null; } catch (error) { return null; } } /** * Parse semver version to numeric for comparison */ function parseVersion(version) { const parts = version.replace(/^v/, '').split('.').map(Number); return (parts[0] || 0) * 10000 + (parts[1] || 0) * 100 + (parts[2] || 0); } /** * Fetch changelog notes between versions */ export async function fetchChangelogNotes(fromVersion, toVersion) { try { const response = await fetch(CHANGELOG_URL, { signal: AbortSignal.timeout(10000), }); if (!response.ok) return undefined; const changelog = await response.text(); const sections = changelog.split(/^## /m).slice(1); const fromNum = parseVersion(fromVersion); const toNum = parseVersion(toVersion); const relevantSections = sections.filter((section) => { const match = section.match(/^\[(\d+\.\d+\.\d+)\]/); if (!match) return false; const ver = parseVersion(match[1]); return ver > fromNum && ver <= toNum; }); if (relevantSections.length === 0) return undefined; return relevantSections .map((s) => '## ' + s.trim()) .join('\n\n'); } catch { return undefined; } } export async function checkForUpdates(options = {}) { const currentVersion = options.currentVersion || getCurrentVersion(); const pm = detectPackageManager(); if (currentVersion !== 'unknown') { const remote = await checkForUpdatesFromApi(currentVersion, options); if (remote) { return { ...remote, packageManager: pm, updateCommand: 'openchamber update', }; } } const latestVersion = await getLatestVersion(); if (!latestVersion || currentVersion === 'unknown') { return { available: false, currentVersion, error: 'Unable to determine versions', }; } const currentNum = parseVersion(currentVersion); const latestNum = parseVersion(latestVersion); const available = latestNum > currentNum; let changelog; if (available) { changelog = await fetchChangelogNotes(currentVersion, latestVersion); } return { available, version: latestVersion, currentVersion, body: changelog, packageManager: pm, // Show our CLI command, not raw package manager command updateCommand: 'openchamber update', }; } /** * Execute the update (used by CLI) */ export function executeUpdate(pm = detectPackageManager(), options = {}) { const command = getUpdateCommand(pm); if (!options?.silent) { console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); console.log(`Running: ${command}`); } const result = spawnSync(command, { stdio: 'inherit', shell: true, ...getSpawnSyncBaseOptions(), }); return { success: result.status === 0, exitCode: result.status, }; }