@leynier/ccst 0.3.2 → 0.4.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/package.json +1 -1
- package/src/commands/ccs/logs.ts +74 -0
- package/src/commands/ccs/start.ts +70 -0
- package/src/commands/ccs/status.ts +41 -0
- package/src/commands/ccs/stop.ts +47 -0
- package/src/index.ts +38 -0
- package/src/utils/daemon.ts +83 -0
package/package.json
CHANGED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { existsSync, unwatchFile, watchFile } from "node:fs";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { getLogPath } from "../../utils/daemon.js";
|
|
4
|
+
|
|
5
|
+
export type LogsOptions = {
|
|
6
|
+
follow?: boolean;
|
|
7
|
+
lines?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export const ccsLogsCommand = async (options?: LogsOptions): Promise<void> => {
|
|
11
|
+
const logPath = getLogPath();
|
|
12
|
+
if (!existsSync(logPath)) {
|
|
13
|
+
console.log(pc.yellow("No log file found"));
|
|
14
|
+
console.log(pc.dim(`Expected at: ${logPath}`));
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const lines = options?.lines ?? 50;
|
|
18
|
+
const follow = options?.follow ?? false;
|
|
19
|
+
// On Unix, use native tail for better performance
|
|
20
|
+
if (process.platform !== "win32" && follow) {
|
|
21
|
+
const tailProcess = Bun.spawn(
|
|
22
|
+
["tail", "-f", "-n", String(lines), logPath],
|
|
23
|
+
{
|
|
24
|
+
stdout: "inherit",
|
|
25
|
+
stderr: "inherit",
|
|
26
|
+
},
|
|
27
|
+
);
|
|
28
|
+
// Handle cleanup on Ctrl+C
|
|
29
|
+
const cleanup = () => {
|
|
30
|
+
tailProcess.kill();
|
|
31
|
+
process.exit(0);
|
|
32
|
+
};
|
|
33
|
+
process.on("SIGINT", cleanup);
|
|
34
|
+
process.on("SIGTERM", cleanup);
|
|
35
|
+
await tailProcess.exited;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
// Read last N lines
|
|
39
|
+
const content = await Bun.file(logPath).text();
|
|
40
|
+
const allLines = content.split("\n");
|
|
41
|
+
const lastLines = allLines.slice(-lines).join("\n");
|
|
42
|
+
console.log(lastLines);
|
|
43
|
+
if (!follow) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
// Follow mode for Windows: watch for changes
|
|
47
|
+
console.log(pc.dim("--- Following log file (Ctrl+C to stop) ---"));
|
|
48
|
+
let lastSize = (await Bun.file(logPath).stat()).size;
|
|
49
|
+
// Handle graceful shutdown
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
unwatchFile(logPath);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
};
|
|
54
|
+
process.on("SIGINT", cleanup);
|
|
55
|
+
process.on("SIGTERM", cleanup);
|
|
56
|
+
// Watch file for changes
|
|
57
|
+
watchFile(logPath, { interval: 500 }, async (curr) => {
|
|
58
|
+
if (curr.size > lastSize) {
|
|
59
|
+
// Read new content
|
|
60
|
+
const file = Bun.file(logPath);
|
|
61
|
+
const newContent = await file.slice(lastSize).text();
|
|
62
|
+
process.stdout.write(newContent);
|
|
63
|
+
lastSize = curr.size;
|
|
64
|
+
} else if (curr.size < lastSize) {
|
|
65
|
+
// File was truncated/rotated
|
|
66
|
+
console.log(pc.dim("--- Log file rotated ---"));
|
|
67
|
+
const newContent = await Bun.file(logPath).text();
|
|
68
|
+
process.stdout.write(newContent);
|
|
69
|
+
lastSize = curr.size;
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
// Keep process alive
|
|
73
|
+
await new Promise(() => {});
|
|
74
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { openSync } from "node:fs";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import {
|
|
5
|
+
ensureDaemonDir,
|
|
6
|
+
getCcsExecutable,
|
|
7
|
+
getLogPath,
|
|
8
|
+
getRunningDaemonPid,
|
|
9
|
+
isProcessRunning,
|
|
10
|
+
writePid,
|
|
11
|
+
} from "../../utils/daemon.js";
|
|
12
|
+
|
|
13
|
+
export type StartOptions = {
|
|
14
|
+
force?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const ccsStartCommand = async (
|
|
18
|
+
options?: StartOptions,
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
// Check if already running
|
|
21
|
+
const existingPid = await getRunningDaemonPid();
|
|
22
|
+
if (existingPid !== null && !options?.force) {
|
|
23
|
+
console.log(
|
|
24
|
+
pc.yellow(`CCS config daemon is already running (PID: ${existingPid})`),
|
|
25
|
+
);
|
|
26
|
+
console.log(pc.dim("Use --force to restart"));
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
// If force and running, stop first
|
|
30
|
+
if (existingPid !== null && options?.force) {
|
|
31
|
+
console.log(pc.dim(`Stopping existing daemon (PID: ${existingPid})...`));
|
|
32
|
+
try {
|
|
33
|
+
process.kill(existingPid, "SIGTERM");
|
|
34
|
+
// Wait for process to terminate
|
|
35
|
+
const maxWait = 3000;
|
|
36
|
+
const startTime = Date.now();
|
|
37
|
+
while (Date.now() - startTime < maxWait) {
|
|
38
|
+
if (!isProcessRunning(existingPid)) {
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
// Process may have already exited
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ensureDaemonDir();
|
|
48
|
+
const logPath = getLogPath();
|
|
49
|
+
const ccsPath = getCcsExecutable();
|
|
50
|
+
// Open log file for writing (append mode)
|
|
51
|
+
const logFd = openSync(logPath, "a");
|
|
52
|
+
// Spawn detached process
|
|
53
|
+
const child = spawn(ccsPath, ["config"], {
|
|
54
|
+
detached: true,
|
|
55
|
+
stdio: ["ignore", logFd, logFd],
|
|
56
|
+
// On Windows, need shell: true for proper detachment
|
|
57
|
+
...(process.platform === "win32" ? { shell: true } : {}),
|
|
58
|
+
});
|
|
59
|
+
if (!child.pid) {
|
|
60
|
+
console.log(pc.red("Failed to start CCS config daemon"));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
await writePid(child.pid);
|
|
64
|
+
// Unref to allow parent to exit independently
|
|
65
|
+
child.unref();
|
|
66
|
+
console.log(pc.green(`CCS config daemon started (PID: ${child.pid})`));
|
|
67
|
+
console.log(pc.dim(`Logs: ${logPath}`));
|
|
68
|
+
console.log(pc.dim("Run 'ccst ccs status' to check status"));
|
|
69
|
+
console.log(pc.dim("Run 'ccst ccs logs' to view logs"));
|
|
70
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import {
|
|
4
|
+
getLogPath,
|
|
5
|
+
getPidPath,
|
|
6
|
+
getRunningDaemonPid,
|
|
7
|
+
} from "../../utils/daemon.js";
|
|
8
|
+
|
|
9
|
+
export const ccsStatusCommand = async (): Promise<void> => {
|
|
10
|
+
const pid = await getRunningDaemonPid();
|
|
11
|
+
if (pid === null) {
|
|
12
|
+
console.log(pc.yellow("CCS config daemon is not running"));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
console.log(pc.green(`CCS config daemon is running (PID: ${pid})`));
|
|
16
|
+
// Show additional info
|
|
17
|
+
const pidPath = getPidPath();
|
|
18
|
+
const logPath = getLogPath();
|
|
19
|
+
console.log(pc.dim(`PID file: ${pidPath}`));
|
|
20
|
+
if (existsSync(logPath)) {
|
|
21
|
+
const stats = statSync(logPath);
|
|
22
|
+
const sizeKb = (stats.size / 1024).toFixed(2);
|
|
23
|
+
console.log(pc.dim(`Log file: ${logPath} (${sizeKb} KB)`));
|
|
24
|
+
}
|
|
25
|
+
// Try to get process uptime (Unix only)
|
|
26
|
+
if (process.platform !== "win32") {
|
|
27
|
+
try {
|
|
28
|
+
const proc = Bun.spawn(["ps", "-p", String(pid), "-o", "etime="], {
|
|
29
|
+
stdout: "pipe",
|
|
30
|
+
stderr: "ignore",
|
|
31
|
+
});
|
|
32
|
+
const output = await new Response(proc.stdout).text();
|
|
33
|
+
const uptime = output.trim();
|
|
34
|
+
if (uptime) {
|
|
35
|
+
console.log(pc.dim(`Uptime: ${uptime}`));
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
// ps command not available or failed
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import {
|
|
3
|
+
getRunningDaemonPid,
|
|
4
|
+
isProcessRunning,
|
|
5
|
+
removePid,
|
|
6
|
+
} from "../../utils/daemon.js";
|
|
7
|
+
|
|
8
|
+
export type StopOptions = {
|
|
9
|
+
force?: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
|
|
13
|
+
const pid = await getRunningDaemonPid();
|
|
14
|
+
if (pid === null) {
|
|
15
|
+
console.log(pc.yellow("CCS config daemon is not running"));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
// Send SIGTERM for graceful shutdown, SIGKILL if --force
|
|
20
|
+
const signal = options?.force ? "SIGKILL" : "SIGTERM";
|
|
21
|
+
process.kill(pid, signal);
|
|
22
|
+
// Wait for process to terminate (with timeout)
|
|
23
|
+
const maxWait = options?.force ? 1000 : 5000;
|
|
24
|
+
const startTime = Date.now();
|
|
25
|
+
while (Date.now() - startTime < maxWait) {
|
|
26
|
+
if (!isProcessRunning(pid)) {
|
|
27
|
+
break;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
30
|
+
}
|
|
31
|
+
removePid();
|
|
32
|
+
console.log(pc.green(`CCS config daemon stopped (PID: ${pid})`));
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const err = error as NodeJS.ErrnoException;
|
|
35
|
+
if (err.code === "ESRCH") {
|
|
36
|
+
// Process doesn't exist - clean up stale PID file
|
|
37
|
+
removePid();
|
|
38
|
+
console.log(pc.yellow("Process not found, cleaned up stale PID file"));
|
|
39
|
+
} else if (err.code === "EPERM") {
|
|
40
|
+
console.log(
|
|
41
|
+
pc.red("Permission denied. Try running with elevated privileges."),
|
|
42
|
+
);
|
|
43
|
+
} else {
|
|
44
|
+
console.log(pc.red(`Failed to stop daemon: ${err.message}`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import pkg from "../package.json";
|
|
4
|
+
import { ccsLogsCommand } from "./commands/ccs/logs.js";
|
|
5
|
+
import { ccsStartCommand } from "./commands/ccs/start.js";
|
|
6
|
+
import { ccsStatusCommand } from "./commands/ccs/status.js";
|
|
7
|
+
import { ccsStopCommand } from "./commands/ccs/stop.js";
|
|
4
8
|
import { completionsCommand } from "./commands/completions.js";
|
|
5
9
|
import { configDumpCommand } from "./commands/config/dump.js";
|
|
6
10
|
import { configLoadCommand } from "./commands/config/load.js";
|
|
@@ -177,6 +181,40 @@ const main = async (): Promise<void> => {
|
|
|
177
181
|
.action(async (input, options) => {
|
|
178
182
|
await configLoadCommand(input, options);
|
|
179
183
|
});
|
|
184
|
+
const ccsCommandGroup = program
|
|
185
|
+
.command("ccs")
|
|
186
|
+
.description("CCS daemon management");
|
|
187
|
+
ccsCommandGroup
|
|
188
|
+
.command("start")
|
|
189
|
+
.description("Start CCS config as background daemon")
|
|
190
|
+
.option("-f, --force", "Force restart if already running")
|
|
191
|
+
.action(async (options) => {
|
|
192
|
+
await ccsStartCommand(options);
|
|
193
|
+
});
|
|
194
|
+
ccsCommandGroup
|
|
195
|
+
.command("stop")
|
|
196
|
+
.description("Stop the CCS config daemon")
|
|
197
|
+
.option("-f, --force", "Force kill (SIGKILL)")
|
|
198
|
+
.action(async (options) => {
|
|
199
|
+
await ccsStopCommand(options);
|
|
200
|
+
});
|
|
201
|
+
ccsCommandGroup
|
|
202
|
+
.command("status")
|
|
203
|
+
.description("Check CCS config daemon status")
|
|
204
|
+
.action(async () => {
|
|
205
|
+
await ccsStatusCommand();
|
|
206
|
+
});
|
|
207
|
+
ccsCommandGroup
|
|
208
|
+
.command("logs")
|
|
209
|
+
.description("View CCS config daemon logs")
|
|
210
|
+
.option("-f, --follow", "Follow log output")
|
|
211
|
+
.option("-n, --lines <number>", "Number of lines", "50")
|
|
212
|
+
.action(async (options) => {
|
|
213
|
+
await ccsLogsCommand({
|
|
214
|
+
follow: options.follow,
|
|
215
|
+
lines: parseInt(options.lines, 10),
|
|
216
|
+
});
|
|
217
|
+
});
|
|
180
218
|
try {
|
|
181
219
|
await program.parseAsync(process.argv);
|
|
182
220
|
} catch {
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// Daemon directory inside CCS home
|
|
6
|
+
export const getDaemonDir = (): string => join(homedir(), ".ccs", "daemon");
|
|
7
|
+
|
|
8
|
+
// PID file path
|
|
9
|
+
export const getPidPath = (): string => join(getDaemonDir(), "ccs-config.pid");
|
|
10
|
+
|
|
11
|
+
// Log file path
|
|
12
|
+
export const getLogPath = (): string => join(getDaemonDir(), "ccs-config.log");
|
|
13
|
+
|
|
14
|
+
// Ensure daemon directory exists
|
|
15
|
+
export const ensureDaemonDir = (): void => {
|
|
16
|
+
const dir = getDaemonDir();
|
|
17
|
+
if (!existsSync(dir)) {
|
|
18
|
+
mkdirSync(dir, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Read PID from file
|
|
23
|
+
export const readPid = async (): Promise<number | null> => {
|
|
24
|
+
const pidPath = getPidPath();
|
|
25
|
+
if (!existsSync(pidPath)) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const content = await Bun.file(pidPath).text();
|
|
30
|
+
const pid = parseInt(content.trim(), 10);
|
|
31
|
+
return Number.isFinite(pid) ? pid : null;
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Write PID to file
|
|
38
|
+
export const writePid = async (pid: number): Promise<void> => {
|
|
39
|
+
ensureDaemonDir();
|
|
40
|
+
await Bun.write(getPidPath(), String(pid));
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// Remove PID file
|
|
44
|
+
export const removePid = (): void => {
|
|
45
|
+
const pidPath = getPidPath();
|
|
46
|
+
if (existsSync(pidPath)) {
|
|
47
|
+
unlinkSync(pidPath);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Check if process is running using kill(pid, 0)
|
|
52
|
+
export const isProcessRunning = (pid: number): boolean => {
|
|
53
|
+
try {
|
|
54
|
+
process.kill(pid, 0);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
// EPERM = permission denied (but process exists)
|
|
58
|
+
if ((error as NodeJS.ErrnoException).code === "EPERM") {
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// Get running daemon PID (validates process is actually running)
|
|
66
|
+
export const getRunningDaemonPid = async (): Promise<number | null> => {
|
|
67
|
+
const pid = await readPid();
|
|
68
|
+
if (pid === null) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
if (!isProcessRunning(pid)) {
|
|
72
|
+
// Stale PID file - clean it up
|
|
73
|
+
removePid();
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return pid;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Locate ccs executable
|
|
80
|
+
export const getCcsExecutable = (): string => {
|
|
81
|
+
// Use 'ccs' from PATH - it should be globally installed
|
|
82
|
+
return "ccs";
|
|
83
|
+
};
|