Prune legacy setup service scaffolding
This commit is contained in:
@@ -1,15 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: setup
|
name: setup
|
||||||
description: Run initial EJClaw setup for the dual-service Discord architecture.
|
description: Run initial EJClaw setup for the unified single-service Discord architecture.
|
||||||
---
|
---
|
||||||
|
|
||||||
# EJClaw Setup
|
# EJClaw Setup
|
||||||
|
|
||||||
설치는 `bash setup.sh`로 부트스트랩하고, 나머지는 `bun run setup -- --step <name>`으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다.
|
설치는 `bash setup.sh`로 부트스트랩하고, 나머지는 `bun run setup -- --step <name>`으로 진행합니다. 현재 기준 채널은 디스코드만 지원합니다.
|
||||||
|
|
||||||
EJClaw는 두 개의 서비스로 구성됩니다:
|
EJClaw는 현재 **단일 서비스**로 동작합니다:
|
||||||
- **ejclaw** — Claude Code 봇 (`@claude`)
|
- **ejclaw** — 통합 런타임 서비스
|
||||||
- **ejclaw-codex** — Codex 봇 (`@codex`) — `.env.codex` 파일이 있으면 자동 설치
|
|
||||||
|
Claude가 기본 봇으로 동작하고, Codex는 별도 서비스가 아니라 필요 시 내부 러너/역할 라우팅으로 사용됩니다.
|
||||||
|
|
||||||
## 1. 부트스트랩
|
## 1. 부트스트랩
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ bun run setup -- --step environment
|
|||||||
|
|
||||||
## 3. 필수 환경 변수
|
## 3. 필수 환경 변수
|
||||||
|
|
||||||
### Claude 서비스 (.env)
|
### 기본 환경 변수 (.env)
|
||||||
|
|
||||||
`.env`에 최소한 아래 값이 있어야 합니다.
|
`.env`에 최소한 아래 값이 있어야 합니다.
|
||||||
|
|
||||||
@@ -51,18 +52,10 @@ CLAUDE_CODE_OAUTH_TOKENS=token1,token2 # 다중 계정 자동 로테이션
|
|||||||
GROQ_API_KEY=... # Discord 음성 전사 (Groq Whisper)
|
GROQ_API_KEY=... # Discord 음성 전사 (Groq Whisper)
|
||||||
```
|
```
|
||||||
|
|
||||||
### Codex 서비스 (.env.codex)
|
Codex 관련 선택 설정은 **별도 서비스 파일이 아니라 같은 `.env` 또는 서비스 환경 변수**에 넣습니다:
|
||||||
|
|
||||||
Codex 봇을 함께 운영하려면 `.env.codex`를 만듭니다. 이 파일이 있으면 `--step service`가 자동으로 `ejclaw-codex` 서비스도 설치합니다.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DISCORD_BOT_TOKEN=... # Codex 봇 토큰 (Claude와 별도)
|
# 같은 .env 또는 서비스 Environment=에 추가 가능
|
||||||
```
|
|
||||||
|
|
||||||
Codex 서비스의 추가 설정은 systemd 유닛에서 `Environment=` 라인으로 지정하거나 `.env.codex`에 추가합니다:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# systemd 유닛 또는 .env.codex에 추가 가능
|
|
||||||
CODEX_MODEL=gpt-5.4
|
CODEX_MODEL=gpt-5.4
|
||||||
CODEX_EFFORT=xhigh
|
CODEX_EFFORT=xhigh
|
||||||
OPENAI_API_KEY=...
|
OPENAI_API_KEY=...
|
||||||
@@ -91,15 +84,17 @@ bun run setup -- --step runners
|
|||||||
- `runners/agent-runner` (Claude Code)
|
- `runners/agent-runner` (Claude Code)
|
||||||
- `runners/codex-runner` (Codex)
|
- `runners/codex-runner` (Codex)
|
||||||
|
|
||||||
|
서비스는 하나지만, 내부 역할 라우팅과 paired 흐름에서 두 러너를 모두 쓸 수 있어서 둘 다 빌드합니다.
|
||||||
|
|
||||||
실패하면 보통 `bun run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다.
|
실패하면 보통 `bun run build:runners` 출력과 각 러너의 `package.json` 의존성을 같이 보면 됩니다.
|
||||||
|
|
||||||
## 5. 디스코드 채널 등록
|
## 5. 디스코드 채널 등록
|
||||||
|
|
||||||
먼저 디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:<channel_id>` 형식입니다.
|
먼저 디스코드에서 개발자 모드를 켜고 채널 ID를 복사합니다. 등록 JID는 `dc:<channel_id>` 형식입니다.
|
||||||
|
|
||||||
듀얼 서비스에서는 **같은 채널을 두 번 등록**할 수 있습니다 — 각 agent type에 한 번씩. 등록은 `(jid, agent_type)` 복합 키로 저장됩니다.
|
채널 등록은 기본적으로 한 번 하면 됩니다.
|
||||||
|
|
||||||
Claude 봇 채널 등록:
|
예시:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run setup -- --step register -- \
|
bun run setup -- --step register -- \
|
||||||
@@ -111,18 +106,6 @@ bun run setup -- --step register -- \
|
|||||||
--no-trigger-required
|
--no-trigger-required
|
||||||
```
|
```
|
||||||
|
|
||||||
Codex 봇도 같은 채널을 쓴다면 별도로 등록합니다:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ASSISTANT_NAME=codex bun run setup -- --step register -- \
|
|
||||||
--jid dc:123456789012345678 \
|
|
||||||
--name "My Server #general" \
|
|
||||||
--folder discord_main \
|
|
||||||
--trigger @codex \
|
|
||||||
--is-main \
|
|
||||||
--no-trigger-required
|
|
||||||
```
|
|
||||||
|
|
||||||
보조 채널 예시:
|
보조 채널 예시:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -140,24 +123,22 @@ bun run setup -- --step service
|
|||||||
```
|
```
|
||||||
|
|
||||||
이 명령은:
|
이 명령은:
|
||||||
- **ejclaw** 서비스를 항상 설치합니다
|
- **ejclaw** 서비스를 설치하고 시작합니다
|
||||||
- `.env.codex`가 있으면 **ejclaw-codex** 서비스도 함께 설치합니다
|
|
||||||
|
|
||||||
플랫폼별:
|
플랫폼별:
|
||||||
- Linux (systemd): `~/.config/systemd/user/ejclaw.service` + `ejclaw-codex.service`
|
- Linux (systemd): `~/.config/systemd/user/ejclaw.service`
|
||||||
- macOS: `~/Library/LaunchAgents/com.ejclaw.plist` + `com.ejclaw-codex.plist`
|
- macOS: `~/Library/LaunchAgents/com.ejclaw.plist`
|
||||||
- WSL (no systemd): `start-ejclaw.sh` + `start-ejclaw-codex.sh`
|
- WSL (no systemd): `start-ejclaw.sh`
|
||||||
|
|
||||||
수동으로 서비스 관리:
|
수동으로 서비스 관리:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (systemd)
|
# Linux (systemd)
|
||||||
systemctl --user status ejclaw ejclaw-codex
|
systemctl --user status ejclaw
|
||||||
systemctl --user restart ejclaw ejclaw-codex
|
systemctl --user restart ejclaw
|
||||||
|
|
||||||
# 로그
|
# 로그
|
||||||
journalctl --user -u ejclaw -f
|
journalctl --user -u ejclaw -f
|
||||||
journalctl --user -u ejclaw-codex -f
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. 최종 검증
|
## 7. 최종 검증
|
||||||
@@ -169,7 +150,6 @@ bun run setup -- --step verify
|
|||||||
성공 기준:
|
성공 기준:
|
||||||
|
|
||||||
- **ejclaw** 서비스가 running
|
- **ejclaw** 서비스가 running
|
||||||
- **ejclaw-codex** 서비스가 running (`.env.codex`가 있을 때)
|
|
||||||
- Claude 인증이 configured
|
- Claude 인증이 configured
|
||||||
- `CHANNEL_AUTH`에 `discord`
|
- `CHANNEL_AUTH`에 `discord`
|
||||||
- 등록 그룹 수가 1 이상
|
- 등록 그룹 수가 1 이상
|
||||||
@@ -178,6 +158,6 @@ bun run setup -- --step verify
|
|||||||
|
|
||||||
- 빌드 문제: `bun run typecheck`, `bun test`, `bun run build:runners`
|
- 빌드 문제: `bun run typecheck`, `bun test`, `bun run build:runners`
|
||||||
- Claude 서비스 문제: `logs/ejclaw.error.log` 또는 `journalctl --user -u ejclaw -f`
|
- Claude 서비스 문제: `logs/ejclaw.error.log` 또는 `journalctl --user -u ejclaw -f`
|
||||||
- Codex 서비스 문제: `logs/ejclaw-codex.error.log` 또는 `journalctl --user -u ejclaw-codex -f`
|
- Codex 실행 문제: 같은 `ejclaw` 서비스 로그에서 `CODEX_MODEL`, `CODEX_EFFORT`, `OPENAI_API_KEY` 설정을 같이 확인
|
||||||
- 디스코드 연결 문제: `.env`의 `DISCORD_BOT_TOKEN`과 등록된 `dc:*` JID 확인
|
- 디스코드 연결 문제: `.env`의 `DISCORD_BOT_TOKEN`과 등록된 `dc:*` JID 확인
|
||||||
- 응답 문제: `tail -f logs/ejclaw.log`
|
- 응답 문제: `tail -f logs/ejclaw.log`
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"allowedRoots": [
|
|
||||||
{
|
|
||||||
"path": "~/projects",
|
|
||||||
"allowReadWrite": true,
|
|
||||||
"description": "Development projects"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "~/repos",
|
|
||||||
"allowReadWrite": true,
|
|
||||||
"description": "Git repositories"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "~/Documents/work",
|
|
||||||
"allowReadWrite": false,
|
|
||||||
"description": "Work documents (read-only)"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"blockedPatterns": [
|
|
||||||
"password",
|
|
||||||
"secret",
|
|
||||||
"token"
|
|
||||||
],
|
|
||||||
"nonMainReadOnly": true
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Codex Assistant
|
|
||||||
|
|
||||||
You are a participant in a Discord chat. Your name is "코덱스" (Codex).
|
|
||||||
|
|
||||||
This channel works with the nanoclaw project at /home/clone-ej/nanoclaw.
|
|
||||||
|
|
||||||
## Core Rules
|
|
||||||
|
|
||||||
- Respond directly to messages. Do NOT give "reply suggestions" or "here's how you could respond".
|
|
||||||
- When someone asks a question, answer it yourself. You are having a conversation, not helping someone draft replies.
|
|
||||||
- Speak in natural, friendly Korean.
|
|
||||||
- When coding, debugging, or file work is needed, do it directly.
|
|
||||||
- When you reach a conclusion or need human judgment, mention @눈쟁이 in your message.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>com.ejclaw-codex</string>
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>{{NODE_PATH}}</string>
|
|
||||||
<string>{{PROJECT_ROOT}}/dist/index.js</string>
|
|
||||||
</array>
|
|
||||||
<key>WorkingDirectory</key>
|
|
||||||
<string>{{PROJECT_ROOT}}</string>
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
<key>EnvironmentVariables</key>
|
|
||||||
<dict>
|
|
||||||
<key>PATH</key>
|
|
||||||
<string>{{HOME}}/.npm-global/bin:{{HOME}}/.local/bin:/usr/local/bin:/usr/bin:/bin</string>
|
|
||||||
<key>HOME</key>
|
|
||||||
<string>{{HOME}}</string>
|
|
||||||
<key>ASSISTANT_NAME</key>
|
|
||||||
<string>Andy-Codex</string>
|
|
||||||
<key>EJCLAW_STORE_DIR</key>
|
|
||||||
<string>{{PROJECT_ROOT}}/store-codex</string>
|
|
||||||
<key>EJCLAW_GROUPS_DIR</key>
|
|
||||||
<string>{{PROJECT_ROOT}}/groups-codex</string>
|
|
||||||
<key>EJCLAW_DATA_DIR</key>
|
|
||||||
<string>{{PROJECT_ROOT}}/data-codex</string>
|
|
||||||
</dict>
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.log</string>
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>{{PROJECT_ROOT}}/logs/ejclaw-codex.error.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
@@ -82,31 +82,6 @@ describe('restartStackServices', () => {
|
|||||||
).toThrow('restart:stack only supports Linux systemd services in this repo');
|
).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', () => {
|
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-'));
|
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ejclaw-restart-'));
|
||||||
tempRoots.push(tempRoot);
|
tempRoots.push(tempRoot);
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { execFileSync } from 'child_process';
|
import { execFileSync } from 'child_process';
|
||||||
import os from 'os';
|
|
||||||
import { pathToFileURL } from 'url';
|
import { pathToFileURL } from 'url';
|
||||||
|
|
||||||
import {
|
|
||||||
detectLegacyServiceIssues,
|
|
||||||
formatLegacyServiceFailureMessage,
|
|
||||||
type LegacyServiceIssue,
|
|
||||||
} from './legacy-service-guard.js';
|
|
||||||
import { getServiceManager, isRoot } from './platform.js';
|
import { getServiceManager, isRoot } from './platform.js';
|
||||||
import { getConfiguredServiceNames } from './service-defs.js';
|
import { getConfiguredServiceNames } from './service-defs.js';
|
||||||
|
|
||||||
@@ -23,8 +17,6 @@ interface RestartStackDeps {
|
|||||||
serviceManager?: ServiceManager;
|
serviceManager?: ServiceManager;
|
||||||
direct?: boolean;
|
direct?: boolean;
|
||||||
serviceId?: string | null;
|
serviceId?: string | null;
|
||||||
legacyServiceIssues?: LegacyServiceIssue[];
|
|
||||||
homeDir?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartStackServicesDirect(
|
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 services = getConfiguredServiceNames(projectRoot);
|
||||||
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
const execImpl = deps.execFileSyncImpl ?? execFileSync;
|
||||||
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
const systemctlArgs = deps.runningAsRoot ?? isRoot() ? [] : ['--user'];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type ServiceKind = 'primary' | 'legacy';
|
export type ServiceKind = 'primary';
|
||||||
|
|
||||||
export interface ServiceDef {
|
export interface ServiceDef {
|
||||||
/** Stable topology kind used by setup/verify logic */
|
/** 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 {
|
function materializeServiceDef(template: ServiceTemplate): ServiceDef {
|
||||||
const environmentFile = undefined;
|
const environmentFile = undefined;
|
||||||
const extraEnv =
|
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[] {
|
export function getConfiguredServiceNames(projectRoot: string): string[] {
|
||||||
return getServiceDefs(projectRoot).map((def) => def.name);
|
return getServiceDefs(projectRoot).map((def) => def.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLegacyServiceNames(projectRoot: string): string[] {
|
|
||||||
return getLegacyServiceDefs(projectRoot).map((def) => def.name);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
buildStackRestartSystemdUnit,
|
buildStackRestartSystemdUnit,
|
||||||
buildSystemdUnit,
|
buildSystemdUnit,
|
||||||
} from './service-renderers.js';
|
} from './service-renderers.js';
|
||||||
import {
|
import { getServiceDefs, type ServiceDef } from './service-defs.js';
|
||||||
getLegacyServiceDefs,
|
|
||||||
getServiceDefs,
|
|
||||||
type ServiceDef,
|
|
||||||
} from './service-defs.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests for service configuration generation.
|
* 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', () => {
|
it('generates a oneshot stack restart unit', () => {
|
||||||
const unit = buildStackRestartSystemdUnit(
|
const unit = buildStackRestartSystemdUnit(
|
||||||
'/srv/ejclaw',
|
'/srv/ejclaw',
|
||||||
|
|||||||
@@ -17,10 +17,6 @@ import {
|
|||||||
getNodePath,
|
getNodePath,
|
||||||
getServiceManager,
|
getServiceManager,
|
||||||
} from './platform.js';
|
} from './platform.js';
|
||||||
import {
|
|
||||||
detectLegacyServiceIssues,
|
|
||||||
formatLegacyServiceFailureMessage,
|
|
||||||
} from './legacy-service-guard.js';
|
|
||||||
import { getServiceDefs } from './service-defs.js';
|
import { getServiceDefs } from './service-defs.js';
|
||||||
import { setupLaunchd, setupLinux } from './service-installers.js';
|
import { setupLaunchd, setupLinux } from './service-installers.js';
|
||||||
import { emitStatus } from './status.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
|
// Build first
|
||||||
logger.info('Building TypeScript');
|
logger.info('Building TypeScript');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,13 +7,7 @@
|
|||||||
*
|
*
|
||||||
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
* Uses better-sqlite3 directly (no sqlite3 CLI), platform-aware service checks.
|
||||||
*/
|
*/
|
||||||
import os from 'os';
|
|
||||||
|
|
||||||
import { logger } from '../src/logger.js';
|
import { logger } from '../src/logger.js';
|
||||||
import {
|
|
||||||
detectLegacyServiceIssues,
|
|
||||||
formatLegacyServiceFailureMessage,
|
|
||||||
} from './legacy-service-guard.js';
|
|
||||||
import { getServiceManager } from './platform.js';
|
import { getServiceManager } from './platform.js';
|
||||||
import { getServiceDefs } from './service-defs.js';
|
import { getServiceDefs } from './service-defs.js';
|
||||||
import { emitStatus } from './status.js';
|
import { emitStatus } from './status.js';
|
||||||
@@ -39,21 +33,10 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
// 1. Check service statuses
|
// 1. Check service statuses
|
||||||
const serviceDefs = getServiceDefs(projectRoot);
|
const serviceDefs = getServiceDefs(projectRoot);
|
||||||
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
|
const services = getServiceChecks(serviceDefs, projectRoot, mgr);
|
||||||
const legacyServiceIssues = detectLegacyServiceIssues(
|
|
||||||
projectRoot,
|
|
||||||
mgr,
|
|
||||||
os.homedir(),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const svc of services) {
|
for (const svc of services) {
|
||||||
logger.info({ service: svc.name, status: svc.status }, 'Service status');
|
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 credentials = detectCredentials(projectRoot);
|
||||||
const channelAuth = detectChannelAuth();
|
const channelAuth = detectChannelAuth();
|
||||||
@@ -80,10 +63,7 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
activeArbiterTasks,
|
activeArbiterTasks,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const legacyServicesSummary = Object.fromEntries(
|
const status = baseStatus;
|
||||||
legacyServiceIssues.map((service) => [service.name, service.status]),
|
|
||||||
);
|
|
||||||
const status = legacyServiceIssues.length > 0 ? 'failed' : baseStatus;
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{
|
{
|
||||||
@@ -92,27 +72,12 @@ export async function run(_args: string[]): Promise<void> {
|
|||||||
tribunalRooms: detectedTribunalRooms,
|
tribunalRooms: detectedTribunalRooms,
|
||||||
activeArbiterTasks: detectedActiveArbiterTasks,
|
activeArbiterTasks: detectedActiveArbiterTasks,
|
||||||
servicesSummary,
|
servicesSummary,
|
||||||
legacyServicesSummary,
|
|
||||||
},
|
},
|
||||||
'Verification complete',
|
'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', {
|
emitStatus('VERIFY', {
|
||||||
SERVICES: JSON.stringify(servicesSummary),
|
SERVICES: JSON.stringify(servicesSummary),
|
||||||
LEGACY_SERVICES: JSON.stringify(legacyServicesSummary),
|
|
||||||
// Legacy field (keep for backward compatibility)
|
// Legacy field (keep for backward compatibility)
|
||||||
SERVICE: services[0].status,
|
SERVICE: services[0].status,
|
||||||
CREDENTIALS: credentials,
|
CREDENTIALS: credentials,
|
||||||
|
|||||||
Reference in New Issue
Block a user