Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 69 additions & 6 deletions bin/commands/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@
* jaw service logs — 로그 보기
*/
import { execFileSync, spawn as nodeSpawn } from 'node:child_process';
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { existsSync, writeFileSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { parseArgs } from 'node:util';
import { JAW_HOME } from '../../src/core/config.js';
import { instanceId, getNodePath, getJawPath, sanitizeUnitName, buildServicePath } from '../../src/core/instance.js';
import { defaultLifecycleDeps, verifyOwnership, type OwnershipVerdict } from '../../src/core/instance-lifecycle.js';
import { manualSupervisionGuidance } from '../../src/manager/manual-supervision.js';
import { renderSupervisorScript, supervisorInstallSummary } from '../../src/manager/supervisor-service.js';
import type { DashboardLifecycleResult, DashboardServiceState } from '../../src/manager/types.js';
import { detectServiceState, permInstance, restartServiceInstance, stopServiceInstance, unpermInstance } from '../../src/manager/platform-service.js';

Expand Down Expand Up @@ -101,16 +102,16 @@ const knownKeys = new Set(['port', 'backend']);
for (const key of Object.keys(opts)) {
if (!knownKeys.has(key)) {
console.error(`❌ Unknown option: --${key}`);
console.error(' Usage: jaw service [--port PORT] [--backend launchd|systemd|docker] [status|stop|restart|unset|logs]');
console.error(' Usage: jaw service [--port PORT] [--backend launchd|systemd|docker|supervisor] [status|stop|restart|unset|logs]');
process.exit(1);
}
}

// --backend whitelist validation
const VALID_BACKENDS = new Set(['launchd', 'systemd', 'windows', 'docker']);
const VALID_BACKENDS = new Set(['launchd', 'systemd', 'windows', 'docker', 'supervisor']);
if (opts.backend && !VALID_BACKENDS.has(opts.backend as string)) {
console.error(`❌ Unknown backend: ${opts.backend}`);
console.error(' Supported: launchd, systemd, windows, docker');
console.error(' Supported: launchd, systemd, windows, docker, supervisor');
process.exit(1);
}

Expand Down Expand Up @@ -181,7 +182,7 @@ const LOG_DIR = join(JAW_HOME, 'logs');

// ─── Backend detection ───────────────────────────────

type Backend = 'launchd' | 'systemd' | 'windows' | 'docker';
type Backend = 'launchd' | 'systemd' | 'windows' | 'docker' | 'supervisor';

function detectBackend(): Backend {
if (process.platform === 'darwin') return 'launchd';
Expand All @@ -207,13 +208,18 @@ function detectBackend(): Backend {
// multiplexer needs an interactive session to attach to and inherits the
// same non-interactive PATH that already failed to resolve `jaw` — so it
// is unusable from the one-shot ssh command that lands operators here.
//
// Auto-selecting the supervisor backend here would be wrong: writing a
// script and telling the operator to wire it into boot is a different act
// from registering autostart, so it stays opt-in via --backend supervisor.
console.error('❌ ' + manualSupervisionGuidance({
nodePath: getNodePath(),
jawPath: getJawPath(),
home: JAW_HOME,
port: PORT,
logPath: join(JAW_HOME, 'logs', 'jaw-serve.log'),
}).join('\n'));
}).join('\n')
+ '\n\n Or generate a supervisor loop: jaw service --backend supervisor --port ' + PORT);
process.exit(1);
}

Expand Down Expand Up @@ -275,6 +281,63 @@ if (backend === 'docker') {
}

// ─── Windows: delegate to platform-service (#379) ───
// ─── Supervisor: script for hosts with no service manager (#479) ───
//
// Opt-in only. This backend does not register autostart — nothing on such a
// host can — it writes the loop the operator would otherwise hand-roll and
// says where to wire it in.
if (backend === 'supervisor') {
const subcommand = pos[0];
const scriptPath = join(JAW_HOME, 'jaw-supervisor.sh');
const logPath = join(LOG_DIR, 'jaw-serve.log');
const ctx = {
nodePath: getNodePath(),
jawPath: getJawPath(),
home: JAW_HOME,
port: PORT,
logPath,
intervalSeconds: 60,
};

if (subcommand === 'status') {
// The generated loop branches on this exit code, so it must report
// liveness rather than the presence of a registration artifact.
// systemd's status branch exits 0 either way; a supervisor trusting
// that would never restart a dead server.
const verdict = verifyOwnership(JAW_HOME, defaultLifecycleDeps);
const running = verdict.status === 'owned';
console.log('🦈 jaw serve — ' + (running ? '🟢 running' : '⚪ not running'));
console.log(' home: ' + JAW_HOME);
console.log(' port: ' + PORT);
console.log(' verdict: ' + verdict.status);
console.log(' supervisor: ' + (existsSync(scriptPath) ? scriptPath : 'not installed'));
process.exit(running ? 0 : 1);
}

if (subcommand === 'logs') {
console.log('📋 tail -n 50 -f ' + logPath);
process.exit(0);
}

if (subcommand === 'unset') {
if (!existsSync(scriptPath)) {
console.log('⚠️ no supervisor script at ' + scriptPath);
process.exit(0);
}
rmSync(scriptPath, { force: true });
console.log('✅ removed ' + scriptPath);
console.log(' A supervisor loop already running keeps its copy in memory —');
console.log(' stop it where you started it (container, cron, or shell job).');
process.exit(0);
}

// default: write the script
mkdirSync(LOG_DIR, { recursive: true });
writeFileSync(scriptPath, renderSupervisorScript(ctx), { mode: 0o755 });
console.log('🦈 ' + supervisorInstallSummary(scriptPath, ctx).join('\n'));
process.exit(0);
}

