Prune legacy setup service scaffolding

This commit is contained in:
ejclaw
2026-04-08 07:38:06 +09:00
parent 0e016395c6
commit 67cb2fa5fc
13 changed files with 22 additions and 619 deletions

View File

@@ -1,123 +0,0 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const { execSyncMock } = vi.hoisted(() => ({
execSyncMock: vi.fn(),
}));
vi.mock('child_process', () => ({
execSync: execSyncMock,
}));
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
describe('legacy service guard', () => {
const tempRoots: string[] = [];
afterEach(() => {
execSyncMock.mockReset();
for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true });
}
});
it('detects legacy systemd units in the opposite scope', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
tempRoots.push(projectRoot);
execSyncMock.mockImplementation((cmd: string) => {
if (cmd === 'systemctl is-active ejclaw-codex') {
throw new Error('inactive');
}
if (cmd === 'systemctl list-unit-files') {
return 'ejclaw-codex.service enabled\n';
}
if (cmd === 'systemctl --user is-active ejclaw-codex') {
throw new Error('inactive');
}
if (cmd === 'systemctl --user list-unit-files') {
return '';
}
if (cmd === 'systemctl is-active ejclaw-review') {
throw new Error('inactive');
}
if (cmd === 'systemctl --user is-active ejclaw-review') {
throw new Error('inactive');
}
if (cmd === 'systemctl list-unit-files' || cmd === 'systemctl --user list-unit-files') {
return '';
}
throw new Error(`unexpected command: ${cmd}`);
});
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system'],
},
]);
});
it('detects legacy nohup artifacts even on systemd hosts', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-legacy-'));
tempRoots.push(projectRoot);
fs.writeFileSync(path.join(projectRoot, 'start-ejclaw-codex.sh'), '#!/bin/bash\n');
execSyncMock.mockImplementation((cmd: string) => {
if (
cmd === 'systemctl is-active ejclaw-codex' ||
cmd === 'systemctl --user is-active ejclaw-codex' ||
cmd === 'systemctl is-active ejclaw-review' ||
cmd === 'systemctl --user is-active ejclaw-review'
) {
throw new Error('inactive');
}
if (
cmd === 'systemctl list-unit-files' ||
cmd === 'systemctl --user list-unit-files'
) {
return '';
}
throw new Error(`unexpected command: ${cmd}`);
});
expect(detectLegacyServiceIssues(projectRoot, 'systemd')).toEqual([
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['nohup'],
},
]);
});
it('formats cleanup instructions for the detected scopes only', () => {
const message = formatLegacyServiceFailureMessage({
projectRoot: '/srv/ejclaw',
serviceManager: 'systemd',
homeDir: '/home/user',
services: [
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system', 'nohup'],
},
{
name: 'ejclaw-review',
status: 'running',
sources: ['systemd-user'],
},
],
});
expect(message).toContain('systemctl disable --now ejclaw-codex');
expect(message).toContain('systemctl --user disable --now ejclaw-review');
expect(message).toContain('pkill -F "/srv/ejclaw/ejclaw-codex.pid"');
expect(message).not.toContain('systemctl --user disable --now ejclaw-codex');
});
});

View File

