@paretools/docker 0.8.5 → 0.10.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.
Files changed (68) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/docker-runner.d.ts +1 -1
  5. package/dist/lib/docker-runner.d.ts.map +1 -1
  6. package/dist/lib/docker-runner.js +2 -2
  7. package/dist/lib/docker-runner.js.map +1 -1
  8. package/dist/lib/formatters.d.ts +62 -21
  9. package/dist/lib/formatters.d.ts.map +1 -1
  10. package/dist/lib/formatters.js +369 -52
  11. package/dist/lib/formatters.js.map +1 -1
  12. package/dist/lib/parsers.d.ts +50 -23
  13. package/dist/lib/parsers.d.ts.map +1 -1
  14. package/dist/lib/parsers.js +852 -129
  15. package/dist/lib/parsers.js.map +1 -1
  16. package/dist/schemas/index.d.ts +267 -9
  17. package/dist/schemas/index.d.ts.map +1 -1
  18. package/dist/schemas/index.js +186 -7
  19. package/dist/schemas/index.js.map +1 -1
  20. package/dist/tools/build.d.ts.map +1 -1
  21. package/dist/tools/build.js +122 -21
  22. package/dist/tools/build.js.map +1 -1
  23. package/dist/tools/compose-build.d.ts.map +1 -1
  24. package/dist/tools/compose-build.js +67 -8
  25. package/dist/tools/compose-build.js.map +1 -1
  26. package/dist/tools/compose-down.d.ts.map +1 -1
  27. package/dist/tools/compose-down.js +39 -7
  28. package/dist/tools/compose-down.js.map +1 -1
  29. package/dist/tools/compose-logs.d.ts.map +1 -1
  30. package/dist/tools/compose-logs.js +52 -7
  31. package/dist/tools/compose-logs.js.map +1 -1
  32. package/dist/tools/compose-ps.d.ts.map +1 -1
  33. package/dist/tools/compose-ps.js +65 -8
  34. package/dist/tools/compose-ps.js.map +1 -1
  35. package/dist/tools/compose-up.d.ts.map +1 -1
  36. package/dist/tools/compose-up.js +83 -6
  37. package/dist/tools/compose-up.js.map +1 -1
  38. package/dist/tools/exec.d.ts.map +1 -1
  39. package/dist/tools/exec.js +66 -17
  40. package/dist/tools/exec.js.map +1 -1
  41. package/dist/tools/images.d.ts.map +1 -1
  42. package/dist/tools/images.js +33 -11
  43. package/dist/tools/images.js.map +1 -1
  44. package/dist/tools/inspect.d.ts.map +1 -1
  45. package/dist/tools/inspect.js +43 -16
  46. package/dist/tools/inspect.js.map +1 -1
  47. package/dist/tools/logs.d.ts.map +1 -1
  48. package/dist/tools/logs.js +34 -11
  49. package/dist/tools/logs.js.map +1 -1
  50. package/dist/tools/network-ls.d.ts.map +1 -1
  51. package/dist/tools/network-ls.js +23 -10
  52. package/dist/tools/network-ls.js.map +1 -1
  53. package/dist/tools/ps.d.ts.map +1 -1
  54. package/dist/tools/ps.js +21 -6
  55. package/dist/tools/ps.js.map +1 -1
  56. package/dist/tools/pull.d.ts.map +1 -1
  57. package/dist/tools/pull.js +16 -14
  58. package/dist/tools/pull.js.map +1 -1
  59. package/dist/tools/run.d.ts.map +1 -1
  60. package/dist/tools/run.js +107 -10
  61. package/dist/tools/run.js.map +1 -1
  62. package/dist/tools/stats.d.ts.map +1 -1
  63. package/dist/tools/stats.js +21 -6
  64. package/dist/tools/stats.js.map +1 -1
  65. package/dist/tools/volume-ls.d.ts.map +1 -1
  66. package/dist/tools/volume-ls.js +23 -10
  67. package/dist/tools/volume-ls.js.map +1 -1
  68. package/package.json +2 -2
@@ -1,17 +1,24 @@
1
+ // ── #117/#118: docker ps with labels and networks ────────────────────
1
2
  /** Parses `docker ps --format json` output into structured container data with ports and state. */
2
3
  export function parsePsJson(stdout) {
3
4
  // docker ps --format json returns one JSON object per line
4
5
  const lines = stdout.trim().split("\n").filter(Boolean);
5
6
  const containers = lines.map((line) => {
6
7
  const c = JSON.parse(line);
8
+ // #117: Parse labels from JSON output
9
+ const labels = parseLabelsField(c.Labels);
10
+ // #118: Parse networks from JSON output
11
+ const networks = parseNetworksField(c.Networks);
7
12
  return {
8
- id: (c.ID ?? c.Id ?? "").slice(0, 12),
13
+ id: c.ID ?? c.Id ?? "",
9
14
  name: c.Names ?? c.Name ?? "",
10
15
  image: c.Image ?? "",
11
16
  status: c.Status ?? "",
12
17
  state: (c.State ?? "created").toLowerCase(),
13
18
  ports: parsePorts(c.Ports ?? ""),
14
19
  created: c.RunningFor ?? c.CreatedAt ?? "",
20
+ ...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
21
+ ...(networks && networks.length > 0 ? { networks } : {}),
15
22
  };
16
23
  });
17
24
  const running = containers.filter((c) => c.state === "running").length;
@@ -22,6 +29,45 @@ export function parsePsJson(stdout) {
22
29
  stopped: containers.length - running,
23
30
  };
24
31
  }
