'use strict'; const fs = require('fs'); const fsp = fs.promises; const os = require('os'); const path = require('path'); const {spawn, execFile} = require('child_process'); const ROOT = path.resolve(__dirname, '..'); const SERVER_FILE = path.join(ROOT, 'server.js'); const SERVICE_ROOT = path.resolve(process.env.LINK_FIELD_SERVICE_DIR || path.join(os.homedir(), '.local', 'share', 'LinkField', 'service')); const PID_FILE = path.join(SERVICE_ROOT, 'server.pid'); const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log')); const STARTUP_TIMEOUT_MS = 12_000; const POLL_INTERVAL_MS = 100; const PUBLIC_ENTRIES = Object.freeze([ 'index.html', 'style.css', 'favicon.svg', 'favicon.ico', 'build-meta.js', 'runtime-config.js', 'shared-contracts.js', 'store-catalog.generated.js', 'store-catalog.json', 'puzzle-patterns.js', 'puzzle-core.js', 'app-logic.js', 'archive-codec.js', 'field-persistence.js', 'field-persistence-worker.js', 'puzzle-worker.js', 'app.js', 'api-bridge.php', 'assets', 'client', ]); function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function execFileText(command, args) { return new Promise((resolve, reject) => execFile(command, args, {encoding:'utf8'}, (error, stdout) => error ? reject(error) : resolve(stdout))); } async function legacyLinkFieldPids() { if (process.platform === 'win32') return []; let output; try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); } catch (_) { return []; } const matches=[]; for (const line of output.split(/\r?\n/)) { const match=line.match(/^\s*(\d+)\s+(\S+)\s+(.+)$/);if(!match)continue; const pid=Number(match[1]),executable=path.basename(match[2]).toLowerCase(),command=match[3]; if(pid===process.pid||!Number.isSafeInteger(pid)||!['node','nodejs'].includes(executable)||!/(?:^|[\s/])server\.js(?:\s|$)/.test(command))continue; let cwd='';try{cwd=await fsp.readlink(`/proc/${pid}/cwd`)}catch(_){} const candidates=[cwd];const absolute=command.match(/(?:^|\s)(\/[^\s]*\/server\.js)(?:\s|$)/);if(absolute)candidates.push(path.dirname(absolute[1])); let linkField=false; for(const directory of candidates.filter(Boolean)){ try{const pkg=JSON.parse(await fsp.readFile(path.join(directory,'package.json'),'utf8'));if(pkg?.name==='link-field-v47-shared-world'){linkField=true;break}}catch(_){} } if(linkField)matches.push(pid); } return [...new Set(matches)]; } async function stopLegacyLinkFieldServers() { const pids=await legacyLinkFieldPids();if(!pids.length)return []; for(const pid of pids)try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error} const started=Date.now();while(Date.now()-started<3000&&pids.some(isProcessRunning))await sleep(100); for(const pid of pids)if(isProcessRunning(pid))try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH'&&error?.code!=='EPERM')throw error} return pids; } function isProcessRunning(pid) { if (!Number.isSafeInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; } } async function readPid() { try { const pid = Number((await fsp.readFile(PID_FILE, 'utf8')).trim()); return Number.isSafeInteger(pid) && pid > 0 ? pid : null; } catch (error) { if (error?.code === 'ENOENT') return null; throw error; } } async function removeStalePid() { const pid = await readPid(); if (pid && isProcessRunning(pid)) return pid; await fsp.unlink(PID_FILE).catch(error => { if (error?.code !== 'ENOENT') throw error; }); return null; } function isWithin(parent, child) { const relative = path.relative(parent, child); return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); } function resolvePublicDir() { const explicit = String(process.env.LINK_FIELD_PUBLIC_DIR || '').trim(); if (explicit) return path.resolve(explicit); const home = os.homedir(); const defaultDir = path.join(home, 'public_html', 'link-field'); const defaultParent = path.dirname(defaultDir); const rootParent = path.dirname(ROOT); const rootName = path.basename(ROOT).toLowerCase(); const parentName = path.basename(rootParent).toLowerCase(); if (path.resolve(ROOT) === path.resolve(defaultDir)) return ROOT; if (isWithin(defaultDir, ROOT)) return defaultDir; if (parentName === 'link-field' && /^link-field-v\d/i.test(rootName)) return rootParent; if (isWithin(defaultParent, ROOT) && /^link-field-v\d/i.test(rootName)) return defaultDir; return defaultDir; } async function copyEntry(source, destination) { const stat = await fsp.stat(source); if (stat.isDirectory()) { await fsp.mkdir(destination, {recursive:true, mode:0o755}); const entries = await fsp.readdir(source, {withFileTypes:true}); for (const entry of entries) { await copyEntry(path.join(source, entry.name), path.join(destination, entry.name)); } return; } if (!stat.isFile()) return; await fsp.mkdir(path.dirname(destination), {recursive:true, mode:0o755}); await fsp.copyFile(source, destination); await fsp.chmod(destination, 0o644).catch(() => {}); } async function deployPublicFiles(publicDir = resolvePublicDir()) { await fsp.mkdir(publicDir, {recursive:true, mode:0o755}); if (path.resolve(publicDir) !== ROOT) { for (const entry of PUBLIC_ENTRIES) { const source = path.join(ROOT, entry); try { await copyEntry(source, path.join(publicDir, entry)); } catch (error) { if (error?.code !== 'ENOENT') throw error; } } } const manifest = { app: 'LinkField', version: require('../build-meta').APP_VERSION, source: ROOT, deployedAt: new Date().toISOString(), }; await fsp.writeFile(path.join(publicDir, '.linkfield-deployment.json'), `${JSON.stringify(manifest, null, 2)}\n`, {encoding:'utf8', mode:0o644}); return publicDir; } async function tailLog(lines = 20) { try { const text = await fsp.readFile(LOG_FILE, 'utf8'); return text.trimEnd().split(/\r?\n/).slice(-lines).join('\n'); } catch (error) { return error?.code === 'ENOENT' ? '' : `Could not read log: ${error.message}`; } } async function waitForStartup(pid, publicDir) { const portFile = path.join(publicDir, '.linkfield-port'); const started = Date.now(); while (Date.now() - started < STARTUP_TIMEOUT_MS) { if (!isProcessRunning(pid)) { const log = await tailLog(); throw new Error(`LinkField exited during startup.${log ? `\n\n${log}` : ''}`); } try { const port = Number((await fsp.readFile(portFile, 'utf8')).trim()); if (Number.isSafeInteger(port) && port > 0 && port <= 65535) return port; } catch (error) { if (error?.code !== 'ENOENT') throw error; } await sleep(POLL_INTERVAL_MS); } const log = await tailLog(); throw new Error(`LinkField did not finish startup within ${STARTUP_TIMEOUT_MS / 1000} seconds.${log ? `\n\n${log}` : ''}`); } async function start() { await fsp.mkdir(SERVICE_ROOT, {recursive:true, mode:0o700}); const existingPid = await removeStalePid(); if (existingPid) { console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`); await stop({quiet:true}); } const stoppedLegacy=await stopLegacyLinkFieldServers(); const publicDir = await deployPublicFiles(); if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`); await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error}); await fsp.mkdir(path.dirname(LOG_FILE), {recursive:true, mode:0o755}); const logFd = fs.openSync(LOG_FILE, 'a'); let child; try { child = spawn(process.execPath, [SERVER_FILE], { cwd: ROOT, detached: true, stdio: ['ignore', logFd, logFd], env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT}, }); } finally { fs.closeSync(logFd); } if (!child.pid) throw new Error('Could not start the LinkField background process.'); await fsp.writeFile(PID_FILE, `${child.pid}\n`, {encoding:'utf8', mode:0o600}); child.unref(); try { const port = await waitForStartup(child.pid, publicDir); console.log('LinkField started in the background. The command prompt is available again.'); console.log(`PID: ${child.pid}`); console.log(`Local port: ${port}`); console.log(`Public directory: ${publicDir}`); console.log(`Log: ${LOG_FILE}`); console.log("Check: curl 'https://host.nishi.boats/~333/link-field/api-bridge.php?path=/api/cloud/status'"); } catch (error) { await fsp.unlink(PID_FILE).catch(() => {}); throw error; } } async function stop({quiet = false} = {}) { const pid = await readPid(); if (!pid || !isProcessRunning(pid)) { await fsp.unlink(PID_FILE).catch(() => {}); if (!quiet) console.log('LinkField is not running.'); return false; } try { process.kill(pid, 'SIGTERM'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } const started = Date.now(); while (isProcessRunning(pid) && Date.now() - started < 5000) await sleep(100); if (isProcessRunning(pid)) { try { process.kill(pid, 'SIGKILL'); } catch (error) { if (error?.code !== 'ESRCH') throw error; } } await fsp.unlink(PID_FILE).catch(() => {}); if (!quiet) console.log(`LinkField stopped (PID ${pid}).`); return true; } async function status() { const pid = await readPid(); const publicDir = resolvePublicDir(); if (!pid || !isProcessRunning(pid)) { console.log('LinkField is stopped.'); process.exitCode = 1; return; } let port = ''; try { port = (await fsp.readFile(path.join(publicDir, '.linkfield-port'), 'utf8')).trim(); } catch {} console.log(`LinkField is running (PID ${pid}${port ? `, port ${port}` : ''}).`); console.log(`Public directory: ${publicDir}`); console.log(`Log: ${LOG_FILE}`); } async function foreground() { const publicDir = await deployPublicFiles(); process.env.LINK_FIELD_PUBLIC_DIR = publicDir; const {main} = require('../server'); await main(); console.log(`LinkField is running in the foreground. Public directory: ${publicDir}`); } async function main() { const command = String(process.argv[2] || 'start').toLowerCase(); if (command === 'start') return start(); if (command === 'stop') return stop(); if (command === 'restart') { await stop({quiet:true}); return start(); } if (command === 'status') return status(); if (command === 'foreground') return foreground(); if (command === 'deploy') { const publicDir = await deployPublicFiles(); console.log(`LinkField public files deployed to ${publicDir}`); return; } throw new Error(`Unknown service command: ${command}`); } module.exports = Object.freeze({ROOT, SERVICE_ROOT, PID_FILE, LOG_FILE, PUBLIC_ENTRIES, isProcessRunning, resolvePublicDir, deployPublicFiles, start, stop, status}); if (require.main === module) main().catch(error => { console.error(`LinkField service command failed: ${error.message}`); process.exitCode = 1; });