nomoreide 0.1.14 → 0.1.16

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.
Files changed (51) hide show
  1. package/README.md +20 -1
  2. package/dist/cli/commands.js +44 -9
  3. package/dist/cli/commands.js.map +1 -1
  4. package/dist/core/agent-context.d.ts +9 -0
  5. package/dist/core/agent-context.js +43 -0
  6. package/dist/core/agent-context.js.map +1 -0
  7. package/dist/core/config-store.js +29 -3
  8. package/dist/core/config-store.js.map +1 -1
  9. package/dist/core/docker-service-runner.d.ts +21 -0
  10. package/dist/core/docker-service-runner.js +80 -0
  11. package/dist/core/docker-service-runner.js.map +1 -0
  12. package/dist/core/git-manager.js +1 -1
  13. package/dist/core/git-manager.js.map +1 -1
  14. package/dist/core/http-inspector.d.ts +20 -0
  15. package/dist/core/http-inspector.js +106 -0
  16. package/dist/core/http-inspector.js.map +1 -0
  17. package/dist/core/log-store.d.ts +4 -0
  18. package/dist/core/log-store.js +34 -0
  19. package/dist/core/log-store.js.map +1 -1
  20. package/dist/core/port-utils.d.ts +6 -0
  21. package/dist/core/port-utils.js +44 -0
  22. package/dist/core/port-utils.js.map +1 -1
  23. package/dist/core/process-manager.d.ts +28 -2
  24. package/dist/core/process-manager.js +367 -14
  25. package/dist/core/process-manager.js.map +1 -1
  26. package/dist/core/service-health.d.ts +2 -1
  27. package/dist/core/service-health.js +9 -1
  28. package/dist/core/service-health.js.map +1 -1
  29. package/dist/core/ssh-service-runner.d.ts +7 -0
  30. package/dist/core/ssh-service-runner.js +31 -0
  31. package/dist/core/ssh-service-runner.js.map +1 -0
  32. package/dist/core/timeline-store.d.ts +16 -0
  33. package/dist/core/timeline-store.js +28 -0
  34. package/dist/core/timeline-store.js.map +1 -0
  35. package/dist/core/types.d.ts +28 -3
  36. package/dist/mcp/server.js +13 -3
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/mcp/tools.d.ts +3 -1
  39. package/dist/mcp/tools.js +86 -4
  40. package/dist/mcp/tools.js.map +1 -1
  41. package/dist/web/client/assets/index-Bkfrzz0V.js +14 -0
  42. package/dist/web/client/assets/index-DYCqol_D.css +1 -0
  43. package/dist/web/client/index.html +2 -2
  44. package/dist/web/dashboard.d.ts +4 -1
  45. package/dist/web/dashboard.js +4 -0
  46. package/dist/web/dashboard.js.map +1 -1
  47. package/dist/web/server.js +96 -18
  48. package/dist/web/server.js.map +1 -1
  49. package/package.json +2 -2
  50. package/dist/web/client/assets/index-BYk1ncPr.js +0 -12
  51. package/dist/web/client/assets/index-BbvMPKsZ.css +0 -1