@@ -1,209 +0,0 @@
import os from 'os';
import path from 'path';
import type { ServiceManager } from './platform.js';
import { getLegacyServiceDefs } from './service-defs.js';
import {
checkLaunchdServiceArtifact,
checkNohupServiceArtifact,
checkSystemdServiceInScope,
type ServiceCheck,
type SystemdScope,
} from './verify-services.js';
type LegacySource = 'launchd' | 'systemd-system' | 'systemd-user' | 'nohup';
export interface LegacyServiceIssue extends ServiceCheck {
sources: LegacySource[];
}
function summarizeLegacyStatus(statuses: ServiceCheck['status'][]): ServiceCheck['status'] {
if (statuses.includes('running')) {
return 'running';
}
if (statuses.includes('stopped')) {
return 'stopped';
}
return 'not_found';
}
export function detectLegacyServiceIssues(
projectRoot: string,
serviceManager: ServiceManager,
homeDir = os.homedir(),
): LegacyServiceIssue[] {
return getLegacyServiceDefs(projectRoot)
.map((def) => {
if (serviceManager === 'launchd') {
const status = checkLaunchdServiceArtifact(
def.launchdLabel,
path.join(homeDir, 'Library', 'LaunchAgents', `${def.launchdLabel}.plist`),
);
if (status === 'not_found') return null;
return {
name: def.name,
status,
sources: ['launchd'] as LegacySource[],
};
}
if (serviceManager === 'systemd') {
const scopedChecks: Array<{ source: LegacySource; status: ServiceCheck['status'] }> = [
{
source: 'systemd-system',
status: checkSystemdServiceInScope(def.name, 'system'),
},
{
source: 'systemd-user',
status: checkSystemdServiceInScope(def.name, 'user'),
},
{
source: 'nohup',
status: checkNohupServiceArtifact(projectRoot, def.name),
},
];
const detected = scopedChecks.filter(
(current) => current.status !== 'not_found',
);
if (detected.length === 0) return null;
return {
name: def.name,
status: summarizeLegacyStatus(detected.map((current) => current.status)),
sources: detected.map((current) => current.source),
};
}
const status = checkNohupServiceArtifact(projectRoot, def.name);
if (status === 'not_found') return null;
return {
name: def.name,
status,
sources: ['nohup'] as LegacySource[],
};
})
.filter((service): service is LegacyServiceIssue => Boolean(service));
}
function formatSystemdCleanupForScope(
serviceNames: string[],
homeDir: string,
scope: SystemdScope,
): string[] {
if (serviceNames.length === 0) return [];
const systemctlPrefix = scope === 'system' ? 'systemctl' : 'systemctl --user';
const unitDir =
scope === 'system'
? '/etc/systemd/system'
: path.join(homeDir, '.config', 'systemd', 'user');
const unitPaths = serviceNames
.map((serviceName) =>
JSON.stringify(path.join(unitDir, `${serviceName}.service`)),
)
.join(' ');
return [
`${systemctlPrefix} disable --now ${serviceNames.join(' ')}`,
`rm -f ${unitPaths}`,
`${systemctlPrefix} daemon-reload`,
];
}
function formatLaunchdCleanup(serviceNames: string[], homeDir: string): string {
if (serviceNames.length === 0) {
return '';
}
const plistPaths = serviceNames.map((serviceName) => {
const label = serviceName === 'ejclaw-codex'
? 'com.ejclaw-codex'
: 'com.ejclaw-review';
return path.join(homeDir, 'Library', 'LaunchAgents', `${label}.plist`);
});
return [
...plistPaths.map(
(plistPath) =>
`launchctl unload ${JSON.stringify(plistPath)} 2>/dev/null || true`,
),
`rm -f ${plistPaths.map((plistPath) => JSON.stringify(plistPath)).join(' ')}`,
].join('\n');
}
function formatNohupCleanup(
projectRoot: string,
serviceNames: string[],
): string {
if (serviceNames.length === 0) {
return '';
}
const pidPaths = serviceNames
.map((serviceName) => path.join(projectRoot, `${serviceName}.pid`))
.map((currentPath) => JSON.stringify(currentPath));
const wrapperPaths = serviceNames
.map((serviceName) => path.join(projectRoot, `start-${serviceName}.sh`))
.map((currentPath) => JSON.stringify(currentPath));
return [
...pidPaths.map((pidPath) => `pkill -F ${pidPath} 2>/dev/null || true`),
`rm -f ${[...pidPaths, ...wrapperPaths].join(' ')}`,
].join('\n');
}
export function formatLegacyServiceFailureMessage(args: {
projectRoot: string;
serviceManager: ServiceManager;
services: LegacyServiceIssue[];
homeDir?: string;
}): string {
const homeDir = args.homeDir ?? os.homedir();
const details = args.services
.map(
(service) =>
`${service.name}=${service.status} [${service.sources.join(',')}]`,
)
.join(', ');
let cleanupLines: string[] = [];
if (args.serviceManager === 'launchd') {
cleanupLines = formatLaunchdCleanup(
args.services
.filter((service) => service.sources.includes('launchd'))
.map((service) => service.name),
homeDir,
).split('\n');
} else if (args.serviceManager === 'systemd') {
cleanupLines = [
...formatSystemdCleanupForScope(
args.services
.filter((service) => service.sources.includes('systemd-system'))
.map((service) => service.name),
homeDir,
'system',
),
...formatSystemdCleanupForScope(
args.services
.filter((service) => service.sources.includes('systemd-user'))
.map((service) => service.name),
homeDir,
'user',
),
...formatNohupCleanup(
args.projectRoot,
args.services
.filter((service) => service.sources.includes('nohup'))
.map((service) => service.name),
).split('\n'),
].filter(Boolean);
} else {
cleanupLines = formatNohupCleanup(
args.projectRoot,
args.services.map((service) => service.name),
).split('\n');
}
return [
`Legacy EJClaw multi-service install detected: ${details}`,
'This setup is reinstall-only. Remove the legacy services before continuing.',
'Suggested cleanup:',
...cleanupLines,
].join('\n');
}

