d
This commit is contained in:
parent
c3a6f5ff37
commit
4c4e767ec6
73 changed files with 3502 additions and 741 deletions
|
|
@ -15,6 +15,12 @@ for(const item of catalog){
|
|||
if(!Number.isSafeInteger(item.cost)||item.cost<=0)throw new Error(`Invalid cost for ${item.id}`);
|
||||
if(item.cursorStyle!==null&&typeof item.cursorStyle!=='string')throw new Error(`Invalid cursorStyle for ${item.id}`);
|
||||
if(typeof item.scoreLens!=='boolean')throw new Error(`Invalid scoreLens for ${item.id}`);
|
||||
if(item.lineColor!=null&&!/^#[0-9a-f]{6}$/i.test(item.lineColor))throw new Error(`Invalid lineColor for ${item.id}`);
|
||||
if(item.lineEffect!=null&&!/^[a-z][a-z0-9-]{0,31}$/.test(item.lineEffect))throw new Error(`Invalid lineEffect for ${item.id}`);
|
||||
if(item.aurora!=null&&typeof item.aurora!=='boolean')throw new Error(`Invalid aurora flag for ${item.id}`);
|
||||
if(item.aurora===true&&item.lineColor==null)throw new Error(`Aurora item must be a line color: ${item.id}`);
|
||||
if(item.reactionStyle!=null&&!/^[a-z][a-z0-9-]{0,31}$/.test(item.reactionStyle))throw new Error(`Invalid reactionStyle for ${item.id}`);
|
||||
if([item.lineColor,item.lineEffect,item.reactionStyle].filter(value=>value!=null).length>1)throw new Error(`Multiple cosmetic contracts for ${item.id}`);
|
||||
ids.add(item.id);
|
||||
}
|
||||
|
||||
|
|
|
|||
303
scripts/service-control.js
Normal file
303
scripts/service-control.js
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
'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;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue