mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 05:29:42 +02:00
feat(cli): codeman web -d and codeman service install (#231)
Two ways to keep the server running, split by how long it should last. `codeman web -d` relaunches the same entry script detached (setsid), with `--stop` and `--status` alongside it. A pidfile and log live in the data dir. `nohup` is not what makes this work: Node re-arms SIGHUP to its default disposition even when it inherits "ignore", and cli.ts handles SIGHUP with a graceful shutdown, so a delivered HUP still stops the server. Removing the shell's ability to send one is the fix. `codeman service install|uninstall|status` writes and loads the systemd user unit or the LaunchAgent, with the installing shell's PATH baked in (launchd hands a job /usr/bin:/bin:/usr/sbin:/sbin, which finds neither a Homebrew/nvm node nor tmux/claude). install.sh already covers one-liner installs; this is for npm globals. Both refuse to start when a server is already up on the data dir, since a second instance on the shared tmux socket attaches PTYs to the first one's live sessions. Both poll /api/status until the child answers or dies rather than reporting a success they have not seen. `--stop` checks the pid still looks like a Codeman server before signalling it. The systemd unit name and launchd label move to config/service-names.ts so install.sh, detectSupervisor() and service install cannot drift into supervising two copies. Instance-scoped, unchanged for the default instance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* Unit tests for the pure halves of daemon-control (issue #231): argv rebuilding,
|
||||
* the readiness URL, pidfile parsing, the stale-pid identity check, and the
|
||||
* `/api/status` probe against a real socket.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import http from 'node:http';
|
||||
import {
|
||||
buildBaseUrl,
|
||||
buildStatusUrl,
|
||||
buildWebArgs,
|
||||
isProcessAlive,
|
||||
looksLikeCodemanWeb,
|
||||
parsePidFileContents,
|
||||
probeServer,
|
||||
} from '../src/daemon-control.js';
|
||||
|
||||
const PORT = 3212;
|
||||
|
||||
describe('buildWebArgs', () => {
|
||||
it('always passes host and port through explicitly', () => {
|
||||
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'3000',
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards every optional flag it was given', () => {
|
||||
const args = buildWebArgs({
|
||||
host: '0.0.0.0',
|
||||
port: 8080,
|
||||
https: true,
|
||||
titleHostname: 'tower',
|
||||
allowUnauthenticatedNetwork: true,
|
||||
multiuser: true,
|
||||
});
|
||||
expect(args).toEqual([
|
||||
'web',
|
||||
'--host',
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8080',
|
||||
'--https',
|
||||
'--title-hostname',
|
||||
'tower',
|
||||
'--allow-unauthenticated-network',
|
||||
'--multiuser',
|
||||
]);
|
||||
});
|
||||
|
||||
it('never re-emits the daemon flags themselves (the child must not re-fork)', () => {
|
||||
const args = buildWebArgs({ host: '127.0.0.1', port: 3000, https: false });
|
||||
expect(args).not.toContain('--daemon');
|
||||
expect(args).not.toContain('-d');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBaseUrl', () => {
|
||||
it('is the address a browser can open, with no path on it', () => {
|
||||
expect(buildBaseUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000');
|
||||
expect(buildBaseUrl({ host: '0.0.0.0', port: 8443, https: true })).toBe('https://127.0.0.1:8443');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildStatusUrl', () => {
|
||||
it('uses http by default and https when asked', () => {
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '127.0.0.1', port: 3000, https: true })).toBe('https://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('rewrites wildcard binds to loopback, since they are not connectable', () => {
|
||||
expect(buildStatusUrl({ host: '0.0.0.0', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '::', port: 3000, https: false })).toBe('http://127.0.0.1:3000/api/status');
|
||||
});
|
||||
|
||||
it('brackets a bare IPv6 literal', () => {
|
||||
expect(buildStatusUrl({ host: '::1', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
expect(buildStatusUrl({ host: '[::1]', port: 3000, https: false })).toBe('http://[::1]:3000/api/status');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePidFileContents', () => {
|
||||
it('accepts a plain pid with surrounding whitespace', () => {
|
||||
expect(parsePidFileContents('4242\n')).toBe(4242);
|
||||
expect(parsePidFileContents(' 4242 ')).toBe(4242);
|
||||
});
|
||||
|
||||
it('rejects garbage, empties and floats', () => {
|
||||
expect(parsePidFileContents('')).toBeNull();
|
||||
expect(parsePidFileContents('not a pid')).toBeNull();
|
||||
expect(parsePidFileContents('42.5')).toBeNull();
|
||||
expect(parsePidFileContents('-42')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects pid 0 and pid 1: neither is ever our server', () => {
|
||||
expect(parsePidFileContents('0')).toBeNull();
|
||||
expect(parsePidFileContents('1')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeCodemanWeb', () => {
|
||||
it('matches the ways the server is actually launched', () => {
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node /home/u/.codeman/app/dist/index.js web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js web --https')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('node /repo/src/index.ts web --port 3000')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('/opt/homebrew/bin/codeman web')).toBe(true);
|
||||
expect(looksLikeCodemanWeb('aicodeman web --host 0.0.0.0')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects anything that inherited a recycled pid', () => {
|
||||
expect(looksLikeCodemanWeb(null)).toBe(false);
|
||||
expect(looksLikeCodemanWeb('')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/bin/node dist/index.js session list')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('vim web')).toBe(false);
|
||||
expect(looksLikeCodemanWeb('/usr/lib/systemd/systemd --user')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isProcessAlive', () => {
|
||||
it('sees this very process', () => {
|
||||
expect(isProcessAlive(process.pid)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not see an unused high pid', () => {
|
||||
// 2^22 is above the default pid_max on Linux and macOS.
|
||||
expect(isProcessAlive(4_194_303)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeServer', () => {
|
||||
let server: http.Server;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
if (req.url === '/unauthorized') {
|
||||
res.writeHead(401).end('Unauthorized');
|
||||
return;
|
||||
}
|
||||
if (req.url === '/foreign') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' }).end('<html>some other app</html>');
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, data: { version: '9.9.9' } }));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(PORT, '127.0.0.1', resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
it('reports up and reads the version back', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/api/status`);
|
||||
expect(result.up).toBe(true);
|
||||
expect(result.version).toBe('9.9.9');
|
||||
});
|
||||
|
||||
it('counts a 401 as up, because auth being active proves a server is there', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/unauthorized`);
|
||||
expect(result.up).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mistake an unrelated service squatting on the port for Codeman', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT}/foreign`);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down when nothing is listening', async () => {
|
||||
const result = await probeServer(`http://127.0.0.1:${PORT + 1}/api/status`, 1000);
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
|
||||
it('reports down for a malformed url instead of throwing', async () => {
|
||||
const result = await probeServer('not-a-url');
|
||||
expect(result.up).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Unit tests for the unit-file builders behind `codeman service install`
|
||||
* (issue #231). These are the parts that must be right without launchctl or
|
||||
* systemctl in the loop: PATH construction, escaping, and the file contents.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
buildLaunchAgentPlist,
|
||||
buildServiceEnv,
|
||||
buildServicePath,
|
||||
buildSystemdUnit,
|
||||
detectServiceKind,
|
||||
systemdQuote,
|
||||
xmlEscape,
|
||||
type ServicePlan,
|
||||
} from '../src/service-installer.js';
|
||||
|
||||
function plan(overrides: Partial<ServicePlan> = {}): ServicePlan {
|
||||
return {
|
||||
kind: 'systemd',
|
||||
name: 'codeman-web.service',
|
||||
nodePath: '/usr/bin/node',
|
||||
execArgv: [],
|
||||
scriptPath: '/home/u/.codeman/app/dist/index.js',
|
||||
args: ['web', '--host', '127.0.0.1', '--port', '3000'],
|
||||
env: { PATH: '/usr/bin:/bin', HOME: '/home/u', LANG: 'en_US.UTF-8' },
|
||||
logPath: '/home/u/.codeman/web.log',
|
||||
workingDir: '/home/u',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildServicePath', () => {
|
||||
it("puts the running node's directory first so nvm/homebrew node wins", () => {
|
||||
const result = buildServicePath('/home/u/.nvm/versions/node/v22.0.0/bin', '/usr/bin:/bin', '/home/u');
|
||||
expect(result.split(':')[0]).toBe('/home/u/.nvm/versions/node/v22.0.0/bin');
|
||||
});
|
||||
|
||||
it('keeps the installing shell PATH, which is the whole point of the fix', () => {
|
||||
const result = buildServicePath('/usr/bin', '/opt/homebrew/bin:/home/u/.bun/bin', '/home/u');
|
||||
expect(result.split(':')).toContain('/home/u/.bun/bin');
|
||||
expect(result.split(':')).toContain('/opt/homebrew/bin');
|
||||
});
|
||||
|
||||
it('appends the fallbacks a bare launchd PATH would otherwise be missing', () => {
|
||||
const entries = buildServicePath('/usr/bin', '/usr/bin', '/home/u').split(':');
|
||||
expect(entries).toContain('/opt/homebrew/bin');
|
||||
expect(entries).toContain('/home/u/.local/bin');
|
||||
expect(entries).toContain('/usr/local/bin');
|
||||
});
|
||||
|
||||
it('never repeats a directory', () => {
|
||||
const entries = buildServicePath('/usr/bin', '/usr/bin:/bin:/usr/bin', '/home/u').split(':');
|
||||
expect(new Set(entries).size).toBe(entries.length);
|
||||
});
|
||||
|
||||
it('drops empty segments from a trailing-colon PATH', () => {
|
||||
expect(buildServicePath('/usr/bin', '/usr/bin::/bin:', '/home/u').split(':')).not.toContain('');
|
||||
});
|
||||
|
||||
it('drops node_modules/.bin, which npx injects for one command only', () => {
|
||||
const entries = buildServicePath(
|
||||
'/usr/bin',
|
||||
'/repo/node_modules/.bin:/repo/node_modules/.bin/:/home/u/bin',
|
||||
'/home/u'
|
||||
).split(':');
|
||||
expect(entries.filter((e) => e.includes('node_modules'))).toEqual([]);
|
||||
expect(entries).toContain('/home/u/bin');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildServiceEnv', () => {
|
||||
it('carries PATH, HOME and a LANG default', () => {
|
||||
const env = buildServiceEnv('/usr/bin', '/usr/bin:/bin', '/home/u');
|
||||
expect(env.HOME).toBe('/home/u');
|
||||
expect(env.LANG).toBe('en_US.UTF-8');
|
||||
expect(env.PATH).toContain('/usr/bin');
|
||||
});
|
||||
|
||||
it('prefers the caller LANG when there is one', () => {
|
||||
expect(buildServiceEnv('/usr/bin', '/usr/bin', '/home/u', 'de_DE.UTF-8').LANG).toBe('de_DE.UTF-8');
|
||||
});
|
||||
|
||||
it('does not carry a password into the unit file', () => {
|
||||
const env = buildServiceEnv('/usr/bin', '/usr/bin', '/home/u');
|
||||
expect(Object.keys(env)).not.toContain('CODEMAN_PASSWORD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('escaping', () => {
|
||||
it('escapes the five XML entities', () => {
|
||||
expect(xmlEscape(`a&b<c>d"e'f`)).toBe('a&b<c>d"e'f');
|
||||
});
|
||||
|
||||
it('quotes systemd values and escapes quotes and backslashes', () => {
|
||||
expect(systemdQuote('plain')).toBe('"plain"');
|
||||
expect(systemdQuote('with "quotes"')).toBe('"with \\"quotes\\""');
|
||||
expect(systemdQuote('back\\slash')).toBe('"back\\\\slash"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildLaunchAgentPlist', () => {
|
||||
it('writes the label, the full command and the log paths', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' }));
|
||||
expect(xml).toContain('<string>com.codeman.web</string>');
|
||||
expect(xml).toContain('<string>/usr/bin/node</string>');
|
||||
expect(xml).toContain('<string>/home/u/.codeman/app/dist/index.js</string>');
|
||||
expect(xml).toContain('<string>web</string>');
|
||||
expect(xml).toContain('<string>/home/u/.codeman/web.log</string>');
|
||||
});
|
||||
|
||||
it('keeps the argument order: node, script, then the web args', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', name: 'com.codeman.web' }));
|
||||
// Match whole <string> elements: the label itself contains the word "web".
|
||||
const order = [
|
||||
'<string>/usr/bin/node</string>',
|
||||
'<string>/home/u/.codeman/app/dist/index.js</string>',
|
||||
'<string>web</string>',
|
||||
'<string>--port</string>',
|
||||
].map((s) => xml.indexOf(s));
|
||||
expect(order).toEqual([...order].sort((a, b) => a - b));
|
||||
expect(order.every((i) => i > -1)).toBe(true);
|
||||
});
|
||||
|
||||
it('carries the runner flags so a tsx dev install still boots', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', execArgv: ['--import', 'tsx'] }));
|
||||
expect(xml).toContain('<string>--import</string>');
|
||||
expect(xml).toContain('<string>tsx</string>');
|
||||
});
|
||||
|
||||
it('restarts on crash and at login', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd' }));
|
||||
expect(xml).toContain('<key>KeepAlive</key>');
|
||||
expect(xml).toContain('<key>RunAtLoad</key>');
|
||||
});
|
||||
|
||||
it('escapes a path with an ampersand instead of emitting broken XML', () => {
|
||||
const xml = buildLaunchAgentPlist(plan({ kind: 'launchd', workingDir: '/Users/a&b' }));
|
||||
expect(xml).toContain('<string>/Users/a&b</string>');
|
||||
expect(xml).not.toContain('<string>/Users/a&b</string>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSystemdUnit', () => {
|
||||
it('builds ExecStart from node, script and args', () => {
|
||||
expect(buildSystemdUnit(plan())).toContain(
|
||||
'ExecStart=/usr/bin/node /home/u/.codeman/app/dist/index.js web --host 127.0.0.1 --port 3000'
|
||||
);
|
||||
});
|
||||
|
||||
it('quotes an argument containing spaces', () => {
|
||||
const unit = buildSystemdUnit(plan({ scriptPath: '/home/my user/app/dist/index.js' }));
|
||||
expect(unit).toContain('"/home/my user/app/dist/index.js"');
|
||||
});
|
||||
|
||||
it('writes each env var as a quoted Environment line', () => {
|
||||
const unit = buildSystemdUnit(plan());
|
||||
expect(unit).toContain('Environment="PATH=/usr/bin:/bin"');
|
||||
expect(unit).toContain('Environment="HOME=/home/u"');
|
||||
});
|
||||
|
||||
it('keeps KillMode=process so agents survive a server restart', () => {
|
||||
expect(buildSystemdUnit(plan())).toContain('KillMode=process');
|
||||
});
|
||||
|
||||
it('is installable and restarts on failure', () => {
|
||||
const unit = buildSystemdUnit(plan());
|
||||
expect(unit).toContain('Restart=always');
|
||||
expect(unit).toContain('WantedBy=default.target');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectServiceKind', () => {
|
||||
it('maps the platform to its supervisor', () => {
|
||||
const expected = process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : null;
|
||||
expect(detectServiceKind()).toBe(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user