bend_puzzle/scripts/service-control.js
2026-08-01 16:06:14 +09:00

303 lines
11 KiB
JavaScript

'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;
});