#!/usr/bin/env node import { spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; import { existsSync, readFileSync, statSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const frontendDir = path.resolve(scriptDir, '..'); const packageJsonPath = path.join(frontendDir, 'package.json'); const packageLockPath = path.join(frontendDir, 'package-lock.json'); const nodeModulesPath = path.join(frontendDir, 'node_modules'); const npmHiddenLockPath = path.join(nodeModulesPath, '.package-lock.json'); const installStatePath = path.join(nodeModulesPath, '.gonavi-install-state.json'); const npmCommand = 'npm'; const commonArgs = [ '--prefer-offline', '--no-audit', '--fund=false', '--fetch-retries=5', '--fetch-retry-mintimeout=20000', '--fetch-retry-maxtimeout=120000', ]; const isCI = process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true'; const fail = (message) => { console.error(`[gonavi-frontend-install] ${message}`); process.exit(1); }; const exitWithStatus = (status) => { process.exit(typeof status === 'number' && status > 0 && status <= 255 ? status : 1); }; const hashFile = (filePath) => { const hash = createHash('sha256'); hash.update(readFileSync(filePath)); return hash.digest('hex'); }; const currentState = () => ({ packageJson: hashFile(packageJsonPath), packageLock: existsSync(packageLockPath) ? hashFile(packageLockPath) : '', }); const readInstalledState = () => { if (!existsSync(installStatePath)) return null; try { return JSON.parse(readFileSync(installStatePath, 'utf8')); } catch { return null; } }; const writeInstalledState = (state) => { writeFileSync(installStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); }; const packageInputsAreOlderThanNpmLock = () => { if (!existsSync(npmHiddenLockPath)) return false; const markerTime = statSync(npmHiddenLockPath).mtimeMs; return [packageJsonPath, packageLockPath] .filter(existsSync) .every((filePath) => statSync(filePath).mtimeMs <= markerTime); }; const runNpm = (subcommand) => { const args = [subcommand, ...commonArgs]; if (isCI) { console.log( `[gonavi-frontend-install] cwd=${process.cwd()} frontend=${frontendDir} node=${process.version} platform=${process.platform}/${process.arch} command=${npmCommand} ${args.join(' ')}`, ); } const result = spawnSync(npmCommand, args, { cwd: frontendDir, env: process.env, stdio: 'inherit', shell: process.platform === 'win32', }); if (result.error) { fail(`failed to start npm: ${result.error.message}`); } if (result.signal) { fail(`npm was terminated by signal ${result.signal}`); } if (result.status !== 0) { console.error(`[gonavi-frontend-install] npm exited with status ${result.status ?? 'unknown'}`); exitWithStatus(result.status); } }; if (!existsSync(packageJsonPath)) { fail(`package.json not found at ${packageJsonPath}; cwd=${process.cwd()}`); } const state = currentState(); const installedState = readInstalledState(); const forceInstall = process.env.GONAVI_FORCE_FRONTEND_INSTALL === '1'; if (!forceInstall && existsSync(nodeModulesPath)) { if ( installedState?.packageJson === state.packageJson && installedState?.packageLock === state.packageLock ) { console.log('Frontend dependencies are up to date; skipping npm install.'); process.exit(0); } if (!installedState && isCI && existsSync(npmHiddenLockPath)) { writeInstalledState(state); console.log('Frontend dependencies are up to date from CI cache; recorded install state.'); process.exit(0); } if (!installedState && packageInputsAreOlderThanNpmLock()) { writeInstalledState(state); console.log('Frontend dependencies are up to date; recorded install state.'); process.exit(0); } } runNpm(isCI ? 'ci' : 'install'); writeInstalledState(state);