@@ -1,3 +1,8 @@
1
+ export interface PortHolder {
2
+ pid: number;
3
+ pgid?: number;
4
+ command: string;
5
+ }
1
6
  export interface PortStatus {
2
7
  port: number;
3
8
  available: boolean;
@@ -15,3 +20,4 @@ export interface PortBindingStatus {
15
20
  export declare function isPortAvailable(port: number, host?: string): Promise<boolean>;
16
21
  export declare function getPortStatus(port: number): Promise<PortStatus>;
17
22
  export declare function getPortBindingStatus(port: number): Promise<PortBindingStatus>;
23
+ export declare function getPortHolder(port: number): Promise<PortHolder | null>;
@@ -1,4 +1,7 @@
1
+ import { exec } from "node:child_process";
1
2
  import net from "node:net";
3
+ import { promisify } from "node:util";
4
+ const execAsync = promisify(exec);
2
5
  export async function isPortAvailable(port, host = "127.0.0.1") {
3
6
  return (await checkHostPort(port, host)).available;
4
7
  }
@@ -21,6 +24,47 @@ export async function getPortBindingStatus(port) {
21
24
  hosts: statuses,
22
25
  };
23
26
  }
27
+ export async function getPortHolder(port) {
28
+ try {
29
+ const { stdout } = await execAsync(`lsof -nP -iTCP:${port} -sTCP:LISTEN -t`, {
30
+ timeout: 1500,
31
+ });
32
+ const pid = Number(stdout.trim().split(/\s+/)[0]);
33
+ if (!Number.isFinite(pid) || pid <= 0)
34
+ return null;
35
+ return {
36
+ pid,
37
+ pgid: await readPgid(pid),
38
+ command: await readCommand(pid),
39
+ };
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ async function readPgid(pid) {
46
+ try {
47
+ const { stdout } = await execAsync(`ps -o pgid= -p ${pid}`, {
48
+ timeout: 1000,
49
+ });
50
+ const pgid = Number(stdout.trim());
51
+ return Number.isFinite(pgid) && pgid > 0 ? pgid : undefined;
52
+ }
53
+ catch {
54
+ return undefined;
55
+ }
56
+ }
57
+ async function readCommand(pid) {
58
+ try {
59
+ const { stdout } = await execAsync(`ps -o command= -p ${pid}`, {
60
+ timeout: 1000,
61
+ });
62
+ return stdout.trim().split("\n")[0] || `pid ${pid}`;
63
+ }
64
+ catch {
65
+ return `pid ${pid}`;
66
+ }
67
+ }
24
68
  async function checkHostPort(port, host) {
25
69
  return new Promise((resolve, reject) => {
26
70
  const server = net.createServer();
@@ -1 +1 @@
1
- {"version":3,"file":"port-utils.js","sourceRoot":"","sources":["../../src/core/port-utils.ts"],"names":[],"mappings":"AAAA,OAAO,GAAG,MAAM,UAAU,CAAC;AAmB3B,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,IAAY,EACZ,IAAI,GAAG,WAAW;IAElB,OAAO,CAAC,MAAM,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC,CAAC,SAAS,CAAC;AACrD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAY;IAC9C,MAAM,MAAM,GAAG,MAAM,oBAAoB,CAAC,IAAI,CAAC,CAAC;IAChD,OAAO;QACL,IAAI;QACJ,SAAS,EAAE,MAAM,CAAC,SAAS;KAC5B,CAAC;AACJ,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,oBAAoB,CACxC,IAAY;IAEZ,MAAM,KAAK,GAAG,CAAC,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,CAAC,CAAC;IAC3D,MAAM,QAAQ,GAAG,MAAM,OAAO,CAAC,GAAG,CAChC,KAAK,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE,CAAC,CAAC;QACzB,IAAI;QACJ,GAAG,CAAC,MAAM,aAAa,CAAC,IAAI,EAAE,IAAI,CAAC,CAAC;KACrC,CAAC,CAAC,CACJ,CAAC;IAEF,OAAO;QACL,IAAI;QACJ,SAAS,EAAE,QAAQ,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,CAAC;QACvD,KAAK,EAAE,QAAQ;KAChB,CAAC;AACJ,CAAC;AAED,KAAK,UAAU,aAAa,CAC1B,IAAY,EACZ,IAAY;IAEZ,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;QACrC,MAAM,MAAM,GAAG,GAAG,CAAC,YAAY,EAAE,CAAC;QAElC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,KAA4B,EAAE,EAAE;YACpD,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY,IAAI,KAAK,CAAC,IAAI,KAAK,QAAQ,EAAE,CAAC;gBAC3D,OAAO,CAAC,EAAE,SAAS,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,CAAC,IAAI,EAAE,CAAC,CAAC;gBACrD,OAAO;YACT,CAAC;YAED,MAAM,CAAC,KAAK,CAAC,CAAC;QAChB,CAAC,CAAC,CAAC;QAEH,MAAM,CAAC,MAAM,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,EAAE;YAC7B,MAAM,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC,CAAC;QACnD,CAAC,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"port-utils.js","sourceRoot":"","sources":["../../src/core/port-utils.ts"],"names":[],"mappings":"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"}
@@ -1,31 +1,57 @@
1
1
  import type { ConfigStore } from "./config-store.js";
2
2
  import type { LogStore } from "./log-store.js";
3
+ import { type PortHolder } from "./port-utils.js";
4
+ import type { TimelineStore } from "./timeline-store.js";
3
5
  import type { ServiceStatus } from "./types.js";
4
6
  interface ProcessManagerOptions {
5
7
  configStore: ConfigStore;
6
8
  logStore: LogStore;
7
9
  stopTimeoutMs?: number;
10
+ timelineStore?: TimelineStore;
8
11
  }
9
12
  export interface NoMoreIdeStatus {
10
13
  services: Record<string, ServiceStatus>;
11
14
  }
15
+ export interface StartServiceOptions {
16
+ killHolder?: boolean;
17
+ }
18
+ export declare class PortConflictError extends Error {
19
+ readonly code = "PORT_IN_USE";
20
+ readonly service: string;
21
+ readonly port: number;
22
+ readonly holder: PortHolder | null;
23
+ constructor(service: string, port: number, holder: PortHolder | null);
24
+ }
12
25
  export declare class ProcessManager {
13
26
  private readonly runtimes;
14
27
  private readonly configStore;
15
28
  private readonly logStore;
16
29
  private readonly stopTimeoutMs;
30
+ private readonly timelineStore?;
17
31
  constructor(options: ProcessManagerOptions);
18
- startService(name: string): Promise<ServiceStatus>;
32
+ startService(name: string, options?: StartServiceOptions): Promise<ServiceStatus>;
19
33
  stopService(name: string): Promise<ServiceStatus>;
20
- restartService(name: string): Promise<ServiceStatus>;
34
+ restartService(name: string, options?: StartServiceOptions): Promise<ServiceStatus>;
35
+ setInspectorEnabled(name: string, enabled: boolean): Promise<ServiceStatus>;
36
+ private maybeStartInspector;
37
+ private stopInspector;
38
+ private inspectorStatus;
39
+ private recordInspectorEvent;
21
40
  startBundle(name: string): Promise<ServiceStatus[]>;
22
41
  stopBundle(name: string): Promise<ServiceStatus[]>;
23
42
  restartBundle(name: string): Promise<ServiceStatus[]>;
24
43
  stopAll(): Promise<void>;
44
+ killAllSync(signal?: NodeJS.Signals): void;
45
+ installShutdownHandlers(): () => void;
25
46
  status(): NoMoreIdeStatus;
26
47
  statusWithResources(): Promise<NoMoreIdeStatus>;
48
+ private startDockerComposeService;
49
+ private stopDockerComposeService;
50
+ readDockerServiceLogs(name: string, tail?: number): Promise<string>;
51
+ readDockerServiceStatus(name: string): Promise<import("./docker-service-runner.js").DockerContainerInfo>;
27
52
  private getService;
28
53
  private getBundle;
29
54
  private captureStream;
55
+ private appendTimeline;
30
56
  }
31
57
  export {};
@@ -1,53 +1,103 @@
1
1
  import { spawn } from "node:child_process";
2
- import { isPortAvailable } from "./port-utils.js";
2
+ import { readDockerServiceLogs, readDockerServiceStatus, startDockerService, stopDockerService, } from "./docker-service-runner.js";
3
+ import { startHttpInspector, } from "./http-inspector.js";
4
+ import { getPortHolder, isPortAvailable } from "./port-utils.js";
3
5
  import { readProcessTree } from "./process-tree.js";
6
+ import { createSshCommand } from "./ssh-service-runner.js";
7
+ export class PortConflictError extends Error {
8
+ code = "PORT_IN_USE";
9
+ service;
10
+ port;
11
+ holder;
12
+ constructor(service, port, holder) {
13
+ const owner = holder ? ` (held by pid ${holder.pid} — ${holder.command})` : "";
14
+ super(`Port ${port} is already in use for ${service}${owner}.`);
15
+ this.name = "PortConflictError";
16
+ this.service = service;
17
+ this.port = port;
18
+ this.holder = holder;
19
+ }
20
+ }
4
21
  export class ProcessManager {
5
22
  runtimes = new Map();
6
23
  configStore;
7
24
  logStore;
8
25
  stopTimeoutMs;
26
+ timelineStore;
9
27
  constructor(options) {
10
28
  this.configStore = options.configStore;
11
29
  this.logStore = options.logStore;
12
30
  this.stopTimeoutMs = options.stopTimeoutMs ?? 3000;
31
+ this.timelineStore = options.timelineStore;
13
32
  }
14
- async startService(name) {
33
+ async startService(name, options = {}) {
15
34
  const service = await this.getService(name);
16
35
  const existing = this.runtimes.get(name);
17
36
  if (existing?.status.state === "running") {
18
37
  return { ...existing.status };
19
38
  }
39
+ const kind = resolveKind(service);
40
+ if (kind === "docker-compose") {
41
+ return this.startDockerComposeService(name, service);
42
+ }
20
43
  if (service.port && !(await isPortAvailable(service.port))) {
21
- throw new Error(`Port ${service.port} is already in use for ${name}.`);
44
+ const holder = await getPortHolder(service.port);
45
+ if (options.killHolder && holder) {
46
+ await killHolder(holder);
47
+ if (!(await waitForPortFree(service.port, 3000))) {
48
+ throw new PortConflictError(name, service.port, holder);
49
+ }
50
+ }
51
+ else {
52
+ throw new PortConflictError(name, service.port, holder);
53
+ }
22
54
  }
23
- const child = spawn(service.command, {
24
- cwd: service.cwd,
25
- env: { ...process.env, ...service.env },
26
- shell: true,
27
- stdio: ["ignore", "pipe", "pipe"],
28
- });
55
+ const child = spawnService(name, service, kind);
29
56
  const runtime = {
30
57
  child,
31
58
  stopping: false,
32
59
  status: {
33
60
  name,
34
61
  state: "running",
62
+ kind,
63
+ host: kind === "ssh" ? service.host : undefined,
35
64
  pid: child.pid,
36
65
  startedAt: new Date().toISOString(),
37
66
  },
38
67
  };
39
68
  this.runtimes.set(name, runtime);
69
+ void this.appendTimeline({
70
+ kind: "service.lifecycle",
71
+ service: name,
72
+ severity: "info",
73
+ title: `${name} started`,
74
+ data: {
75
+ pid: child.pid,
76
+ },
77
+ });
40
78
  this.captureStream(name, "stdout", child.stdout);
41
79
  this.captureStream(name, "stderr", child.stderr);
42
80
  child.once("exit", (exitCode, signal) => {
81
+ const nextState = runtime.stopping ? "stopped" : "exited";
43
82
  runtime.status = {
44
83
  ...runtime.status,
45
- state: runtime.stopping ? "stopped" : "exited",
84
+ state: nextState,
46
85
  exitedAt: new Date().toISOString(),
47
86
  exitCode,
48
87
  signal,
49
88
  };
50
89
  runtime.child = undefined;
90
+ void this.stopInspector(runtime);
91
+ void this.appendTimeline({
92
+ kind: "service.lifecycle",
93
+ service: name,
94
+ severity: nextState === "exited" && exitCode ? "error" : "info",
95
+ title: `${name} ${nextState}`,
96
+ data: {
97
+ exitCode,
98
+ signal,
99
+ },
100
+ });
51
101
  });
52
102
  child.once("error", (error) => {
53
103
  runtime.status = {
@@ -58,14 +108,30 @@ export class ProcessManager {
58
108
  signal: null,
59
109
  };
60
110
  void this.logStore.append(name, "stderr", error.message);
111
+ void this.appendTimeline({
112
+ kind: "service.lifecycle",
113
+ service: name,
114
+ severity: "error",
115
+ title: `${name} failed`,
116
+ detail: error.message,
117
+ });
61
118
  });
62
119
  return { ...runtime.status };
63
120
  }
64
121
  async stopService(name) {
65
122
  const runtime = this.runtimes.get(name);
123
+ if (runtime?.status.kind === "docker-compose" && runtime.status.state === "running") {
124
+ return this.stopDockerComposeService(name, runtime);
125
+ }
66
126
  if (!runtime?.child || runtime.status.state !== "running") {
67
127
  const stopped = { name, state: "stopped" };
68
128
  this.runtimes.set(name, { status: stopped, stopping: false });
129
+ void this.appendTimeline({
130
+ kind: "service.lifecycle",
131
+ service: name,
132
+ severity: "info",
133
+ title: `${name} stopped`,
134
+ });
69
135
  return stopped;
70
136
  }
71
137
  runtime.stopping = true;
@@ -77,11 +143,95 @@ export class ProcessManager {
77
143
  };
78
144
  runtime.status = stopped;
79
145
  runtime.child = undefined;
146
+ void this.appendTimeline({
147
+ kind: "service.lifecycle",
148
+ service: name,
149
+ severity: "info",
150
+ title: `${name} stopped`,
151
+ });
80
152
  return { ...stopped };
81
153
  }
82
- async restartService(name) {
154
+ async restartService(name, options = {}) {
83
155
  await this.stopService(name);
84
- return this.startService(name);
156
+ return this.startService(name, options);
157
+ }
158
+ async setInspectorEnabled(name, enabled) {
159
+ const runtime = this.runtimes.get(name);
160
+ if (!runtime) {
161
+ throw new Error(`Service "${name}" is not running.`);
162
+ }
163
+ runtime.inspectorEnabled = enabled;
164
+ if (enabled) {
165
+ await this.maybeStartInspector(name);
166
+ }
167
+ else {
168
+ await this.stopInspector(runtime);
169
+ }
170
+ runtime.status = {
171
+ ...runtime.status,
172
+ inspector: this.inspectorStatus(runtime),
173
+ };
174
+ return { ...runtime.status };
175
+ }
176
+ async maybeStartInspector(name) {
177
+ const runtime = this.runtimes.get(name);
178
+ if (!runtime || !runtime.inspectorEnabled || runtime.inspectorHandle)
179
+ return;
180
+ const upstreamPort = portFromUrl(runtime.status.url);
181
+ if (!upstreamPort)
182
+ return;
183
+ const handle = await startHttpInspector({
184
+ upstreamPort,
185
+ onEvent: (event) => this.recordInspectorEvent(name, event),
186
+ });
187
+ runtime.inspectorHandle = handle;
188
+ runtime.status = {
189
+ ...runtime.status,
190
+ inspector: { enabled: true, port: handle.port, upstreamPort },
191
+ };
192
+ void this.appendTimeline({
193
+ kind: "service.lifecycle",
194
+ service: name,
195
+ severity: "info",
196
+ title: `${name} HTTP inspector on :${handle.port}`,
197
+ detail: `Proxying to upstream :${upstreamPort}`,
198
+ });
199
+ }
200
+ async stopInspector(runtime) {
201
+ if (!runtime.inspectorHandle)
202
+ return;
203
+ const handle = runtime.inspectorHandle;
204
+ runtime.inspectorHandle = undefined;
205
+ await handle.stop();
206
+ }
207
+ inspectorStatus(runtime) {
208
+ if (!runtime.inspectorEnabled)
209
+ return undefined;
210
+ return {
211
+ enabled: true,
212
+ port: runtime.inspectorHandle?.port,
213
+ upstreamPort: portFromUrl(runtime.status.url),
214
+ };
215
+ }
216
+ recordInspectorEvent(name, event) {
217
+ const severity = event.status >= 500 ? "error" : event.status >= 400 ? "warning" : "info";
218
+ void this.appendTimeline({
219
+ kind: "service.http",
220
+ service: name,
221
+ severity,
222
+ title: `${event.method} ${event.path} → ${event.status}`,
223
+ detail: `${event.durationMs} ms`,
224
+ timestamp: event.startedAt,
225
+ data: {
226
+ id: event.id,
227
+ method: event.method,
228
+ path: event.path,
229
+ status: event.status,
230
+ durationMs: event.durationMs,
231
+ reqBytes: event.reqBytes,
232
+ resBytes: event.resBytes,
233
+ },
234
+ });
85
235
  }
86
236
  async startBundle(name) {
87
237
  const bundle = await this.getBundle(name);
@@ -106,6 +256,38 @@ export class ProcessManager {
106
256
  async stopAll() {
107
257
  await Promise.all([...this.runtimes.keys()].map((name) => this.stopService(name)));
108
258
  }
259
+ killAllSync(signal = "SIGTERM") {
260
+ for (const runtime of this.runtimes.values()) {
261
+ if (!runtime.child || runtime.child.exitCode !== null)
262
+ continue;
263
+ signalProcessTree(runtime.child, signal);
264
+ }
265
+ }
266
+ installShutdownHandlers() {
267
+ let firing = false;
268
+ const handle = (signal) => () => {
269
+ if (firing)
270
+ return;
271
+ firing = true;
272
+ this.killAllSync("SIGTERM");
273
+ // Re-raise the signal with default behavior so the host exits.
274
+ setTimeout(() => process.kill(process.pid, signal), 50);
275
+ };
276
+ const onExit = () => this.killAllSync("SIGTERM");
277
+ const sigint = handle("SIGINT");
278
+ const sigterm = handle("SIGTERM");
279
+ const sighup = handle("SIGHUP");
280
+ process.once("SIGINT", sigint);
281
+ process.once("SIGTERM", sigterm);
282
+ process.once("SIGHUP", sighup);
283
+ process.once("exit", onExit);
284
+ return () => {
285
+ process.removeListener("SIGINT", sigint);
286
+ process.removeListener("SIGTERM", sigterm);
287
+ process.removeListener("SIGHUP", sighup);
288
+ process.removeListener("exit", onExit);
289
+ };
290
+ }
109
291
  status() {
110
292
  return {
111
293
  services: Object.fromEntries([...this.runtimes.entries()].map(([name, runtime]) => [
@@ -121,10 +303,63 @@ export class ProcessManager {
121
303
  if (status.pid && status.state === "running") {
122
304
  status.processTree = await readProcessTree(status.pid);
123
305
  }
306
+ status.inspector = this.inspectorStatus(runtime);
124
307
  services[name] = status;
125
308
  }
126
309
  return { services };
127
310
  }
311
+ async startDockerComposeService(name, service) {
312
+ const target = toDockerTarget(name, service);
313
+ const info = await startDockerService(target);
314
+ const status = {
315
+ name,
316
+ state: "running",
317
+ kind: "docker-compose",
318
+ startedAt: new Date().toISOString(),
319
+ containerId: info.containerId,
320
+ };
321
+ this.runtimes.set(name, { status, stopping: false });
322
+ void this.appendTimeline({
323
+ kind: "service.lifecycle",
324
+ service: name,
325
+ severity: "info",
326
+ title: `${name} started`,
327
+ data: { containerId: info.containerId },
328
+ });
329
+ return { ...status };
330
+ }
331
+ async stopDockerComposeService(name, runtime) {
332
+ const service = await this.getService(name);
333
+ runtime.stopping = true;
334
+ await stopDockerService(toDockerTarget(name, service));
335
+ const stopped = {
336
+ ...runtime.status,
337
+ state: "stopped",
338
+ exitedAt: new Date().toISOString(),
339
+ };
340
+ runtime.status = stopped;
341
+ void this.appendTimeline({
342
+ kind: "service.lifecycle",
343
+ service: name,
344
+ severity: "info",
345
+ title: `${name} stopped`,
346
+ });
347
+ return { ...stopped };
348
+ }
349
+ async readDockerServiceLogs(name, tail = 120) {
350
+ const service = await this.getService(name);
351
+ if (resolveKind(service) !== "docker-compose") {
352
+ throw new Error(`Service "${name}" is not a docker-compose service.`);
353
+ }
354
+ return readDockerServiceLogs(toDockerTarget(name, service), tail);
355
+ }
356
+ async readDockerServiceStatus(name) {
357
+ const service = await this.getService(name);
358
+ if (resolveKind(service) !== "docker-compose") {
359
+ throw new Error(`Service "${name}" is not a docker-compose service.`);
360
+ }
361
+ return readDockerServiceStatus(toDockerTarget(name, service));
362
+ }
128
363
  async getService(name) {
129
364
  const config = await this.configStore.load();
130
365
  const service = config.services.find((item) => item.name === name);
@@ -157,7 +392,15 @@ export class ProcessManager {
157
392
  const runtime = this.runtimes.get(service);
158
393
  if (runtime) {
159
394
  runtime.status = { ...runtime.status, url };
395
+ void this.maybeStartInspector(service);
160
396
  }
397
+ void this.appendTimeline({
398
+ kind: "service.port",
399
+ service,
400
+ severity: "info",
401
+ title: `${service} reported ${url}`,
402
+ detail: url,
403
+ });
161
404
  }
162
405
  void this.logStore.append(service, stream, line);
163
406
  }
@@ -170,10 +413,62 @@ export class ProcessManager {
170
413
  }
171
414
  });
172
415
  }
416
+ async appendTimeline(event) {
417
+ if (!this.timelineStore)
418
+ return;
419
+ await this.timelineStore.append(event);
420
+ }
421
+ }
422
+ function resolveKind(service) {
423
+ return service.kind ?? "local";
424
+ }
425
+ function toDockerTarget(name, service) {
426
+ if (!service.cwd || !service.composeService) {
427
+ throw new Error(`Service "${name}" is missing docker-compose cwd or composeService.`);
428
+ }
429
+ return {
430
+ cwd: service.cwd,
431
+ composeFile: service.composeFile,
432
+ composeService: service.composeService,
433
+ };
434
+ }
435
+ function spawnService(name, service, kind) {
436
+ if (kind === "ssh") {
437
+ if (!service.host || !service.cwd || !service.command) {
438
+ throw new Error(`Service "${name}" is missing ssh host, cwd, or command.`);
439
+ }
440
+ const [bin, args] = createSshCommand({
441
+ host: service.host,
442
+ cwd: service.cwd,
443
+ command: service.command,
444
+ env: service.env,
445
+ });
446
+ return spawn(bin, args, {
447
+ env: { ...process.env },
448
+ stdio: ["ignore", "pipe", "pipe"],
449
+ detached: true,
450
+ });
451
+ }
452
+ if (!service.command || !service.cwd) {
453
+ throw new Error(`Service "${name}" is missing command or cwd.`);
454
+ }
455
+ return spawn(service.command, {
456
+ cwd: service.cwd,
457
+ env: { ...process.env, ...service.env },
458
+ shell: true,
459
+ stdio: ["ignore", "pipe", "pipe"],
460
+ detached: true,
461
+ });
173
462
  }
174
463
  function localUrlFromLine(line) {
175
464
  return line.match(/https?:\/\/(?:localhost|127\.0\.0\.1):\d+\/?/)?.[0];
176
465
  }
466
+ function portFromUrl(url) {
467
+ if (!url)
468
+ return undefined;
469
+ const match = url.match(/:(\d+)/);
470
+ return match ? Number(match[1]) : undefined;
471
+ }
177
472
  async function stopChild(child, timeoutMs) {
178
473
  if (child.exitCode !== null || child.signalCode !== null) {
179
474
  return;
@@ -188,11 +483,69 @@ async function stopChild(child, timeoutMs) {
188
483
  }
189
484
  };
190
485
  const timer = setTimeout(() => {
191
- child.kill("SIGKILL");
486
+ signalProcessTree(child, "SIGKILL");
192
487
  finish();
193
488
  }, timeoutMs);
194
489
  child.once("exit", finish);
195
- child.kill("SIGTERM");
490
+ signalProcessTree(child, "SIGTERM");
196
491
  });
197
492
  }
493
+ async function killHolder(holder) {
494
+ const target = holder.pgid ? -holder.pgid : holder.pid;
495
+ try {
496
+ process.kill(target, "SIGTERM");
497
+ }
498
+ catch (error) {
499
+ const code = error.code;
500
+ if (code !== "ESRCH")
501
+ throw error;
502
+ }
503
+ // Give the process a moment to exit cleanly before escalating.
504
+ for (let i = 0; i < 20; i += 1) {
505
+ await new Promise((resolve) => setTimeout(resolve, 100));
506
+ try {
507
+ process.kill(holder.pid, 0);
508
+ }
509
+ catch {
510
+ return;
511
+ }
512
+ }
513
+ try {
514
+ process.kill(target, "SIGKILL");
515
+ }
516
+ catch {
517
+ // ignore
518
+ }
519
+ }
520
+ async function waitForPortFree(port, timeoutMs) {
521
+ const deadline = Date.now() + timeoutMs;
522
+ while (Date.now() < deadline) {
523
+ if (await isPortAvailable(port))
524
+ return true;
525
+ await new Promise((resolve) => setTimeout(resolve, 100));
526
+ }
527
+ return isPortAvailable(port);
528
+ }
529
+ function signalProcessTree(child, signal) {
530
+ const pid = child.pid;
531
+ if (!pid)
532
+ return;
533
+ try {
534
+ // Negative PID = signal the whole process group whose leader is `pid`.
535
+ // The service was spawned with detached: true so child.pid === PGID.
536
+ process.kill(-pid, signal);
537
+ }
538
+ catch (error) {
539
+ const code = error.code;
540
+ if (code === "ESRCH")
541
+ return;
542
+ // Fall back to signaling the direct child if the group is gone.
543
+ try {
544
+ child.kill(signal);
545
+ }
546
+ catch {
547
+ // ignore
548
+ }
549
+ }
550
+ }
198
551
  //# sourceMappingURL=process-manager.js.map