32
+ /** #117: Parses a labels string or object from Docker JSON output into Record<string, string>. */
33
+ function parseLabelsField(labels) {
34
+ if (!labels)
35
+ return undefined;
36
+ // If labels is already an object
37
+ if (typeof labels === "object" && !Array.isArray(labels)) {
38
+ return labels;
39
+ }
40
+ // If labels is a comma-separated string: "key1=val1,key2=val2"
41
+ if (typeof labels === "string" && labels.trim()) {
42
+ const result = {};
43
+ for (const pair of labels.split(",")) {
44
+ const eqIdx = pair.indexOf("=");
45
+ if (eqIdx > 0) {
46
+ result[pair.slice(0, eqIdx).trim()] = pair.slice(eqIdx + 1).trim();
47
+ }
48
+ }
49
+ return Object.keys(result).length > 0 ? result : undefined;
50
+ }
51
+ return undefined;
52
+ }
53
+ /** #118: Parses a networks field from Docker JSON output into string[]. */
54
+ function parseNetworksField(networks) {
55
+ if (!networks)
56
+ return undefined;
57
+ // If networks is already an array
58
+ if (Array.isArray(networks)) {
59
+ return networks.length > 0 ? networks : undefined;
60
+ }
61
+ // If networks is a comma-separated string: "bridge,mynet"
62
+ if (typeof networks === "string" && networks.trim()) {
63
+ const parts = networks
64
+ .split(",")
65
+ .map((n) => n.trim())
66
+ .filter(Boolean);
67
+ return parts.length > 0 ? parts : undefined;
68
+ }
69
+ return undefined;
70
+ }
25
71
  function parsePorts(portsStr) {
26
72
  if (!portsStr)
27
73
  return [];
@@ -35,7 +81,8 @@ function parsePorts(portsStr) {
35
81
  const protocol = (protoMatch?.[1] ?? "tcp");
36
82
  const arrowParts = p.split("->");
37
83
  if (arrowParts.length === 2) {
38
- const hostPort = arrowParts[0].split(":").pop();
84
+ const hostPortMatch = arrowParts[0].match(/:(\d+)$/);
85
+ const hostPort = hostPortMatch?.[1] ?? arrowParts[0].split(":").pop();
39
86
  const containerPort = arrowParts[1].replace(/\/(tcp|udp)/, "");
40
87
  return {
41
88
  host: parseInt(hostPort ?? "0", 10),
@@ -47,89 +94,293 @@ function parsePorts(portsStr) {
47
94
  return { container: port, protocol };
48
95
  });
49
96
  }
97
+ function parseBuildCache(stdout, stderr) {
98
+ const combined = `${stdout}\n${stderr}`;
99
+ const lines = combined.split("\n");
100
+ const cacheByStep = [];
101
+ const seen = new Set();
102
+ for (const line of lines) {
103
+ const stepMatch = line.match(/^#\d+\s+\[([^\]]+)\]\s*(.*)$/);
104
+ if (!stepMatch)
105
+ continue;
106
+ const step = stepMatch[1].trim();
107
+ if (step.toLowerCase() === "internal")
108
+ continue;
109
+ if (seen.has(step))
110
+ continue;
111
+ const tail = stepMatch[2]?.toUpperCase() ?? "";
112
+ const cached = tail.includes("CACHED");
113
+ cacheByStep.push({ step, cached });
114
+ seen.add(step);
115
+ }
116
+ const cacheHits = cacheByStep.filter((s) => s.cached).length;
117
+ const cacheMisses = cacheByStep.filter((s) => !s.cached).length;
118
+ return { cacheHits, cacheMisses, cacheByStep };
119
+ }
120
+ /** #97: Parses build errors from output, extracting line numbers and Dockerfile context. */
121
+ function parseBuildErrors(stdout, stderr) {
122
+ const combined = stdout + "\n" + stderr;
123
+ const errors = [];
124
+ // Match "Dockerfile:N" or "dockerfile:N" patterns in error lines
125
+ const lines = combined.split("\n");
126
+ for (const line of lines) {
127
+ const trimmed = line.trim();
128
+ if (!trimmed)
129
+ continue;
130
+ // Match error lines that reference Dockerfile with line numbers
131
+ // e.g., "Dockerfile:5 error: ..." or "ERROR: failed to solve: Dockerfile:10: ..."
132
+ const dockerfileLineMatch = trimmed.match(/(?:(?:([^\s:]+\.?[Dd]ockerfile[^\s:]*)|Dockerfile):(\d+))[:\s]+(.+)/);
133
+ if (dockerfileLineMatch) {
134
+ errors.push({
135
+ message: dockerfileLineMatch[3].trim(),
136
+ line: parseInt(dockerfileLineMatch[2], 10),
137
+ dockerfile: dockerfileLineMatch[1] || "Dockerfile",
138
+ });
139
+ continue;
140
+ }
141
+ // Match "line N" patterns in error messages
142
+ const lineNumMatch = trimmed.match(/(?:error|ERROR|Error).*?(?:line\s+(\d+)).*$/i);
143
+ if (lineNumMatch && trimmed.match(/error|ERROR|Error/) && !trimmed.match(/^#\d+/)) {
144
+ errors.push({
145
+ message: trimmed,
146
+ line: parseInt(lineNumMatch[1], 10),
147
+ });
148
+ continue;
149
+ }
150
+ // Match generic error lines (no line number)
151
+ if (trimmed.match(/error|ERROR|Error/) && !trimmed.match(/^#\d+/)) {
152
+ errors.push({ message: trimmed });
153
+ }
154
+ }
155
+ return errors;
156
+ }
50
157
  /** Parses `docker build` output into structured results with success status, image ID, and errors. */
51
158
  export function parseBuildOutput(stdout, stderr, exitCode, duration) {
52
- const errors = [];
53
159
  const imageIdMatch = stdout.match(/writing image sha256:([a-f0-9]+)/i) ||
54
160
  stdout.match(/Successfully built ([a-f0-9]+)/) ||
55
161
  stderr.match(/writing image sha256:([a-f0-9]+)/i);
162
+ // #97: Parse structured errors with line numbers
163
+ let errors;
164
+ let errorCount;
56
165
  if (exitCode !== 0) {
57
- const errorLines = (stdout + "\n" + stderr)
58
- .split("\n")
59
- .filter((l) => l.match(/error|ERROR|Error/) && !l.match(/^#\d+/));
60
- errors.push(...errorLines.map((l) => l.trim()).filter(Boolean));
166
+ errors = parseBuildErrors(stdout, stderr);
167
+ errorCount = errors.length;
168
+ // Fallback: if no structured errors found, extract raw error lines
169
+ if (errors.length === 0) {
170
+ const errorLines = (stdout + "\n" + stderr)
171
+ .split("\n")
172
+ .filter((l) => l.match(/error|ERROR|Error/) && !l.match(/^#\d+/));
173
+ errors = errorLines
174
+ .map((l) => l.trim())
175
+ .filter(Boolean)
176
+ .map((msg) => ({ message: msg }));
177
+ errorCount = errors.length;
178
+ }
61
179
  }
62
180
  const stepMatch = stdout.match(/#(\d+) /g);
63
181
  const steps = stepMatch ? new Set(stepMatch).size : undefined;
182
+ const cache = parseBuildCache(stdout, stderr);
64
183
  return {
65
184
  success: exitCode === 0,
66
185
  imageId: imageIdMatch?.[1]?.slice(0, 12),
67
186
  duration,
68
187
  ...(steps ? { steps } : {}),
69
- errors,
188
+ ...(cache.cacheByStep.length > 0
189
+ ? {
190
+ cacheHits: cache.cacheHits,
191
+ cacheMisses: cache.cacheMisses,
192
+ cacheByStep: cache.cacheByStep,
193
+ }
194
+ : {}),
195
+ ...(errors && errors.length > 0 ? { errors, errorCount } : {}),
70
196
  };
71
197
  }
72
- /** Parses `docker logs` output into structured data with container name and log lines. Caps output to `limit` lines when provided. */
73
- export function parseLogsOutput(stdout, container, limit) {
198
+ // ── #113: Separate stdout/stderr in logs ─────────────────────────────
199
+ /** Parses `docker logs` output into structured data with container name and log lines.
200
+ * #113: Supports separate stdout/stderr streams when provided.
201
+ * Caps output to `limit` lines when provided. */
202
+ export function parseLogsOutput(stdout, container, limit, stderr) {
74
203
  const allLines = stdout.split("\n").filter(Boolean);
75
204
  const totalLines = allLines.length;
76
205
  const isTruncated = limit != null && totalLines > limit;
77
206
  const lines = isTruncated ? allLines.slice(0, limit) : allLines;
207
+ const tsRe = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/;
208
+ const entries = lines.map((line) => {
209
+ const tsMatch = line.match(tsRe);
210
+ if (tsMatch) {
211
+ return { timestamp: tsMatch[1], message: tsMatch[2] };
212
+ }
213
+ return { message: line };
214
+ });
215
+ // #113: Separate stdout and stderr lines
216
+ const stdoutLines = stdout ? stdout.split("\n").filter(Boolean) : undefined;
217
+ const stderrLines = stderr && stderr.trim() ? stderr.split("\n").filter(Boolean) : undefined;
78
218
  return {
79
219
  container,
80
220
  lines,
221
+ ...(entries.length > 0 ? { entries } : {}),
81
222
  total: lines.length,
82
223
  ...(isTruncated ? { isTruncated: true, totalLines } : {}),
224
+ ...(stdoutLines && stdoutLines.length > 0 ? { stdoutLines } : {}),
225
+ ...(stderrLines && stderrLines.length > 0 ? { stderrLines } : {}),
83
226
  };
84
227
  }
85
- /** Parses `docker images --format json` output into structured image data with repository, tag, and size. */
228
+ // ── #110: Parse CreatedAt as ISO timestamp for images ────────────────
229
+ /** Parses `docker images --format json` output into structured image data with repository, tag, size, and digest. */
86
230
  export function parseImagesJson(stdout) {
87
231
  const lines = stdout.trim().split("\n").filter(Boolean);
88
232
  const images = lines.map((line) => {
89
233
  const img = JSON.parse(line);
234
+ // #110: Parse CreatedAt as ISO timestamp
235
+ const createdAt = img.CreatedAt ? normalizeTimestamp(img.CreatedAt) : undefined;
236
+ const labels = parseLabelsField(img.Labels);
237
+ const size = img.Size ?? "";
238
+ const sizeBytes = size ? parseSizeToBytes(size) : undefined;
90
239
  return {
91
240
  id: (img.ID ?? "").slice(0, 12),
92
241
  repository: img.Repository ?? "",
93
242
  tag: img.Tag ?? "",
94
- size: img.Size ?? "",
243
+ size,
244
+ ...(sizeBytes != null ? { sizeBytes } : {}),
245
+ ...(img.Digest && img.Digest !== "<none>" ? { digest: img.Digest } : {}),
95
246
  created: img.CreatedSince ?? img.CreatedAt ?? "",
247
+ ...(createdAt ? { createdAt } : {}),
248
+ ...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
96
249
  };
97
250
  });
98
251
  return { images, total: images.length };
99
252
  }
100
- /** Parses `docker run` output into structured data with container ID and image info. */
101
- export function parseRunOutput(stdout, image, detached, name) {
253
+ /** #110: Normalize a Docker timestamp to ISO 8601 format. */
254
+ function normalizeTimestamp(ts) {
255
+ if (!ts)
256
+ return undefined;
257
+ // Docker CreatedAt: "2024-01-15 10:30:00 +0000 UTC" or ISO format
258
+ // Try to convert to ISO
259
+ const isoMatch = ts.match(/^\d{4}-\d{2}-\d{2}T/);
260
+ if (isoMatch)
261
+ return ts; // Already ISO
262
+ // Convert "2024-01-15 10:30:00 +0000 UTC" format
263
+ const dockerMatch = ts.match(/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})\s*([+-]\d{4})?\s*\w*$/);
264
+ if (dockerMatch) {
265
+ const date = dockerMatch[1];
266
+ const time = dockerMatch[2];
267
+ const offset = dockerMatch[3] || "+0000";
268
+ const offsetFormatted = `${offset.slice(0, 3)}:${offset.slice(3)}`;
269
+ return `${date}T${time}${offsetFormatted}`;
270
+ }
271
+ return ts;
272
+ }
273
+ // ── #121/#122: Run with structured error and stdout/stderr ───────────
274
+ /** Parses `docker run` output into structured data with container ID and image info.
275
+ * #121: Supports structured error output with exitCode, stderr, errorCategory.
276
+ * #122: Captures stdout/stderr for non-detached runs. */
277
+ export function parseRunOutput(stdout, image, detached, name, exitCode, stderr) {
102
278
  // When detached, docker run outputs the full container ID
103
279
  const containerId = stdout.trim().split("\n").pop()?.trim() ?? "";
104
- return {
280
+ const result = {
105
281
  containerId: containerId.slice(0, 12),
106
282
  image,
107
283
  detached,
108
284
  ...(name ? { name } : {}),
109
285
  };
286
+ // #121: Include exitCode and error category for failed runs
287
+ if (exitCode != null && exitCode !== 0) {
288
+ result.exitCode = exitCode;
289
+ if (stderr)
290
+ result.stderr = stderr;
291
+ result.errorCategory = categorizeRunError(stderr ?? "");
292
+ }
293
+ // #122: Capture stdout/stderr for non-detached runs
294
+ if (!detached) {
295
+ result.exitCode = exitCode ?? 0;
296
+ if (stdout.trim())
297
+ result.stdout = stdout;
298
+ if (stderr && stderr.trim())
299
+ result.stderr = stderr;
300
+ }
301
+ return result;
110
302
  }
111
- /** Parses `docker exec` output into structured data with exit code, stdout, stderr, and success. */
112
- export function parseExecOutput(stdout, stderr, exitCode) {
303
+ /** #121: Categorize a docker run error into a known category. */
304
+ function categorizeRunError(stderr) {
305
+ const lower = stderr.toLowerCase();
306
+ if (lower.includes("not found") ||
307
+ lower.includes("manifest unknown") ||
308
+ lower.includes("no such image") ||
309
+ lower.includes("repository does not exist")) {
310
+ return "image-not-found";
311
+ }
312
+ if (lower.includes("port is already allocated") || lower.includes("address already in use")) {
313
+ return "port-conflict";
314
+ }
315
+ if (lower.includes("permission denied") || lower.includes("access denied")) {
316
+ return "permission-denied";
317
+ }
318
+ if (lower.includes("cannot connect to the docker daemon") ||
319
+ lower.includes("is the docker daemon running")) {
320
+ return "daemon-error";
321
+ }
322
+ return "unknown";
323
+ }
324
+ // ── #108: Exec with output truncation ────────────────────────────────
325
+ /** Parses `docker exec` output into structured data with exit code, stdout, stderr, success, and duration.
326
+ * #108: Supports output truncation with `limit` parameter. */
327
+ export function parseExecOutput(stdout, stderr, exitCode, duration, limit, opts) {
328
+ let truncatedStdout = stdout;
329
+ let truncatedStderr = stderr;
330
+ let isTruncated = false;
331
+ // #108: Truncate output if limit is specified (in bytes/characters)
332
+ if (limit != null && limit > 0) {
333
+ if (stdout.length > limit) {
334
+ truncatedStdout = stdout.slice(0, limit);
335
+ isTruncated = true;
336
+ }
337
+ if (stderr.length > limit) {
338
+ truncatedStderr = stderr.slice(0, limit);
339
+ isTruncated = true;
340
+ }
341
+ }
342
+ let parsedJson;
343
+ let parseJsonError;
344
+ if (opts?.parseJson) {
345
+ try {
346
+ parsedJson = JSON.parse(truncatedStdout.trim());
347
+ }
348
+ catch (err) {
349
+ parseJsonError = err instanceof Error ? err.message : "Invalid JSON output";
350
+ }
351
+ }
113
352
  return {
114
353
  exitCode,
115
- stdout,
116
- stderr,
354
+ stdout: truncatedStdout,
355
+ stderr: truncatedStderr,
117
356
  success: exitCode === 0,
357
+ ...(duration != null ? { duration } : {}),
358
+ ...(isTruncated ? { isTruncated: true } : {}),
359
+ ...(opts?.timedOut ? { timedOut: true } : {}),
360
+ ...(opts?.parseJson && parsedJson !== undefined ? { json: parsedJson } : {}),
361
+ ...(opts?.parseJson && parseJsonError ? { parseJsonError } : {}),
118
362
  };
119
363
  }
120
- /** Parses `docker compose up` output into structured data with service names and started count. */
364
+ // ── #107: Compose up per-service state ───────────────────────────────
365
+ /** Parses `docker compose up` output into structured data with service names and started count.
366
+ * #107: Enriches output with per-service state details. */
121
367
  export function parseComposeUpOutput(stdout, stderr, exitCode) {
122
- // Compose v2 outputs to stderr; service names appear in lines like:
123
- // ✔ Container myapp-web-1 Started
124
- // ✔ Container myapp-db-1 Started
125
- // Or: Container myapp-web-1 Creating / Created / Starting / Started
126
368
  const combined = stdout + "\n" + stderr;
127
369
  const serviceSet = new Set();
128
- // Match "Container <name> Started" or "Container <name> Running"
129
- const startedPattern = /Container\s+(\S+)\s+(?:Started|Running|Created)/g;
370
+ const serviceStates = [];
371
+ const seenStates = new Set(); // Deduplicate "name:action" pairs
372
+ const networksCreated = (combined.match(/Network\s+\S+\s+Created/g) ?? []).length;
373
+ const volumesCreated = (combined.match(/Volume\s+\S+\s+Created/g) ?? []).length;
374
+ // Match "Container <name> <Action>" patterns
375
+ const containerPattern = /Container\s+(\S+)\s+(Started|Running|Created|Stopped|Removed|Healthy|Starting|Waiting)/g;
130
376
  let match;
131
- while ((match = startedPattern.exec(combined)) !== null) {
377
+ while ((match = containerPattern.exec(combined)) !== null) {
132
378
  serviceSet.add(match[1]);
379
+ const key = `${match[1]}:${match[2]}`;
380
+ if (!seenStates.has(key)) {
381
+ seenStates.add(key);
382
+ serviceStates.push({ name: match[1], action: match[2] });
383
+ }
133
384
  }
134
385
  // Also match "service "<name>" started" pattern (older compose output)
135
386
  const servicePattern = /[Ss]ervice\s+"?(\S+?)"?\s+(?:started|created)/g;
@@ -141,61 +392,198 @@ export function parseComposeUpOutput(stdout, stderr, exitCode) {
141
392
  success: exitCode === 0,
142
393
  services,
143
394
  started: services.length,
395
+ ...(serviceStates.length > 0 ? { serviceStates } : {}),
396
+ ...(networksCreated > 0 ? { networksCreated } : {}),
397
+ ...(volumesCreated > 0 ? { volumesCreated } : {}),
144
398
  };
145
399
  }
146
- /** Parses `docker compose down` output into structured data with stopped and removed counts. */
147
- export function parseComposeDownOutput(stdout, stderr, exitCode) {
400
+ // ── #100/#101: Compose down with per-container details ───────────────
401
+ /** Parses `docker compose down` output into structured data with stopped and removed counts.
402
+ * #100: Enriches output with per-container {name, action} array.
403
+ * #101: Separates volume and network removal counts. */
404
+ export function parseComposeDownOutput(stdout, stderr, exitCode, opts) {
148
405
  const combined = stdout + "\n" + stderr;
149
406
  let stopped = 0;
150
407
  let removed = 0;
408
+ let volumesRemoved = 0;
409
+ let networksRemoved = 0;
410
+ const containers = [];
151
411
  // Match "Container <name> Stopped"
152
- const stoppedPattern = /Container\s+\S+\s+Stopped/g;
153
- while (stoppedPattern.exec(combined) !== null) {
412
+ const stoppedPattern = /Container\s+(\S+)\s+Stopped/g;
413
+ let match;
414
+ while ((match = stoppedPattern.exec(combined)) !== null) {
154
415
  stopped++;
416
+ containers.push({ name: match[1], action: "Stopped" });
155
417
  }
156
418
  // Match "Container <name> Removed"
157
- const removedPattern = /Container\s+\S+\s+Removed/g;
158
- while (removedPattern.exec(combined) !== null) {
419
+ const removedPattern = /Container\s+(\S+)\s+Removed/g;
420
+ while ((match = removedPattern.exec(combined)) !== null) {
159
421
  removed++;
422
+ containers.push({ name: match[1], action: "Removed" });
423
+ }
424
+ // #101: Count network removals separately
425
+ const networkPattern = /Network\s+(\S+)\s+Removed/g;
426
+ while ((match = networkPattern.exec(combined)) !== null) {
427
+ networksRemoved++;
428
+ removed++; // Still count in total removed for backwards compat
160
429
  }
161
- // Also count network/volume removals as part of removed
162
- const networkPattern = /Network\s+\S+\s+Removed/g;
163
- while (networkPattern.exec(combined) !== null) {
430
+ // #101: Count volume removals separately
431
+ const volumePattern = /Volume\s+(\S+)\s+Removed/g;
432
+ while ((match = volumePattern.exec(combined)) !== null) {
433
+ volumesRemoved++;
164
434
  removed++;
165
435
  }
166
436
  return {
167
437
  success: exitCode === 0,
168
438
  stopped,
169
439
  removed,
440
+ ...(containers.length > 0 ? { containers } : {}),
441
+ ...(volumesRemoved > 0 || opts?.trackVolumes ? { volumesRemoved } : {}),
442
+ ...(networksRemoved > 0 || opts?.trackNetworks ? { networksRemoved } : {}),
170
443
  };
171
444
  }
172
- /** Parses `docker pull` output into structured data with image, tag, digest, and success flag. */
445
+ // ── #119/#120: Pull with digest-only parsing and size ────────────────
446
+ /** Parses `docker pull` output into structured data with image, tag, digest, status, and success flag.
447
+ * #119: Handles digest-only pulls (no tag).
448
+ * #120: Extracts size from pull summary. */
173
449
  export function parsePullOutput(stdout, stderr, exitCode, image) {
174
450
  const combined = stdout + "\n" + stderr;
175
- // Extract tag from image string (e.g., "nginx:latest" -> "latest", "nginx" -> "latest")
176
- const parts = image.split(":");
177
- const tag = parts.length > 1 ? parts[parts.length - 1] : "latest";
178
- const imageName = parts.length > 1 ? parts.slice(0, -1).join(":") : image;
451
+ // #119: Handle digest-only references (e.g., "nginx@sha256:abc123...")
452
+ const isDigestRef = image.includes("@sha256:");
453
+ let tag;
454
+ let imageName;
455
+ if (isDigestRef) {
456
+ const atIdx = image.indexOf("@");
457
+ imageName = image.slice(0, atIdx);
458
+ tag = image.slice(atIdx + 1); // "sha256:abc123..."
459
+ }
460
+ else {
461
+ const parts = image.split(":");
462
+ tag = parts.length > 1 ? parts[parts.length - 1] : "latest";
463
+ imageName = parts.length > 1 ? parts.slice(0, -1).join(":") : image;
464
+ }
179
465
  // Extract digest from "Digest: sha256:abc123..."
180
466
  const digestMatch = combined.match(/Digest:\s*(sha256:[a-f0-9]+)/);
181
467
  const digest = digestMatch?.[1];
468
+ // #120: Extract size from pull output
469
+ // Patterns: "Downloaded newer image for nginx:latest" doesn't have size
470
+ // But "Status: Downloaded newer image ... (42.5MB)" or layer sizes
471
+ const sizeMatch = combined.match(/(?:Downloaded|Pull complete).*?(\d+(?:\.\d+)?(?:kB|KB|MB|GB|MiB|GiB|B))/i);
472
+ // Also check for the total size summary line pattern
473
+ const totalSizeMatch = combined.match(/(?:Total|Size):\s*(\d+(?:\.\d+)?(?:kB|KB|MB|GB|MiB|GiB|B))/i);
474
+ const size = totalSizeMatch?.[1] || sizeMatch?.[1] || undefined;
475
+ // Determine pull status from output
476
+ let status;
477
+ if (exitCode !== 0) {
478
+ status = "error";
479
+ }
480
+ else if (combined.includes("Image is up to date") ||
481
+ (combined.includes("Already exists") && !combined.includes("Pull complete"))) {
482
+ status = "up-to-date";
483
+ }
484
+ else {
485
+ status = "pulled";
486
+ }
182
487
  return {
183
488
  image: imageName,
184
489
  tag,
185
490
  ...(digest ? { digest } : {}),
491
+ status,
186
492
  success: exitCode === 0,
493
+ ...(size ? { size } : {}),
494
+ ...(exitCode !== 0
495
+ ? {
496
+ errorType: categorizePullError(combined),
497
+ errorMessage: (stderr || stdout).trim() || "Unknown pull error",
498
+ }
499
+ : {}),
187
500
  };
188
501
  }
189
- /** Parses `docker inspect --format json` output into structured inspect data. */
502
+ function categorizePullError(text) {
503
+ const lower = text.toLowerCase();
504
+ if (lower.includes("unauthorized") ||
505
+ lower.includes("authentication required") ||
506
+ lower.includes("access denied")) {
507
+ return "auth";
508
+ }
509
+ if (lower.includes("manifest unknown") ||
510
+ lower.includes("not found") ||
511
+ lower.includes("no such")) {
512
+ return "not-found";
513
+ }
514
+ if (lower.includes("timed out") ||
515
+ lower.includes("request canceled") ||
516
+ lower.includes("connection reset") ||
517
+ lower.includes("i/o timeout")) {
518
+ return "network-timeout";
519
+ }
520
+ if (lower.includes("toomanyrequests") || lower.includes("rate limit")) {
521
+ return "rate-limit";
522
+ }
523
+ return "unknown";
524
+ }
525
+ // ── #111/#112: Inspect with networkSettings and mounts ───────────────
526
+ /**
527
+ * Detects whether the inspected JSON object is an image (no State field) vs a container.
528
+ * Returns true when the object looks like a Docker image inspect result.
529
+ */
530
+ function isImageInspect(obj) {
531
+ // Images lack a State field and typically have RepoTags
532
+ return !("State" in obj) && ("RepoTags" in obj || "RootFS" in obj);
533
+ }
534
+ function isVolumeInspect(obj) {
535
+ return "Mountpoint" in obj && "Driver" in obj && !("State" in obj);
536
+ }
537
+ function isNetworkInspect(obj) {
538
+ return "IPAM" in obj && "Driver" in obj && ("Scope" in obj || "Containers" in obj);
539
+ }
540
+ /** Parses `docker inspect --format json` output into structured inspect data with healthStatus, env, and restartPolicy.
541
+ * Handles both container and image inspect output. */
190
542
  export function parseInspectJson(stdout) {
543
+ const items = parseInspectJsonAll(stdout);
544
+ return items[0];
545
+ }
546
+ /** Parses `docker inspect --format json` output and returns all objects. */
547
+ export function parseInspectJsonAll(stdout) {
191
548
  // docker inspect --format json returns a JSON array with one element
192
549
  const parsed = JSON.parse(stdout);
193
- const obj = Array.isArray(parsed) ? parsed[0] : parsed;
194
- const state = obj.State ?? {};
195
- const config = obj.Config ?? {};
550
+ const objects = (Array.isArray(parsed) ? parsed : [parsed]);
551
+ return objects.map((obj) => {
552
+ if (isImageInspect(obj)) {
553
+ return parseImageInspect(obj);
554
+ }
555
+ if (isVolumeInspect(obj)) {
556
+ return parseVolumeInspect(obj);
557
+ }
558
+ if (isNetworkInspect(obj)) {
559
+ return parseNetworkInspect(obj);
560
+ }
561
+ return parseContainerInspect(obj);
562
+ });
563
+ }
564
+ /** Parses container-type inspect JSON into DockerInspect. */
565
+ function parseContainerInspect(obj) {
566
+ const state = (obj.State ?? {});
567
+ const config = (obj.Config ?? {});
568
+ const hostConfig = (obj.HostConfig ?? {});
569
+ // Extract health status from State.Health.Status
570
+ const healthRaw = state.Health?.Status;
571
+ const validHealth = ["healthy", "unhealthy", "starting", "none"];
572
+ const healthStatus = healthRaw && validHealth.includes(healthRaw.toLowerCase())
573
+ ? healthRaw.toLowerCase()
574
+ : undefined;
575
+ // Extract environment variables from Config.Env
576
+ const envArr = Array.isArray(config.Env) && config.Env.length > 0 ? config.Env : undefined;
577
+ // Extract restart policy from HostConfig.RestartPolicy.Name
578
+ const restartPolicy = hostConfig.RestartPolicy?.Name;
579
+ // #111: Extract network settings
580
+ const networkSettings = parseNetworkSettings(obj.NetworkSettings);
581
+ // #112: Extract mounts
582
+ const mounts = parseMounts(obj.Mounts);
196
583
  return {
197
584
  id: (obj.Id ?? "").slice(0, 12),
198
585
  name: (obj.Name ?? "").replace(/^\//, ""),
586
+ inspectType: "container",
199
587
  state: {
200
588
  status: (state.Status ?? "unknown").toLowerCase(),
201
589
  running: state.Running ?? false,
@@ -206,42 +594,350 @@ export function parseInspectJson(stdout) {
206
594
  image: config.Image ?? obj.Image ?? "",
207
595
  ...(obj.Platform ? { platform: obj.Platform } : {}),
208
596
  created: obj.Created ?? "",
597
+ ...(healthStatus ? { healthStatus } : {}),
598
+ ...(envArr ? { env: envArr } : {}),
599
+ ...(restartPolicy && restartPolicy !== "" ? { restartPolicy } : {}),
600
+ ...(parseLabelsField(obj.ConfigLabels ?? obj.Labels)
601
+ ? { labels: parseLabelsField(obj.ConfigLabels ?? obj.Labels) }
602
+ : {}),
603
+ ...(networkSettings ? { networkSettings } : {}),
604
+ ...(mounts && mounts.length > 0 ? { mounts } : {}),
605
+ };
606
+ }
607
+ /** #111: Parses NetworkSettings from Docker inspect JSON. */
608
+ function parseNetworkSettings(ns) {
609
+ if (!ns)
610
+ return undefined;
611
+ const ipAddress = ns.IPAddress ?? "";
612
+ const portsRaw = ns.Ports;
613
+ let ports;
614
+ if (portsRaw && typeof portsRaw === "object") {
615
+ ports = {};
616
+ for (const [key, value] of Object.entries(portsRaw)) {
617
+ if (value === null) {
618
+ ports[key] = null;
619
+ }
620
+ else if (Array.isArray(value)) {
621
+ ports[key] = value.map((entry) => {
622
+ const e = entry;
623
+ return {
624
+ ...(e.HostIp ? { hostIp: e.HostIp } : {}),
625
+ ...(e.HostPort ? { hostPort: e.HostPort } : {}),
626
+ };
627
+ });
628
+ }
629
+ }
630
+ }
631
+ // Only return if there's meaningful data
632
+ if (!ipAddress && !ports)
633
+ return undefined;
634
+ return {
635
+ ipAddress,
636
+ ...(ports && Object.keys(ports).length > 0 ? { ports } : {}),
637
+ };
638
+ }
639
+ /** #112: Parses Mounts from Docker inspect JSON. */
640
+ function parseMounts(mounts) {
641
+ if (!mounts || !Array.isArray(mounts))
642
+ return undefined;
643
+ const result = mounts.map((m) => {
644
+ const mount = m;
645
+ return {
646
+ source: mount.Source ?? "",
647
+ destination: mount.Destination ?? "",
648
+ ...(mount.Mode && mount.Mode !== "" ? { mode: mount.Mode } : {}),
649
+ };
650
+ });
651
+ return result.length > 0 ? result : undefined;
652
+ }
653
+ /** Parses image-type inspect JSON into DockerInspect. */
654
+ function parseImageInspect(obj) {
655
+ const config = (obj.Config ?? {});
656
+ const repoTags = Array.isArray(obj.RepoTags) ? obj.RepoTags : [];
657
+ const repoDigests = Array.isArray(obj.RepoDigests) ? obj.RepoDigests : [];
658
+ // Extract environment variables from Config.Env
659
+ const envArr = Array.isArray(config.Env) && config.Env.length > 0 ? config.Env : undefined;
660
+ // Extract command and entrypoint
661
+ const cmd = Array.isArray(config.Cmd) ? config.Cmd : undefined;
662
+ const entrypoint = Array.isArray(config.Entrypoint) ? config.Entrypoint : undefined;
663
+ // Build platform from Architecture + Os
664
+ const arch = obj.Architecture;
665
+ const os = obj.Os;
666
+ const platform = arch && os ? `${os}/${arch}` : arch || os || undefined;
667
+ // Derive image name from first RepoTag or the image ID
668
+ const imageName = repoTags.length > 0 ? repoTags[0] : (obj.Id ?? "").slice(0, 12);
669
+ return {
670
+ id: (obj.Id ?? "").slice(0, 12),
671
+ name: imageName,
672
+ inspectType: "image",
673
+ image: imageName,
674
+ ...(platform ? { platform } : {}),
675
+ created: obj.Created ?? "",
676
+ ...(repoTags.length > 0 ? { repoTags } : {}),
677
+ ...(repoDigests.length > 0 ? { repoDigests } : {}),
678
+ ...(obj.Size != null ? { size: obj.Size } : {}),
679
+ ...(envArr ? { env: envArr } : {}),
680
+ ...(cmd ? { cmd } : {}),
681
+ ...(entrypoint ? { entrypoint } : {}),
682
+ ...(parseLabelsField(config.Labels) ? { labels: parseLabelsField(config.Labels) } : {}),
683
+ };
684
+ }
685
+ function parseVolumeInspect(obj) {
686
+ return {
687
+ id: (obj.Name ?? "").slice(0, 12),
688
+ name: obj.Name ?? "",
689
+ inspectType: "volume",
690
+ image: "",
691
+ ...(obj.Driver ? { driver: obj.Driver } : {}),
692
+ ...(obj.Scope ? { scope: obj.Scope } : {}),
693
+ ...(obj.Mountpoint ? { mountpoint: obj.Mountpoint } : {}),
694
+ ...(parseLabelsField(obj.Labels) ? { labels: parseLabelsField(obj.Labels) } : {}),
695
+ ...(obj.CreatedAt ? { created: obj.CreatedAt } : {}),
209
696
  };
210
697
  }
211
- /** Parses `docker network ls --format json` output into structured network data. */
698
+ function parseNetworkInspect(obj) {
699
+ return {
700
+ id: (obj.Id ?? "").slice(0, 12),
701
+ name: obj.Name ?? "",
702
+ inspectType: "network",
703
+ image: "",
704
+ ...(obj.Driver ? { driver: obj.Driver } : {}),
705
+ ...(obj.Scope ? { scope: obj.Scope } : {}),
706
+ ...(parseLabelsField(obj.Labels) ? { labels: parseLabelsField(obj.Labels) } : {}),
707
+ ...(obj.Created ? { created: obj.Created } : {}),
708
+ };
709
+ }
710
+ // ── #115/#116: Network LS with labels and boolean fields ─────────────
711
+ /** Parses `docker network ls --format json` output into structured network data.
712
+ * #115: Includes labels.
713
+ * #116: Includes ipv6, internal, attachable booleans. */
212
714
  export function parseNetworkLsJson(stdout) {
213
715
  const lines = stdout.trim().split("\n").filter(Boolean);
214
716
  const networks = lines.map((line) => {
215
717
  const n = JSON.parse(line);
718
+ // #115: Parse labels
719
+ const labels = parseLabelsField(n.Labels);
216
720
  return {
217
721
  id: (n.ID ?? "").slice(0, 12),
218
722
  name: n.Name ?? "",
219
723
  driver: n.Driver ?? "",
220
724
  scope: n.Scope ?? "",
725
+ ...(n.CreatedAt ? { createdAt: n.CreatedAt } : {}),
726
+ ...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
727
+ // #116: Parse boolean fields
728
+ ...(n.IPv6 != null ? { ipv6: toBool(n.IPv6) } : {}),
729
+ ...(n.Internal != null ? { internal: toBool(n.Internal) } : {}),
730
+ ...(n.Attachable != null ? { attachable: toBool(n.Attachable) } : {}),
221
731
  };
222
732
  });
223
733
  return { networks, total: networks.length };
224
734
  }
225
- /** Parses `docker volume ls --format json` output into structured volume data. */
735
+ /** Converts various truthy representations to boolean. */
736
+ function toBool(value) {
737
+ if (typeof value === "boolean")
738
+ return value;
739
+ if (typeof value === "string")
740
+ return value.toLowerCase() === "true";
741
+ return Boolean(value);
742
+ }
743
+ // ── #125: Volume LS with labels ──────────────────────────────────────
744
+ /** Parses `docker volume ls --format json` output into structured volume data with createdAt.
745
+ * #125: Includes labels. */
226
746
  export function parseVolumeLsJson(stdout) {
227
747
  const lines = stdout.trim().split("\n").filter(Boolean);
228
748
  const volumes = lines.map((line) => {
229
749
  const v = JSON.parse(line);
750
+ // #125: Parse labels
751
+ const labels = parseLabelsField(v.Labels);
230
752
  return {
231
753
  name: v.Name ?? "",
232
754
  driver: v.Driver ?? "",
233
755
  mountpoint: v.Mountpoint ?? "",
234
756
  scope: v.Scope ?? "",
757
+ ...(v.Status ? { status: String(v.Status) } : {}),
758
+ ...(v.CreatedAt ? { createdAt: v.CreatedAt } : {}),
759
+ ...(labels && Object.keys(labels).length > 0 ? { labels } : {}),
235
760
  };
236
761
  });
237
762
  return { volumes, total: volumes.length };
238
763
  }
239
- /** Parses `docker compose build` output into structured per-service build status. */
764
+ // ── #105/#106: Compose PS with health and counts ─────────────────────
765
+ /** Parses `docker compose ps --format json` output into structured compose service data.
766
+ * #105: Includes health field.
767
+ * #106: Includes running/stopped counts. */
768
+ export function parseComposePsJson(stdout) {
769
+ const lines = stdout.trim().split("\n").filter(Boolean);
770
+ const services = lines.map((line) => {
771
+ const s = JSON.parse(line);
772
+ const ports = parseComposePorts(s.Publishers, s.Ports);
773
+ // #105: Parse health field
774
+ const health = s.Health ?? s.health ?? undefined;
775
+ const statusText = s.Status ?? "";
776
+ const exitMatch = typeof statusText === "string" ? statusText.match(/Exited\s*\((\d+)\)/i) : null;
777
+ return {
778
+ name: s.Name ?? "",
779
+ service: s.Service ?? "",
780
+ state: (s.State ?? "unknown").toLowerCase(),
781
+ status: statusText,
782
+ ...(ports.length > 0 ? { ports } : {}),
783
+ ...(health && health !== "" ? { health } : {}),
784
+ ...(exitMatch ? { exitCode: parseInt(exitMatch[1], 10) } : {}),
785
+ };
786
+ });
787
+ // #106: Calculate running/stopped counts
788
+ const running = services.filter((s) => s.state === "running").length;
789
+ const stopped = services.filter((s) => s.state === "exited" || s.state === "dead" || s.state === "removing").length;
790
+ return {
791
+ services,
792
+ total: services.length,
793
+ running,
794
+ stopped,
795
+ };
796
+ }
797
+ /**
798
+ * Parses compose service port info from the Publishers array or Ports string.
799
+ */
800
+ function parseComposePorts(publishers, portsStr) {
801
+ // Try structured Publishers array first
802
+ if (Array.isArray(publishers) && publishers.length > 0) {
803
+ return publishers
804
+ .filter((p) => p != null && typeof p === "object" && (p.TargetPort != null || p.target_port != null))
805
+ .map((p) => {
806
+ const container = p.TargetPort ?? p.target_port ?? 0;
807
+ const host = p.PublishedPort ?? p.published_port ?? 0;
808
+ const protocol = (p.Protocol ?? p.protocol ?? "tcp").toLowerCase();
809
+ return {
810
+ ...(host > 0 ? { host } : {}),
811
+ container,
812
+ protocol,
813
+ };
814
+ });
815
+ }
816
+ // Fallback: parse Ports string (same format as docker ps)
817
+ if (typeof portsStr === "string" && portsStr.trim()) {
818
+ return portsStr
819
+ .split(",")
820
+ .map((p) => p.trim())
821
+ .filter(Boolean)
822
+ .map((p) => {
823
+ const protoMatch = p.match(/\/(tcp|udp)/);
824
+ const protocol = (protoMatch?.[1] ?? "tcp");
825
+ const arrowParts = p.split("->");
826
+ if (arrowParts.length === 2) {
827
+ const hostPort = arrowParts[0].split(":").pop();
828
+ const containerPort = arrowParts[1].replace(/\/(tcp|udp)/, "");
829
+ return {
830
+ host: parseInt(hostPort ?? "0", 10),
831
+ container: parseInt(containerPort, 10),
832
+ protocol,
833
+ };
834
+ }
835
+ const port = parseInt(p.replace(/\/(tcp|udp)/, ""), 10);
836
+ return { container: port, protocol };
837
+ });
838
+ }
839
+ return [];
840
+ }
841
+ // ── #103/#104: Compose logs with improved timestamp parsing and log levels ──
842
+ /** #103: Improved timestamp regex that handles timezone offsets and nanoseconds. */
843
+ const COMPOSE_TS_RE = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)/;
844
+ /** #104: Extract log level from common patterns in log messages. */
845
+ function extractLogLevel(message) {
846
+ // Match common log level patterns: [ERROR], ERROR:, level=error, ERROR -, etc.
847
+ const upper = message.toUpperCase();
848
+ // Check bracket patterns: [ERROR], [WARN], etc.
849
+ const bracketMatch = upper.match(/\[(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\]/);
850
+ if (bracketMatch)
851
+ return normalizeLevel(bracketMatch[1]);
852
+ // Check "level=" patterns (structured logging): level=error, level="warn"
853
+ const levelMatch = message.match(/\blevel[=:]\s*"?(debug|info|warn(?:ing)?|error|fatal)"?/i);
854
+ if (levelMatch)
855
+ return normalizeLevel(levelMatch[1]);
856
+ // Check prefix patterns: "ERROR:", "WARN -", "INFO "
857
+ const prefixMatch = upper.match(/(?:^|\s)(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\s*[:\-|]/);
858
+ if (prefixMatch)
859
+ return normalizeLevel(prefixMatch[1]);
860
+ return undefined;
861
+ }
862
+ /** Normalize log level string to standard enum values. */
863
+ function normalizeLevel(level) {
864
+ const upper = level.toUpperCase();
865
+ if (upper === "DEBUG")
866
+ return "debug";
867
+ if (upper === "INFO")
868
+ return "info";
869
+ if (upper === "WARNING" || upper === "WARN")
870
+ return "warn";
871
+ if (upper === "ERROR")
872
+ return "error";
873
+ if (upper === "FATAL")
874
+ return "fatal";
875
+ return "info";
876
+ }
877
+ /** Parses `docker compose logs` output into structured entries grouped by service.
878
+ * #103: Improved timestamp parsing for timezone offsets and nanoseconds.
879
+ * #104: Extracts log level from common patterns. */
880
+ export function parseComposeLogsOutput(stdout, limit) {
881
+ const allLines = stdout.split("\n").filter(Boolean);
882
+ const serviceSet = new Set();
883
+ // Parse each line: "service | [timestamp] message"
884
+ const entries = allLines.map((line) => {
885
+ const pipeIdx = line.indexOf("|");
886
+ if (pipeIdx === -1) {
887
+ // No pipe — treat entire line as message with unknown service
888
+ const level = extractLogLevel(line.trim());
889
+ return {
890
+ service: "unknown",
891
+ message: line.trim(),
892
+ ...(level ? { level } : {}),
893
+ };
894
+ }
895
+ const service = line.slice(0, pipeIdx).trim();
896
+ const rest = line.slice(pipeIdx + 1).trimStart();
897
+ serviceSet.add(service);
898
+ // #103: Use improved timestamp regex
899
+ const tsMatch = rest.match(COMPOSE_TS_RE);
900
+ if (tsMatch) {
901
+ const level = extractLogLevel(tsMatch[2]);
902
+ return {
903
+ timestamp: tsMatch[1],
904
+ service,
905
+ message: tsMatch[2],
906
+ ...(level ? { level } : {}),
907
+ };
908
+ }
909
+ const level = extractLogLevel(rest);
910
+ return {
911
+ service,
912
+ message: rest,
913
+ ...(level ? { level } : {}),
914
+ };
915
+ });
916
+ const totalEntries = entries.length;
917
+ const isTruncated = limit != null && totalEntries > limit;
918
+ const limited = isTruncated ? entries.slice(0, limit) : entries;
919
+ // Rebuild service set from limited entries
920
+ if (isTruncated) {
921
+ serviceSet.clear();
922
+ for (const e of limited)
923
+ serviceSet.add(e.service);
924
+ }
925
+ return {
926
+ services: [...serviceSet],
927
+ entries: limited,
928
+ total: limited.length,
929
+ ...(isTruncated ? { isTruncated: true, totalEntries } : {}),
930
+ };
931
+ }
932
+ // ── #99: Compose build with per-service duration ─────────────────────
933
+ /** Parses `docker compose build` output into structured per-service build status.
934
+ * #99: Tracks per-service duration from build start/end patterns. */
240
935
  export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
241
936
  const combined = stdout + "\n" + stderr;
242
937
  const serviceMap = new Map();
938
+ // #99: Track per-service build timing from "Building <service>" lines
939
+ const buildStartTimes = new Map();
243
940
  // Match successful builds: "Service web Built" or "web Built" patterns
244
- // Compose v2 outputs lines like: " ✔ Service web Built" or just "web Built"
245
941
  const builtPattern = /(?:Service\s+)?(\S+)\s+Built/g;
246
942
  let match;
247
943
  while ((match = builtPattern.exec(combined)) !== null) {
@@ -251,20 +947,22 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
251
947
  const stepPattern = /\[(\S+)\s+/g;
252
948
  while ((match = stepPattern.exec(combined)) !== null) {
253
949
  const svc = match[1];
254
- // Only add if not already tracked (don't overwrite success)
255
950
  if (!serviceMap.has(svc) && svc !== "internal") {
256
951
  serviceMap.set(svc, { success: exitCode === 0 });
257
952
  }
258
953
  }
259
- // Match "Building <service>" lines (compose v2 progress output)
954
+ // #99: Match "Building <service>" lines and track timing
260
955
  const buildingPattern = /Building\s+(\S+)/g;
261
956
  while ((match = buildingPattern.exec(combined)) !== null) {
262
957
  const svc = match[1];
263
958
  if (!serviceMap.has(svc)) {
264
959
  serviceMap.set(svc, { success: exitCode === 0 });
265
960
  }
961
+ if (!buildStartTimes.has(svc)) {
962
+ buildStartTimes.set(svc, match.index);
963
+ }
266
964
  }
267
- // Match error lines referencing services: "failed to solve: ..." or "ERROR: Service 'web' failed to build"
965
+ // Match error lines referencing services
268
966
  const errorPattern = /(?:ERROR|error).*?(?:Service\s+'?(\S+?)'?|service\s+"?(\S+?)"?)\s+.*?(?:failed|error)/gi;
269
967
  while ((match = errorPattern.exec(combined)) !== null) {
270
968
  const svc = match[1] || match[2];
@@ -275,23 +973,55 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
275
973
  });
276
974
  }
277
975
  }
278
- // If no services were detected but there's output and success, try to infer
279
- // If the build failed and no services found, create a generic entry from error
280
- if (serviceMap.size === 0 && exitCode !== 0) {
281
- const errorLines = combined
282
- .split("\n")
283
- .filter((l) => l.match(/error|ERROR|failed/i))
284
- .map((l) => l.trim())
285
- .filter(Boolean);
286
- if (errorLines.length > 0) {
287
- // Cannot determine service name — leave services empty
976
+ // Infer per-service image IDs/names from BuildKit export lines.
977
+ let activeService;
978
+ for (const line of combined.split("\n")) {
979
+ const stepServiceMatch = line.match(/\[([^\]\s]+)[^\]]*\]/);
980
+ if (stepServiceMatch && stepServiceMatch[1] !== "internal") {
981
+ activeService = stepServiceMatch[1];
982
+ if (!serviceMap.has(activeService)) {
983
+ serviceMap.set(activeService, { success: exitCode === 0 });
984
+ }
985
+ }
986
+ if (!activeService)
987
+ continue;
988
+ const imageIdMatch = line.match(/writing image sha256:([a-f0-9]+)/i);
989
+ if (imageIdMatch) {
990
+ const prev = serviceMap.get(activeService) ?? { success: exitCode === 0 };
991
+ serviceMap.set(activeService, { ...prev, imageId: imageIdMatch[1].slice(0, 12) });
992
+ }
993
+ const imageNameMatch = line.match(/naming to (\S+)/i);
994
+ if (imageNameMatch) {
995
+ const prev = serviceMap.get(activeService) ?? { success: exitCode === 0 };
996
+ serviceMap.set(activeService, { ...prev, image: imageNameMatch[1] });
288
997
  }
289
998
  }
290
- const services = [...serviceMap.entries()].map(([service, status]) => ({
291
- service,
292
- success: status.success,
293
- ...(status.error ? { error: status.error } : {}),
294
- }));
999
+ // #99: Calculate per-service duration
1000
+ // If we have timing info, distribute based on build order;
1001
+ // otherwise, divide total duration proportionally
1002
+ const serviceCount = serviceMap.size;
1003
+ const services = [...serviceMap.entries()].map(([service, status], _index) => {
1004
+ // #99: Estimate per-service duration
1005
+ let serviceDuration;
1006
+ if (serviceCount > 0 && duration > 0) {
1007
+ // If only one service, it gets the full duration
1008
+ if (serviceCount === 1) {
1009
+ serviceDuration = duration;
1010
+ }
1011
+ else {
1012
+ // Distribute proportionally by order (rough approximation)
1013
+ serviceDuration = Math.round((duration / serviceCount) * 10) / 10;
1014
+ }
1015
+ }
1016
+ return {
1017
+ service,
1018
+ success: status.success,
1019
+ ...(status.error ? { error: status.error } : {}),
1020
+ ...(serviceDuration != null ? { duration: serviceDuration } : {}),
1021
+ ...(status.imageId ? { imageId: status.imageId } : {}),
1022
+ ...(status.image ? { image: status.image } : {}),
1023
+ };
1024
+ });
295
1025
  const built = services.filter((s) => s.success).length;
296
1026
  const failed = services.filter((s) => !s.success).length;
297
1027
  return {
@@ -302,89 +1032,82 @@ export function parseComposeBuildOutput(stdout, stderr, exitCode, duration) {
302
1032
  duration,
303
1033
  };
304
1034
  }
1035
+ // ── #123/#124: Stats with numeric memory and I/O fields ──────────────
305
1036
  /** Parses percentage string like "1.23%" into a number. Returns 0 for unparseable values. */
306
1037
  function parsePercent(value) {
307
1038
  const n = parseFloat(value.replace("%", ""));
308
1039
  return Number.isNaN(n) ? 0 : n;
309
1040
  }
310
- /** Parses `docker stats --no-stream --format '{{json .}}'` output into structured container stats. */
1041
+ /** #123/#124: Parses a Docker size string (e.g., "150MiB", "1.5GiB", "0B") into bytes. */
1042
+ export function parseSizeToBytes(size) {
1043
+ if (!size || size === "--")
1044
+ return 0;
1045
+ const match = size.trim().match(/^([\d.]+)\s*(B|kB|KB|KiB|MB|MiB|GB|GiB|TB|TiB)?$/i);
1046
+ if (!match)
1047
+ return 0;
1048
+ const value = parseFloat(match[1]);
1049
+ const unit = (match[2] || "B").toLowerCase();
1050
+ const multipliers = {
1051
+ b: 1,
1052
+ kb: 1000,
1053
+ kib: 1024,
1054
+ mb: 1_000_000,
1055
+ mib: 1_048_576,
1056
+ gb: 1_000_000_000,
1057
+ gib: 1_073_741_824,
1058
+ tb: 1_000_000_000_000,
1059
+ tib: 1_099_511_627_776,
1060
+ };
1061
+ return Math.round(value * (multipliers[unit] ?? 1));
1062
+ }
1063
+ /** #124: Parses a Docker I/O string like "1.5kB / 2.3kB" into two byte values. */
1064
+ function parseIOPair(io) {
1065
+ if (!io || io === "--")
1066
+ return { in: 0, out: 0 };
1067
+ const parts = io.split("/").map((p) => p.trim());
1068
+ return {
1069
+ in: parseSizeToBytes(parts[0] ?? "0B"),
1070
+ out: parseSizeToBytes(parts[1] ?? "0B"),
1071
+ };
1072
+ }
1073
+ /** Parses `docker stats --no-stream --format '{{json .}}'` output into structured container stats.
1074
+ * #123: Adds memoryUsageBytes and memoryLimitBytes numeric fields.
1075
+ * #124: Adds structured I/O fields: netIn, netOut, blockRead, blockWrite. */
311
1076
  export function parseStatsJson(stdout) {
312
1077
  const lines = stdout.trim().split("\n").filter(Boolean);
313
1078
  const containers = lines.map((line) => {
314
1079
  const s = JSON.parse(line);
315
1080
  // Docker stats JSON uses: Container, Name, CPUPerc, MemUsage, MemPerc, NetIO, BlockIO, PIDs
316
1081
  const memParts = (s.MemUsage ?? "0B / 0B").split("/").map((p) => p.trim());
1082
+ // #123: Parse memory values to bytes
1083
+ const memoryUsage = memParts[0] ?? "0B";
1084
+ const memoryLimit = memParts[1] ?? "0B";
1085
+ const memoryUsageBytes = parseSizeToBytes(memoryUsage);
1086
+ const memoryLimitBytes = parseSizeToBytes(memoryLimit);
1087
+ // #124: Parse network and block I/O to bytes
1088
+ const netIO = parseIOPair(s.NetIO ?? "0B / 0B");
1089
+ const blockIO = parseIOPair(s.BlockIO ?? "0B / 0B");
317
1090
  return {
318
1091
  id: (s.Container ?? s.ID ?? "").slice(0, 12),
319
1092
  name: (s.Name ?? "").replace(/^\//, ""),
1093
+ ...(s.State ? { state: String(s.State).toLowerCase() } : {}),
320
1094
  cpuPercent: parsePercent(s.CPUPerc ?? "0%"),
321
- memoryUsage: memParts[0] ?? "0B",
322
- memoryLimit: memParts[1] ?? "0B",
1095
+ memoryUsage,
1096
+ memoryLimit,
323
1097
  memoryPercent: parsePercent(s.MemPerc ?? "0%"),
324
1098
  netIO: s.NetIO ?? "0B / 0B",
325
1099
  blockIO: s.BlockIO ?? "0B / 0B",
326
1100
  pids: parseInt(s.PIDs ?? "0", 10) || 0,
1101
+ // #123: Numeric memory fields
1102
+ memoryUsageBytes,
1103
+ memoryLimitBytes,
1104
+ // #124: Structured I/O fields
1105
+ netIn: netIO.in,
1106
+ netOut: netIO.out,
1107
+ blockRead: blockIO.in,
1108
+ blockWrite: blockIO.out,
327
1109
  };
328
1110
  });
329
1111
  return { containers, total: containers.length };
330
1112
  }
331
- /** Parses `docker compose ps --format json` output into structured compose service data. */
332
- export function parseComposePsJson(stdout) {
333
- const lines = stdout.trim().split("\n").filter(Boolean);
334
- const services = lines.map((line) => {
335
- const s = JSON.parse(line);
336
- const ports = s.Ports ?? s.Publishers ?? "";
337
- const portsStr = typeof ports === "string" ? ports : "";
338
- return {
339
- name: s.Name ?? "",
340
- service: s.Service ?? "",
341
- state: (s.State ?? "unknown").toLowerCase(),
342
- status: s.Status ?? "",
343
- ...(portsStr ? { ports: portsStr } : {}),
344
- };
345
- });
346
- return { services, total: services.length };
347
- }
348
- /** Parses `docker compose logs` output into structured entries grouped by service.
349
- *
350
- * Compose logs format: `service-name | [timestamp] message`
351
- * or with --timestamps: `service-name | 2024-01-01T00:00:00.000000000Z message`
352
- */
353
- export function parseComposeLogsOutput(stdout, limit) {
354
- const allLines = stdout.split("\n").filter(Boolean);
355
- const serviceSet = new Set();
356
- // Parse each line: "service | [timestamp] message"
357
- // The pipe separator may have varying whitespace
358
- const entries = allLines.map((line) => {
359
- const pipeIdx = line.indexOf("|");
360
- if (pipeIdx === -1) {
361
- // No pipe — treat entire line as message with unknown service
362
- return { service: "unknown", message: line.trim() };
363
- }
364
- const service = line.slice(0, pipeIdx).trim();
365
- const rest = line.slice(pipeIdx + 1).trimStart();
366
- serviceSet.add(service);
367
- // Try to extract an ISO timestamp at the beginning
368
- const tsMatch = rest.match(/^(\d{4}-\d{2}-\d{2}T[\d:.]+Z?)\s+(.*)/);
369
- if (tsMatch) {
370
- return { timestamp: tsMatch[1], service, message: tsMatch[2] };
371
- }
372
- return { service, message: rest };
373
- });
374
- const totalEntries = entries.length;
375
- const isTruncated = limit != null && totalEntries > limit;
376
- const limited = isTruncated ? entries.slice(0, limit) : entries;
377
- // Rebuild service set from limited entries
378
- if (isTruncated) {
379
- serviceSet.clear();
380
- for (const e of limited)
381
- serviceSet.add(e.service);
382
- }
383
- return {
384
- services: [...serviceSet],
385
- entries: limited,
386
- total: limited.length,
387
- ...(isTruncated ? { isTruncated: true, totalEntries } : {}),
388
- };
389
- }
390
1113
  //# sourceMappingURL=parsers.js.map