zozul-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +44 -0
- package/.github/workflows/publish.yml +26 -0
- package/DEVELOPMENT.md +288 -0
- package/LICENSE +201 -0
- package/README.md +178 -0
- package/dist/cli/commands.d.ts +3 -0
- package/dist/cli/commands.d.ts.map +1 -0
- package/dist/cli/commands.js +307 -0
- package/dist/cli/commands.js.map +1 -0
- package/dist/cli/format.d.ts +5 -0
- package/dist/cli/format.d.ts.map +1 -0
- package/dist/cli/format.js +115 -0
- package/dist/cli/format.js.map +1 -0
- package/dist/context/index.d.ts +8 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +37 -0
- package/dist/context/index.js.map +1 -0
- package/dist/dashboard/html.d.ts +17 -0
- package/dist/dashboard/html.d.ts.map +1 -0
- package/dist/dashboard/html.js +79 -0
- package/dist/dashboard/html.js.map +1 -0
- package/dist/dashboard/index.html +1245 -0
- package/dist/hooks/config.d.ts +19 -0
- package/dist/hooks/config.d.ts.map +1 -0
- package/dist/hooks/config.js +106 -0
- package/dist/hooks/config.js.map +1 -0
- package/dist/hooks/git.d.ts +6 -0
- package/dist/hooks/git.d.ts.map +1 -0
- package/dist/hooks/git.js +73 -0
- package/dist/hooks/git.js.map +1 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +3 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/server.d.ts +16 -0
- package/dist/hooks/server.d.ts.map +1 -0
- package/dist/hooks/server.js +349 -0
- package/dist/hooks/server.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/otel/config.d.ts +36 -0
- package/dist/otel/config.d.ts.map +1 -0
- package/dist/otel/config.js +109 -0
- package/dist/otel/config.js.map +1 -0
- package/dist/otel/index.d.ts +4 -0
- package/dist/otel/index.d.ts.map +1 -0
- package/dist/otel/index.js +3 -0
- package/dist/otel/index.js.map +1 -0
- package/dist/otel/receiver.d.ts +10 -0
- package/dist/otel/receiver.d.ts.map +1 -0
- package/dist/otel/receiver.js +155 -0
- package/dist/otel/receiver.js.map +1 -0
- package/dist/parser/index.d.ts +4 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +3 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/ingest.d.ts +20 -0
- package/dist/parser/ingest.d.ts.map +1 -0
- package/dist/parser/ingest.js +98 -0
- package/dist/parser/ingest.js.map +1 -0
- package/dist/parser/jsonl.d.ts +14 -0
- package/dist/parser/jsonl.d.ts.map +1 -0
- package/dist/parser/jsonl.js +202 -0
- package/dist/parser/jsonl.js.map +1 -0
- package/dist/parser/types.d.ts +81 -0
- package/dist/parser/types.d.ts.map +1 -0
- package/dist/parser/types.js +9 -0
- package/dist/parser/types.js.map +1 -0
- package/dist/parser/watcher.d.ts +16 -0
- package/dist/parser/watcher.d.ts.map +1 -0
- package/dist/parser/watcher.js +103 -0
- package/dist/parser/watcher.js.map +1 -0
- package/dist/pricing/index.d.ts +2 -0
- package/dist/pricing/index.d.ts.map +1 -0
- package/dist/pricing/index.js +37 -0
- package/dist/pricing/index.js.map +1 -0
- package/dist/service/index.d.ts +31 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +252 -0
- package/dist/service/index.js.map +1 -0
- package/dist/storage/db.d.ts +75 -0
- package/dist/storage/db.d.ts.map +1 -0
- package/dist/storage/db.js +117 -0
- package/dist/storage/db.js.map +1 -0
- package/dist/storage/index.d.ts +4 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +3 -0
- package/dist/storage/index.js.map +1 -0
- package/dist/storage/repo.d.ts +162 -0
- package/dist/storage/repo.d.ts.map +1 -0
- package/dist/storage/repo.js +472 -0
- package/dist/storage/repo.js.map +1 -0
- package/dist/sync/client.d.ts +24 -0
- package/dist/sync/client.d.ts.map +1 -0
- package/dist/sync/client.js +41 -0
- package/dist/sync/client.js.map +1 -0
- package/dist/sync/index.d.ts +18 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +135 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/sync.test.d.ts +2 -0
- package/dist/sync/sync.test.d.ts.map +1 -0
- package/dist/sync/sync.test.js +412 -0
- package/dist/sync/sync.test.js.map +1 -0
- package/dist/sync/transform.d.ts +80 -0
- package/dist/sync/transform.d.ts.map +1 -0
- package/dist/sync/transform.js +90 -0
- package/dist/sync/transform.js.map +1 -0
- package/package.json +50 -0
- package/src/cli/commands.ts +332 -0
- package/src/cli/format.ts +133 -0
- package/src/context/index.ts +42 -0
- package/src/dashboard/html.ts +97 -0
- package/src/dashboard/index.html +1245 -0
- package/src/hooks/config.ts +119 -0
- package/src/hooks/git.ts +77 -0
- package/src/hooks/index.ts +7 -0
- package/src/hooks/server.ts +397 -0
- package/src/index.ts +6 -0
- package/src/otel/config.ts +141 -0
- package/src/otel/index.ts +8 -0
- package/src/otel/receiver.ts +183 -0
- package/src/parser/index.ts +3 -0
- package/src/parser/ingest.ts +119 -0
- package/src/parser/jsonl.ts +241 -0
- package/src/parser/types.ts +89 -0
- package/src/parser/watcher.ts +116 -0
- package/src/pricing/index.ts +51 -0
- package/src/service/index.ts +272 -0
- package/src/storage/db.ts +198 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/repo.ts +601 -0
- package/src/sync/client.ts +63 -0
- package/src/sync/index.ts +207 -0
- package/src/sync/sync.test.ts +447 -0
- package/src/sync/transform.ts +184 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types representing the JSONL session file format that Claude Code writes to:
|
|
3
|
+
* ~/.claude/projects/<encoded-path>/sessions/<session-uuid>.jsonl
|
|
4
|
+
*
|
|
5
|
+
* Each line is one JSON object. The first line is typically a summary.
|
|
6
|
+
* Subsequent lines are user/assistant messages with full content.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface ContentBlock {
|
|
10
|
+
type: string;
|
|
11
|
+
text?: string;
|
|
12
|
+
id?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
input?: Record<string, unknown>;
|
|
15
|
+
tool_use_id?: string;
|
|
16
|
+
content?: string | ContentBlock[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface UsageInfo {
|
|
20
|
+
input_tokens?: number;
|
|
21
|
+
output_tokens?: number;
|
|
22
|
+
cache_creation_input_tokens?: number;
|
|
23
|
+
cache_read_input_tokens?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface MessagePayload {
|
|
27
|
+
role: string;
|
|
28
|
+
content: string | ContentBlock[];
|
|
29
|
+
model?: string;
|
|
30
|
+
usage?: UsageInfo;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface SessionEntry {
|
|
34
|
+
type: string;
|
|
35
|
+
uuid?: string;
|
|
36
|
+
parentUuid?: string;
|
|
37
|
+
timestamp?: string;
|
|
38
|
+
sessionId?: string;
|
|
39
|
+
cwd?: string;
|
|
40
|
+
version?: string;
|
|
41
|
+
message?: MessagePayload;
|
|
42
|
+
requestId?: string;
|
|
43
|
+
costUSD?: number;
|
|
44
|
+
durationMs?: number;
|
|
45
|
+
isSidechain?: boolean;
|
|
46
|
+
sourceToolAssistantUUID?: string;
|
|
47
|
+
|
|
48
|
+
// Summary-specific fields
|
|
49
|
+
summary?: string;
|
|
50
|
+
leafUuid?: string;
|
|
51
|
+
numLeaves?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ParsedSession {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
projectPath: string | null;
|
|
57
|
+
startedAt: string;
|
|
58
|
+
endedAt: string | null;
|
|
59
|
+
model: string | null;
|
|
60
|
+
turns: ParsedTurn[];
|
|
61
|
+
totalInputTokens: number;
|
|
62
|
+
totalOutputTokens: number;
|
|
63
|
+
totalCacheReadTokens: number;
|
|
64
|
+
totalCacheCreationTokens: number;
|
|
65
|
+
totalCostUsd: number;
|
|
66
|
+
totalDurationMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ParsedTurn {
|
|
70
|
+
turnIndex: number;
|
|
71
|
+
role: string;
|
|
72
|
+
timestamp: string;
|
|
73
|
+
inputTokens: number;
|
|
74
|
+
outputTokens: number;
|
|
75
|
+
cacheReadTokens: number;
|
|
76
|
+
cacheCreationTokens: number;
|
|
77
|
+
costUsd: number;
|
|
78
|
+
durationMs: number;
|
|
79
|
+
model: string | null;
|
|
80
|
+
contentText: string;
|
|
81
|
+
toolCalls: ToolCallInfo[];
|
|
82
|
+
isRealUser: boolean;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ToolCallInfo {
|
|
86
|
+
toolName: string;
|
|
87
|
+
toolInput: Record<string, unknown>;
|
|
88
|
+
toolResult?: string;
|
|
89
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import type { SessionRepo } from "../storage/repo.js";
|
|
5
|
+
import { ingestSessionFile } from "./ingest.js";
|
|
6
|
+
import { discoverSessionFiles } from "./jsonl.js";
|
|
7
|
+
|
|
8
|
+
const PROJECTS_DIR = path.join(os.homedir(), ".claude", "projects");
|
|
9
|
+
const DEBOUNCE_MS = 500;
|
|
10
|
+
|
|
11
|
+
export interface WatcherOptions {
|
|
12
|
+
repo: SessionRepo;
|
|
13
|
+
verbose?: boolean;
|
|
14
|
+
/** Re-ingest all existing JSONL files on startup to catch up on missed sessions. Default true. */
|
|
15
|
+
catchUp?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Watch ~/.claude/projects for JSONL session file changes and ingest them
|
|
20
|
+
* into the database as they are written. Returns a stop function.
|
|
21
|
+
*
|
|
22
|
+
* On startup, performs an initial catch-up pass so that sessions written
|
|
23
|
+
* while zozul was not running are immediately available.
|
|
24
|
+
*/
|
|
25
|
+
export async function watchSessionFiles(opts: WatcherOptions): Promise<() => void> {
|
|
26
|
+
const { repo, verbose } = opts;
|
|
27
|
+
const catchUp = opts.catchUp ?? true;
|
|
28
|
+
|
|
29
|
+
// ── Initial catch-up pass ──
|
|
30
|
+
if (catchUp) {
|
|
31
|
+
const files = discoverSessionFiles();
|
|
32
|
+
let caught = 0;
|
|
33
|
+
for (const { filePath, projectPath } of files) {
|
|
34
|
+
try {
|
|
35
|
+
await ingestSessionFile(repo, filePath, projectPath);
|
|
36
|
+
caught++;
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore parse errors on individual files
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (verbose && caught > 0) {
|
|
42
|
+
process.stderr.write(`[watcher] catch-up: ingested ${caught} session file(s)\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!fs.existsSync(PROJECTS_DIR)) {
|
|
47
|
+
if (verbose) {
|
|
48
|
+
process.stderr.write(`[watcher] ${PROJECTS_DIR} not found, watching skipped\n`);
|
|
49
|
+
}
|
|
50
|
+
return () => {};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Per-file debounce ──
|
|
54
|
+
const timers = new Map<string, NodeJS.Timeout>();
|
|
55
|
+
|
|
56
|
+
function scheduleIngest(filePath: string) {
|
|
57
|
+
const existing = timers.get(filePath);
|
|
58
|
+
if (existing) clearTimeout(existing);
|
|
59
|
+
|
|
60
|
+
timers.set(filePath, setTimeout(async () => {
|
|
61
|
+
timers.delete(filePath);
|
|
62
|
+
try {
|
|
63
|
+
const projectPath = decodeProjectPath(filePath);
|
|
64
|
+
await ingestSessionFile(repo, filePath, projectPath ?? undefined);
|
|
65
|
+
if (verbose) {
|
|
66
|
+
process.stderr.write(`[watcher] ingested: ${filePath}\n`);
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if (verbose) {
|
|
70
|
+
process.stderr.write(`[watcher] ingest failed (${filePath}): ${err}\n`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}, DEBOUNCE_MS));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── fs.watch with recursive ──
|
|
77
|
+
// recursive: true uses FSEvents on macOS and ReadDirectoryChangesW on Windows.
|
|
78
|
+
const watcher = fs.watch(PROJECTS_DIR, { recursive: true }, (_event, filename) => {
|
|
79
|
+
if (!filename) return;
|
|
80
|
+
if (!filename.endsWith(".jsonl")) return;
|
|
81
|
+
|
|
82
|
+
// filename is relative to PROJECTS_DIR on macOS/Windows
|
|
83
|
+
const filePath = path.join(PROJECTS_DIR, filename);
|
|
84
|
+
|
|
85
|
+
// Only act if the file actually exists (ignore delete events)
|
|
86
|
+
if (!fs.existsSync(filePath)) return;
|
|
87
|
+
|
|
88
|
+
scheduleIngest(filePath);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
watcher.on("error", (err) => {
|
|
92
|
+
if (verbose) process.stderr.write(`[watcher] error: ${err}\n`);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (verbose) {
|
|
96
|
+
process.stderr.write(`[watcher] watching ${PROJECTS_DIR}\n`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
watcher.close();
|
|
101
|
+
for (const t of timers.values()) clearTimeout(t);
|
|
102
|
+
timers.clear();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract the decoded project path from an absolute JSONL file path.
|
|
108
|
+
* ~/.claude/projects/<encoded>/<uuid>.jsonl
|
|
109
|
+
* where <encoded> has "/" replaced with "-".
|
|
110
|
+
*/
|
|
111
|
+
function decodeProjectPath(filePath: string): string | null {
|
|
112
|
+
// Match project dir directly containing the UUID file
|
|
113
|
+
const match = filePath.match(/projects\/([^/]+)\/[0-9a-f-]{36}\.jsonl$/i);
|
|
114
|
+
if (!match) return null;
|
|
115
|
+
return match[1].replace(/-/g, "/");
|
|
116
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude model pricing per million tokens.
|
|
3
|
+
* Cache creation uses 5-minute TTL pricing (the default for Claude Code).
|
|
4
|
+
*/
|
|
5
|
+
interface ModelPricing {
|
|
6
|
+
input: number;
|
|
7
|
+
output: number;
|
|
8
|
+
cacheRead: number;
|
|
9
|
+
cacheCreation: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Prices per million tokens
|
|
13
|
+
const PRICING: Record<string, ModelPricing> = {
|
|
14
|
+
"claude-opus-4-6": { input: 5.00, output: 25.00, cacheRead: 0.50, cacheCreation: 6.25 },
|
|
15
|
+
"claude-sonnet-4-6": { input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
16
|
+
"claude-sonnet-4-5-20250514":{ input: 3.00, output: 15.00, cacheRead: 0.30, cacheCreation: 3.75 },
|
|
17
|
+
"claude-haiku-4-5-20251001": { input: 1.00, output: 5.00, cacheRead: 0.10, cacheCreation: 1.25 },
|
|
18
|
+
"claude-3-5-haiku-20241022": { input: 0.80, output: 4.00, cacheRead: 0.08, cacheCreation: 1.00 },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function findPricing(model: string): ModelPricing | null {
|
|
22
|
+
if (PRICING[model]) return PRICING[model];
|
|
23
|
+
// Fuzzy match: try prefix matching for versioned model names
|
|
24
|
+
for (const [key, pricing] of Object.entries(PRICING)) {
|
|
25
|
+
if (model.startsWith(key) || key.startsWith(model)) return pricing;
|
|
26
|
+
}
|
|
27
|
+
// Match by family name
|
|
28
|
+
if (model.includes("opus")) return PRICING["claude-opus-4-6"];
|
|
29
|
+
if (model.includes("sonnet")) return PRICING["claude-sonnet-4-6"];
|
|
30
|
+
if (model.includes("haiku")) return PRICING["claude-haiku-4-5-20251001"];
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function computeTurnCost(
|
|
35
|
+
model: string | null,
|
|
36
|
+
inputTokens: number,
|
|
37
|
+
outputTokens: number,
|
|
38
|
+
cacheReadTokens: number,
|
|
39
|
+
cacheCreationTokens: number,
|
|
40
|
+
): number {
|
|
41
|
+
if (!model) return 0;
|
|
42
|
+
const pricing = findPricing(model);
|
|
43
|
+
if (!pricing) return 0;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
(inputTokens * pricing.input) / 1_000_000 +
|
|
47
|
+
(outputTokens * pricing.output) / 1_000_000 +
|
|
48
|
+
(cacheReadTokens * pricing.cacheRead) / 1_000_000 +
|
|
49
|
+
(cacheCreationTokens * pricing.cacheCreation) / 1_000_000
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const LABEL = "com.zozul.serve";
|
|
7
|
+
const PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
|
|
8
|
+
const SYSTEMD_DIR = path.join(os.homedir(), ".config", "systemd", "user");
|
|
9
|
+
const SYSTEMD_PATH = path.join(SYSTEMD_DIR, "zozul.service");
|
|
10
|
+
const LOG_PATH = path.join(os.homedir(), ".zozul", "zozul.log");
|
|
11
|
+
|
|
12
|
+
export interface ServiceInstallOptions {
|
|
13
|
+
port: number;
|
|
14
|
+
dbPath?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ServiceResult {
|
|
18
|
+
platform: "macos" | "linux" | "unsupported";
|
|
19
|
+
servicePath: string;
|
|
20
|
+
alreadyRunning: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Install and immediately start zozul as a background service.
|
|
25
|
+
* Uses launchd on macOS, systemd --user on Linux.
|
|
26
|
+
*/
|
|
27
|
+
export function installService(opts: ServiceInstallOptions): ServiceResult {
|
|
28
|
+
const platform = detectPlatform();
|
|
29
|
+
if (platform === "unsupported") {
|
|
30
|
+
throw new Error(`Service install is not supported on ${process.platform}. Run 'zozul serve' manually.`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build env vars to bake into the service so it doesn't depend on .env
|
|
34
|
+
const env: Record<string, string> = {
|
|
35
|
+
ZOZUL_PORT: String(opts.port),
|
|
36
|
+
};
|
|
37
|
+
if (opts.dbPath) env.ZOZUL_DB_PATH = opts.dbPath;
|
|
38
|
+
if (process.env.ZOZUL_API_URL) env.ZOZUL_API_URL = process.env.ZOZUL_API_URL;
|
|
39
|
+
if (process.env.ZOZUL_API_KEY) env.ZOZUL_API_KEY = process.env.ZOZUL_API_KEY;
|
|
40
|
+
if (process.env.ZOZUL_VERBOSE) env.ZOZUL_VERBOSE = process.env.ZOZUL_VERBOSE;
|
|
41
|
+
|
|
42
|
+
if (platform === "macos") {
|
|
43
|
+
return installLaunchd(env);
|
|
44
|
+
} else {
|
|
45
|
+
return installSystemd(env);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Stop and remove the zozul background service.
|
|
51
|
+
*/
|
|
52
|
+
export function uninstallService(): { removed: boolean; platform: "macos" | "linux" | "unsupported" } {
|
|
53
|
+
const platform = detectPlatform();
|
|
54
|
+
|
|
55
|
+
if (platform === "macos") {
|
|
56
|
+
let removed = false;
|
|
57
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
58
|
+
try {
|
|
59
|
+
const uid = os.userInfo().uid;
|
|
60
|
+
execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
|
|
61
|
+
} catch {
|
|
62
|
+
// Not loaded — that's fine
|
|
63
|
+
}
|
|
64
|
+
fs.unlinkSync(PLIST_PATH);
|
|
65
|
+
removed = true;
|
|
66
|
+
}
|
|
67
|
+
return { removed, platform };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (platform === "linux") {
|
|
71
|
+
let removed = false;
|
|
72
|
+
if (fs.existsSync(SYSTEMD_PATH)) {
|
|
73
|
+
try {
|
|
74
|
+
execSync("systemctl --user disable --now zozul", { stdio: "ignore" });
|
|
75
|
+
} catch {
|
|
76
|
+
// Not enabled — that's fine
|
|
77
|
+
}
|
|
78
|
+
fs.unlinkSync(SYSTEMD_PATH);
|
|
79
|
+
try {
|
|
80
|
+
execSync("systemctl --user daemon-reload", { stdio: "ignore" });
|
|
81
|
+
} catch { /* ignore */ }
|
|
82
|
+
removed = true;
|
|
83
|
+
}
|
|
84
|
+
return { removed, platform };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { removed: false, platform: "unsupported" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Restart the running service in-place (kills and relaunches the current process).
|
|
92
|
+
* Throws if the service is not installed.
|
|
93
|
+
*/
|
|
94
|
+
export function restartService(): void {
|
|
95
|
+
const platform = detectPlatform();
|
|
96
|
+
|
|
97
|
+
if (platform === "macos") {
|
|
98
|
+
if (!fs.existsSync(PLIST_PATH)) throw new Error("Service is not installed. Run 'zozul install --service' first.");
|
|
99
|
+
const uid = os.userInfo().uid;
|
|
100
|
+
execSync(`launchctl kickstart -k gui/${uid}/${LABEL}`, { stdio: "ignore" });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (platform === "linux") {
|
|
105
|
+
if (!fs.existsSync(SYSTEMD_PATH)) throw new Error("Service is not installed. Run 'zozul install --service' first.");
|
|
106
|
+
execSync("systemctl --user restart zozul", { stdio: "ignore" });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
throw new Error("Service restart is not supported on this platform.");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns a human-readable status string for the running service.
|
|
115
|
+
*/
|
|
116
|
+
export function serviceStatus(): string {
|
|
117
|
+
const platform = detectPlatform();
|
|
118
|
+
|
|
119
|
+
if (platform === "macos") {
|
|
120
|
+
if (!fs.existsSync(PLIST_PATH)) return "not installed";
|
|
121
|
+
try {
|
|
122
|
+
const uid = os.userInfo().uid;
|
|
123
|
+
const out = execSync(`launchctl print gui/${uid}/${LABEL} 2>&1`, { encoding: "utf-8" });
|
|
124
|
+
const stateMatch = out.match(/state = (.+)/);
|
|
125
|
+
const state = stateMatch?.[1]?.trim() ?? "unknown";
|
|
126
|
+
const pidMatch = out.match(/pid = (\d+)/);
|
|
127
|
+
if (pidMatch) return `running (pid ${pidMatch[1]})`;
|
|
128
|
+
if (state === "running") return "running";
|
|
129
|
+
if (state === "spawn scheduled") return "installed (starting…)";
|
|
130
|
+
return `installed (${state})`;
|
|
131
|
+
} catch {
|
|
132
|
+
return "installed (not running)";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (platform === "linux") {
|
|
137
|
+
if (!fs.existsSync(SYSTEMD_PATH)) return "not installed";
|
|
138
|
+
try {
|
|
139
|
+
execSync("systemctl --user is-active zozul", { stdio: "ignore" });
|
|
140
|
+
return "running";
|
|
141
|
+
} catch {
|
|
142
|
+
return "installed (not running)";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return "unsupported platform";
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── macOS launchd ──
|
|
150
|
+
|
|
151
|
+
function installLaunchd(env: Record<string, string>): ServiceResult {
|
|
152
|
+
const nodeBin = process.execPath;
|
|
153
|
+
const scriptPath = path.resolve(process.argv[1]);
|
|
154
|
+
|
|
155
|
+
fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
|
|
156
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
157
|
+
|
|
158
|
+
const envEntries = Object.entries(env)
|
|
159
|
+
.map(([k, v]) => `\t\t<key>${k}</key>\n\t\t<string>${v}</string>`)
|
|
160
|
+
.join("\n");
|
|
161
|
+
|
|
162
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
163
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
164
|
+
<plist version="1.0">
|
|
165
|
+
<dict>
|
|
166
|
+
\t<key>Label</key>
|
|
167
|
+
\t<string>${LABEL}</string>
|
|
168
|
+
\t<key>ProgramArguments</key>
|
|
169
|
+
\t<array>
|
|
170
|
+
\t\t<string>${nodeBin}</string>
|
|
171
|
+
\t\t<string>${scriptPath}</string>
|
|
172
|
+
\t\t<string>serve</string>
|
|
173
|
+
\t</array>
|
|
174
|
+
\t<key>EnvironmentVariables</key>
|
|
175
|
+
\t<dict>
|
|
176
|
+
${envEntries}
|
|
177
|
+
\t</dict>
|
|
178
|
+
\t<key>RunAtLoad</key>
|
|
179
|
+
\t<true/>
|
|
180
|
+
\t<key>KeepAlive</key>
|
|
181
|
+
\t<dict>
|
|
182
|
+
\t\t<key>SuccessfulExit</key>
|
|
183
|
+
\t\t<false/>
|
|
184
|
+
\t</dict>
|
|
185
|
+
\t<key>StandardOutPath</key>
|
|
186
|
+
\t<string>${LOG_PATH}</string>
|
|
187
|
+
\t<key>StandardErrorPath</key>
|
|
188
|
+
\t<string>${LOG_PATH}</string>
|
|
189
|
+
\t<key>WorkingDirectory</key>
|
|
190
|
+
\t<string>${path.dirname(LOG_PATH)}</string>
|
|
191
|
+
</dict>
|
|
192
|
+
</plist>
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
fs.writeFileSync(PLIST_PATH, plist, "utf-8");
|
|
196
|
+
|
|
197
|
+
// Unload any previous version first, then bootstrap
|
|
198
|
+
let alreadyRunning = false;
|
|
199
|
+
const uid = os.userInfo().uid;
|
|
200
|
+
try {
|
|
201
|
+
execSync(`launchctl bootout gui/${uid}/${LABEL}`, { stdio: "ignore" });
|
|
202
|
+
} catch {
|
|
203
|
+
// Wasn't loaded — fine
|
|
204
|
+
}
|
|
205
|
+
try {
|
|
206
|
+
execSync(`launchctl bootstrap gui/${uid} "${PLIST_PATH}"`, { stdio: "pipe" });
|
|
207
|
+
} catch (err: unknown) {
|
|
208
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
209
|
+
if (msg.includes("already")) {
|
|
210
|
+
alreadyRunning = true;
|
|
211
|
+
} else {
|
|
212
|
+
throw new Error(`launchctl bootstrap failed: ${msg}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return { platform: "macos", servicePath: PLIST_PATH, alreadyRunning };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Linux systemd ──
|
|
220
|
+
|
|
221
|
+
function installSystemd(env: Record<string, string>): ServiceResult {
|
|
222
|
+
const nodeBin = process.execPath;
|
|
223
|
+
const scriptPath = path.resolve(process.argv[1]);
|
|
224
|
+
|
|
225
|
+
fs.mkdirSync(SYSTEMD_DIR, { recursive: true });
|
|
226
|
+
fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true });
|
|
227
|
+
|
|
228
|
+
const envLines = Object.entries(env)
|
|
229
|
+
.map(([k, v]) => `Environment="${k}=${v}"`)
|
|
230
|
+
.join("\n");
|
|
231
|
+
|
|
232
|
+
const unit = `[Unit]
|
|
233
|
+
Description=zozul — Agent Observability
|
|
234
|
+
After=network.target
|
|
235
|
+
|
|
236
|
+
[Service]
|
|
237
|
+
ExecStart=${nodeBin} ${scriptPath} serve
|
|
238
|
+
${envLines}
|
|
239
|
+
WorkingDirectory=${path.dirname(LOG_PATH)}
|
|
240
|
+
Restart=on-failure
|
|
241
|
+
RestartSec=5
|
|
242
|
+
StandardOutput=append:${LOG_PATH}
|
|
243
|
+
StandardError=append:${LOG_PATH}
|
|
244
|
+
|
|
245
|
+
[Install]
|
|
246
|
+
WantedBy=default.target
|
|
247
|
+
`;
|
|
248
|
+
|
|
249
|
+
fs.writeFileSync(SYSTEMD_PATH, unit, "utf-8");
|
|
250
|
+
|
|
251
|
+
execSync("systemctl --user daemon-reload", { stdio: "ignore" });
|
|
252
|
+
|
|
253
|
+
let alreadyRunning = false;
|
|
254
|
+
try {
|
|
255
|
+
execSync("systemctl --user enable --now zozul", { stdio: "pipe" });
|
|
256
|
+
} catch (err: unknown) {
|
|
257
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
258
|
+
if (msg.includes("already")) {
|
|
259
|
+
alreadyRunning = true;
|
|
260
|
+
} else {
|
|
261
|
+
throw new Error(`systemctl enable failed: ${msg}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return { platform: "linux", servicePath: SYSTEMD_PATH, alreadyRunning };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function detectPlatform(): "macos" | "linux" | "unsupported" {
|
|
269
|
+
if (process.platform === "darwin") return "macos";
|
|
270
|
+
if (process.platform === "linux") return "linux";
|
|
271
|
+
return "unsupported";
|
|
272
|
+
}
|