clankie 0.2.2 → 0.2.4
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/README.md +17 -16
- package/dist/cli.js +301862 -0
- package/dist/koffi-216xhpes.node +0 -0
- package/dist/koffi-2erktc37.node +0 -0
- package/dist/koffi-2rrez93a.node +0 -0
- package/dist/koffi-2wv0r22g.node +0 -0
- package/dist/koffi-3kae4xj3.node +0 -0
- package/dist/koffi-3rkr2zqv.node +0 -0
- package/dist/koffi-abxfktv9.node +0 -0
- package/dist/koffi-c67c0c5b.node +0 -0
- package/dist/koffi-cnf0q0dx.node +0 -0
- package/dist/koffi-df38sqz5.node +0 -0
- package/dist/koffi-gfbqb3a0.node +0 -0
- package/dist/koffi-kjemmmem.node +0 -0
- package/dist/koffi-kkrfq9yv.node +0 -0
- package/dist/koffi-mzaqwwqy.node +0 -0
- package/dist/koffi-q49fgkeq.node +0 -0
- package/dist/koffi-q54bk8bf.node +0 -0
- package/dist/koffi-x1790w0j.node +0 -0
- package/dist/koffi-yxvjwcj6.node +0 -0
- package/package.json +8 -7
- package/web-ui-dist/_shell.html +2 -2
- package/web-ui-dist/assets/{card-BUP-xovx.js → card-Ce8RCN8-.js} +1 -1
- package/web-ui-dist/assets/{extensions-DC620Nmx.js → extensions-D-3Wl_TA.js} +1 -1
- package/web-ui-dist/assets/{index-DurjG9O_.js → index-ClDMn-6f.js} +1 -1
- package/web-ui-dist/assets/{loader-circle-DbOtKfCA.js → loader-circle-CpT1_nns.js} +1 -1
- package/web-ui-dist/assets/{main-B2sRcuyZ.js → main-J9rrgTOF.js} +4 -4
- package/web-ui-dist/assets/{sessions._sessionId-BJazw9EJ.js → sessions._sessionId-D6gfJDaW.js} +2 -2
- package/web-ui-dist/assets/{settings-Bv8oeIho.js → settings-ZDTymG3K.js} +1 -1
- package/web-ui-dist/manifest.json +23 -23
- package/src/agent.ts +0 -118
- package/src/channels/channel.ts +0 -57
- package/src/channels/slack.ts +0 -376
- package/src/channels/web.ts +0 -1375
- package/src/cli.ts +0 -505
- package/src/config.ts +0 -261
- package/src/daemon.ts +0 -380
- package/src/extensions/workspace-jail.ts +0 -171
- package/src/service.ts +0 -374
- package/src/sessions.ts +0 -262
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Workspace Jail Extension
|
|
3
|
-
*
|
|
4
|
-
* Restricts agent file/command access to the workspace directory.
|
|
5
|
-
* Prevents the agent from reading, writing, or executing commands
|
|
6
|
-
* that reference paths outside the configured workspace.
|
|
7
|
-
*
|
|
8
|
-
* Uses pi's tool_call event to intercept and validate all tool calls.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { existsSync, realpathSync } from "node:fs";
|
|
12
|
-
import { isAbsolute, normalize, resolve } from "node:path";
|
|
13
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Create a workspace jail extension that restricts file access.
|
|
17
|
-
*
|
|
18
|
-
* @param workspaceDir - Absolute path to the workspace directory
|
|
19
|
-
* @param allowedPaths - Additional paths outside workspace that should be permitted
|
|
20
|
-
*/
|
|
21
|
-
export function createWorkspaceJailExtension(workspaceDir: string, allowedPaths: string[] = []) {
|
|
22
|
-
// Normalize workspace path and ensure it ends with /
|
|
23
|
-
const normalizedWorkspace = `${normalize(workspaceDir).replace(/\/$/, "")}/`;
|
|
24
|
-
|
|
25
|
-
// Normalize allowed paths (resolve to absolute, real paths where they exist)
|
|
26
|
-
const normalizedAllowedPaths = allowedPaths.map((p) => {
|
|
27
|
-
const resolved = resolve(p);
|
|
28
|
-
try {
|
|
29
|
-
return existsSync(resolved) ? realpathSync(resolved) : resolved;
|
|
30
|
-
} catch {
|
|
31
|
-
return resolved;
|
|
32
|
-
}
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
return function workspaceJail(pi: ExtensionAPI) {
|
|
36
|
-
/**
|
|
37
|
-
* Check if a path is within the workspace or allowed paths.
|
|
38
|
-
* Handles symlinks, relative paths, ~ expansion, etc.
|
|
39
|
-
*/
|
|
40
|
-
function isPathAllowed(inputPath: string): { allowed: boolean; reason?: string } {
|
|
41
|
-
// Resolve to absolute path
|
|
42
|
-
let absolutePath: string;
|
|
43
|
-
|
|
44
|
-
// Handle ~ expansion
|
|
45
|
-
if (inputPath.startsWith("~/")) {
|
|
46
|
-
return { allowed: false, reason: "Access to home directory (~/) is blocked" };
|
|
47
|
-
}
|
|
48
|
-
if (inputPath === "~") {
|
|
49
|
-
return { allowed: false, reason: "Access to home directory (~) is blocked" };
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
// Resolve relative/absolute paths against workspace
|
|
53
|
-
absolutePath = isAbsolute(inputPath) ? inputPath : resolve(workspaceDir, inputPath);
|
|
54
|
-
|
|
55
|
-
// Resolve symlinks if the path exists
|
|
56
|
-
let realPath: string;
|
|
57
|
-
try {
|
|
58
|
-
realPath = existsSync(absolutePath) ? realpathSync(absolutePath) : absolutePath;
|
|
59
|
-
} catch {
|
|
60
|
-
// If realpathSync fails (permission denied, etc), use the absolute path
|
|
61
|
-
realPath = absolutePath;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
// Normalize for comparison
|
|
65
|
-
const normalized = `${normalize(realPath).replace(/\/$/, "")}/`;
|
|
66
|
-
|
|
67
|
-
// Check if within workspace
|
|
68
|
-
if (normalized.startsWith(normalizedWorkspace)) {
|
|
69
|
-
return { allowed: true };
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// Check if within allowed paths
|
|
73
|
-
for (const allowedPath of normalizedAllowedPaths) {
|
|
74
|
-
const normalizedAllowed = `${normalize(allowedPath).replace(/\/$/, "")}/`;
|
|
75
|
-
if (normalized.startsWith(normalizedAllowed)) {
|
|
76
|
-
return { allowed: true };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
return {
|
|
81
|
-
allowed: false,
|
|
82
|
-
reason: `Access denied: path '${inputPath}' is outside workspace (${workspaceDir})`,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Scan a bash command for obvious path escapes.
|
|
88
|
-
* This is defense-in-depth, not a complete sandbox.
|
|
89
|
-
*/
|
|
90
|
-
function scanBashCommand(command: string): { allowed: boolean; reason?: string } {
|
|
91
|
-
// Block absolute paths outside workspace
|
|
92
|
-
const absolutePathPattern = /(?:^|\s)([~/][\w\-./]+)/g;
|
|
93
|
-
let match: RegExpExecArray | null;
|
|
94
|
-
|
|
95
|
-
// biome-ignore lint/suspicious/noAssignInExpressions: regex exec pattern
|
|
96
|
-
while ((match = absolutePathPattern.exec(command)) !== null) {
|
|
97
|
-
const pathLike = match[1];
|
|
98
|
-
|
|
99
|
-
// Check if it looks like a path to a sensitive location
|
|
100
|
-
if (
|
|
101
|
-
pathLike.startsWith("/etc") ||
|
|
102
|
-
pathLike.startsWith("/var") ||
|
|
103
|
-
pathLike.startsWith("/usr") ||
|
|
104
|
-
pathLike.startsWith("/sys") ||
|
|
105
|
-
pathLike.startsWith("/proc") ||
|
|
106
|
-
pathLike.startsWith("/root") ||
|
|
107
|
-
pathLike.startsWith("~/")
|
|
108
|
-
) {
|
|
109
|
-
return {
|
|
110
|
-
allowed: false,
|
|
111
|
-
reason: `Blocked: command references path outside workspace: ${pathLike}`,
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Block cd to absolute paths outside workspace
|
|
117
|
-
if (/\bcd\s+\//.test(command)) {
|
|
118
|
-
return { allowed: false, reason: "Blocked: 'cd /' or 'cd /path' outside workspace" };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Block obvious .. traversal attempts that escape workspace
|
|
122
|
-
// This is heuristic - catches patterns like "cd ../../.." but won't catch all evasion
|
|
123
|
-
const dotsPattern = /(?:^|\s)cd\s+(?:\.\.\/){3,}/;
|
|
124
|
-
if (dotsPattern.test(command)) {
|
|
125
|
-
return {
|
|
126
|
-
allowed: false,
|
|
127
|
-
reason: "Blocked: command attempts to traverse outside workspace using '..'",
|
|
128
|
-
};
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
return { allowed: true };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Intercept all tool calls
|
|
135
|
-
pi.on("tool_call", async (event) => {
|
|
136
|
-
const { toolName, input } = event;
|
|
137
|
-
|
|
138
|
-
// File tools: validate the 'path' parameter
|
|
139
|
-
if (["read", "write", "edit", "grep", "find", "ls"].includes(toolName)) {
|
|
140
|
-
const toolInput = input as { path?: string };
|
|
141
|
-
if (toolInput.path) {
|
|
142
|
-
const check = isPathAllowed(toolInput.path);
|
|
143
|
-
if (!check.allowed) {
|
|
144
|
-
return { block: true, reason: check.reason };
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Bash: scan command for obvious path escapes
|
|
150
|
-
if (toolName === "bash") {
|
|
151
|
-
const toolInput = input as { command?: string };
|
|
152
|
-
if (toolInput.command) {
|
|
153
|
-
const check = scanBashCommand(toolInput.command);
|
|
154
|
-
if (!check.allowed) {
|
|
155
|
-
return { block: true, reason: check.reason };
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return undefined;
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// Inject system prompt reminder
|
|
164
|
-
pi.on("before_agent_start", async () => {
|
|
165
|
-
return {
|
|
166
|
-
systemPrompt: `\n\nIMPORTANT: You are restricted to working within the directory: ${workspaceDir}
|
|
167
|
-
Do not access files, run commands, or reference paths outside this directory.`,
|
|
168
|
-
};
|
|
169
|
-
});
|
|
170
|
-
};
|
|
171
|
-
}
|
package/src/service.ts
DELETED
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* clankie service installer — manages systemd (Linux) and launchd (macOS) services.
|
|
3
|
-
*
|
|
4
|
-
* Commands:
|
|
5
|
-
* clankie daemon install — install and start the service
|
|
6
|
-
* clankie daemon uninstall — stop and remove the service
|
|
7
|
-
* clankie daemon logs — show service logs
|
|
8
|
-
*
|
|
9
|
-
* On Linux: installs a systemd user service (~/.config/systemd/user/clankie.service)
|
|
10
|
-
* On macOS: installs a launchd user agent (~/Library/LaunchAgents/ai.clankie.daemon.plist)
|
|
11
|
-
*
|
|
12
|
-
* Note: Requires Node 24+ for native TypeScript execution.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import { execSync } from "node:child_process";
|
|
16
|
-
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
17
|
-
import { homedir, platform } from "node:os";
|
|
18
|
-
import { dirname, join } from "node:path";
|
|
19
|
-
import { getAppDir } from "./config.ts";
|
|
20
|
-
|
|
21
|
-
const SERVICE_NAME = "clankie";
|
|
22
|
-
const LAUNCHD_LABEL = "ai.clankie.daemon";
|
|
23
|
-
|
|
24
|
-
// ─── Resolve the app binary path ──────────────────────────────────────────────
|
|
25
|
-
|
|
26
|
-
function _resolveAppBinary(): string {
|
|
27
|
-
// If running from a compiled binary, use its path
|
|
28
|
-
if (!process.argv[1]?.endsWith(".ts")) {
|
|
29
|
-
return process.argv[0];
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Running from source — use bun + script path
|
|
33
|
-
// Return the full command that systemd/launchd will use
|
|
34
|
-
return process.argv[0]; // bun binary path
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function resolveProgramArguments(): string[] {
|
|
38
|
-
if (!process.argv[1]?.endsWith(".ts")) {
|
|
39
|
-
// Compiled binary
|
|
40
|
-
return [process.argv[0], "start", "--foreground"];
|
|
41
|
-
}
|
|
42
|
-
// Running from source with Node.js (TypeScript files)
|
|
43
|
-
return [process.argv[0], process.argv[1], "start", "--foreground"];
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// ─── Systemd (Linux) ──────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
function systemdUnitPath(): string {
|
|
49
|
-
return join(homedir(), ".config", "systemd", "user", `${SERVICE_NAME}.service`);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function buildSystemdUnit(): string {
|
|
53
|
-
const args = resolveProgramArguments();
|
|
54
|
-
const execStart = args.map(systemdEscapeArg).join(" ");
|
|
55
|
-
const workspace = join(getAppDir(), "workspace");
|
|
56
|
-
const logDir = join(getAppDir(), "logs");
|
|
57
|
-
|
|
58
|
-
return [
|
|
59
|
-
"[Unit]",
|
|
60
|
-
`Description=clankie — personal AI assistant daemon`,
|
|
61
|
-
"After=network-online.target",
|
|
62
|
-
"Wants=network-online.target",
|
|
63
|
-
"",
|
|
64
|
-
"[Service]",
|
|
65
|
-
`ExecStart=${execStart}`,
|
|
66
|
-
`WorkingDirectory=${workspace}`,
|
|
67
|
-
"Restart=always",
|
|
68
|
-
"RestartSec=5",
|
|
69
|
-
"KillMode=process",
|
|
70
|
-
`Environment=HOME=${homedir()}`,
|
|
71
|
-
`Environment=PATH=${process.env.PATH}`,
|
|
72
|
-
`StandardOutput=append:${join(logDir, "daemon.log")}`,
|
|
73
|
-
`StandardError=append:${join(logDir, "daemon.log")}`,
|
|
74
|
-
"",
|
|
75
|
-
"[Install]",
|
|
76
|
-
"WantedBy=default.target",
|
|
77
|
-
"",
|
|
78
|
-
].join("\n");
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function systemdEscapeArg(value: string): string {
|
|
82
|
-
if (!/[\s"\\]/.test(value)) return value;
|
|
83
|
-
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function execSafe(cmd: string): { ok: boolean; stdout: string; stderr: string } {
|
|
87
|
-
try {
|
|
88
|
-
const stdout = execSync(cmd, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
89
|
-
return { ok: true, stdout: stdout.trim(), stderr: "" };
|
|
90
|
-
} catch (err: unknown) {
|
|
91
|
-
const error = err as { stdout?: string; stderr?: string };
|
|
92
|
-
return { ok: false, stdout: error.stdout?.trim() ?? "", stderr: error.stderr?.trim() ?? "" };
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function installSystemd(): Promise<void> {
|
|
97
|
-
// Check systemctl is available
|
|
98
|
-
const check = execSafe("systemctl --user status");
|
|
99
|
-
if (!check.ok) {
|
|
100
|
-
const detail = `${check.stderr} ${check.stdout}`.toLowerCase();
|
|
101
|
-
if (detail.includes("not found") || detail.includes("no such file")) {
|
|
102
|
-
console.error("systemctl not found. systemd user services are required on Linux.");
|
|
103
|
-
process.exit(1);
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const unitPath = systemdUnitPath();
|
|
108
|
-
const logDir = join(getAppDir(), "logs");
|
|
109
|
-
const workspace = join(getAppDir(), "workspace");
|
|
110
|
-
|
|
111
|
-
// Ensure directories exist
|
|
112
|
-
mkdirSync(dirname(unitPath), { recursive: true });
|
|
113
|
-
mkdirSync(logDir, { recursive: true });
|
|
114
|
-
mkdirSync(workspace, { recursive: true });
|
|
115
|
-
|
|
116
|
-
// Write unit file
|
|
117
|
-
const unit = buildSystemdUnit();
|
|
118
|
-
writeFileSync(unitPath, unit, "utf-8");
|
|
119
|
-
console.log(`Wrote systemd unit: ${unitPath}`);
|
|
120
|
-
|
|
121
|
-
// Enable lingering so services run without an active login session
|
|
122
|
-
const linger = execSafe("loginctl enable-linger");
|
|
123
|
-
if (linger.ok) {
|
|
124
|
-
console.log("Enabled user linger (service runs without active login).");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Reload, enable, and start
|
|
128
|
-
const reload = execSafe("systemctl --user daemon-reload");
|
|
129
|
-
if (!reload.ok) {
|
|
130
|
-
console.error(`daemon-reload failed: ${reload.stderr}`);
|
|
131
|
-
process.exit(1);
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const enable = execSafe(`systemctl --user enable ${SERVICE_NAME}.service`);
|
|
135
|
-
if (!enable.ok) {
|
|
136
|
-
console.error(`enable failed: ${enable.stderr}`);
|
|
137
|
-
process.exit(1);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const restart = execSafe(`systemctl --user restart ${SERVICE_NAME}.service`);
|
|
141
|
-
if (!restart.ok) {
|
|
142
|
-
console.error(`restart failed: ${restart.stderr}`);
|
|
143
|
-
process.exit(1);
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
console.log(`\n✓ Installed and started systemd service: ${SERVICE_NAME}.service`);
|
|
147
|
-
console.log(` Logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
|
148
|
-
console.log(` Or: ${join(logDir, "daemon.log")}`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
async function uninstallSystemd(): Promise<void> {
|
|
152
|
-
const unitPath = systemdUnitPath();
|
|
153
|
-
|
|
154
|
-
execSafe(`systemctl --user disable --now ${SERVICE_NAME}.service`);
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
unlinkSync(unitPath);
|
|
158
|
-
console.log(`Removed: ${unitPath}`);
|
|
159
|
-
} catch {
|
|
160
|
-
console.log(`Service file not found at ${unitPath}`);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
execSafe("systemctl --user daemon-reload");
|
|
164
|
-
console.log(`✓ Uninstalled systemd service.`);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function logsSystemd(): void {
|
|
168
|
-
const logFile = join(getAppDir(), "logs", "daemon.log");
|
|
169
|
-
console.log(`Log file: ${logFile}\n`);
|
|
170
|
-
|
|
171
|
-
// Try journalctl first, fall back to log file
|
|
172
|
-
const result = execSafe(`journalctl --user -u ${SERVICE_NAME} --no-pager -n 50`);
|
|
173
|
-
if (result.ok && result.stdout) {
|
|
174
|
-
console.log(result.stdout);
|
|
175
|
-
} else if (existsSync(logFile)) {
|
|
176
|
-
const content = readFileSync(logFile, "utf-8");
|
|
177
|
-
const lines = content.split("\n");
|
|
178
|
-
const last50 = lines.slice(-50).join("\n");
|
|
179
|
-
console.log(last50);
|
|
180
|
-
} else {
|
|
181
|
-
console.log("No logs found.");
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function statusSystemd(): void {
|
|
186
|
-
const result = execSafe(`systemctl --user status ${SERVICE_NAME}.service`);
|
|
187
|
-
console.log(result.stdout || result.stderr || "Service not found.");
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// ─── launchd (macOS) ──────────────────────────────────────────────────────────
|
|
191
|
-
|
|
192
|
-
function launchdPlistPath(): string {
|
|
193
|
-
return join(homedir(), "Library", "LaunchAgents", `${LAUNCHD_LABEL}.plist`);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function plistEscape(value: string): string {
|
|
197
|
-
return value
|
|
198
|
-
.replace(/&/g, "&")
|
|
199
|
-
.replace(/</g, "<")
|
|
200
|
-
.replace(/>/g, ">")
|
|
201
|
-
.replace(/"/g, """)
|
|
202
|
-
.replace(/'/g, "'");
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function buildLaunchdPlist(): string {
|
|
206
|
-
const args = resolveProgramArguments();
|
|
207
|
-
const logDir = join(getAppDir(), "logs");
|
|
208
|
-
const workspace = join(getAppDir(), "workspace");
|
|
209
|
-
|
|
210
|
-
const argsXml = args.map((a) => ` <string>${plistEscape(a)}</string>`).join("\n");
|
|
211
|
-
|
|
212
|
-
// Build environment variables
|
|
213
|
-
const envVars: Record<string, string> = {};
|
|
214
|
-
if (process.env.PATH) envVars.PATH = process.env.PATH;
|
|
215
|
-
if (process.env.HOME) envVars.HOME = process.env.HOME;
|
|
216
|
-
|
|
217
|
-
const envXml = Object.entries(envVars)
|
|
218
|
-
.map(([k, v]) => ` <key>${plistEscape(k)}</key>\n <string>${plistEscape(v)}</string>`)
|
|
219
|
-
.join("\n");
|
|
220
|
-
|
|
221
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
222
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
223
|
-
<plist version="1.0">
|
|
224
|
-
<dict>
|
|
225
|
-
<key>Label</key>
|
|
226
|
-
<string>${plistEscape(LAUNCHD_LABEL)}</string>
|
|
227
|
-
<key>RunAtLoad</key>
|
|
228
|
-
<true/>
|
|
229
|
-
<key>KeepAlive</key>
|
|
230
|
-
<true/>
|
|
231
|
-
<key>ProgramArguments</key>
|
|
232
|
-
<array>
|
|
233
|
-
${argsXml}
|
|
234
|
-
</array>
|
|
235
|
-
<key>WorkingDirectory</key>
|
|
236
|
-
<string>${plistEscape(workspace)}</string>
|
|
237
|
-
<key>StandardOutPath</key>
|
|
238
|
-
<string>${plistEscape(join(logDir, "daemon.log"))}</string>
|
|
239
|
-
<key>StandardErrorPath</key>
|
|
240
|
-
<string>${plistEscape(join(logDir, "daemon.log"))}</string>
|
|
241
|
-
<key>EnvironmentVariables</key>
|
|
242
|
-
<dict>
|
|
243
|
-
${envXml}
|
|
244
|
-
</dict>
|
|
245
|
-
</dict>
|
|
246
|
-
</plist>
|
|
247
|
-
`;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function installLaunchd(): Promise<void> {
|
|
251
|
-
const plistPath = launchdPlistPath();
|
|
252
|
-
const logDir = join(getAppDir(), "logs");
|
|
253
|
-
const workspace = join(getAppDir(), "workspace");
|
|
254
|
-
|
|
255
|
-
mkdirSync(dirname(plistPath), { recursive: true });
|
|
256
|
-
mkdirSync(logDir, { recursive: true });
|
|
257
|
-
mkdirSync(workspace, { recursive: true });
|
|
258
|
-
|
|
259
|
-
// Unload existing if present
|
|
260
|
-
if (existsSync(plistPath)) {
|
|
261
|
-
execSafe(`launchctl unload "${plistPath}"`);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const plist = buildLaunchdPlist();
|
|
265
|
-
writeFileSync(plistPath, plist, "utf-8");
|
|
266
|
-
console.log(`Wrote plist: ${plistPath}`);
|
|
267
|
-
|
|
268
|
-
const load = execSafe(`launchctl load "${plistPath}"`);
|
|
269
|
-
if (!load.ok) {
|
|
270
|
-
console.error(`launchctl load failed: ${load.stderr}`);
|
|
271
|
-
process.exit(1);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
console.log(`\n✓ Installed and started launchd agent: ${LAUNCHD_LABEL}`);
|
|
275
|
-
console.log(` Logs: tail -f ${join(logDir, "daemon.log")}`);
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
async function uninstallLaunchd(): Promise<void> {
|
|
279
|
-
const plistPath = launchdPlistPath();
|
|
280
|
-
|
|
281
|
-
if (existsSync(plistPath)) {
|
|
282
|
-
execSafe(`launchctl unload "${plistPath}"`);
|
|
283
|
-
unlinkSync(plistPath);
|
|
284
|
-
console.log(`Removed: ${plistPath}`);
|
|
285
|
-
} else {
|
|
286
|
-
console.log(`Plist not found at ${plistPath}`);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
console.log(`✓ Uninstalled launchd agent.`);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function logsLaunchd(): void {
|
|
293
|
-
const logFile = join(getAppDir(), "logs", "daemon.log");
|
|
294
|
-
console.log(`Log file: ${logFile}\n`);
|
|
295
|
-
|
|
296
|
-
if (existsSync(logFile)) {
|
|
297
|
-
const content = readFileSync(logFile, "utf-8");
|
|
298
|
-
const lines = content.split("\n");
|
|
299
|
-
const last50 = lines.slice(-50).join("\n");
|
|
300
|
-
console.log(last50);
|
|
301
|
-
} else {
|
|
302
|
-
console.log("No logs found.");
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function statusLaunchd(): void {
|
|
307
|
-
const result = execSafe(`launchctl list | grep ${LAUNCHD_LABEL}`);
|
|
308
|
-
if (result.ok && result.stdout) {
|
|
309
|
-
const parts = result.stdout.split(/\s+/);
|
|
310
|
-
const pid = parts[0];
|
|
311
|
-
const exitCode = parts[1];
|
|
312
|
-
if (pid && pid !== "-") {
|
|
313
|
-
console.log(`Daemon is running (pid ${pid}).`);
|
|
314
|
-
} else {
|
|
315
|
-
console.log(`Daemon is not running (last exit code: ${exitCode}).`);
|
|
316
|
-
}
|
|
317
|
-
} else {
|
|
318
|
-
console.log("Daemon is not installed as a launchd agent.");
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// ─── Platform dispatch ────────────────────────────────────────────────────────
|
|
323
|
-
|
|
324
|
-
const isMac = platform() === "darwin";
|
|
325
|
-
const isLinux = platform() === "linux";
|
|
326
|
-
|
|
327
|
-
export async function installService(): Promise<void> {
|
|
328
|
-
if (isMac) {
|
|
329
|
-
await installLaunchd();
|
|
330
|
-
} else if (isLinux) {
|
|
331
|
-
await installSystemd();
|
|
332
|
-
} else {
|
|
333
|
-
console.error(`Service installation not supported on ${platform()}.`);
|
|
334
|
-
console.log("Run 'clankie start' manually instead.");
|
|
335
|
-
process.exit(1);
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
export async function uninstallService(): Promise<void> {
|
|
340
|
-
if (isMac) {
|
|
341
|
-
await uninstallLaunchd();
|
|
342
|
-
} else if (isLinux) {
|
|
343
|
-
await uninstallSystemd();
|
|
344
|
-
} else {
|
|
345
|
-
console.error(`Service management not supported on ${platform()}.`);
|
|
346
|
-
process.exit(1);
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
export function showServiceLogs(): void {
|
|
351
|
-
if (isMac) {
|
|
352
|
-
logsLaunchd();
|
|
353
|
-
} else if (isLinux) {
|
|
354
|
-
logsSystemd();
|
|
355
|
-
} else {
|
|
356
|
-
const logFile = join(getAppDir(), "logs", "daemon.log");
|
|
357
|
-
if (existsSync(logFile)) {
|
|
358
|
-
console.log(readFileSync(logFile, "utf-8"));
|
|
359
|
-
} else {
|
|
360
|
-
console.log("No logs found.");
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export function showServiceStatus(): void {
|
|
366
|
-
if (isMac) {
|
|
367
|
-
statusLaunchd();
|
|
368
|
-
} else if (isLinux) {
|
|
369
|
-
statusSystemd();
|
|
370
|
-
} else {
|
|
371
|
-
console.log(`Service management not supported on ${platform()}.`);
|
|
372
|
-
console.log("Use 'clankie status' to check if the daemon is running via PID file.");
|
|
373
|
-
}
|
|
374
|
-
}
|