Refine server update-check flow in runtime endpoints Improve package-manager update detection consistency
506 lines
15 KiB
JavaScript
506 lines
15 KiB
JavaScript
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,
|
|
};
|
|
}
|