View File

@@ -82,31 +82,6 @@ describe('restartStackServices', () => {
).toThrow('restart:stack only supports Linux systemd services in this repo');
});
it('fails fast when legacy services are still present', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);
const execFileSyncImpl = vi.fn();
expect(() =>
restartStackServices(tempRoot, {
direct: true,
execFileSyncImpl,
runningAsRoot: false,
serviceManager: 'systemd',
serviceId: null,
legacyServiceIssues: [
{
name: 'ejclaw-codex',
status: 'stopped',
sources: ['systemd-system'],
},
],
}),
).toThrow('Legacy EJClaw multi-service install detected');
expect(execFileSyncImpl).not.toHaveBeenCalled();
});
it('falls back to direct restart when the oneshot unit is not installed for an external caller', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
tempRoots.push(tempRoot);

View File

@@ -1,12 +1,6 @@
import { execFileSync } from 'child_process';
import os from 'os';
import { pathToFileURL } from 'url';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
type LegacyServiceIssue,
} from './legacy-service-guard.js';
import { getServiceManager, isRoot } from './platform.js';
import { getConfiguredServiceNames } from './service-defs.js';
@@ -23,8 +17,6 @@ interface RestartStackDeps {
serviceManager?: ServiceManager;
direct?: boolean;
serviceId?: string | null;
legacyServiceIssues?: LegacyServiceIssue[];
homeDir?: string;
}
function restartStackServicesDirect(
@@ -70,24 +62,6 @@ export function restartStackServices(
);
}
const legacyServiceIssues =
deps.legacyServiceIssues ??
detectLegacyServiceIssues(
projectRoot,
serviceManager,
deps.homeDir ?? os.homedir(),
);
if (legacyServiceIssues.length > 0) {
throw new Error(
formatLegacyServiceFailureMessage({
projectRoot,
serviceManager,
services: legacyServiceIssues,
homeDir: deps.homeDir,
}),
);
}
const services = getConfiguredServiceNames(projectRoot);
const execImpl = deps.execFileSyncImpl ?? execFileSync;
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];

View File

@@ -1,4 +1,4 @@
export type ServiceKind = 'primary' | 'legacy';
export type ServiceKind = 'primary';
export interface ServiceDef {
/** Stable topology kind used by setup/verify logic */
@@ -35,23 +35,6 @@ const CURRENT_SERVICE_TEMPLATES: ServiceTemplate[] = [
},
];
const LEGACY_SERVICE_TEMPLATES: ServiceTemplate[] = [
{
kind: 'legacy',
name: 'ejclaw-codex',
launchdLabel: 'com.ejclaw-codex',
description: 'Legacy EJClaw Codex Assistant',
logName: 'ejclaw-codex',
},
{
kind: 'legacy',
name: 'ejclaw-review',
launchdLabel: 'com.ejclaw-review',
description: 'Legacy EJClaw Codex Review Assistant',
logName: 'ejclaw-review',
},
];
function materializeServiceDef(template: ServiceTemplate): ServiceDef {
const environmentFile = undefined;
const extraEnv =
@@ -79,17 +62,6 @@ export function getServiceDefs(projectRoot: string): ServiceDef[] {
);
}
export function getLegacyServiceDefs(projectRoot: string): ServiceDef[] {
void projectRoot;
return LEGACY_SERVICE_TEMPLATES.map((template) =>
materializeServiceDef(template),
);
}
export function getConfiguredServiceNames(projectRoot: string): string[] {
return getServiceDefs(projectRoot).map((def) => def.name);
}
export function getLegacyServiceNames(projectRoot: string): string[] {
return getLegacyServiceDefs(projectRoot).map((def) => def.name);
}

View File

