@longshot/cli 0.0.1

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,194 @@
1
+ import { spawn } from "node:child_process";
2
+ import { appendFile, writeFile, readFile } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import * as store from "./store.js";
6
+ import { getProjectRoot } from "./projects.js";
7
+ const MAX_LOG_LINES = 10_000;
8
+ const TRUNCATE_CHECK_INTERVAL = 1000; // check every 1000 lines appended
9
+ // In-memory map of running services
10
+ const runningServices = new Map();
11
+ export function getServiceStatus(id) {
12
+ const running = runningServices.get(id);
13
+ if (running) {
14
+ return {
15
+ status: running.status,
16
+ pid: running.pid,
17
+ startedAt: running.startedAt,
18
+ exitCode: running.exitCode,
19
+ };
20
+ }
21
+ return { status: "stopped" };
22
+ }
23
+ export function getAllServiceStatuses() {
24
+ const result = new Map();
25
+ for (const [id, running] of runningServices) {
26
+ result.set(id, {
27
+ status: running.status,
28
+ pid: running.pid,
29
+ startedAt: running.startedAt,
30
+ exitCode: running.exitCode,
31
+ });
32
+ }
33
+ return result;
34
+ }
35
+ function logPath(id) {
36
+ return join(store.getServicesLogDir(), `${id}.log`);
37
+ }
38
+ async function appendLog(id, data) {
39
+ const dir = await store.ensureServicesLogDir();
40
+ const path = logPath(id);
41
+ const timestamp = new Date().toISOString();
42
+ const lines = data.split("\n").filter(Boolean);
43
+ const formatted = lines.map((line) => `[${timestamp}] ${line}\n`).join("");
44
+ await appendFile(path, formatted, "utf-8");
45
+ // Track lines for truncation
46
+ const running = runningServices.get(id);
47
+ if (running) {
48
+ running.linesAppended += lines.length;
49
+ if (running.linesAppended >= TRUNCATE_CHECK_INTERVAL) {
50
+ running.linesAppended = 0;
51
+ truncateLog(id).catch(() => { });
52
+ }
53
+ }
54
+ }
55
+ async function truncateLog(id) {
56
+ const path = logPath(id);
57
+ if (!existsSync(path))
58
+ return;
59
+ try {
60
+ const content = await readFile(path, "utf-8");
61
+ const lines = content.split("\n");
62
+ if (lines.length > MAX_LOG_LINES) {
63
+ const kept = lines.slice(lines.length - MAX_LOG_LINES).join("\n");
64
+ await writeFile(path, kept, "utf-8");
65
+ }
66
+ }
67
+ catch {
68
+ // Ignore truncation errors
69
+ }
70
+ }
71
+ export async function startService(id) {
72
+ if (runningServices.has(id)) {
73
+ const existing = runningServices.get(id);
74
+ if (existing.status === "running") {
75
+ return { ok: false, error: "Service already running" };
76
+ }
77
+ // Clean up crashed/stopped entry
78
+ runningServices.delete(id);
79
+ }
80
+ const service = await store.getService(id);
81
+ if (!service) {
82
+ return { ok: false, error: "Service not found" };
83
+ }
84
+ const cwd = service.cwd || getProjectRoot();
85
+ // Ensure log directory exists
86
+ await store.ensureServicesLogDir();
87
+ const child = spawn("sh", ["-c", service.command], {
88
+ cwd,
89
+ stdio: ["ignore", "pipe", "pipe"],
90
+ detached: false,
91
+ });
92
+ if (!child.pid) {
93
+ return { ok: false, error: "Failed to spawn process" };
94
+ }
95
+ const running = {
96
+ id,
97
+ process: child,
98
+ status: "running",
99
+ pid: child.pid,
100
+ startedAt: new Date().toISOString(),
101
+ linesAppended: 0,
102
+ };
103
+ runningServices.set(id, running);
104
+ // Pipe stdout/stderr to log file
105
+ child.stdout?.on("data", (data) => {
106
+ appendLog(id, data.toString()).catch(() => { });
107
+ });
108
+ child.stderr?.on("data", (data) => {
109
+ appendLog(id, data.toString()).catch(() => { });
110
+ });
111
+ child.on("exit", (code, signal) => {
112
+ const entry = runningServices.get(id);
113
+ if (entry) {
114
+ entry.exitCode = code ?? undefined;
115
+ if (entry.status === "running") {
116
+ // Was not manually stopped — it crashed
117
+ entry.status = code === 0 ? "stopped" : "crashed";
118
+ }
119
+ }
120
+ appendLog(id, `Process exited with code ${code} (signal: ${signal})`).catch(() => { });
121
+ });
122
+ child.on("error", (err) => {
123
+ const entry = runningServices.get(id);
124
+ if (entry) {
125
+ entry.status = "crashed";
126
+ }
127
+ appendLog(id, `Process error: ${err.message}`).catch(() => { });
128
+ });
129
+ await appendLog(id, `Started: ${service.command} (pid: ${child.pid})`);
130
+ return { ok: true };
131
+ }
132
+ export async function stopService(id) {
133
+ const running = runningServices.get(id);
134
+ if (!running || running.status !== "running") {
135
+ return { ok: false, error: "Service not running" };
136
+ }
137
+ running.status = "stopped";
138
+ return new Promise((resolve) => {
139
+ const child = running.process;
140
+ // Send SIGTERM
141
+ child.kill("SIGTERM");
142
+ // Escalate to SIGKILL after 5s
143
+ const killTimeout = setTimeout(() => {
144
+ try {
145
+ child.kill("SIGKILL");
146
+ }
147
+ catch {
148
+ // Already dead
149
+ }
150
+ }, 5000);
151
+ child.on("exit", () => {
152
+ clearTimeout(killTimeout);
153
+ resolve({ ok: true });
154
+ });
155
+ // Safety timeout — if exit never fires
156
+ setTimeout(() => {
157
+ clearTimeout(killTimeout);
158
+ resolve({ ok: true });
159
+ }, 10000);
160
+ });
161
+ }
162
+ export async function restartService(id) {
163
+ const running = runningServices.get(id);
164
+ if (running && running.status === "running") {
165
+ await stopService(id);
166
+ }
167
+ runningServices.delete(id);
168
+ return startService(id);
169
+ }
170
+ export async function getServiceLogs(id, tailLines = 200) {
171
+ const path = logPath(id);
172
+ if (!existsSync(path))
173
+ return "";
174
+ try {
175
+ const content = await readFile(path, "utf-8");
176
+ const lines = content.split("\n");
177
+ return lines.slice(-tailLines).join("\n");
178
+ }
179
+ catch {
180
+ return "";
181
+ }
182
+ }
183
+ export async function clearServiceLogs(id) {
184
+ const path = logPath(id);
185
+ await store.ensureServicesLogDir();
186
+ await writeFile(path, "", "utf-8");
187
+ }
188
+ export async function getServicesWithStatus() {
189
+ const services = await store.readServices();
190
+ return services.map((s) => {
191
+ const status = getServiceStatus(s.id);
192
+ return { ...s, ...status };
193
+ });
194
+ }