bend_puzzle/scripts/service-control.js
33333-33333 9d70afb4cc
Some checks failed
BEND FIELD CI / release (push) Has been cancelled
BEND FIELD CI / production-bridge (push) Has been cancelled
t
2026-08-01 22:31:04 +09:00

471 lines
26 KiB
JavaScript

'use strict';
const fs = require('fs');
const fsp = fs.promises;
const os = require('os');
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const {spawn, execFile} = require('child_process');
const {createWorldBackup,verifyWorldBackup,restoreWorldBackup}=require('../server/world-backup');
const {checkPublicDeployment}=require('../server/public-health');
const {renderApacheBootstrap,replaceManagedBlock}=require('../server/apache-bridge');
const ROOT = path.resolve(__dirname, '..');
const SERVER_FILE = path.join(ROOT, 'server.js');
const RUNNER_FILE = path.join(ROOT, 'scripts', 'service-runner.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 IDENTITY_FILE = path.join(SERVICE_ROOT, 'service.identity.json');
const LOG_FILE = path.resolve(process.env.LINK_FIELD_LOG_FILE || path.join(SERVICE_ROOT, 'server.log'));
const TEST_DATA_ROOT=String(process.env.LINK_FIELD_TEST_DATA_ROOT||'').trim();
const WORLD_DIR=TEST_DATA_ROOT?path.join(path.resolve(TEST_DATA_ROOT),'world'):path.resolve(String(process.env.LINK_FIELD_WORLD_DIR||'/link-field/world'));
const BACKUP_DIR=path.resolve(String(process.env.LINK_FIELD_BACKUP_DIR||path.join(path.dirname(WORLD_DIR),'backups')));
const STARTUP_TIMEOUT_MS = 12_000;
const POLL_INTERVAL_MS = 100;
const MAX_LOG_BYTES = 10 * 1024 * 1024;
const LOG_GENERATIONS = 5;
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)));
}
function rootMatches(value){
if(typeof value!=='string'||!value.trim())return false;
const actual=path.resolve(value),expected=ROOT;
return process.platform==='win32'?actual.toLowerCase()===expected.toLowerCase():actual===expected;
}
async function readWorldInstanceLock(worldDir=WORLD_DIR){
try{
const raw=(await fsp.readFile(path.join(worldDir,'server.pid'),'utf8')).trim();
if(/^\d+$/.test(raw))return{pid:Number(raw),legacy:true};
const value=JSON.parse(raw);return value&&typeof value==='object'?value:null;
}catch(_){return null}
}
function isFreshRuntimeRecord(record,maxAgeMs){
return Boolean(record&&rootMatches(record.root)&&Number.isSafeInteger(Number(record.pid))&&Number(record.pid)>0&&isProcessRunning(Number(record.pid))&&Date.now()-Number(record.heartbeatAt||0)<maxAgeMs);
}
async function isLinkFieldRoot(value){
if(rootMatches(value))return true;
if(typeof value!=='string'||!value.trim())return false;
try{const pkg=JSON.parse(await fsp.readFile(path.join(path.resolve(value),'package.json'),'utf8'));return['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)}catch(_){return false}
}
async function isTrustedRuntimeRecord(record,maxAgeMs){
const pid=Number(record?.pid),age=Date.now()-Number(record?.heartbeatAt||0);
return Boolean(Number.isSafeInteger(pid)&&pid>0&&pid!==process.pid&&isProcessRunning(pid)&&age>=-5000&&age<maxAgeMs&&await isLinkFieldRoot(record?.root));
}
async function processExecutableIsNode(pid){
if(!Number.isSafeInteger(pid)||pid<=0||pid===process.pid||!isProcessRunning(pid))return false;
if(process.platform==='win32'){
try{const output=await execFileText('powershell.exe',['-NoProfile','-NonInteractive','-Command',`(Get-Process -Id ${pid} -ErrorAction Stop).Path`]);return['node.exe','nodejs.exe'].includes(path.basename(output.trim()).toLowerCase())}catch(_){return false}
}
try{return['node','nodejs'].includes(path.basename(await fsp.readlink(`/proc/${pid}/exe`)).toLowerCase())}catch(_){return false}
}
async function legacyLinkFieldPids({worldDir=WORLD_DIR}={}) {
const matches=[];
const [identity,lock]=await Promise.all([readIdentity(),readWorldInstanceLock(worldDir)]);
if(await isTrustedRuntimeRecord(identity,5_000))matches.push(Number(identity.pid));
if(await isTrustedRuntimeRecord(lock,15_000))matches.push(Number(lock.pid));
if(process.platform==='win32'){
return[...new Set(matches.filter(pid=>pid!==process.pid))];
}
let output;
try { output = await execFileText('ps', ['-ax', '-o', 'pid=', '-o', 'comm=', '-o', 'command=']); }
catch (_) { return [...new Set(matches.filter(pid=>pid!==process.pid))]; }
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|service-runner\.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(['link-field-v47-shared-world','link-field-shared-world'].includes(pkg?.name)){linkField=true;break}}catch(_){}
}
if(linkField)matches.push(pid);
}
return [...new Set(matches.filter(pid=>pid!==process.pid))];
}
async function stopLegacyLinkFieldServers() {
const stopped=new Set(),deadline=Date.now()+5_000;let quietSince=0;
while(Date.now()<deadline){
const active=await legacyLinkFieldPids(),pids=active.filter(pid=>!stopped.has(pid));
for(const pid of pids){
stopped.add(pid);
if(!await terminateProcess(pid,2_000))throw new Error(`Older LinkField process PID ${pid} did not stop.`);
}
if(active.length||pids.length)quietSince=0;else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=1_500)break;
await sleep(pids.length?300:150);
}
const remaining=await legacyLinkFieldPids();
if(remaining.length)throw new Error(`Older LinkField process PID ${remaining.join(', ')} kept restarting and could not be stopped.`);
return[...stopped];
}
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 readServiceRecord() {
try {
const raw=(await fsp.readFile(PID_FILE, 'utf8')).trim();
if(/^\d+$/.test(raw)){const pid=Number(raw);return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:null,legacy:true}:null}
const value=JSON.parse(raw),pid=Number(value?.pid);
return Number.isSafeInteger(pid)&&pid>0?{pid,nonce:typeof value.nonce==='string'?value.nonce:null,root:value.root}:null;
} catch (error) {
if (error?.code === 'ENOENT') return null;
return null;
}
}
async function readPid(){return(await readServiceRecord())?.pid||null}
async function readIdentity(){try{return JSON.parse(await fsp.readFile(IDENTITY_FILE,'utf8'))}catch(error){if(error?.code==='ENOENT')return null;return null}}
async function isManagedService(pid){
if(!isProcessRunning(pid))return false;const [identity,record]=await Promise.all([readIdentity(),readServiceRecord()]);
return Boolean(identity&&record&&record.pid===pid&&identity.pid===pid&&path.resolve(identity.root||'')===ROOT&&path.resolve(record.root||ROOT)===ROOT&&(!record.nonce||identity.nonce===record.nonce)&&Date.now()-Number(identity.heartbeatAt||0)<5000);
}
async function terminateProcess(pid,timeoutMs=5000){
if(!isProcessRunning(pid))return true;
try{process.kill(pid,'SIGTERM')}catch(error){if(error?.code!=='ESRCH')throw error}
const started=Date.now();while(isProcessRunning(pid)&&Date.now()-started<timeoutMs)await sleep(100);
if(isProcessRunning(pid)){try{process.kill(pid,'SIGKILL')}catch(error){if(error?.code!=='ESRCH')throw error}}
const killedAt=Date.now();while(isProcessRunning(pid)&&Date.now()-killedAt<2000)await sleep(50);
return !isProcessRunning(pid);
}
async function removeStalePid() {
const pid = await readPid();
if (pid && await isManagedService(pid)) return pid;
const identity=await readIdentity();
if(isFreshRuntimeRecord(identity,5_000)&&typeof identity.nonce==='string'&&identity.nonce){
await fsp.writeFile(PID_FILE,`${JSON.stringify({pid:Number(identity.pid),nonce:identity.nonce,root:ROOT})}\n`,{encoding:'utf8',mode:0o600});
return Number(identity.pid);
}
await fsp.unlink(PID_FILE).catch(error => {
if (error?.code !== 'ENOENT') throw error;
});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
return null;
}
async function recoverWorldLock(){
await fsp.mkdir(SERVICE_ROOT,{recursive:true,mode:0o700});
const stopped=new Set(),deadline=Date.now()+15_000;let quietSince=0;
while(Date.now()<deadline){
const candidates=new Set(await legacyLinkFieldPids()),identity=await readIdentity(),lock=await readWorldInstanceLock();
if(identity&&Date.now()-Number(identity.heartbeatAt||0)<5_000&&await processExecutableIsNode(Number(identity.pid)))candidates.add(Number(identity.pid));
const lockPid=Number(lock?.pid);
if(Number.isSafeInteger(lockPid)&&lockPid>0&&isProcessRunning(lockPid)){
if(!await processExecutableIsNode(lockPid))throw new Error(`Refusing recovery: world-lock PID ${lockPid} is not a verified Node process.`);
candidates.add(lockPid);
}else if(lock){await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error})}
const active=[...candidates].filter(pid=>pid!==process.pid&&isProcessRunning(pid));
if(active.length){quietSince=0;for(const pid of active){if(!await terminateProcess(pid,3_000))throw new Error(`LinkField recovery could not stop PID ${pid}.`);stopped.add(pid)}}
else if(!quietSince)quietSince=Date.now();
if(quietSince&&Date.now()-quietSince>=2_500)break;
await sleep(active.length?300:150);
}
const remaining=await readWorldInstanceLock(),remainingPid=Number(remaining?.pid);
if(Number.isSafeInteger(remainingPid)&&remainingPid>0&&isProcessRunning(remainingPid))throw new Error(`World-lock PID ${remainingPid} is still active after recovery.`);
await fsp.unlink(path.join(WORLD_DIR,'server.pid')).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(PID_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(IDENTITY_FILE).catch(error=>{if(error?.code!=='ENOENT')throw error});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(error=>{if(error?.code!=='ENOENT')throw error});
console.log(stopped.size?`Recovered the LinkField world lock and stopped ${stopped.size} old process${stopped.size===1?'':'es'}.`:'Removed a stale LinkField world lock.');
console.log('Run npm start now.');
return[...stopped];
}
async function rotateLogs(){
let stat;try{stat=await fsp.stat(LOG_FILE)}catch(error){if(error?.code==='ENOENT')return;if(error)throw error}
if(!stat||stat.size<MAX_LOG_BYTES)return;
await fsp.unlink(`${LOG_FILE}.${LOG_GENERATIONS}`).catch(error=>{if(error?.code!=='ENOENT')throw error});
for(let index=LOG_GENERATIONS-1;index>=1;index--){try{await fsp.rename(`${LOG_FILE}.${index}`,`${LOG_FILE}.${index+1}`)}catch(error){if(error?.code!=='ENOENT')throw error}}
await fsp.rename(LOG_FILE,`${LOG_FILE}.1`);
}
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.lstat(source);
if(stat.isSymbolicLink())return;
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(() => {});
}
function assertSafePublicDir(publicDir){
const resolved=path.resolve(publicDir),root=path.parse(resolved).root,home=path.resolve(os.homedir());
if(resolved===root||resolved===home||path.dirname(resolved)===resolved)throw new Error(`Unsafe LinkField public directory: ${resolved}`);
return resolved;
}
async function writeApacheBootstrap(publicDir){
const apacheFile=path.join(publicDir,'.htaccess');let existing='';
try{existing=await fsp.readFile(apacheFile,'utf8')}catch(error){if(error?.code!=='ENOENT')throw error}
await fsp.writeFile(apacheFile,replaceManagedBlock(existing,renderApacheBootstrap()),{encoding:'utf8',mode:0o644});
}
async function deployPublicFiles(publicDir = resolvePublicDir()) {
publicDir=assertSafePublicDir(publicDir);
const manifest = {
app: 'LinkField',
version: require('../build-meta').APP_VERSION,
source: ROOT,
deployedAt: new Date().toISOString(),
entries:[...PUBLIC_ENTRIES],
};
if(path.resolve(publicDir)===ROOT){await writeApacheBootstrap(publicDir);await fsp.writeFile(path.join(publicDir,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});return publicDir}
const parent=path.dirname(publicDir),name=path.basename(publicDir),stamp=`${process.pid}-${Date.now()}`,stage=path.join(parent,`.${name}.linkfield-stage-${stamp}`),backup=path.join(parent,`.${name}.linkfield-old-${stamp}`);
await fsp.mkdir(parent,{recursive:true,mode:0o755});
const artifacts=(await fsp.readdir(parent,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&(entry.name.startsWith(`.${name}.linkfield-stage-`)||entry.name.startsWith(`.${name}.linkfield-old-`))).map(entry=>path.join(parent,entry.name)).sort();
if(!fs.existsSync(publicDir)){const recoverable=[...artifacts].reverse().find(item=>path.basename(item).startsWith(`.${name}.linkfield-old-`));if(recoverable){await fsp.rename(recoverable,publicDir);artifacts.splice(artifacts.indexOf(recoverable),1)}}
for(const artifact of artifacts)await fsp.rm(artifact,{recursive:true,force:true});
try{
if(fs.existsSync(publicDir))await copyEntry(publicDir,stage);else await fsp.mkdir(stage,{recursive:true,mode:0o755});
let previous=null;try{previous=JSON.parse(await fsp.readFile(path.join(stage,'.linkfield-deployment.json'),'utf8'))}catch(_){}
for(const retired of Array.isArray(previous?.entries)?previous.entries.filter(entry=>!PUBLIC_ENTRIES.includes(entry)):[]){const target=path.resolve(stage,retired);if(target.startsWith(`${stage}${path.sep}`))await fsp.rm(target,{recursive:true,force:true})}
for(const entry of PUBLIC_ENTRIES){const source=path.join(ROOT,entry),destination=path.join(stage,entry);await fsp.rm(destination,{recursive:true,force:true});try{await copyEntry(source,destination)}catch(error){if(error?.code!=='ENOENT')throw error}}
await writeApacheBootstrap(stage);
await fsp.writeFile(path.join(stage,'.linkfield-deployment.json'),`${JSON.stringify(manifest,null,2)}\n`,{encoding:'utf8',mode:0o644});
if(fs.existsSync(publicDir))await fsp.rename(publicDir,backup);
try{await fsp.rename(stage,publicDir)}catch(error){if(fs.existsSync(backup)&&!fs.existsSync(publicDir))await fsp.rename(backup,publicDir);throw error}
await fsp.rm(backup,{recursive:true,force:true});
}catch(error){await fsp.rm(stage,{recursive:true,force:true}).catch(()=>{});throw error}
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}`;
}
}
function localStatus(port){
return new Promise((resolve,reject)=>{const request=http.get({hostname:'127.0.0.1',port,path:'/api/cloud/status',timeout:1500,headers:{connection:'close'}},response=>{let body='';response.setEncoding('utf8');response.on('data',chunk=>{if(body.length<65536)body+=chunk});response.on('end',()=>{try{const value=JSON.parse(body);if(response.statusCode===200&&value?.available===true&&value?.appVersion===require('../build-meta').APP_VERSION)resolve(value);else reject(new Error(`Local health returned HTTP ${response.statusCode}`))}catch(error){reject(error)}})});request.on('timeout',()=>request.destroy(new Error('Local health timed out')));request.on('error',reject)});
}
async function publicStatus(publicUrl){
const result=await checkPublicDeployment(publicUrl,{appVersion:require('../build-meta').APP_VERSION,timeoutMs:10_000});return result?.api||null;
}
function configuredPublicUrl(){return String(process.env.LINK_FIELD_PUBLIC_URL||'').trim()}
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&&await isManagedService(pid)){try{await localStatus(port);return port}catch(_){}}
} 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();
let publicDir=null;
if (existingPid) {
publicDir = await deployPublicFiles();
console.log(`Replacing the running LinkField server (PID ${existingPid}) with v${require('../build-meta').APP_VERSION}.`);
await stop({quiet:true});
}
const stoppedLegacy=await stopLegacyLinkFieldServers();
if(stoppedLegacy.length)console.log(`Stopped ${stoppedLegacy.length} older LinkField server process${stoppedLegacy.length===1?'':'es'}.`);
const unresolvedLock=await readWorldInstanceLock(),unresolvedPid=Number(unresolvedLock?.pid);
if(Number.isSafeInteger(unresolvedPid)&&unresolvedPid>0&&isProcessRunning(unresolvedPid))throw new Error(`A legacy LinkField world lock is still held by PID ${unresolvedPid}. Run npm run recover once, then run npm start again.`);
if(!publicDir)publicDir = await deployPublicFiles();
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});
await rotateLogs();
const logFd = fs.openSync(LOG_FILE, 'a');
let child;
const nonce=crypto.randomBytes(16).toString('hex');
try {
child = spawn(process.execPath, [RUNNER_FILE], {
cwd: ROOT,
detached: true,
stdio: ['ignore', logFd, logFd],
env: {...process.env, LINK_FIELD_PUBLIC_DIR: publicDir, LINK_FIELD_SERVICE_DIR: SERVICE_ROOT,LINK_FIELD_SERVICE_IDENTITY_FILE:IDENTITY_FILE,LINK_FIELD_SERVICE_NONCE:nonce,LINK_FIELD_LOG_FILE:LOG_FILE},
});
} finally {
fs.closeSync(logFd);
}
if (!child.pid) throw new Error('Could not start the LinkField background process.');
try {
await fsp.writeFile(PID_FILE, `${JSON.stringify({pid:child.pid,nonce,root:ROOT})}\n`, {encoding:'utf8', mode:0o600});
child.unref();
const port = await waitForStartup(child.pid, publicDir);
const publicEndpoint=await publicStatus(configuredPublicUrl());
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}`);
if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);
console.log(`Check: ${process.execPath} scripts/public-smoke-test.js https://YOUR-HOST/~333/link-field/`);
} catch (error) {
const stopped=!child?.pid||await terminateProcess(child.pid).catch(()=>false);
if(!stopped)throw new Error(`${error.message}\nLinkField supervisor PID ${child.pid} could not be stopped; its PID record was preserved for a safe manual stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(publicDir,'.linkfield-port')).catch(()=>{});
throw error;
}
}
async function stop({quiet = false} = {}) {
const pid = await readPid();
if (!pid || !isProcessRunning(pid)) {
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log('LinkField is not running.');
return false;
}
if(!await isManagedService(pid))throw new Error(`Refusing to stop PID ${pid}: it is not a verified LinkField supervisor.`);
if(!await terminateProcess(pid))throw new Error(`LinkField supervisor PID ${pid} did not stop.`);
await fsp.unlink(PID_FILE).catch(() => {});
await fsp.unlink(IDENTITY_FILE).catch(()=>{});
await fsp.unlink(path.join(resolvePublicDir(),'.linkfield-port')).catch(()=>{});
if (!quiet) console.log(`LinkField stopped (PID ${pid}).`);
return true;
}
async function status() {
const pid = await readPid();
const publicDir = resolvePublicDir();
if (!pid || !await isManagedService(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 {}
let healthy=false,publicEndpoint=null;if(port)try{await localStatus(Number(port));publicEndpoint=await publicStatus(configuredPublicUrl());healthy=true}catch(_){}
console.log(`LinkField is ${healthy?'healthy':'unhealthy'} (supervisor PID ${pid}${port ? `, port ${port}` : ''}).`);
console.log(`Public directory: ${publicDir}`);
console.log(`Log: ${LOG_FILE}`);if(publicEndpoint)console.log(`Public health: ${publicEndpoint}`);if(!healthy)process.exitCode=1;
}
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 assertServiceStopped(){const pid=await readPid();if(pid&&await isManagedService(pid))throw new Error('Stop LinkField before running an offline backup or restore.');try{const raw=(await fsp.readFile(path.join(WORLD_DIR,'server.pid'),'utf8')).trim(),lock=/^\d+$/.test(raw)?{pid:Number(raw)}:JSON.parse(raw);if(isProcessRunning(Number(lock?.pid)))throw new Error('A LinkField world process is still active. Stop it before backup or restore.')}catch(error){if(error?.code!=='ENOENT'&&!/Unexpected token|Unexpected end/.test(error?.message||''))throw error}}
async function backup(){await assertServiceStopped();const result=await createWorldBackup(WORLD_DIR,BACKUP_DIR,{retain:7,appVersion:require('../build-meta').APP_VERSION});console.log(`LinkField backup created: ${result.destination}`);return result}
async function listBackups(){await fsp.mkdir(BACKUP_DIR,{recursive:true,mode:0o700});const names=(await fsp.readdir(BACKUP_DIR,{withFileTypes:true})).filter(entry=>entry.isDirectory()&&/^\d{4}-/.test(entry.name)).map(entry=>entry.name).sort().reverse();for(const name of names){try{const result=await verifyWorldBackup(BACKUP_DIR,name);console.log(`${name} revision=${result.manifest.worldRevision} files=${result.manifest.fileCount}`)}catch(error){console.log(`${name} INVALID ${error.message}`)}}return names}
async function restore(name){await assertServiceStopped();if(!name)throw new Error('Specify a backup name from npm run backups.');const result=await restoreWorldBackup(WORLD_DIR,BACKUP_DIR,name);console.log(`LinkField restored backup ${result.restored}. Previous data remains at ${result.previous}`);return result}
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 === 'backup') return backup();
if (command === 'backups') return listBackups();
if (command === 'restore') return restore(process.argv[3]);
if (command === 'recover') return recoverWorldLock();
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, IDENTITY_FILE, LOG_FILE, WORLD_DIR, BACKUP_DIR, PUBLIC_ENTRIES, isProcessRunning,legacyLinkFieldPids,recoverWorldLock,resolvePublicDir,deployPublicFiles,start,stop,status,backup,listBackups,restore});
if (require.main === module) main().catch(error => {
console.error(`LinkField service command failed: ${error.message}`);
process.exitCode = 1;
});