@leynier/ccst 0.5.2 → 0.7.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.
@@ -0,0 +1,136 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import pc from "picocolors";
3
+ import { promptInput } from "../../utils/interactive.js";
4
+
5
+ type PackageManager = {
6
+ name: string;
7
+ displayCommand: string;
8
+ command: string;
9
+ args: string[];
10
+ };
11
+
12
+ const packageManagers: PackageManager[] = [
13
+ {
14
+ name: "bun",
15
+ displayCommand: "bun add -g @kaitranntt/ccs",
16
+ command: "bun",
17
+ args: ["add", "-g", "@kaitranntt/ccs"],
18
+ },
19
+ {
20
+ name: "npm",
21
+ displayCommand: "npm install -g @kaitranntt/ccs",
22
+ command: "npm",
23
+ args: ["install", "-g", "@kaitranntt/ccs"],
24
+ },
25
+ {
26
+ name: "pnpm",
27
+ displayCommand: "pnpm add -g @kaitranntt/ccs",
28
+ command: "pnpm",
29
+ args: ["add", "-g", "@kaitranntt/ccs"],
30
+ },
31
+ {
32
+ name: "yarn",
33
+ displayCommand: "yarn global add @kaitranntt/ccs",
34
+ command: "yarn",
35
+ args: ["global", "add", "@kaitranntt/ccs"],
36
+ },
37
+ ];
38
+
39
+ const selectPackageManager = async (): Promise<PackageManager | undefined> => {
40
+ const lines = packageManagers.map(
41
+ (pm, index) => `${index + 1}. ${pm.name} (${pm.displayCommand})`,
42
+ );
43
+ console.log("Select package manager to install @kaitranntt/ccs:");
44
+ console.log(lines.join("\n"));
45
+
46
+ const input = await promptInput("Select option (1-4)");
47
+ const index = Number.parseInt(input || "", 10);
48
+
49
+ if (!Number.isFinite(index) || index < 1 || index > packageManagers.length) {
50
+ console.log(pc.red("Invalid selection"));
51
+ return undefined;
52
+ }
53
+
54
+ return packageManagers[index - 1];
55
+ };
56
+
57
+ const verifyInstallation = async (): Promise<string | null> => {
58
+ const result = spawnSync("ccs", ["--version"], {
59
+ stdio: ["ignore", "pipe", "pipe"],
60
+ encoding: "utf8",
61
+ });
62
+
63
+ if (result.status !== 0) {
64
+ return null;
65
+ }
66
+
67
+ const version = result.stdout?.trim();
68
+ return version && version.length > 0 ? version : null;
69
+ };
70
+
71
+ const promptForSetup = async (): Promise<boolean> => {
72
+ const response = await promptInput("Run ccs setup now? (y/n)");
73
+ return response?.toLowerCase() === "y";
74
+ };
75
+
76
+ export const ccsInstallCommand = async (): Promise<void> => {
77
+ // Step 1: Select package manager
78
+ const selectedPm = await selectPackageManager();
79
+ if (!selectedPm) {
80
+ console.log(pc.dim("Installation cancelled"));
81
+ return;
82
+ }
83
+
84
+ // Step 2: Execute installation
85
+ console.log(
86
+ pc.dim(
87
+ `Installing @kaitranntt/ccs using ${selectedPm.name}... (this may take a moment)`,
88
+ ),
89
+ );
90
+ const installResult = spawnSync(selectedPm.command, selectedPm.args, {
91
+ stdio: "inherit",
92
+ });
93
+
94
+ if (installResult.status !== 0) {
95
+ console.log(
96
+ pc.red(
97
+ `Error: Installation failed with exit code ${installResult.status}`,
98
+ ),
99
+ );
100
+ return;
101
+ }
102
+
103
+ // Step 3: Verify installation
104
+ console.log(pc.dim("Verifying installation..."));
105
+ const version = await verifyInstallation();
106
+
107
+ if (!version) {
108
+ console.log(
109
+ pc.yellow("Warning: ccs installed but could not verify installation"),
110
+ );
111
+ console.log(pc.dim("You may need to restart your terminal"));
112
+ console.log(pc.dim("Try running 'which ccs' or 'ccs --version' manually"));
113
+ return;
114
+ }
115
+
116
+ console.log(pc.green(`ccs installed successfully (${version})`));
117
+
118
+ // Step 4: Ask if user wants to run setup
119
+ const shouldRunSetup = await promptForSetup();
120
+ if (shouldRunSetup) {
121
+ console.log(pc.dim("Running ccs setup..."));
122
+ const setupResult = spawnSync("ccs", ["setup"], { stdio: "inherit" });
123
+
124
+ if (setupResult.status === 0) {
125
+ console.log(pc.green("Setup completed successfully"));
126
+ } else {
127
+ console.log(
128
+ pc.yellow(
129
+ `Setup exited with code ${setupResult.status} (this may not be an error)`,
130
+ ),
131
+ );
132
+ }
133
+ } else {
134
+ console.log(pc.dim("You can run 'ccst ccs setup' later to configure ccs"));
135
+ }
136
+ };
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import pc from "picocolors";
3
+
4
+ export type SetupOptions = {
5
+ force?: boolean;
6
+ };
7
+
8
+ export const ccsSetupCommand = async (
9
+ options?: SetupOptions,
10
+ ): Promise<void> => {
11
+ // Check if ccs is installed
12
+ const which = spawnSync("which", ["ccs"], { stdio: "ignore" });
13
+ if (which.status !== 0) {
14
+ console.log(pc.red("Error: ccs command not found"));
15
+ console.log(pc.dim("Run 'ccst ccs install' to install it"));
16
+ return;
17
+ }
18
+
19
+ // Build arguments
20
+ const args = ["setup"];
21
+ if (options?.force) {
22
+ args.push("--force");
23
+ }
24
+
25
+ // Execute ccs setup with real-time output
26
+ const result = spawnSync("ccs", args, {
27
+ stdio: "inherit",
28
+ });
29
+
30
+ // Check exit code
31
+ if (result.status !== 0) {
32
+ console.log(
33
+ pc.red(`Error: ccs setup failed with exit code ${result.status}`),
34
+ );
35
+ return;
36
+ }
37
+
38
+ console.log(pc.green("Setup completed successfully"));
39
+ };
@@ -1,18 +1,27 @@
1
1
  import { spawn } from "node:child_process";
