singularity-forge/scripts/build-web-if-stale.cjs
ace-pm 6b0ac484ba refactor: update log prefixes and string values from gsd- to sf- namespace
Updates channel prefixes, log messages, comments, and configuration values
across daemon, mcp-server, and related packages to complete the rebrand from
gsd to sf-run naming.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:37:12 +02:00

110 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
/**
* Rebuild the Next.js web host only when web source files are newer than the
* staged standalone build. Skips the build when nothing has changed.
*
* Also self-heals a missing/incomplete web dependency install so `npm run sf:web`
* doesn't fail with bare `next` command-not-found errors.
*
* Exit codes:
* 0 — build was up-to-date or successfully rebuilt
* 1 — build failed
*/
'use strict'
const { execSync } = require('node:child_process')
const { existsSync, readdirSync, statSync } = require('node:fs')
const { join, resolve } = require('node:path')
// Skip on Windows — Next.js webpack build hits EPERM scanning system dirs
if (process.platform === 'win32') {
console.log('[forge] Web build skipped on Windows.')
process.exit(0)
}
const root = resolve(__dirname, '..')
const webRoot = join(root, 'web')
// Also watch src/ because api routes import directly from src/web/* and src/resources/*
const srcRoot = join(root, 'src')
const stagedSentinel = join(root, 'dist', 'web', 'standalone', 'server.js')
// Directories inside web/ that are not source and should be ignored for
// staleness comparison.
const IGNORED_DIRS = new Set(['node_modules', '.next', '.turbo', 'dist', 'out', '.cache'])
/**
* Walk a directory tree, yield the mtime of every file, skipping ignored dirs.
* Returns the maximum mtime found (ms since epoch), or 0 if nothing found.
*/
function newestMtime(dir) {
let max = 0
let stack = [dir]
while (stack.length > 0) {
const current = stack.pop()
let entries
try {
entries = readdirSync(current, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (!IGNORED_DIRS.has(entry.name)) {
stack.push(join(current, entry.name))
}
continue
}
try {
const mt = statSync(join(current, entry.name)).mtimeMs
if (mt > max) max = mt
} catch {
// skip unreadable files
}
}
}
return max
}
function sentinelMtime() {
try {
return statSync(stagedSentinel).mtimeMs
} catch {
return 0
}
}
function hasWebBuildDependencies() {
return existsSync(join(webRoot, 'node_modules', '.bin', 'next'))
}
function ensureWebBuildDependencies() {
if (hasWebBuildDependencies()) {
return
}
console.log('[forge] Web build dependencies are missing or incomplete — running npm --prefix web ci...')
execSync('npm --prefix web ci', { cwd: root, stdio: 'inherit' })
}
const sourceMtime = Math.max(newestMtime(webRoot), newestMtime(srcRoot))
const builtMtime = sentinelMtime()
if (builtMtime > 0 && builtMtime >= sourceMtime) {
console.log('[forge] Web build is up-to-date, skipping rebuild.')
process.exit(0)
}
if (builtMtime === 0) {
console.log('[forge] No staged web build found — building now...')
} else {
console.log('[forge] Web/src source has changed since last build — rebuilding...')
}
try {
ensureWebBuildDependencies()
execSync('npm run build:web-host', { cwd: root, stdio: 'inherit' })
} catch (err) {
console.error('[forge] Web build failed:', err.message)
process.exit(1)
}