if (backend === 'windows') {
const subcommand = pos[0];
if (subcommand === 'status') {
Expand Down
125 changes: 125 additions & 0 deletions src/manager/supervisor-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Supervisor backend for hosts with no service manager (#479).
*
* A container whose PID 1 is tini has nothing to register autostart with, so
* `jaw service` previously refused and handed back a manual recipe. That left
* every operator writing the same loop by hand — the reporter shipped an
* `ensure-jaw.sh` plus a 60s while-loop to keep `jaw serve` alive.
*
* This generates that loop instead. Two properties matter, and are why the
* generated script is not a naive `while ! pgrep jaw; do start; done`:
*
* 1. Liveness is decided by the pidfile's OS start time, the same check
* `jaw service stop` uses. A pidfile whose PID has been recycled by an
* unrelated process must read as dead, or the supervisor sits forever
* beside a server that is not running — the #479 misdiagnosis exactly.
* 2. Every path is absolute. The supervisor is launched from the same
* non-interactive context that cannot resolve `jaw` on PATH.
*/
import { dirname } from 'node:path';

export interface SupervisorContext {
nodePath: string;
jawPath: string;
home: string;
port: string;
logPath: string;
/** Seconds between liveness checks. */
intervalSeconds: number;
}

function shQuote(value: string): string {
return "'" + String(value).replace(/'/g, "'\\''") + "'";
}

/**
* POSIX sh supervisor loop.
*
* Deliberately sh, not bash: the hosts that need this are minimal containers
* where /bin/sh may be dash and bash may not be installed at all.
*/
export function renderSupervisorScript(ctx: SupervisorContext): string {
const node = shQuote(ctx.nodePath);
const jaw = shQuote(ctx.jawPath);
const home = shQuote(ctx.home);
const log = shQuote(ctx.logPath);
const interval = Math.max(1, Math.floor(ctx.intervalSeconds));

return [
'#!/bin/sh',
'# Generated by `jaw service --backend supervisor` (#479).',
'# Keeps one `jaw serve` alive on a host with no service manager.',
'#',
'# Every path is absolute on purpose: this runs in a non-interactive shell',
'# that does not have ' + dirname(ctx.jawPath) + ' on PATH.',
'set -u',
'',
'NODE=' + node,
'JAW=' + jaw,
'HOME_DIR=' + home,
'PORT=' + ctx.port,
'LOG=' + log,
'INTERVAL=' + interval,
'',
'log() {',
' printf \'%s supervisor: %s\\n\' "$(date -u \'+%Y-%m-%dT%H:%M:%SZ\')" "$1" >> "$LOG" 2>&1',
'}',
'',
'# Ask jaw itself whether the instance is alive. `service status` verifies',
'# the pidfile against the OS process start time, so a recycled PID reads',
'# as dead instead of masquerading as a healthy server.',
'is_running() {',
' "$NODE" "$JAW" --home "$HOME_DIR" service status --port "$PORT" >/dev/null 2>&1',
'}',
'',
'start_once() {',
' log "starting jaw serve on port $PORT"',
' # setsid detaches from the controlling terminal so the server outlives',
' # the ssh session that started the supervisor. It is util-linux, so it',
' # is absent on macOS and on some minimal images; without this guard the',
' # loop would fail to start anything at all there. nohup alone still',
' # survives SIGHUP, which is the property that matters most.',
' if [ -n "$SETSID" ]; then',
' "$SETSID" nohup "$NODE" "$JAW" --home "$HOME_DIR" serve --port "$PORT" --no-open \\',
' >> "$LOG" 2>&1 < /dev/null &',
' else',
' nohup "$NODE" "$JAW" --home "$HOME_DIR" serve --port "$PORT" --no-open \\',
' >> "$LOG" 2>&1 < /dev/null &',
' fi',
'}',
'',
'# Resolved once, not per cycle.',
'SETSID="$(command -v setsid 2>/dev/null || true)"',
'',
'mkdir -p "$(dirname "$LOG")" 2>/dev/null || true',
'',
'# Stop supervising when the host stops us, rather than leaving an orphan',
'# loop that would fight the next start.',
'trap \'log "supervisor received TERM; exiting"; exit 0\' TERM INT',
'',
'while true; do',
' if ! is_running; then',
' start_once',
' fi',
' sleep "$INTERVAL"',
'done',
'',
].join('\n');
}

/** Operator-facing summary after the supervisor script is written. */
export function supervisorInstallSummary(scriptPath: string, ctx: SupervisorContext): string[] {
return [
'Supervisor script written: ' + scriptPath,
'',
' This host has no service manager, so nothing starts it at boot for you.',
' Wire it into whatever does own startup here:',
'',
' container entrypoint / CMD: ' + scriptPath,
' cron: @reboot ' + scriptPath + ' &',
' ad-hoc over ssh: setsid nohup ' + scriptPath + ' >> ' + ctx.logPath + ' 2>&1 < /dev/null &',
'',
' Log: tail -n 50 ' + ctx.logPath,
' Status: ' + ctx.jawPath + ' --home ' + ctx.home + ' service status --port ' + ctx.port,
];
}
109 changes: 109 additions & 0 deletions tests/unit/supervisor-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* #479: hosts with no service manager (PID 1 = tini, no systemd) had no way
* to keep `jaw serve` alive, so operators hand-rolled a supervisor loop. This
* covers the generated loop's decision logic and the portability traps that
* only surfaced by actually running it.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { renderSupervisorScript, supervisorInstallSummary } from '../../src/manager/supervisor-service.ts';

const CTX = {
nodePath: '/home/box/.local/lib/nodejs/node-v24.19.0-linux-x64/bin/node',
jawPath: '/home/box/.local/bin/jaw',
home: '/home/box/.cli-jaw',
port: '3457',
logPath: '/home/box/.cli-jaw/logs/jaw-serve.log',
intervalSeconds: 60,
};

test('SUP-001: the generated script is valid POSIX sh', () => {
// Not bash: minimal containers ship dash as /bin/sh and may lack bash.
const dir = mkdtempSync(join(tmpdir(), 'jaw-sup-'));
const path = join(dir, 'supervisor.sh');
writeFileSync(path, renderSupervisorScript(CTX));
execFileSync('sh', ['-n', path]); // throws on a syntax error
});

test('SUP-002: setsid is optional, because macOS and minimal images lack it', () => {
// Observed by running the loop: an unguarded `setsid` made every start
// fail with "command not found", so the supervisor started nothing at all.
const script = renderSupervisorScript(CTX);
assert.match(script, /command -v setsid/, 'must probe before use');
assert.match(script, /if \[ -n "\$SETSID" \]/, 'must branch on availability');
assert.match(script, /else\n\s+nohup /, 'must still nohup when setsid is missing');
});

test('SUP-003: liveness is asked of jaw, not of a process name', () => {
// `service status` verifies the pidfile against the OS process start time,
// so a recycled PID reads as dead. A pgrep-style check would keep a dead
// server "running" forever — the #479 misdiagnosis.
const script = renderSupervisorScript(CTX);
assert.match(script, /service status/);
assert.doesNotMatch(script, /pgrep|pidof/, 'process-name matching cannot detect PID recycling');
});

test('SUP-004: every command uses an absolute path', () => {
const script = renderSupervisorScript(CTX);
assert.match(script, /NODE='\/home\/box/);
assert.match(script, /JAW='\/home\/box\/\.local\/bin\/jaw'/);
// A bare `jaw` is exactly what does not resolve on these hosts.
assert.doesNotMatch(script, /^\s*jaw /m);
});

test('SUP-005: the loop terminates on TERM instead of orphaning', () => {
// An orphan loop would fight the next start.
const script = renderSupervisorScript(CTX);
assert.match(script, /trap .* TERM INT/);
assert.match(script, /exit 0/);
});

test('SUP-006: the loop redirects stdin so it cannot block on a dead terminal', () => {
assert.match(renderSupervisorScript(CTX), /< \/dev\/null/);
});

test('SUP-007: shell metacharacters in paths are quoted', () => {
const script = renderSupervisorScript({ ...CTX, home: "/home/box/it's here" });
const dir = mkdtempSync(join(tmpdir(), 'jaw-sup-'));
const path = join(dir, 'supervisor.sh');
writeFileSync(path, script);
execFileSync('sh', ['-n', path]);
});

test('SUP-008: a sub-second interval cannot produce a busy loop', () => {
assert.match(renderSupervisorScript({ ...CTX, intervalSeconds: 0 }), /INTERVAL=1/);
assert.match(renderSupervisorScript({ ...CTX, intervalSeconds: 0.4 }), /INTERVAL=1/);
});

test('SUP-009: the summary tells the operator where to wire the loop in', () => {
// Writing the script is not registering autostart; saying otherwise would
// repeat the #479 failure of implying a daemon exists.
const text = supervisorInstallSummary('/home/box/.cli-jaw/jaw-supervisor.sh', CTX).join('\n');
assert.match(text, /no service manager/);
assert.match(text, /entrypoint/);
assert.match(text, /@reboot/);
});

test('SUP-010: service.ts exposes supervisor as an opt-in backend', () => {
const src = readFileSync(new URL('../../bin/commands/service.ts', import.meta.url), 'utf8');
assert.match(src, /'supervisor'/, 'must be a selectable backend');
assert.match(src, /VALID_BACKENDS = new Set\(\[[^\]]*'supervisor'/, 'must pass flag validation');
// detectBackend must not choose it silently: writing a script is not the
// same act as registering autostart.
const detect = src.slice(src.indexOf('function detectBackend'), src.indexOf('const backend: Backend'));
assert.doesNotMatch(detect, /return 'supervisor'/, 'supervisor stays opt-in');
});

test('SUP-011: supervisor status exits non-zero when the server is down', () => {
// The generated loop branches on this exit code. systemd's status branch
// exits 0 either way; a supervisor trusting that never restarts anything.
const src = readFileSync(new URL('../../bin/commands/service.ts', import.meta.url), 'utf8');
const branch = src.slice(src.indexOf("if (backend === 'supervisor')"), src.indexOf("if (backend === 'windows')"));
assert.match(branch, /verifyOwnership\(JAW_HOME, defaultLifecycleDeps\)/, 'must verify real ownership');
assert.match(branch, /process\.exit\(running \? 0 : 1\)/, 'liveness must reach the exit code');
});

Loading