@@ -9,11 +9,7 @@ import {
buildStackRestartSystemdUnit,
buildSystemdUnit,
} from './service-renderers.js';
import {
getLegacyServiceDefs,
getServiceDefs,
type ServiceDef,
} from './service-defs.js';
import { getServiceDefs, type ServiceDef } from './service-defs.js';
/**
* Tests for service configuration generation.
@@ -189,19 +185,6 @@ describe('service definitions', () => {
});
});
it('keeps legacy service identities available for migration guards', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-stack-'));
tempRoots.push(tempRoot);
const defs = getLegacyServiceDefs(tempRoot);
expect(defs.map((def) => def.name)).toEqual([
'ejclaw-codex',
'ejclaw-review',
]);
expect(defs.map((def) => def.kind)).toEqual(['legacy', 'legacy']);
});
it('generates a oneshot stack restart unit', () => {
const unit = buildStackRestartSystemdUnit(
'/srv/ejclaw',

View File

@@ -17,10 +17,6 @@ import {
getNodePath,
getServiceManager,
} from './platform.js';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
import { getServiceDefs } from './service-defs.js';
import { setupLaunchd, setupLinux } from './service-installers.js';
import { emitStatus } from './status.js';
@@ -54,37 +50,6 @@ export async function run(_args: string[]): Promise<void> {
);
}
const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot,
serviceManager,
homeDir,
);
if (legacyServiceIssues.length > 0) {
const errorMessage = formatLegacyServiceFailureMessage({
projectRoot,
serviceManager,
homeDir,
services: legacyServiceIssues,
});
logger.error(
{ legacyServiceIssues, serviceManager },
'Legacy multi-service install detected during setup',
);
emitStatus('SETUP_SERVICE', {
SERVICE_TYPE: serviceManager,
NODE_PATH: nodePath,
PROJECT_PATH: projectRoot,
STATUS: 'failed',
ERROR: 'legacy_services_detected',
LEGACY_SERVICES: legacyServiceIssues
.map((service) => `${service.name}:${service.status}`)
.join(','),
LOG: 'logs/setup.log',
});
console.error(errorMessage);
process.exit(1);
}
// Build first
logger.info('Building TypeScript');
try {

View File

@@ -7,13 +7,7 @@
*
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
*/
import os from 'os';
import { logger } from '../src/logger.js';
import {
detectLegacyServiceIssues,
formatLegacyServiceFailureMessage,
} from './legacy-service-guard.js';
import { getServiceManager } from './platform.js';
import { getServiceDefs } from './service-defs.js';
import { emitStatus } from './status.js';
@@ -39,21 +33,10 @@ export async function run(_args: string[]): Promise<void> {
// 1. Check service statuses
const serviceDefs = getServiceDefs(projectRoot);
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
const legacyServiceIssues = detectLegacyServiceIssues(
projectRoot,
mgr,
os.homedir(),
);
for (const svc of services) {
logger.info({ service: svc.name, status: svc.status }, 'Service status');
}
for (const svc of legacyServiceIssues) {
logger.error(
{ service: svc.name, status: svc.status },
'Legacy service detected during verification',
);
}
const credentials = detectCredentials(projectRoot);
const channelAuth = detectChannelAuth();
@@ -80,10 +63,7 @@ export async function run(_args: string[]): Promise<void> {
activeArbiterTasks,
},
);
const legacyServicesSummary = Object.fromEntries(
legacyServiceIssues.map((service) => [service.name, service.status]),
);
const status = legacyServiceIssues.length > 0 ? 'failed' : baseStatus;
const status = baseStatus;
logger.info(
{
@@ -92,27 +72,12 @@ export async function run(_args: string[]): Promise<void> {
tribunalRooms: detectedTribunalRooms,
activeArbiterTasks: detectedActiveArbiterTasks,
servicesSummary,
legacyServicesSummary,
},
'Verification complete',
);
if (legacyServiceIssues.length > 0) {
logger.error(
{
cleanup: formatLegacyServiceFailureMessage({
projectRoot,
serviceManager: mgr,
homeDir: os.homedir(),
services: legacyServiceIssues,
}),
},
'Verification failed due to legacy multi-service install',
);
}
emitStatus('VERIFY', {
SERVICES: JSON.stringify(servicesSummary),
LEGACY_SERVICES: JSON.stringify(legacyServicesSummary),
// Legacy field (keep for backward compatibility)
SERVICE: services[0].status,
CREDENTIALS: credentials,