2
- import { openSync } from "node:fs";
2
+ import { openSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import getPort from "get-port";
3
6
  import pc from "picocolors";
4
7
  import {
5
8
  ensureDaemonDir,
6
9
  getCcsExecutable,
10
+ getCliproxyPort,
7
11
  getLogPath,
12
+ getProcessByPort,
8
13
  getRunningDaemonPid,
9
14
  isProcessRunning,
10
15
  killProcessTree,
16
+ truncateFile,
11
17
  writePid,
18
+ writePorts,
12
19
  } from "../../utils/daemon.js";
13
20
 
14
21
  export type StartOptions = {
15
22
  force?: boolean;
23
+ keepLogs?: boolean;
24
+ port?: number;
16
25
  };
17
26
 
18
27
  export const ccsStartCommand = async (
@@ -43,34 +52,86 @@ export const ccsStartCommand = async (
43
52
  }
44
53
  ensureDaemonDir();
45
54
  const logPath = getLogPath();
55
+ if (!options?.keepLogs) {
56
+ try {
57
+ await truncateFile(logPath);
58
+ } catch {
59
+ console.warn(
60
+ pc.yellow(
61
+ `Warning: could not truncate log; continuing (logs will be appended): ${logPath}`,
62
+ ),
63
+ );
64
+ }
65
+ }
66
+ // Detect ports
67
+ const cliproxyPort = await getCliproxyPort();
68
+ const dashboardPort =
69
+ options?.port ??
70
+ (await getPort({
71
+ port: [3000, 3001, 3002, 8000, 8080],
72
+ }));
73
+ // Save ports for stop command
74
+ await writePorts({ dashboard: dashboardPort, cliproxy: cliproxyPort });
75
+ console.log(
76
+ pc.dim(
77
+ `Using dashboard port: ${dashboardPort}, CLIProxy port: ${cliproxyPort}`,
78
+ ),
79
+ );
46
80
  const ccsPath = getCcsExecutable();
47
81
  let pid: number | undefined;
48
82
  if (process.platform === "win32") {
49
- // On Windows, use PowerShell with Start-Process -WindowStyle Hidden
50
- // This is the only way to completely hide a console app that creates its own window
51
- const ps = spawn(
52
- "powershell",
53
- [
54
- "-WindowStyle",
55
- "Hidden",
56
- "-Command",
57
- `$p = Start-Process -FilePath '${ccsPath}' -ArgumentList 'config' -WindowStyle Hidden -PassThru; $p.Id`,
58
- ],
59
- {
60
- stdio: ["ignore", "pipe", "ignore"],
61
- windowsHide: true,
62
- },
83
+ // VBScript is the ONLY reliable way to run a process completely hidden on Windows
84
+ // WScript.Shell.Run with 0 = hidden window, False = don't wait
85
+ // Redirect output to log file using cmd /c with shell redirection
86
+ const escapedLogPath = logPath.replace(/\\/g, "\\\\");
87
+ const vbsContent = `CreateObject("WScript.Shell").Run "cmd /c ${ccsPath} config --port ${dashboardPort} >> ${escapedLogPath} 2>&1", 0, False`;
88
+ const vbsPath = join(tmpdir(), `ccs-start-${Date.now()}.vbs`);
89
+ await Bun.write(vbsPath, vbsContent);
90
+
91
+ // Run the vbs file (wscript itself doesn't show a window)
92
+ const proc = spawn("wscript", [vbsPath], {
93
+ detached: true,
94
+ stdio: "ignore",
95
+ windowsHide: true,
96
+ });
97
+ proc.unref();
98
+
99
+ // Clean up the vbs file after a short delay
100
+ setTimeout(() => {
101
+ try {
102
+ unlinkSync(vbsPath);
103
+ } catch {}
104
+ }, 1000);
105
+
106
+ // Poll for the port to become available
107
+ // ccs config takes ~6s to start (5s CLIProxy timeout + dashboard startup)
108
+ console.log(
109
+ pc.dim("Starting CCS config daemon (this may take a few seconds)..."),
63
110
  );
64
- const output = await new Response(ps.stdout).text();
65
- pid = Number.parseInt(output.trim(), 10);
66
- if (!Number.isFinite(pid)) {
111
+
112
+ const maxWaitMs = 15000; // 15 seconds max
113
+ const pollIntervalMs = 500;
114
+ const startTime = Date.now();
115
+ let foundPid: number | null = null;
116
+
117
+ while (Date.now() - startTime < maxWaitMs) {
118
+ foundPid = await getProcessByPort(dashboardPort);
119
+ if (foundPid !== null) break;
120
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
121
+ }
122
+
123
+ if (foundPid === null) {
67
124
  console.log(pc.red("Failed to start CCS config daemon"));
125
+ console.log(
126
+ pc.dim("Check if ccs is installed: npm install -g @kaitranntt/ccs"),
127
+ );
68
128
  return;
69
129
  }
130
+ pid = foundPid;
70
131
  } else {
71
132
  // On Unix, use regular spawn with detached mode
72
133
  const logFd = openSync(logPath, "a");
73
- const child = spawn(ccsPath, ["config"], {
134
+ const child = spawn(ccsPath, ["config", "--port", String(dashboardPort)], {
74
135
  detached: true,
75
136
  stdio: ["ignore", logFd, logFd],
76
137
  });
@@ -1,11 +1,12 @@
1
1
  import pc from "picocolors";
2
2
  import {
3
- CCS_PORTS,
3
+ getPortsToKill,
4
4
  getRunningDaemonPid,
5
5
  isProcessRunning,
6
6
  killProcessByPort,
7
7
  killProcessTree,
8
8
  removePid,
9
+ removePorts,
9
10
  } from "../../utils/daemon.js";
10
11
 
11
12
  export type StopOptions = {
@@ -32,13 +33,17 @@ export const ccsStopCommand = async (options?: StopOptions): Promise<void> => {
32
33
  stopped = true;
33
34
  }
34
35
  // Phase 2: Kill processes by port (especially important on Windows)
35
- for (const port of CCS_PORTS) {
36
+ // Use saved ports or fallback to defaults
37
+ const ports = await getPortsToKill();
38
+ for (const port of ports) {
36
39
  const killed = await killProcessByPort(port, options?.force ?? true);
37
40
  if (killed) {
38
41
  console.log(pc.dim(`Cleaned up process on port ${port}`));
39
42
  stopped = true;
40
43
  }
41
44
  }
45
+ // Clean up ports file
46
+ removePorts();
42
47
  if (!stopped) {
43
48
  console.log(pc.yellow("CCS config daemon is not running"));
44
49
  }
package/src/index.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Command } from "commander";
3
3
  import pkg from "../package.json";
4
+ import { ccsInstallCommand } from "./commands/ccs/install.js";
4
5
  import { ccsLogsCommand } from "./commands/ccs/logs.js";
6
+ import { ccsSetupCommand } from "./commands/ccs/setup.js";
5
7
  import { ccsStartCommand } from "./commands/ccs/start.js";
6
8
  import { ccsStatusCommand } from "./commands/ccs/status.js";
7
9
  import { ccsStopCommand } from "./commands/ccs/stop.js";
@@ -188,8 +190,17 @@ const main = async (): Promise<void> => {
188
190
  .command("start")
189
191
  .description("Start CCS config as background daemon")
190
192
  .option("-f, --force", "Force restart if already running")
193
+ .option("--keep-logs", "Keep existing log file (append)")
194
+ .option(
195
+ "-p, --port <number>",
196
+ "Dashboard port (auto-detect if not specified)",
197
+ )
191
198
  .action(async (options) => {
192
- await ccsStartCommand(options);
199
+ await ccsStartCommand({
200
+ force: options.force,
201
+ keepLogs: options.keepLogs,
202
+ port: options.port ? Number.parseInt(options.port, 10) : undefined,
203
+ });
193
204
  });
194
205
  ccsCommandGroup
195
206
  .command("stop")
@@ -215,6 +226,19 @@ const main = async (): Promise<void> => {
215
226
  lines: parseInt(options.lines, 10),
216
227
  });
217
228
  });
229
+ ccsCommandGroup
230
+ .command("setup")
231
+ .description("Run CCS initial setup")
232
+ .option("-f, --force", "Force setup even if already configured")
233
+ .action(async (options) => {
234
+ await ccsSetupCommand(options);
235
+ });
236
+ ccsCommandGroup
237
+ .command("install")
238
+ .description("Install CCS CLI tool")
239
+ .action(async () => {
240
+ await ccsInstallCommand();
241
+ });
218
242
  try {
219
243
  await program.parseAsync(process.argv);
220
244
  } catch {
@@ -11,6 +11,11 @@ export const getPidPath = (): string => join(getDaemonDir(), "ccs-config.pid");
11
11
  // Log file path
12
12
  export const getLogPath = (): string => join(getDaemonDir(), "ccs-config.log");
13
13
 
14
+ // Truncate a file (or create it empty if missing)
15
+ export const truncateFile = async (filePath: string): Promise<void> => {
16
+ await Bun.write(filePath, "");
17
+ };
18
+
14
19
  // Ensure daemon directory exists
15
20
  export const ensureDaemonDir = (): void => {
16
21
  const dir = getDaemonDir();
@@ -82,8 +87,84 @@ export const getCcsExecutable = (): string => {
82
87
  return "ccs";
83
88
  };
84
89
 
85
- // Known CCS daemon ports
86
- export const CCS_PORTS = [3000, 8317];
90
+ // Default CCS daemon ports (fallback)
91
+ export const DEFAULT_DASHBOARD_PORT = 3000;
92
+ export const DEFAULT_CLIPROXY_PORT = 8317;
93
+
94
+ // Ports file path
95
+ export const getPortsPath = (): string => join(getDaemonDir(), "ports.json");
96
+
97
+ // Type for daemon ports
98
+ export type DaemonPorts = {
99
+ dashboard: number;
100
+ cliproxy: number;
101
+ };
102
+
103
+ // Read saved ports
104
+ export const readPorts = async (): Promise<DaemonPorts | null> => {
105
+ const portsPath = getPortsPath();
106
+ if (!existsSync(portsPath)) {
107
+ return null;
108
+ }
109
+ try {
110
+ const content = await Bun.file(portsPath).text();
111
+ const ports = JSON.parse(content) as DaemonPorts;
112
+ if (
113
+ typeof ports.dashboard === "number" &&
114
+ typeof ports.cliproxy === "number"
115
+ ) {
116
+ return ports;
117
+ }
118
+ return null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+
124
+ // Write ports to file
125
+ export const writePorts = async (ports: DaemonPorts): Promise<void> => {
126
+ ensureDaemonDir();
127
+ await Bun.write(getPortsPath(), JSON.stringify(ports, null, 2));
128
+ };
129
+
130
+ // Remove ports file
131
+ export const removePorts = (): void => {
132
+ const portsPath = getPortsPath();
133
+ if (existsSync(portsPath)) {
134
+ unlinkSync(portsPath);
135
+ }
136
+ };
137
+
138
+ // Read CLIProxy port from config.yaml
139
+ export const getCliproxyPort = async (): Promise<number> => {
140
+ const configPath = join(homedir(), ".ccs", "cliproxy", "config.yaml");
141
+ if (!existsSync(configPath)) {
142
+ return DEFAULT_CLIPROXY_PORT;
143
+ }
144
+ try {
145
+ const content = await Bun.file(configPath).text();
146
+ // Simple YAML parsing for "port: XXXX"
147
+ const match = content.match(/^port:\s*(\d+)/m);
148
+ if (match?.[1]) {
149
+ const port = Number.parseInt(match[1], 10);
150
+ if (Number.isFinite(port) && port > 0) {
151
+ return port;
152
+ }
153
+ }
154
+ return DEFAULT_CLIPROXY_PORT;
155
+ } catch {
156
+ return DEFAULT_CLIPROXY_PORT;
157
+ }
158
+ };
159
+
160
+ // Get ports to kill (from saved file or defaults)
161
+ export const getPortsToKill = async (): Promise<number[]> => {
162
+ const saved = await readPorts();
163
+ if (saved) {
164
+ return [saved.dashboard, saved.cliproxy];
165
+ }
166
+ return [DEFAULT_DASHBOARD_PORT, DEFAULT_CLIPROXY_PORT];
167
+ };
87
168
 
88
169
  // Kill process tree (on Windows, kills all child processes)
89
170
  export const killProcessTree = async (
@@ -114,24 +195,41 @@ export const getProcessByPort = async (
114
195
  port: number,
115
196
  ): Promise<number | null> => {
116
197
  if (process.platform === "win32") {
117
- const proc = Bun.spawn(["cmd", "/c", `netstat -ano | findstr :${port}`], {
198
+ // Run netstat directly and parse ourselves for exact port matching
199
+ const proc = Bun.spawn(["netstat", "-ano", "-p", "tcp"], {
118
200
  stdout: "pipe",
119
201
  stderr: "ignore",
120
202
  });
121
203
  const output = await new Response(proc.stdout).text();
122
204
  await proc.exited;
123
- const lines = output.trim().split("\n");
205
+
206
+ const lines = output.split("\n");
124
207
  for (const line of lines) {
125
- if (line.includes("LISTENING") || line.includes("ESTABLISHED")) {
126
- const parts = line.trim().split(/\s+/);
127
- const pid = Number.parseInt(parts[parts.length - 1], 10);
128
- if (Number.isFinite(pid) && pid > 0) {
129
- return pid;
130
- }
208
+ // Format: Proto Local Address Foreign Address State PID
209
+ // TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 12345
210
+ const parts = line.trim().split(/\s+/);
211
+ if (parts.length < 5) continue;
212
+
213
+ const state = parts[3];
214
+ const localAddr = parts[1];
215
+ const pidStr = parts[4];
216
+ if (state !== "LISTENING" || !localAddr || !pidStr) continue;
217
+
218
+ // Extract port from local address (last part after colon)
219
+ const portMatch = localAddr.match(/:(\d+)$/);
220
+ if (!portMatch?.[1]) continue;
221
+
222
+ const localPort = Number.parseInt(portMatch[1], 10);
223
+ if (localPort !== port) continue;
224
+
225
+ const pid = Number.parseInt(pidStr, 10);
226
+ if (Number.isFinite(pid) && pid > 0) {
227
+ return pid;
131
228
  }
132
229
  }
133
230
  return null;
134
231
  }
232
+ // Unix: use lsof
135
233
  const proc = Bun.spawn(["lsof", "-ti", `:${port}`], {
136
234
  stdout: "pipe",
137
235
  stderr: "ignore",
@@ -71,6 +71,9 @@ const readStdinLine = async (): Promise<string> => {
71
71
  const cleanup = () => {
72
72
  process.stdin.off("data", onData);
73
73
  process.stdin.off("end", onEnd);
74
+ if (process.stdin.isTTY) {
75
+ process.stdin.pause();
76
+ }
74
77
  };
75
78
  if (process.stdin.isTTY) {
76
79
  process.stdin.resume();