mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
180 lines
6.9 KiB
TypeScript
180 lines
6.9 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|