laracrew 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/program.ts
4
- import { readFileSync as readFileSync4 } from "fs";
5
- import path11 from "path";
4
+ import { readFileSync as readFileSync5 } from "fs";
5
+ import path12 from "path";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
7
  import { Command } from "commander";
8
8
 
@@ -315,6 +315,7 @@ var mergeServiceFragments = (document, dir) => {
315
315
  const existing = Array.isArray(document.services) ? document.services : [];
316
316
  return { ...document, services: [...existing, ...extra] };
317
317
  };
318
+ var validateStack = (document, file) => validate(stackSchema, document, file);
318
319
  var loadStack = (name, env = process.env) => {
319
320
  const dir = stackDir(name, env);
320
321
  const file = stackFile(name, env);
@@ -345,41 +346,192 @@ var listTasks = (env = process.env) => {
345
346
  };
346
347
 
347
348
  // src/cli/render/colors.ts
348
- var CODES = {
349
- reset: 0,
350
- bold: 1,
351
- dim: 2,
352
- italic: 3,
353
- underline: 4,
354
- black: 30,
355
- red: 31,
356
- green: 32,
357
- yellow: 33,
358
- blue: 34,
359
- magenta: 35,
360
- cyan: 36,
361
- white: 37,
362
- gray: 90
349
+ import { Chalk, supportsColor as chalkDetected } from "chalk";
350
+
351
+ // src/core/logs/sanitize.ts
352
+ var ESC = String.fromCharCode(27);
353
+ var BEL = String.fromCharCode(7);
354
+ var CSI = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g");
355
+ var OSC = new RegExp(ESC + "\\][^" + BEL + ESC + "]*(?:" + BEL + "|" + ESC + "\\\\)", "g");
356
+ var SINGLE = new RegExp(ESC + "[=>78MD]", "g");
357
+ var SGR = new RegExp(ESC + "\\[[0-9;]*m", "g");
358
+ var stripAnsi = (text) => text.replace(SGR, "");
359
+ var TAB = 9;
360
+ var ESC_CODE = 27;
361
+ var sanitize = (input) => {
362
+ let out = input.replace(OSC, "").replace(SINGLE, "");
363
+ out = out.replace(CSI, (seq) => seq.endsWith("m") ? seq : "");
364
+ if (out.includes("\r")) {
365
+ const segments = out.split("\r").filter((segment) => segment !== "");
366
+ out = segments.length > 0 ? segments[segments.length - 1] : "";
367
+ }
368
+ return out.replace(new RegExp("\\p{Cc}", "gu"), (character) => {
369
+ const code = character.charCodeAt(0);
370
+ return code === TAB || code === ESC_CODE ? character : "";
371
+ });
372
+ };
373
+ var LineSplitter = class {
374
+ #buffer = "";
375
+ #maxLineLength;
376
+ constructor(maxLineLength = 16384) {
377
+ this.#maxLineLength = maxLineLength;
378
+ }
379
+ push(chunk) {
380
+ this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
381
+ const lines = [];
382
+ let index = this.#buffer.indexOf("\n");
383
+ while (index !== -1) {
384
+ const line = this.#buffer.slice(0, index).replace(/\r$/, "");
385
+ lines.push(sanitize(line));
386
+ this.#buffer = this.#buffer.slice(index + 1);
387
+ index = this.#buffer.indexOf("\n");
388
+ }
389
+ if (this.#buffer.length > this.#maxLineLength) {
390
+ lines.push(sanitize(this.#buffer));
391
+ this.#buffer = "";
392
+ }
393
+ return lines;
394
+ }
395
+ /** Emits whatever is left when the stream closes. */
396
+ flush() {
397
+ if (this.#buffer === "") return [];
398
+ const line = sanitize(this.#buffer.replace(/\r$/, ""));
399
+ this.#buffer = "";
400
+ return line === "" ? [] : [line];
401
+ }
363
402
  };
403
+
404
+ // src/cli/render/colors.ts
405
+ var NAMES = [
406
+ "reset",
407
+ "bold",
408
+ "dim",
409
+ "italic",
410
+ "underline",
411
+ "black",
412
+ "red",
413
+ "green",
414
+ "yellow",
415
+ "blue",
416
+ "magenta",
417
+ "cyan",
418
+ "white",
419
+ "gray"
420
+ ];
364
421
  var supportsColor = (stream = process.stdout, env = process.env) => {
365
422
  if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
366
- if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
423
+ if (env.FORCE_COLOR !== void 0) return env.FORCE_COLOR !== "0" && env.FORCE_COLOR !== "false";
367
424
  if (env.TERM === "dumb") return false;
368
425
  return Boolean(stream.isTTY);
369
426
  };
427
+ var levelFor = (enabled) => enabled ? Math.max(1, chalkDetected ? chalkDetected.level : 1) : 0;
428
+ var createColor = (enabled) => new Chalk({ level: levelFor(enabled) });
429
+ var color = createColor(supportsColor());
370
430
  var createPainter = (enabled) => {
371
- const paint2 = ((name, text) => enabled ? `\x1B[${CODES[name]}m${text}\x1B[${CODES.reset}m` : text);
372
- paint2.enabled = enabled;
373
- return paint2;
431
+ const chalk = createColor(enabled);
432
+ return Object.assign((name, text) => chalk[name](text), { enabled });
374
433
  };
375
434
  var paint = createPainter(supportsColor());
376
- var asColorName = (value) => value && value in CODES ? value : "cyan";
377
- var stripAnsi = (text) => text.replace(/\[[0-9;]*m/g, "");
435
+ var asColorName = (value) => value && NAMES.includes(value) ? value : "cyan";
378
436
  var padEnd = (text, width) => {
379
437
  const visible = stripAnsi(text).length;
380
438
  return visible >= width ? text : text + " ".repeat(width - visible);
381
439
  };
382
440
 
441
+ // src/tui/theme.ts
442
+ var asciiOnly = (env = process.env) => env.LARACREW_ASCII === "1" || env.TERM === "dumb";
443
+ var UNICODE = {
444
+ running: "\u25CF",
445
+ ready: "\u25CF",
446
+ starting: "\u25D0",
447
+ stopped: "\u25FC",
448
+ failed: "\u2716",
449
+ queued: "\u25CB",
450
+ branch: "\u251C\u2500",
451
+ lastBranch: "\u2514\u2500",
452
+ pipe: "\u2502",
453
+ pointer: "\u25B8",
454
+ restart: "\u27F3",
455
+ rule: "\u2500",
456
+ dot: "\xB7",
457
+ tick: "\u2714",
458
+ warn: "\u25B2",
459
+ arrow: "\u2192"
460
+ };
461
+ var ASCII = {
462
+ running: "*",
463
+ ready: "*",
464
+ starting: "o",
465
+ stopped: "#",
466
+ failed: "X",
467
+ queued: ".",
468
+ branch: "|-",
469
+ lastBranch: "`-",
470
+ pipe: "|",
471
+ pointer: ">",
472
+ restart: "r",
473
+ rule: "-",
474
+ dot: ".",
475
+ tick: "v",
476
+ warn: "!",
477
+ arrow: "->"
478
+ };
479
+ var glyphs = (env = process.env) => asciiOnly(env) ? ASCII : UNICODE;
480
+ var STATE_STYLE = {
481
+ queued: { glyph: "queued", color: "gray", label: "QUEUED" },
482
+ starting: { glyph: "starting", color: "yellow", label: "STARTING" },
483
+ ready: { glyph: "ready", color: "cyan", label: "READY" },
484
+ running: { glyph: "running", color: "green", label: "RUNNING" },
485
+ stopping: { glyph: "starting", color: "yellow", label: "STOPPING" },
486
+ stopped: { glyph: "stopped", color: "gray", label: "STOPPED" },
487
+ crashed: { glyph: "failed", color: "red", label: "CRASHED" },
488
+ backoff: { glyph: "starting", color: "yellow", label: "RESTARTING" },
489
+ failed: { glyph: "failed", color: "red", label: "FAILED" }
490
+ };
491
+ var formatUptime = (ms) => {
492
+ if (ms === void 0 || ms < 0) return "--:--:--";
493
+ const total = Math.floor(ms / 1e3);
494
+ const hours = Math.floor(total / 3600);
495
+ const minutes = Math.floor(total % 3600 / 60);
496
+ const seconds = total % 60;
497
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
498
+ };
499
+ var formatDuration = (ms) => {
500
+ if (ms === void 0 || ms < 0) return "\u2014";
501
+ const total = Math.floor(ms / 1e3);
502
+ if (total < 60) return `${total}s`;
503
+ const minutes = Math.floor(total / 60);
504
+ const seconds = total % 60;
505
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
506
+ const hours = Math.floor(minutes / 60);
507
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
508
+ };
509
+ var formatClock = (at) => {
510
+ const date = new Date(at);
511
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
512
+ };
513
+
514
+ // src/cli/render/marks.ts
515
+ var marks = (env = process.env) => {
516
+ const glyph = glyphs(env);
517
+ return {
518
+ ok: color.green(glyph.tick),
519
+ fail: color.red(glyph.failed),
520
+ warn: color.yellow(glyph.warn),
521
+ skip: color.gray(glyph.dot),
522
+ hint: color.cyan(glyph.pointer),
523
+ arrow: color.gray(glyph.arrow)
524
+ };
525
+ };
526
+ var nextSteps = (rows, env = process.env) => {
527
+ const mark = marks(env);
528
+ const width = Math.max(...rows.map(([command]) => command.length));
529
+ const lines = rows.map(
530
+ ([command, why]) => command ? ` ${mark.hint} ${color.cyan(padEnd(command, width))} ${why}` : `${" ".repeat(width + 6)}${why}`
531
+ );
532
+ return [color.bold("Next"), ...lines].join("\n");
533
+ };
534
+
383
535
  // src/cli/commands/doctor.ts
384
536
  import { existsSync as existsSync4 } from "fs";
385
537
  import path7 from "path";
@@ -1063,57 +1215,6 @@ var RingBuffer = class {
1063
1215
  }
1064
1216
  };
1065
1217
 
1066
- // src/core/logs/sanitize.ts
1067
- var ESC = String.fromCharCode(27);
1068
- var BEL = String.fromCharCode(7);
1069
- var CSI = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g");
1070
- var OSC = new RegExp(ESC + "\\][^" + BEL + ESC + "]*(?:" + BEL + "|" + ESC + "\\\\)", "g");
1071
- var SINGLE = new RegExp(ESC + "[=>78MD]", "g");
1072
- var TAB = 9;
1073
- var ESC_CODE = 27;
1074
- var sanitize = (input) => {
1075
- let out = input.replace(OSC, "").replace(SINGLE, "");
1076
- out = out.replace(CSI, (seq) => seq.endsWith("m") ? seq : "");
1077
- if (out.includes("\r")) {
1078
- const segments = out.split("\r").filter((segment) => segment !== "");
1079
- out = segments.length > 0 ? segments[segments.length - 1] : "";
1080
- }
1081
- return out.replace(new RegExp("\\p{Cc}", "gu"), (character) => {
1082
- const code = character.charCodeAt(0);
1083
- return code === TAB || code === ESC_CODE ? character : "";
1084
- });
1085
- };
1086
- var LineSplitter = class {
1087
- #buffer = "";
1088
- #maxLineLength;
1089
- constructor(maxLineLength = 16384) {
1090
- this.#maxLineLength = maxLineLength;
1091
- }
1092
- push(chunk) {
1093
- this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
1094
- const lines = [];
1095
- let index = this.#buffer.indexOf("\n");
1096
- while (index !== -1) {
1097
- const line = this.#buffer.slice(0, index).replace(/\r$/, "");
1098
- lines.push(sanitize(line));
1099
- this.#buffer = this.#buffer.slice(index + 1);
1100
- index = this.#buffer.indexOf("\n");
1101
- }
1102
- if (this.#buffer.length > this.#maxLineLength) {
1103
- lines.push(sanitize(this.#buffer));
1104
- this.#buffer = "";
1105
- }
1106
- return lines;
1107
- }
1108
- /** Emits whatever is left when the stream closes. */
1109
- flush() {
1110
- if (this.#buffer === "") return [];
1111
- const line = sanitize(this.#buffer.replace(/\r$/, ""));
1112
- this.#buffer = "";
1113
- return line === "" ? [] : [line];
1114
- }
1115
- };
1116
-
1117
1218
  // src/core/process/stop.ts
1118
1219
  import { spawn as spawn2 } from "child_process";
1119
1220
  var IS_WINDOWS2 = process.platform === "win32";
@@ -1721,6 +1822,7 @@ var time = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.
1721
1822
  var attachPlainRenderer = (bus, options) => {
1722
1823
  const write2 = options.write ?? ((text) => write(text));
1723
1824
  const painter = options.painter ?? paint;
1825
+ const glyph = options.glyph ?? glyphs();
1724
1826
  const showTime = options.timestamps ?? true;
1725
1827
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
1726
1828
  const colorOf = new Map(options.stack.services.map((service) => [service.name, asColorName(service.color)]));
@@ -1731,10 +1833,19 @@ var attachPlainRenderer = (bus, options) => {
1731
1833
  write2(`${stamp2}${prefix} ${painter("gray", "|")} ${text}
1732
1834
  `);
1733
1835
  };
1734
- const system = (text, level = "info") => {
1735
- const color = level === "error" ? "red" : level === "warn" ? "yellow" : "gray";
1736
- line("laracrew", painter(color, text), "bold");
1836
+ const note = (tone, text) => {
1837
+ switch (tone) {
1838
+ case "ok":
1839
+ return `${painter("green", glyph.tick)} ${painter("green", text)}`;
1840
+ case "warn":
1841
+ return `${painter("yellow", glyph.warn)} ${painter("yellow", text)}`;
1842
+ case "error":
1843
+ return `${painter("red", glyph.failed)} ${painter("red", text)}`;
1844
+ default:
1845
+ return painter("gray", text);
1846
+ }
1737
1847
  };
1848
+ const system = (text, tone = "info") => line("laracrew", note(tone, text), "bold");
1738
1849
  const handle = (event) => {
1739
1850
  switch (event.type) {
1740
1851
  case "service:log":
@@ -1749,7 +1860,7 @@ var attachPlainRenderer = (bus, options) => {
1749
1860
  break;
1750
1861
  }
1751
1862
  case "stack:ready":
1752
- system(`${event.stack} ready in ${(event.durationMs / 1e3).toFixed(1)}s`);
1863
+ system(`${event.stack} ready in ${(event.durationMs / 1e3).toFixed(1)}s`, "ok");
1753
1864
  break;
1754
1865
  case "stack:stopping":
1755
1866
  system(`stopping ${event.stack}\u2026`);
@@ -1761,30 +1872,32 @@ var attachPlainRenderer = (bus, options) => {
1761
1872
  line(event.service, painter("gray", `$ ${event.command}`));
1762
1873
  break;
1763
1874
  case "service:ready":
1764
- line(event.service, painter("green", `ready (${event.probe}) in ${event.durationMs}ms`));
1875
+ line(event.service, note("ok", `ready (${event.probe}) in ${event.durationMs}ms`));
1765
1876
  break;
1766
1877
  case "service:exit":
1767
1878
  if (!event.intentional) {
1768
- line(
1769
- event.service,
1770
- painter("red", `exited (${event.signal ? `signal ${event.signal}` : `code ${event.code}`})`)
1771
- );
1879
+ const clean = event.code === 0 && !event.signal;
1880
+ const why = event.signal ? `signal ${event.signal}` : `code ${event.code}`;
1881
+ line(event.service, note(clean ? "warn" : "error", `exited (${why})`));
1772
1882
  }
1773
1883
  break;
1774
1884
  case "service:restart":
1775
1885
  line(
1776
1886
  event.service,
1777
- painter("yellow", `restarting in ${event.delayMs}ms (attempt ${event.attempt}/${event.maxAttempts})`)
1887
+ `${painter("yellow", glyph.restart)} ${painter(
1888
+ "yellow",
1889
+ `restarting in ${event.delayMs}ms (attempt ${event.attempt}/${event.maxAttempts})`
1890
+ )}`
1778
1891
  );
1779
1892
  break;
1780
1893
  case "service:state":
1781
1894
  if (event.to === "failed") {
1782
- line(event.service, painter("red", `failed${event.detail ? `: ${event.detail}` : ""}`));
1895
+ line(event.service, note("error", `failed${event.detail ? `: ${event.detail}` : ""}`));
1783
1896
  }
1784
1897
  break;
1785
1898
  case "notice":
1786
1899
  if (event.service) {
1787
- line(event.service, painter(event.level === "error" ? "red" : "yellow", event.message));
1900
+ line(event.service, note(event.level, event.message));
1788
1901
  } else {
1789
1902
  system(event.message, event.level);
1790
1903
  }
@@ -1925,75 +2038,6 @@ var handleKey = (ui, key, rowCount) => {
1925
2038
  }
1926
2039
  };
1927
2040
 
1928
- // src/tui/theme.ts
1929
- var asciiOnly = (env = process.env) => env.LARACREW_ASCII === "1" || env.TERM === "dumb";
1930
- var UNICODE = {
1931
- running: "\u25CF",
1932
- ready: "\u25CF",
1933
- starting: "\u25D0",
1934
- stopped: "\u25FC",
1935
- failed: "\u2716",
1936
- queued: "\u25CB",
1937
- branch: "\u251C\u2500",
1938
- lastBranch: "\u2514\u2500",
1939
- pipe: "\u2502",
1940
- pointer: "\u25B8",
1941
- restart: "\u27F3",
1942
- rule: "\u2500",
1943
- dot: "\xB7",
1944
- tick: "\u2714"
1945
- };
1946
- var ASCII = {
1947
- running: "*",
1948
- ready: "*",
1949
- starting: "o",
1950
- stopped: "#",
1951
- failed: "X",
1952
- queued: ".",
1953
- branch: "|-",
1954
- lastBranch: "`-",
1955
- pipe: "|",
1956
- pointer: ">",
1957
- restart: "r",
1958
- rule: "-",
1959
- dot: ".",
1960
- tick: "v"
1961
- };
1962
- var glyphs = (env = process.env) => asciiOnly(env) ? ASCII : UNICODE;
1963
- var STATE_STYLE = {
1964
- queued: { glyph: "queued", color: "gray", label: "QUEUED" },
1965
- starting: { glyph: "starting", color: "yellow", label: "STARTING" },
1966
- ready: { glyph: "ready", color: "cyan", label: "READY" },
1967
- running: { glyph: "running", color: "green", label: "RUNNING" },
1968
- stopping: { glyph: "starting", color: "yellow", label: "STOPPING" },
1969
- stopped: { glyph: "stopped", color: "gray", label: "STOPPED" },
1970
- crashed: { glyph: "failed", color: "red", label: "CRASHED" },
1971
- backoff: { glyph: "starting", color: "yellow", label: "RESTARTING" },
1972
- failed: { glyph: "failed", color: "red", label: "FAILED" }
1973
- };
1974
- var formatUptime = (ms) => {
1975
- if (ms === void 0 || ms < 0) return "--:--:--";
1976
- const total = Math.floor(ms / 1e3);
1977
- const hours = Math.floor(total / 3600);
1978
- const minutes = Math.floor(total % 3600 / 60);
1979
- const seconds = total % 60;
1980
- return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
1981
- };
1982
- var formatDuration = (ms) => {
1983
- if (ms === void 0 || ms < 0) return "\u2014";
1984
- const total = Math.floor(ms / 1e3);
1985
- if (total < 60) return `${total}s`;
1986
- const minutes = Math.floor(total / 60);
1987
- const seconds = total % 60;
1988
- if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1989
- const hours = Math.floor(minutes / 60);
1990
- return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
1991
- };
1992
- var formatClock = (at) => {
1993
- const date = new Date(at);
1994
- return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
1995
- };
1996
-
1997
2041
  // src/tui/screen.ts
1998
2042
  var initialUiState = () => ({
1999
2043
  view: "overview",
@@ -2096,8 +2140,8 @@ var processLine = (row, isSelected, isLast, nameWidth, { paint: paint2, glyph, s
2096
2140
  var dependencyLine = (row, nameWidth, { paint: paint2, glyph, size }) => {
2097
2141
  const style = STATE_STYLE[row.state];
2098
2142
  const label = row.state === "ready" || row.state === "running" ? "ready" : style.label.toLowerCase();
2099
- const color = row.state === "ready" || row.state === "running" ? "green" : style.color;
2100
- const line = ` ${paint2(color, glyph[style.glyph])} ${pad(row.name, nameWidth)}${paint2(color, pad(label, 11))}${paint2("gray", row.probe ?? "")}`;
2143
+ const color2 = row.state === "ready" || row.state === "running" ? "green" : style.color;
2144
+ const line = ` ${paint2(color2, glyph[style.glyph])} ${pad(row.name, nameWidth)}${paint2(color2, pad(label, 11))}${paint2("gray", row.probe ?? "")}`;
2101
2145
  return truncate(line, size.columns + (line.length - stripAnsi(line).length));
2102
2146
  };
2103
2147
  var dependencies = (input, nameWidth) => {
@@ -2355,7 +2399,7 @@ var runTui = async (supervisor, bus, options = {}) => {
2355
2399
  });
2356
2400
  const basePainter = createPainter(supportsColor(stdout, env));
2357
2401
  const paint2 = Object.assign(
2358
- (color, text) => basePainter(color, text),
2402
+ (color2, text) => basePainter(color2, text),
2359
2403
  { enabled: basePainter.enabled }
2360
2404
  );
2361
2405
  const glyph = glyphs(env);
@@ -2523,6 +2567,7 @@ var shouldUseTui = (options, stdout = process.stdout) => !options.plain && !opti
2523
2567
  var upCommand = async (name, options = {}) => {
2524
2568
  const stack = prepareStack(name, options);
2525
2569
  const bus = new EventBus();
2570
+ const mark = marks(options.env);
2526
2571
  if (shouldUseTui(options)) {
2527
2572
  const supervisor2 = new Supervisor(stack, bus, options.env ? { env: options.env } : {});
2528
2573
  return runTui(supervisor2, bus, options.env ? { env: options.env } : {});
@@ -2538,7 +2583,8 @@ var upCommand = async (name, options = {}) => {
2538
2583
  };
2539
2584
  const onSignal = (signal) => {
2540
2585
  if (stopping) {
2541
- console.error(paint("red", "\nforced exit \u2014 some children may survive"));
2586
+ console.error(`
2587
+ ${mark.fail} ${color.red.bold("forced exit \u2014 some children may survive")}`);
2542
2588
  process.exit(130);
2543
2589
  }
2544
2590
  void shutdown(`received ${signal}`);
@@ -2552,7 +2598,7 @@ var upCommand = async (name, options = {}) => {
2552
2598
  await supervisor.up();
2553
2599
  await waitUntilIdle(supervisor, () => stopping);
2554
2600
  } catch (error) {
2555
- console.error(paint("red", error instanceof Error ? error.message : String(error)));
2601
+ console.error(`${mark.fail} ${color.red(error instanceof Error ? error.message : String(error))}`);
2556
2602
  exitCode = 1;
2557
2603
  } finally {
2558
2604
  await shutdown("done");
@@ -2868,27 +2914,577 @@ var runDoctor = async (stackName, env = process.env) => {
2868
2914
  };
2869
2915
  var doctorCommand = async (stackName, env = process.env) => {
2870
2916
  const findings = await runDoctor(stackName, env);
2871
- const glyph = { ok: paint("green", "\u2714"), warn: paint("yellow", "\u25B2"), error: paint("red", "\u2716") };
2917
+ const mark = marks(env);
2918
+ console.log(`${color.bold("doctor")} ${color.gray(glyphs(env).dot)} ${color.cyan.bold(stackName)}
2919
+ `);
2920
+ const lead = { ok: mark.ok, warn: mark.warn, error: mark.fail };
2921
+ const tone = { ok: (text) => text, warn: color.yellow, error: color.red };
2872
2922
  for (const finding of findings) {
2873
- console.log(`${glyph[finding.level]} ${finding.message}`);
2874
- if (finding.hint) console.log(` ${paint("gray", finding.hint)}`);
2923
+ console.log(`${lead[finding.level]} ${tone[finding.level](finding.message)}`);
2924
+ if (finding.hint) console.log(` ${color.gray(finding.hint)}`);
2875
2925
  }
2876
2926
  const errors = findings.filter((finding) => finding.level === "error").length;
2877
2927
  const warnings = findings.filter((finding) => finding.level === "warn").length;
2928
+ const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`;
2929
+ const tail = warnings > 0 ? color.yellow(`, ${count(warnings, "warning")}`) : "";
2878
2930
  console.log(
2879
- `
2880
- ${errors === 0 ? paint("green", "no blocking problems") : paint("red", `${errors} problem(s)`)}` + (warnings > 0 ? paint("yellow", `, ${warnings} warning(s)`) : "")
2931
+ errors === 0 ? `
2932
+ ${mark.ok} ${color.green.bold("no blocking problems")}${tail}` : `
2933
+ ${mark.fail} ${color.red.bold(count(errors, "problem"))}${tail}`
2881
2934
  );
2882
2935
  return errors > 0 ? 1 : 0;
2883
2936
  };
2884
2937
 
2885
- // src/cli/commands/init.ts
2886
- import { mkdirSync as mkdirSync2, writeFileSync, existsSync as existsSync5 } from "fs";
2887
- import path8 from "path";
2938
+ // src/cli/commands/draft.ts
2939
+ import { existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync4, readdirSync as readdirSync3, realpathSync, writeFileSync as writeFileSync2 } from "fs";
2940
+ import path9 from "path";
2941
+ import { isMap, isSeq, parseDocument } from "yaml";
2888
2942
 
2889
- // src/cli/commands/templates.ts
2890
- var CONFIG_YAML = `# laracrew global preferences
2891
- # theme: auto | dark | light | mono | high-contrast
2943
+ // src/cli/commands/link.ts
2944
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync3, readdirSync as readdirSync2, unlinkSync, writeFileSync } from "fs";
2945
+ import path8 from "path";
2946
+ import { fileURLToPath } from "url";
2947
+ var IS_WINDOWS3 = process.platform === "win32";
2948
+ var MARKER = "laracrew-generated";
2949
+ var RESERVED = /* @__PURE__ */ new Set(["laracrew", "node", "npm", "npx", "php", "git", "cd", "ls", "rm", "sh", "bash"]);
2950
+ var VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
2951
+ var entryScript = (argv = process.argv) => {
2952
+ const main = argv[1];
2953
+ if (!main) return fileURLToPath(import.meta.url);
2954
+ const resolved = path8.resolve(main);
2955
+ if (!resolved.endsWith(".ts")) return resolved;
2956
+ const dist = path8.join(path8.dirname(path8.dirname(resolved)), "dist", "index.js");
2957
+ if (existsSync5(dist)) return dist;
2958
+ throw new ConfigError("run `npm run build` first \u2014 a generated command has to point at dist/index.js");
2959
+ };
2960
+ var currentTarget = (stack) => ({
2961
+ node: process.execPath,
2962
+ entry: entryScript(),
2963
+ stack
2964
+ });
2965
+ var forward = (value) => value.replace(/\\/g, "/");
2966
+ var shShim = ({ node, entry, stack }) => `#!/bin/sh
2967
+ # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
2968
+ exec "${forward(node)}" "${forward(entry)}" up ${stack} "$@"
2969
+ `;
2970
+ var cmdShim = ({ node, entry, stack }) => `@ECHO OFF\r
2971
+ REM ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.\r
2972
+ "${node}" "${entry}" up ${stack} %*\r
2973
+ `;
2974
+ var ps1Shim = ({ node, entry, stack }) => `#!/usr/bin/env pwsh
2975
+ # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
2976
+ & "${node}" "${entry}" up ${stack} @args
2977
+ exit $LASTEXITCODE
2978
+ `;
2979
+ var shimFiles = (name, target) => {
2980
+ const posix = { file: name, contents: shShim(target), exec: true };
2981
+ if (!IS_WINDOWS3) return [posix];
2982
+ return [
2983
+ posix,
2984
+ // Git Bash reads the extensionless file
2985
+ { file: `${name}.cmd`, contents: cmdShim(target), exec: false },
2986
+ { file: `${name}.ps1`, contents: ps1Shim(target), exec: true }
2987
+ ];
2988
+ };
2989
+ var pathDirs = (env) => (env.PATH ?? env.Path ?? "").split(path8.delimiter).filter(Boolean);
2990
+ var samePath = (a, b) => IS_WINDOWS3 ? a.toLowerCase() === b.toLowerCase() : a === b;
2991
+ var ownBinDir = (env) => {
2992
+ for (const dir of pathDirs(env)) {
2993
+ for (const candidate of IS_WINDOWS3 ? ["laracrew.cmd", "laracrew"] : ["laracrew"]) {
2994
+ try {
2995
+ if (existsSync5(path8.join(dir, candidate))) return dir;
2996
+ } catch {
2997
+ }
2998
+ }
2999
+ }
3000
+ return void 0;
3001
+ };
3002
+ var resolveBinDir = (env = process.env) => {
3003
+ if (env.LARACREW_BIN) return { dir: path8.resolve(env.LARACREW_BIN), onPath: true };
3004
+ const own = ownBinDir(env);
3005
+ if (own) return { dir: own, onPath: true };
3006
+ const conventional = IS_WINDOWS3 ? env.APPDATA ? path8.join(env.APPDATA, "npm") : void 0 : path8.dirname(process.execPath);
3007
+ if (conventional && existsSync5(conventional) && pathDirs(env).some((dir) => samePath(dir, conventional))) {
3008
+ return { dir: conventional, onPath: true };
3009
+ }
3010
+ return { dir: path8.join(paths(env).root, "bin"), onPath: false };
3011
+ };
3012
+ var isOurs = (file) => {
3013
+ try {
3014
+ return readFileSync3(file, "utf8").includes(MARKER);
3015
+ } catch {
3016
+ return false;
3017
+ }
3018
+ };
3019
+ var linkStack = (stackName, options = {}) => {
3020
+ const env = options.env ?? process.env;
3021
+ const { config } = loadStack(stackName, env);
3022
+ const name = options.as ?? config.command ?? stackName;
3023
+ if (!VALID_NAME.test(name)) {
3024
+ throw new ConfigError(
3025
+ `"${name}" is not a usable command name \u2014 letters, digits, dot, dash and underscore only`
3026
+ );
3027
+ }
3028
+ if (RESERVED.has(name.toLowerCase()) && !options.force) {
3029
+ throw new ConfigError(`"${name}" would shadow an important command`, {
3030
+ details: ["pick another with `--as <name>`, or pass --force if you really mean it"]
3031
+ });
3032
+ }
3033
+ const resolved = options.dir ? { dir: path8.resolve(options.dir), onPath: true } : resolveBinDir(env);
3034
+ mkdirSync2(resolved.dir, { recursive: true });
3035
+ const target = currentTarget(stackName);
3036
+ const planned = shimFiles(name, target);
3037
+ let replaced = false;
3038
+ for (const { file } of planned) {
3039
+ const full = path8.join(resolved.dir, file);
3040
+ if (!existsSync5(full)) continue;
3041
+ if (isOurs(full)) {
3042
+ replaced = true;
3043
+ continue;
3044
+ }
3045
+ if (!options.force) {
3046
+ throw new ConfigError(`"${full}" already exists and was not created by laracrew`, {
3047
+ details: ["choose another name with `--as <name>`, or pass --force to overwrite"]
3048
+ });
3049
+ }
3050
+ replaced = true;
3051
+ }
3052
+ const written = [];
3053
+ for (const { file, contents, exec } of planned) {
3054
+ const full = path8.join(resolved.dir, file);
3055
+ writeFileSync(full, contents, "utf8");
3056
+ if (exec && !IS_WINDOWS3) chmodSync(full, 493);
3057
+ else if (exec) {
3058
+ try {
3059
+ chmodSync(full, 493);
3060
+ } catch {
3061
+ }
3062
+ }
3063
+ written.push(full);
3064
+ }
3065
+ return { name, stack: stackName, dir: resolved.dir, onPath: resolved.onPath, files: written, replaced };
3066
+ };
3067
+ var unlinkCommandName = (name, options = {}) => {
3068
+ const env = options.env ?? process.env;
3069
+ const dir = options.dir ? path8.resolve(options.dir) : resolveBinDir(env).dir;
3070
+ const removed = [];
3071
+ for (const candidate of [name, `${name}.cmd`, `${name}.ps1`]) {
3072
+ const full = path8.join(dir, candidate);
3073
+ if (!existsSync5(full)) continue;
3074
+ if (!isOurs(full)) {
3075
+ throw new ConfigError(`"${full}" was not created by laracrew \u2014 leaving it alone`);
3076
+ }
3077
+ unlinkSync(full);
3078
+ removed.push(full);
3079
+ }
3080
+ if (removed.length === 0) throw new ConfigError(`no laracrew command named "${name}" in ${dir}`);
3081
+ return removed;
3082
+ };
3083
+ var listLinks = (options = {}) => {
3084
+ const env = options.env ?? process.env;
3085
+ const dir = options.dir ? path8.resolve(options.dir) : resolveBinDir(env).dir;
3086
+ if (!existsSync5(dir)) return [];
3087
+ const found = [];
3088
+ for (const entry of readdirSync2(dir).sort()) {
3089
+ if (entry.endsWith(".cmd") || entry.endsWith(".ps1")) continue;
3090
+ const full = path8.join(dir, entry);
3091
+ let contents;
3092
+ try {
3093
+ contents = readFileSync3(full, "utf8");
3094
+ } catch {
3095
+ continue;
3096
+ }
3097
+ if (!contents.includes(MARKER)) continue;
3098
+ const stack = /\bup ([^\s"]+)/.exec(contents)?.[1];
3099
+ if (stack) found.push({ name: entry, stack, file: full });
3100
+ }
3101
+ return found;
3102
+ };
3103
+ var linkCommand = (stackName, options, env = process.env) => {
3104
+ const stacks = options.all ? listStacks(env) : stackName ? [stackName] : [];
3105
+ if (stacks.length === 0) {
3106
+ throw new ConfigError("name a stack to link, or pass --all");
3107
+ }
3108
+ const results = [];
3109
+ for (const stack of stacks) {
3110
+ if (options.all) {
3111
+ const { config } = loadStack(stack, env);
3112
+ if (!config.command) continue;
3113
+ }
3114
+ results.push(
3115
+ linkStack(stack, {
3116
+ ...options.as ? { as: options.as } : {},
3117
+ ...options.dir ? { dir: options.dir } : {},
3118
+ ...options.force ? { force: options.force } : {},
3119
+ env
3120
+ })
3121
+ );
3122
+ }
3123
+ const mark = marks(env);
3124
+ if (results.length === 0) {
3125
+ console.log(
3126
+ `${mark.warn} ${color.yellow("nothing to link \u2014 add `command: <name>` to the stacks you want a command for")}`
3127
+ );
3128
+ return 0;
3129
+ }
3130
+ for (const result of results) {
3131
+ const verb = result.replaced ? "updated" : "created";
3132
+ console.log(
3133
+ `${mark.ok} ${color.green(verb)} ${color.bold(result.name)} ${mark.arrow} ${color.gray(`laracrew up ${result.stack}`)}`
3134
+ );
3135
+ }
3136
+ const first = results[0];
3137
+ console.log(`
3138
+ ${color.gray(`in ${first.dir}`)}`);
3139
+ if (!first.onPath) {
3140
+ console.log(
3141
+ `
3142
+ ${mark.warn} ${color.yellow("that directory is not on your PATH.")} Add it once:
3143
+ ` + (IS_WINDOWS3 ? ` ${color.cyan(`setx PATH "%PATH%;${first.dir}"`)} ${color.gray("(then reopen the terminal)")}` : ` ${color.cyan(`echo 'export PATH="$PATH:${first.dir}"' >> ~/.bashrc`)}`)
3144
+ );
3145
+ } else {
3146
+ console.log(`
3147
+ Run it from anywhere: ${color.cyan.bold(first.name)}`);
3148
+ console.log(color.gray(`Flags pass straight through: ${first.name} --only workers`));
3149
+ }
3150
+ return 0;
3151
+ };
3152
+ var unlinkCommandAction = (name, env = process.env) => {
3153
+ const removed = unlinkCommandName(name, { env });
3154
+ const mark = marks(env);
3155
+ for (const file of removed) console.log(`${mark.ok} removed ${color.gray(file)}`);
3156
+ return 0;
3157
+ };
3158
+
3159
+ // src/cli/commands/draft.ts
3160
+ var SUFFIX = ".laracrew.yaml";
3161
+ var DRAFT_FILE = /^(.+)\.laracrew\.ya?ml$/i;
3162
+ var NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
3163
+ var SERVICE_NAME = /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/;
3164
+ var RUNNERS = /* @__PURE__ */ new Set(["node", "npm", "npx", "yarn", "pnpm", "bun", "deno", "python", "python3", "php", "run", "exec", "poetry"]);
3165
+ var forward2 = (value) => value.replace(/\\/g, "/");
3166
+ var canonical = (value) => {
3167
+ try {
3168
+ return realpathSync.native(value);
3169
+ } catch {
3170
+ return path9.resolve(value);
3171
+ }
3172
+ };
3173
+ var samePath2 = (a, b) => {
3174
+ const [x, y] = [canonical(a), canonical(b)];
3175
+ return process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
3176
+ };
3177
+ var isRecord2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3178
+ var draftFile = (name, cwd = process.cwd()) => path9.join(cwd, `${name}${SUFFIX}`);
3179
+ var listDrafts = (cwd = process.cwd()) => readdirSync3(cwd).map((entry) => ({ entry, match: DRAFT_FILE.exec(entry) })).filter((item) => item.match !== null).map(({ entry, match }) => ({ name: match[1], file: path9.join(cwd, entry) })).sort((a, b) => a.name.localeCompare(b.name));
3180
+ var resolveDraft = (cwd = process.cwd(), name, pick = "--draft <name>") => {
3181
+ if (name) {
3182
+ const file = draftFile(name, cwd);
3183
+ if (!existsSync6(file)) {
3184
+ throw new ConfigError(`no draft named "${name}" in ${cwd}`, { details: ["start one with `laracrew draft <name>`"] });
3185
+ }
3186
+ return { name, file };
3187
+ }
3188
+ const drafts = listDrafts(cwd);
3189
+ if (drafts.length === 1) return drafts[0];
3190
+ if (drafts.length === 0) {
3191
+ throw new ConfigError(`no draft in ${cwd}`, { details: ["start one with `laracrew draft <name>`"] });
3192
+ }
3193
+ throw new ConfigError(`several drafts here: ${drafts.map((draft) => draft.name).join(", ")} \u2014 pick one with ${pick}`);
3194
+ };
3195
+ var template = (name) => `# Built with \`laracrew draft\`. Add services with \`laracrew draft add\`, install the stack with
3196
+ # \`laracrew draft publish\`, or edit by hand \u2014 this is an ordinary stack.yaml.
3197
+ #
3198
+ # laracrew draft add --project api --path C:/work/api --command "php artisan queue:work"
3199
+ # laracrew draft add --project api --command "php artisan schedule:work"
3200
+ # laracrew draft add --command "npm run dev" --default-state stopped
3201
+ name: ${name}
3202
+ command: ${name} # \`laracrew link ${name}\` installs this as a global command
3203
+ projects: {}
3204
+ services: []
3205
+ `;
3206
+ var createDraft = (name, options = {}) => {
3207
+ const cwd = options.cwd ?? process.cwd();
3208
+ if (!NAME.test(name)) {
3209
+ throw new ConfigError(`"${name}" is not a usable stack name \u2014 letters, digits, dot, dash and underscore only`);
3210
+ }
3211
+ if (name === "add" || name === "publish") {
3212
+ throw new ConfigError(`"${name}" would clash with \`laracrew draft ${name}\` \u2014 pick another name`);
3213
+ }
3214
+ const file = draftFile(name, cwd);
3215
+ if (existsSync6(file) && !options.force) {
3216
+ throw new ConfigError(`${path9.basename(file)} already exists here`, {
3217
+ details: ["add to it with `laracrew draft add`, or pass --force to start over"]
3218
+ });
3219
+ }
3220
+ writeFileSync2(file, template(name), "utf8");
3221
+ return file;
3222
+ };
3223
+ var readDraft = (file) => {
3224
+ const doc = parseDocument(readFileSync4(file, "utf8"), { prettyErrors: true });
3225
+ const [problem] = doc.errors;
3226
+ if (problem) throw new ConfigError(`invalid YAML: ${problem.message}`, { file });
3227
+ if (!isMap(doc.contents)) throw new ConfigError("the draft should be a YAML mapping (name:, services: \u2026)", { file });
3228
+ return doc;
3229
+ };
3230
+ var mapAt = (doc, key, file) => {
3231
+ let node = doc.get(key);
3232
+ if (node === void 0 || node === null) {
3233
+ node = doc.createNode({});
3234
+ doc.set(key, node);
3235
+ }
3236
+ if (!isMap(node)) throw new ConfigError(`\`${key}:\` should be a map`, { file });
3237
+ return node;
3238
+ };
3239
+ var seqAt = (doc, key, file) => {
3240
+ let node = doc.get(key);
3241
+ if (node === void 0 || node === null) {
3242
+ node = doc.createNode([]);
3243
+ doc.set(key, node);
3244
+ }
3245
+ if (!isSeq(node)) throw new ConfigError(`\`${key}:\` should be a list`, { file });
3246
+ return node;
3247
+ };
3248
+ var deriveServiceName = (command, project) => {
3249
+ const artisan = /\bartisan\s+([A-Za-z0-9:_.-]+)/.exec(command);
3250
+ let verb = artisan?.[1];
3251
+ if (!verb) {
3252
+ const words = command.trim().split(/\s+/).filter((word2) => word2 && !word2.startsWith("-")).map((word2) => word2.split(/[\\/]/).pop() ?? word2);
3253
+ const word = words.find((candidate) => !RUNNERS.has(candidate.toLowerCase())) ?? words[0] ?? "service";
3254
+ verb = word.replace(/\.[A-Za-z0-9]+$/, "");
3255
+ }
3256
+ const clean = verb.toLowerCase().replace(/[^a-z0-9:_.-]+/g, "-").replace(/^[-.:]+|[-.:]+$/g, "") || "service";
3257
+ return project ? `${project}:${clean}` : clean;
3258
+ };
3259
+ var uniqueName = (base, taken) => {
3260
+ if (!taken.has(base)) return base;
3261
+ for (let n = 2; ; n += 1) {
3262
+ const candidate = `${base}-${n}`;
3263
+ if (!taken.has(candidate)) return candidate;
3264
+ }
3265
+ };
3266
+ var parseState = (value) => {
3267
+ if (value === void 0 || value === "running") return true;
3268
+ if (value === "stopped") return false;
3269
+ throw new ConfigError(`--default-state is "running" or "stopped", not "${value}"`);
3270
+ };
3271
+ var addToDraft = (options) => {
3272
+ const cwd = options.cwd ?? process.cwd();
3273
+ const command = options.command.trim();
3274
+ if (!command) throw new ConfigError('--command needs something to run, e.g. "php artisan queue:work"');
3275
+ const autostart = parseState(options.state);
3276
+ if (options.project !== void 0 && !NAME.test(options.project)) {
3277
+ throw new ConfigError(`"${options.project}" is not a usable project key \u2014 letters, digits, dot, dash and underscore only`);
3278
+ }
3279
+ const { name: draftName, file } = resolveDraft(cwd, options.draft);
3280
+ const doc = readDraft(file);
3281
+ let project;
3282
+ if (options.project) {
3283
+ const projects = mapAt(doc, "projects", file);
3284
+ const wanted = options.path ? forward2(path9.resolve(cwd, options.path)) : void 0;
3285
+ const existing = projects.get(options.project);
3286
+ if (existing !== void 0 && existing !== null) {
3287
+ const current = isMap(existing) ? String(existing.get("path") ?? "") : "";
3288
+ if (wanted && !samePath2(current, wanted)) {
3289
+ throw new ConfigError(`project "${options.project}" already points at ${current}`, {
3290
+ file,
3291
+ details: ["a draft keeps one path per project \u2014 leave --path off to reuse it, or pick another key"]
3292
+ });
3293
+ }
3294
+ project = { key: options.project, path: current, created: false };
3295
+ } else {
3296
+ const dir = wanted ?? forward2(cwd);
3297
+ projects.set(options.project, doc.createNode({ path: dir }));
3298
+ projects.flow = false;
3299
+ project = { key: options.project, path: dir, created: true };
3300
+ }
3301
+ }
3302
+ const services = seqAt(doc, "services", file);
3303
+ const taken = new Set(services.items.map((item) => isMap(item) ? String(item.get("name") ?? "") : ""));
3304
+ let name;
3305
+ if (options.name) {
3306
+ if (!SERVICE_NAME.test(options.name)) {
3307
+ throw new ConfigError(`"${options.name}" is not a usable service name \u2014 letters, digits, colon, dot, dash and underscore only`);
3308
+ }
3309
+ if (taken.has(options.name)) throw new ConfigError(`a service named "${options.name}" is already in the draft`, { file });
3310
+ name = options.name;
3311
+ } else {
3312
+ name = uniqueName(deriveServiceName(command, options.project), taken);
3313
+ }
3314
+ const service = { name };
3315
+ if (options.project) service.project = options.project;
3316
+ service.cmd = command;
3317
+ const serviceCwd = options.project ? void 0 : forward2(path9.resolve(cwd, options.path ?? "."));
3318
+ if (serviceCwd) service.cwd = serviceCwd;
3319
+ if (!autostart) service.autostart = false;
3320
+ services.add(doc.createNode(service));
3321
+ services.flow = false;
3322
+ writeFileSync2(file, doc.toString(), "utf8");
3323
+ return {
3324
+ draft: draftName,
3325
+ file,
3326
+ service: { name, cmd: command, autostart, ...serviceCwd ? { cwd: serviceCwd } : {} },
3327
+ ...project ? { project } : {},
3328
+ services: services.items.length
3329
+ };
3330
+ };
3331
+ var describeDraft = (cwd = process.cwd(), name) => {
3332
+ const ref = resolveDraft(cwd, name, "laracrew draft <name>");
3333
+ const raw = parseYaml(readFileSync4(ref.file, "utf8"), ref.file);
3334
+ const record = isRecord2(raw) ? raw : {};
3335
+ const stackName = typeof record.name === "string" ? record.name : ref.name;
3336
+ const projects = isRecord2(record.projects) ? Object.entries(record.projects).map(([key, value]) => ({
3337
+ key,
3338
+ path: isRecord2(value) && typeof value.path === "string" ? value.path : ""
3339
+ })) : [];
3340
+ const services = Array.isArray(record.services) ? record.services.filter(isRecord2).map((entry) => ({
3341
+ name: typeof entry.name === "string" ? entry.name : "?",
3342
+ cmd: Array.isArray(entry.cmd) ? entry.cmd.map(String).join(" ") : typeof entry.cmd === "string" ? entry.cmd : "",
3343
+ ...typeof entry.project === "string" ? { project: entry.project } : {},
3344
+ autostart: entry.autostart !== false
3345
+ })) : [];
3346
+ return {
3347
+ name: ref.name,
3348
+ file: ref.file,
3349
+ stackName,
3350
+ command: typeof record.command === "string" ? record.command : stackName,
3351
+ projects,
3352
+ services
3353
+ };
3354
+ };
3355
+ var publishDraft = (options = {}) => {
3356
+ const cwd = options.cwd ?? process.cwd();
3357
+ const env = options.env ?? process.env;
3358
+ const { name: draftName, file } = resolveDraft(cwd, options.draft);
3359
+ const source = readFileSync4(file, "utf8");
3360
+ const raw = parseYaml(source, file);
3361
+ if (isRecord2(raw) && Array.isArray(raw.services) && raw.services.length === 0) {
3362
+ throw new ConfigError("the draft has no services yet", {
3363
+ file,
3364
+ details: ['add one with `laracrew draft add --command "\u2026"`']
3365
+ });
3366
+ }
3367
+ const config = validateStack(raw, file);
3368
+ const target = stackFile(config.name, env);
3369
+ const replaced = existsSync6(target);
3370
+ if (replaced && !options.force) {
3371
+ throw new ConfigError(`a stack named "${config.name}" already exists`, {
3372
+ file: target,
3373
+ details: ["pass --force to replace it, or change `name:` in the draft"]
3374
+ });
3375
+ }
3376
+ mkdirSync3(stackDir(config.name, env), { recursive: true });
3377
+ writeFileSync2(target, source, "utf8");
3378
+ return { name: config.name, draft: draftName, file, target, replaced, config };
3379
+ };
3380
+ var plural = (count, noun) => `${count} ${noun}${count === 1 ? "" : "s"}`;
3381
+ var draftCommand = (name, options = {}, cwd = process.cwd(), env = process.env) => {
3382
+ const mark = marks(env);
3383
+ if (name && (options.force || !existsSync6(draftFile(name, cwd)))) {
3384
+ const file = createDraft(name, { cwd, ...options.force ? { force: true } : {} });
3385
+ console.log(`${mark.ok} started draft ${color.bold(name)} ${mark.arrow} ${color.gray(path9.basename(file))}`);
3386
+ console.log(
3387
+ `
3388
+ ${nextSteps(
3389
+ [
3390
+ ["laracrew draft add", `--project api --path C:/work/api --command ${color.green('"php artisan queue:work"')}`],
3391
+ ["", `--project api --command ${color.green('"php artisan schedule:work"')} ${color.gray("the path is remembered")}`],
3392
+ ["", `--command ${color.green('"npm run dev"')} --default-state stopped ${color.gray("idle until you press s")}`],
3393
+ ["laracrew draft", "show what the draft holds so far"],
3394
+ ["laracrew draft publish", `install it as the ${color.cyan(name)} stack in ~/.laracrew`]
3395
+ ],
3396
+ env
3397
+ )}
3398
+ `
3399
+ );
3400
+ return 0;
3401
+ }
3402
+ const draft = describeDraft(cwd, name);
3403
+ console.log(`${color.bold("draft")} ${color.gray("\xB7")} ${color.cyan.bold(draft.stackName)} ${color.gray(path9.basename(draft.file))}`);
3404
+ if (draft.command !== draft.stackName) console.log(` ${color.gray(`command: ${draft.command}`)}`);
3405
+ console.log(`
3406
+ ${color.bold("PROJECTS")} ${color.gray(`(${draft.projects.length})`)}`);
3407
+ if (draft.projects.length === 0) console.log(color.gray(" none \u2014 `--project <key> --path <dir>` on the next add"));
3408
+ const keyWidth = Math.max(4, ...draft.projects.map((project) => project.key.length));
3409
+ for (const project of draft.projects) {
3410
+ console.log(` ${color.magenta.bold(padEnd(project.key, keyWidth))} ${project.path}`);
3411
+ }
3412
+ console.log(`
3413
+ ${color.bold("SERVICES")} ${color.gray(`(${draft.services.length})`)}`);
3414
+ if (draft.services.length === 0) console.log(color.gray(' none yet \u2014 `laracrew draft add --command "\u2026"`'));
3415
+ const nameWidth = Math.max(4, ...draft.services.map((service) => service.name.length));
3416
+ for (const service of draft.services) {
3417
+ const idle = service.autostart ? "" : ` ${color.yellow("idle until started")}`;
3418
+ console.log(` ${color.cyan.bold(padEnd(service.name, nameWidth))} ${service.cmd}${idle}`);
3419
+ }
3420
+ console.log(
3421
+ `
3422
+ ${nextSteps(
3423
+ [
3424
+ ["laracrew draft add", `--command ${color.green('"\u2026"')} to add another`],
3425
+ ["laracrew draft publish", `install it as the ${color.cyan(draft.stackName)} stack`]
3426
+ ],
3427
+ env
3428
+ )}
3429
+ `
3430
+ );
3431
+ return 0;
3432
+ };
3433
+ var draftAddCommand = (options, cwd = process.cwd(), env = process.env) => {
3434
+ const result = addToDraft({ ...options, cwd });
3435
+ const mark = marks(env);
3436
+ const idle = result.service.autostart ? "" : ` ${color.yellow("idle until started")}`;
3437
+ console.log(`${mark.ok} added ${color.bold(result.service.name)} ${mark.arrow} ${color.gray(result.service.cmd)}${idle}`);
3438
+ if (result.project?.created) {
3439
+ const guessed = options.path ? "" : ` ${color.yellow("this folder \u2014 pass --path if that is wrong")}`;
3440
+ console.log(` ${mark.ok} project ${color.magenta.bold(result.project.key)} ${mark.arrow} ${color.gray(result.project.path)}${guessed}`);
3441
+ }
3442
+ if (result.service.cwd) console.log(` ${color.gray(`runs in ${result.service.cwd}`)}`);
3443
+ console.log(
3444
+ ` ${color.gray(`${plural(result.services, "service")} in ${path9.basename(result.file)} \u2014 \`laracrew draft publish\` when done`)}`
3445
+ );
3446
+ return 0;
3447
+ };
3448
+ var draftPublishCommand = (options = {}, cwd = process.cwd(), env = process.env) => {
3449
+ const result = publishDraft({
3450
+ cwd,
3451
+ env,
3452
+ ...options.draft ? { draft: options.draft } : {},
3453
+ ...options.force ? { force: true } : {}
3454
+ });
3455
+ const mark = marks(env);
3456
+ const command = result.config.command ?? result.name;
3457
+ console.log(
3458
+ `${mark.ok} ${color.green(result.replaced ? "replaced" : "published")} ${color.bold(result.name)} ${mark.arrow} ${color.gray(result.target)}`
3459
+ );
3460
+ console.log(
3461
+ ` ${color.gray(
3462
+ `${plural(Object.keys(result.config.projects).length, "project")}, ${plural(result.config.services.length, "service")}; the draft stays at ${path9.basename(result.file)}`
3463
+ )}`
3464
+ );
3465
+ if (options.link) {
3466
+ console.log("");
3467
+ const code = linkCommand(result.name, {}, env);
3468
+ if (code !== 0) return code;
3469
+ }
3470
+ const steps = [
3471
+ [`laracrew doctor ${result.name}`, "check paths, ports and PHP before the first boot"],
3472
+ [`laracrew up ${result.name}`, "boot it"]
3473
+ ];
3474
+ if (!options.link) steps.push([`laracrew link ${result.name}`, `type ${color.cyan(command)} from anywhere to boot it`]);
3475
+ console.log(`
3476
+ ${nextSteps(steps, env)}
3477
+ `);
3478
+ return 0;
3479
+ };
3480
+
3481
+ // src/cli/commands/init.ts
3482
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync3, existsSync as existsSync7 } from "fs";
3483
+ import path10 from "path";
3484
+
3485
+ // src/cli/commands/templates.ts
3486
+ var CONFIG_YAML = `# laracrew global preferences
3487
+ # theme: auto | dark | light | mono | high-contrast
2892
3488
  theme: auto
2893
3489
 
2894
3490
  # Polling interval for queue depth and stream lag, in milliseconds.
@@ -3068,6 +3664,7 @@ secrets in it.
3068
3664
  Start here:
3069
3665
 
3070
3666
  laracrew init --examples # add a demo stack and a two-project template
3667
+ laracrew draft <name> # build a stack in a project folder, one command at a time
3071
3668
  laracrew ls # what is defined
3072
3669
  laracrew doctor <stack> # check a stack before booting it
3073
3670
  laracrew link <stack> # give a stack its own global command
@@ -3079,278 +3676,65 @@ var runInit = (env = process.env, options = {}) => {
3079
3676
  const created = [];
3080
3677
  const skipped = [];
3081
3678
  for (const dir of [home2.root, home2.stacksDir, home2.tasksDir, home2.fragmentsDir, home2.logsDir, home2.runDir]) {
3082
- mkdirSync2(dir, { recursive: true });
3679
+ mkdirSync4(dir, { recursive: true });
3083
3680
  }
3084
3681
  const files = [
3085
3682
  [home2.configFile, CONFIG_YAML],
3086
3683
  [home2.projectsFile, PROJECTS_YAML],
3087
- [path8.join(home2.root, "README.md"), HOME_README]
3684
+ [path10.join(home2.root, "README.md"), HOME_README]
3088
3685
  ];
3089
3686
  if (options.examples) {
3090
- mkdirSync2(path8.join(home2.stacksDir, "example"), { recursive: true });
3091
- mkdirSync2(path8.join(home2.stacksDir, "dual"), { recursive: true });
3687
+ mkdirSync4(path10.join(home2.stacksDir, "example"), { recursive: true });
3688
+ mkdirSync4(path10.join(home2.stacksDir, "dual"), { recursive: true });
3092
3689
  files.push(
3093
- [path8.join(home2.stacksDir, "example", "stack.yaml"), EXAMPLE_STACK_YAML],
3094
- [path8.join(home2.stacksDir, "dual", "stack.yaml"), REAL_STACK_TEMPLATE],
3095
- [path8.join(home2.tasksDir, "reset.yaml"), RESET_TASK_YAML]
3690
+ [path10.join(home2.stacksDir, "example", "stack.yaml"), EXAMPLE_STACK_YAML],
3691
+ [path10.join(home2.stacksDir, "dual", "stack.yaml"), REAL_STACK_TEMPLATE],
3692
+ [path10.join(home2.tasksDir, "reset.yaml"), RESET_TASK_YAML]
3096
3693
  );
3097
3694
  }
3098
3695
  for (const [file, contents] of files) {
3099
- if (existsSync5(file)) {
3696
+ if (existsSync7(file)) {
3100
3697
  skipped.push(file);
3101
3698
  continue;
3102
3699
  }
3103
- writeFileSync(file, contents, "utf8");
3700
+ writeFileSync3(file, contents, "utf8");
3104
3701
  created.push(file);
3105
3702
  }
3106
3703
  return { root: home2.root, created, skipped, examples: options.examples ?? false };
3107
3704
  };
3108
3705
  var initCommand = (options = {}, env = process.env) => {
3109
3706
  const result = runInit(env, options);
3110
- console.log(`${paint("bold", "laracrew")} home: ${result.root}`);
3111
- for (const file of result.created) console.log(` ${paint("green", "+")} ${path8.relative(result.root, file)}`);
3707
+ const mark = marks(env);
3708
+ console.log(`${color.bold("laracrew home")} ${color.gray(result.root)}`);
3709
+ for (const file of result.created) console.log(` ${mark.ok} ${path10.relative(result.root, file)}`);
3112
3710
  for (const file of result.skipped) {
3113
- console.log(` ${paint("gray", "\xB7")} ${path8.relative(result.root, file)} ${paint("gray", "(kept)")}`);
3114
- }
3115
- if (result.examples) {
3116
- console.log(`
3117
- ${paint("bold", "Next:")}
3118
- ${paint("cyan", "laracrew up example")} run the demo stack \u2014 proves it works, no Laravel needed
3119
- ${paint("cyan", "laracrew link example")} install it as a global command you can type from anywhere
3120
- edit ${paint("gray", path8.join(result.root, "projects.yaml"))} with your project paths,
3121
- then ${paint("cyan", "laracrew doctor dual")} and ${paint("cyan", "laracrew up dual")}
3122
- `);
3123
- return;
3124
- }
3711
+ console.log(` ${mark.skip} ${color.gray(`${path10.relative(result.root, file)} (kept)`)}`);
3712
+ }
3713
+ const steps = result.examples ? [
3714
+ ["laracrew up example", "run the demo stack \u2014 proves it works, no Laravel needed"],
3715
+ ["laracrew link example", "install it as a global command you can type from anywhere"],
3716
+ ["laracrew doctor dual", `check the real stack once ${color.gray(path10.join(result.root, "projects.yaml"))}`],
3717
+ ["", `has your project paths, then ${color.cyan("laracrew up dual")}`]
3718
+ ] : [
3719
+ ["laracrew init --examples", "add a runnable demo stack and a two-project template,"],
3720
+ ["", `or write your own at ${color.gray(path10.join(result.root, "stacks", "<name>", "stack.yaml"))}`],
3721
+ ["laracrew draft <name>", "or build one in a project folder, one command at a time"],
3722
+ ["laracrew up <name>", `boot it \u2014 ${color.cyan("laracrew ls")} shows what is defined`]
3723
+ ];
3125
3724
  console.log(`
3126
- ${paint("bold", "Next:")}
3127
- ${paint("cyan", "laracrew init --examples")} add a runnable demo stack and a two-project template
3128
- or write your own at ${paint("gray", path8.join(result.root, "stacks", "<name>", "stack.yaml"))}
3129
- then ${paint("cyan", "laracrew ls")} and ${paint("cyan", "laracrew up <name>")}
3725
+ ${nextSteps(steps, env)}
3130
3726
  `);
3131
3727
  };
3132
3728
 
3133
- // src/cli/commands/link.ts
3134
- import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
3135
- import path9 from "path";
3136
- import { fileURLToPath } from "url";
3137
- var IS_WINDOWS3 = process.platform === "win32";
3138
- var MARKER = "laracrew-generated";
3139
- var RESERVED = /* @__PURE__ */ new Set(["laracrew", "node", "npm", "npx", "php", "git", "cd", "ls", "rm", "sh", "bash"]);
3140
- var VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
3141
- var entryScript = (argv = process.argv) => {
3142
- const main = argv[1];
3143
- if (!main) return fileURLToPath(import.meta.url);
3144
- const resolved = path9.resolve(main);
3145
- if (!resolved.endsWith(".ts")) return resolved;
3146
- const dist = path9.join(path9.dirname(path9.dirname(resolved)), "dist", "index.js");
3147
- if (existsSync6(dist)) return dist;
3148
- throw new ConfigError("run `npm run build` first \u2014 a generated command has to point at dist/index.js");
3149
- };
3150
- var currentTarget = (stack) => ({
3151
- node: process.execPath,
3152
- entry: entryScript(),
3153
- stack
3154
- });
3155
- var forward = (value) => value.replace(/\\/g, "/");
3156
- var shShim = ({ node, entry, stack }) => `#!/bin/sh
3157
- # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
3158
- exec "${forward(node)}" "${forward(entry)}" up ${stack} "$@"
3159
- `;
3160
- var cmdShim = ({ node, entry, stack }) => `@ECHO OFF\r
3161
- REM ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.\r
3162
- "${node}" "${entry}" up ${stack} %*\r
3163
- `;
3164
- var ps1Shim = ({ node, entry, stack }) => `#!/usr/bin/env pwsh
3165
- # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
3166
- & "${node}" "${entry}" up ${stack} @args
3167
- exit $LASTEXITCODE
3168
- `;
3169
- var shimFiles = (name, target) => {
3170
- const posix = { file: name, contents: shShim(target), exec: true };
3171
- if (!IS_WINDOWS3) return [posix];
3172
- return [
3173
- posix,
3174
- // Git Bash reads the extensionless file
3175
- { file: `${name}.cmd`, contents: cmdShim(target), exec: false },
3176
- { file: `${name}.ps1`, contents: ps1Shim(target), exec: true }
3177
- ];
3178
- };
3179
- var pathDirs = (env) => (env.PATH ?? env.Path ?? "").split(path9.delimiter).filter(Boolean);
3180
- var samePath = (a, b) => IS_WINDOWS3 ? a.toLowerCase() === b.toLowerCase() : a === b;
3181
- var ownBinDir = (env) => {
3182
- for (const dir of pathDirs(env)) {
3183
- for (const candidate of IS_WINDOWS3 ? ["laracrew.cmd", "laracrew"] : ["laracrew"]) {
3184
- try {
3185
- if (existsSync6(path9.join(dir, candidate))) return dir;
3186
- } catch {
3187
- }
3188
- }
3189
- }
3190
- return void 0;
3191
- };
3192
- var resolveBinDir = (env = process.env) => {
3193
- if (env.LARACREW_BIN) return { dir: path9.resolve(env.LARACREW_BIN), onPath: true };
3194
- const own = ownBinDir(env);
3195
- if (own) return { dir: own, onPath: true };
3196
- const conventional = IS_WINDOWS3 ? env.APPDATA ? path9.join(env.APPDATA, "npm") : void 0 : path9.dirname(process.execPath);
3197
- if (conventional && existsSync6(conventional) && pathDirs(env).some((dir) => samePath(dir, conventional))) {
3198
- return { dir: conventional, onPath: true };
3199
- }
3200
- return { dir: path9.join(paths(env).root, "bin"), onPath: false };
3201
- };
3202
- var isOurs = (file) => {
3203
- try {
3204
- return readFileSync3(file, "utf8").includes(MARKER);
3205
- } catch {
3206
- return false;
3207
- }
3208
- };
3209
- var linkStack = (stackName, options = {}) => {
3210
- const env = options.env ?? process.env;
3211
- const { config } = loadStack(stackName, env);
3212
- const name = options.as ?? config.command ?? stackName;
3213
- if (!VALID_NAME.test(name)) {
3214
- throw new ConfigError(
3215
- `"${name}" is not a usable command name \u2014 letters, digits, dot, dash and underscore only`
3216
- );
3217
- }
3218
- if (RESERVED.has(name.toLowerCase()) && !options.force) {
3219
- throw new ConfigError(`"${name}" would shadow an important command`, {
3220
- details: ["pick another with `--as <name>`, or pass --force if you really mean it"]
3221
- });
3222
- }
3223
- const resolved = options.dir ? { dir: path9.resolve(options.dir), onPath: true } : resolveBinDir(env);
3224
- mkdirSync3(resolved.dir, { recursive: true });
3225
- const target = currentTarget(stackName);
3226
- const planned = shimFiles(name, target);
3227
- let replaced = false;
3228
- for (const { file } of planned) {
3229
- const full = path9.join(resolved.dir, file);
3230
- if (!existsSync6(full)) continue;
3231
- if (isOurs(full)) {
3232
- replaced = true;
3233
- continue;
3234
- }
3235
- if (!options.force) {
3236
- throw new ConfigError(`"${full}" already exists and was not created by laracrew`, {
3237
- details: ["choose another name with `--as <name>`, or pass --force to overwrite"]
3238
- });
3239
- }
3240
- replaced = true;
3241
- }
3242
- const written = [];
3243
- for (const { file, contents, exec } of planned) {
3244
- const full = path9.join(resolved.dir, file);
3245
- writeFileSync2(full, contents, "utf8");
3246
- if (exec && !IS_WINDOWS3) chmodSync(full, 493);
3247
- else if (exec) {
3248
- try {
3249
- chmodSync(full, 493);
3250
- } catch {
3251
- }
3252
- }
3253
- written.push(full);
3254
- }
3255
- return { name, stack: stackName, dir: resolved.dir, onPath: resolved.onPath, files: written, replaced };
3256
- };
3257
- var unlinkCommandName = (name, options = {}) => {
3258
- const env = options.env ?? process.env;
3259
- const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3260
- const removed = [];
3261
- for (const candidate of [name, `${name}.cmd`, `${name}.ps1`]) {
3262
- const full = path9.join(dir, candidate);
3263
- if (!existsSync6(full)) continue;
3264
- if (!isOurs(full)) {
3265
- throw new ConfigError(`"${full}" was not created by laracrew \u2014 leaving it alone`);
3266
- }
3267
- unlinkSync(full);
3268
- removed.push(full);
3269
- }
3270
- if (removed.length === 0) throw new ConfigError(`no laracrew command named "${name}" in ${dir}`);
3271
- return removed;
3272
- };
3273
- var listLinks = (options = {}) => {
3274
- const env = options.env ?? process.env;
3275
- const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3276
- if (!existsSync6(dir)) return [];
3277
- const found = [];
3278
- for (const entry of readdirSync2(dir).sort()) {
3279
- if (entry.endsWith(".cmd") || entry.endsWith(".ps1")) continue;
3280
- const full = path9.join(dir, entry);
3281
- let contents;
3282
- try {
3283
- contents = readFileSync3(full, "utf8");
3284
- } catch {
3285
- continue;
3286
- }
3287
- if (!contents.includes(MARKER)) continue;
3288
- const stack = /\bup ([^\s"]+)/.exec(contents)?.[1];
3289
- if (stack) found.push({ name: entry, stack, file: full });
3290
- }
3291
- return found;
3292
- };
3293
- var linkCommand = (stackName, options, env = process.env) => {
3294
- const stacks = options.all ? listStacks(env) : stackName ? [stackName] : [];
3295
- if (stacks.length === 0) {
3296
- throw new ConfigError("name a stack to link, or pass --all");
3297
- }
3298
- const results = [];
3299
- for (const stack of stacks) {
3300
- if (options.all) {
3301
- const { config } = loadStack(stack, env);
3302
- if (!config.command) continue;
3303
- }
3304
- results.push(
3305
- linkStack(stack, {
3306
- ...options.as ? { as: options.as } : {},
3307
- ...options.dir ? { dir: options.dir } : {},
3308
- ...options.force ? { force: options.force } : {},
3309
- env
3310
- })
3311
- );
3312
- }
3313
- if (results.length === 0) {
3314
- console.log(paint("yellow", "nothing to link \u2014 add `command: <name>` to the stacks you want a command for"));
3315
- return 0;
3316
- }
3317
- for (const result of results) {
3318
- const verb = result.replaced ? "updated" : "created";
3319
- console.log(
3320
- `${paint("green", verb)} ${paint("bold", result.name)} ${paint("gray", `-> laracrew up ${result.stack}`)}`
3321
- );
3322
- }
3323
- const first = results[0];
3324
- console.log(paint("gray", `
3325
- in ${first.dir}`));
3326
- if (!first.onPath) {
3327
- console.log(
3328
- `
3329
- ${paint("yellow", "that directory is not on your PATH.")} Add it once:
3330
- ` + (IS_WINDOWS3 ? ` ${paint("cyan", `setx PATH "%PATH%;${first.dir}"`)} ${paint("gray", "(then reopen the terminal)")}` : ` ${paint("cyan", `echo 'export PATH="$PATH:${first.dir}"' >> ~/.bashrc`)}`)
3331
- );
3332
- } else {
3333
- console.log(`
3334
- Run it from anywhere: ${paint("cyan", first.name)}`);
3335
- console.log(paint("gray", `Flags pass straight through: ${first.name} --only workers`));
3336
- }
3337
- return 0;
3338
- };
3339
- var unlinkCommandAction = (name, env = process.env) => {
3340
- const removed = unlinkCommandName(name, { env });
3341
- for (const file of removed) console.log(`${paint("red", "removed")} ${file}`);
3342
- return 0;
3343
- };
3344
-
3345
3729
  // src/cli/commands/logs.ts
3346
- import { existsSync as existsSync8, readdirSync as readdirSync3 } from "fs";
3347
- import path10 from "path";
3730
+ import { existsSync as existsSync9, readdirSync as readdirSync4 } from "fs";
3731
+ import path11 from "path";
3348
3732
 
3349
3733
  // src/core/logs/read.ts
3350
- import { openSync as openSync2, readSync, closeSync as closeSync2, statSync as statSync3, existsSync as existsSync7 } from "fs";
3734
+ import { openSync as openSync2, readSync, closeSync as closeSync2, statSync as statSync3, existsSync as existsSync8 } from "fs";
3351
3735
  var CHUNK = 64 * 1024;
3352
3736
  var tailFile = (file, limit) => {
3353
- if (!existsSync7(file) || limit <= 0) return [];
3737
+ if (!existsSync8(file) || limit <= 0) return [];
3354
3738
  const size = statSync3(file).size;
3355
3739
  if (size === 0) return [];
3356
3740
  const handle = openSync2(file, "r");
@@ -3397,10 +3781,10 @@ var parseSince = (input) => {
3397
3781
  return Number.isNaN(absolute) ? void 0 : absolute;
3398
3782
  };
3399
3783
  var followFile = (file, options) => {
3400
- let position = existsSync7(file) ? statSync3(file).size : 0;
3784
+ let position = existsSync8(file) ? statSync3(file).size : 0;
3401
3785
  let carry = "";
3402
3786
  const read = () => {
3403
- if (!existsSync7(file)) return;
3787
+ if (!existsSync8(file)) return;
3404
3788
  const size = statSync3(file).size;
3405
3789
  if (size === position) return;
3406
3790
  if (size < position) {
@@ -3429,23 +3813,23 @@ var followFile = (file, options) => {
3429
3813
  };
3430
3814
 
3431
3815
  // src/cli/commands/logs.ts
3432
- var logDirFor = (stackName, env = process.env) => path10.join(paths(env).logsDir, stackName);
3816
+ var logDirFor = (stackName, env = process.env) => path11.join(paths(env).logsDir, stackName);
3433
3817
  var targetsFor = (stackName, env = process.env) => {
3434
3818
  const stack = prepareStack(stackName, { env });
3435
3819
  const dir = logDirFor(stackName, env);
3436
3820
  const names = fileNamesFor(stack.services.map((service) => service.name));
3437
3821
  return stack.services.map((service) => ({
3438
3822
  service: service.name,
3439
- file: path10.join(dir, names.get(service.name)),
3823
+ file: path11.join(dir, names.get(service.name)),
3440
3824
  color: service.color
3441
- })).filter((target) => existsSync8(target.file));
3825
+ })).filter((target) => existsSync9(target.file));
3442
3826
  };
3443
3827
  var formatEntry = (entry, width, withName) => {
3444
3828
  const time2 = new Date(entry.at || Date.now());
3445
3829
  const clock = [time2.getHours(), time2.getMinutes(), time2.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
3446
- const who = withName ? `${paint(asColorName(void 0), padEnd(entry.service, width))} ` : "";
3447
- const body = entry.stream === "stderr" ? paint("red", entry.line) : entry.line;
3448
- return `${paint("gray", clock)} ${who}${paint("gray", "|")} ${body}
3830
+ const who = withName ? `${color.cyan(padEnd(entry.service, width))} ` : "";
3831
+ const body = entry.stream === "stderr" ? color.red(entry.line) : entry.line;
3832
+ return `${color.gray(clock)} ${who}${color.gray("|")} ${body}
3449
3833
  `;
3450
3834
  };
3451
3835
  var logsCommand = async (service, options = {}) => {
@@ -3454,25 +3838,24 @@ var logsCommand = async (service, options = {}) => {
3454
3838
  if (!stackName) throw new ConfigError("name a stack with --stack, or run `laracrew logs` from a known stack");
3455
3839
  const dir = logDirFor(stackName, env);
3456
3840
  const available = targetsFor(stackName, env);
3841
+ const mark = marks(env);
3457
3842
  if (options.list) {
3458
3843
  if (available.length === 0) {
3459
- console.log(paint("yellow", `no log files yet in ${dir}`));
3460
- console.log(paint("gray", "they appear once the stack has run at least once"));
3844
+ console.log(`${mark.warn} ${color.yellow(`no log files yet in ${dir}`)}`);
3845
+ console.log(` ${color.gray("they appear once the stack has run at least once")}`);
3461
3846
  return 0;
3462
3847
  }
3463
- console.log(paint("gray", dir));
3848
+ console.log(`${color.bold("logs")} ${color.gray(glyphs(env).dot)} ${color.cyan.bold(stackName)}`);
3849
+ console.log(` ${color.gray(dir)}`);
3464
3850
  for (const target of available) {
3465
- console.log(` ${paint("cyan", target.service)} ${paint("gray", path10.basename(target.file))}`);
3851
+ console.log(` ${color.cyan(target.service)} ${color.gray(path11.basename(target.file))}`);
3466
3852
  }
3467
3853
  return 0;
3468
3854
  }
3469
3855
  if (available.length === 0) {
3470
- console.error(paint("yellow", `no log files in ${dir}`));
3856
+ console.error(`${mark.warn} ${color.yellow(`no log files in ${dir}`)}`);
3471
3857
  console.error(
3472
- paint(
3473
- "gray",
3474
- "run the stack at least once, and check `defaults.logs.toFile` is not set to false"
3475
- )
3858
+ ` ${color.gray("run the stack at least once, and check `defaults.logs.toFile` is not set to false")}`
3476
3859
  );
3477
3860
  return 1;
3478
3861
  }
@@ -3534,11 +3917,12 @@ var logsCommand = async (service, options = {}) => {
3534
3917
  };
3535
3918
 
3536
3919
  // src/cli/commands/ls.ts
3537
- import { existsSync as existsSync9 } from "fs";
3920
+ import { existsSync as existsSync10 } from "fs";
3538
3921
  var lsCommand = (env = process.env, json = false) => {
3539
3922
  const home2 = paths(env);
3540
- if (!existsSync9(home2.root)) {
3541
- console.error(`${paint("yellow", "no laracrew home yet")} \u2014 run ${paint("cyan", "laracrew init")}`);
3923
+ const mark = marks(env);
3924
+ if (!existsSync10(home2.root)) {
3925
+ console.error(`${mark.warn} ${color.yellow("no laracrew home yet")} \u2014 run ${color.cyan("laracrew init")}`);
3542
3926
  return 1;
3543
3927
  }
3544
3928
  const stackNames = listStacks(env);
@@ -3571,43 +3955,46 @@ var lsCommand = (env = process.env, json = false) => {
3571
3955
  console.log(JSON.stringify({ root: home2.root, stacks, tasks: taskNames, projects }, null, 2));
3572
3956
  return 0;
3573
3957
  }
3574
- console.log(paint("gray", home2.root));
3575
- console.log(`
3576
- ${paint("bold", "STACKS")}`);
3577
- if (stacks.length === 0) console.log(paint("gray", " none"));
3958
+ const heading = (label, count) => `
3959
+ ${color.bold(label)} ${color.gray(`(${count})`)}`;
3960
+ console.log(`${color.bold("laracrew")} ${color.gray(home2.root)}`);
3961
+ console.log(heading("STACKS", stacks.length));
3962
+ if (stacks.length === 0) {
3963
+ console.log(color.gray(" none \u2014 run `laracrew init --examples`, or write stacks/<name>/stack.yaml"));
3964
+ }
3578
3965
  const width = Math.max(4, ...stacks.map((stack) => stack.name.length));
3966
+ const indent = " ".repeat(width + 4);
3579
3967
  for (const stack of stacks) {
3580
- const label = stack.valid ? paint("cyan", padEnd(stack.name, width)) : paint("red", padEnd(stack.name, width));
3581
- const meta = stack.valid ? paint("gray", `${stack.services} services`) : paint("red", "invalid");
3582
- console.log(` ${label} ${meta} ${paint("gray", stack.description)}`);
3583
- if (stack.command) {
3584
- console.log(` ${" ".repeat(width)} ${paint("green", `$ ${stack.command}`)} ${paint("gray", "(global command)")}`);
3585
- }
3586
- if (stack.profiles.length > 0) {
3587
- console.log(` ${" ".repeat(width)} ${paint("gray", `profiles: ${stack.profiles.join(", ")}`)}`);
3968
+ if (stack.valid) {
3969
+ const meta = padEnd(`${stack.services} service${stack.services === 1 ? "" : "s"}`, 11);
3970
+ console.log(` ${color.cyan.bold(padEnd(stack.name, width))} ${color.gray(meta)} ${stack.description}`.trimEnd());
3971
+ } else {
3972
+ console.log(
3973
+ `${mark.fail} ${color.red.bold(padEnd(stack.name, width))} ${color.red(padEnd("invalid", 11))} ${color.red(stack.description)}`
3974
+ );
3588
3975
  }
3976
+ if (stack.command) console.log(`${indent}${color.green(`$ ${stack.command}`)} ${color.gray("global command")}`);
3977
+ if (stack.profiles.length > 0) console.log(`${indent}${color.gray(`profiles: ${stack.profiles.join(", ")}`)}`);
3589
3978
  }
3590
- console.log(`
3591
- ${paint("bold", "PROJECTS")}`);
3592
3979
  const projectKeys = Object.keys(projects);
3593
- if (projectKeys.length === 0) console.log(paint("gray", " none \u2014 add them to projects.yaml"));
3980
+ console.log(heading("PROJECTS", projectKeys.length));
3981
+ if (projectKeys.length === 0) console.log(color.gray(" none \u2014 add them to projects.yaml"));
3594
3982
  const projectWidth = Math.max(4, ...projectKeys.map((key) => key.length));
3595
3983
  for (const [key, project] of Object.entries(projects)) {
3596
- const exists = existsSync9(project.path);
3597
- console.log(
3598
- ` ${paint("magenta", padEnd(key, projectWidth))} ${project.path} ${exists ? "" : paint("red", "(missing)")}`
3599
- );
3600
- }
3601
- console.log(`
3602
- ${paint("bold", "TASKS")}`);
3603
- if (taskNames.length === 0) console.log(paint("gray", " none"));
3604
- for (const task of taskNames) console.log(` ${paint("green", task)}`);
3984
+ const exists = existsSync10(project.path);
3985
+ const lead = exists ? " " : `${mark.fail} `;
3986
+ const where = exists ? project.path : color.red(`${project.path} (missing)`);
3987
+ console.log(`${lead}${color.magenta.bold(padEnd(key, projectWidth))} ${where}`);
3988
+ }
3989
+ console.log(heading("TASKS", taskNames.length));
3990
+ if (taskNames.length === 0) console.log(color.gray(" none"));
3991
+ for (const task of taskNames) console.log(` ${color.green(task)}`);
3605
3992
  console.log("");
3606
3993
  return 0;
3607
3994
  };
3608
3995
 
3609
3996
  // src/cli/program.ts
3610
- var VERSION = true ? "0.2.2" : versionFromPackageJson();
3997
+ var VERSION = true ? "0.3.0" : versionFromPackageJson();
3611
3998
  var resolveStackName = (explicit, env = process.env) => {
3612
3999
  if (explicit) return explicit;
3613
4000
  const configured = loadGlobalConfig(env).defaultStack;
@@ -3625,10 +4012,38 @@ var resolveStackName = (explicit, env = process.env) => {
3625
4012
  var collect = (value, previous) => [...previous, value];
3626
4013
  var buildProgram = () => {
3627
4014
  const program = new Command();
3628
- program.name("laracrew").description("Boot and supervise all the long-running processes of your projects with one command.").version(VERSION, "-v, --version").showHelpAfterError();
4015
+ program.name("laracrew").description("Boot and supervise all the long-running processes of your projects with one command.").version(VERSION, "-v, --version").showHelpAfterError().configureHelp({
4016
+ styleTitle: (text) => color.bold(text),
4017
+ styleCommandText: (text) => color.cyan(text),
4018
+ styleSubcommandText: (text) => color.cyan(text),
4019
+ styleOptionText: (text) => color.green(text),
4020
+ styleArgumentText: (text) => color.yellow(text)
4021
+ }).configureOutput({
4022
+ // One colour decision for the whole program — ours, so NO_COLOR is honoured here too.
4023
+ getOutHasColors: () => color.level > 0,
4024
+ getErrHasColors: () => color.level > 0,
4025
+ outputError: (message, write2) => write2(`${marks().fail} ${color.red(message.trimEnd())}
4026
+ `)
4027
+ });
3629
4028
  program.command("init").description("create ~/.laracrew").option("--examples", "also write a runnable demo stack and a two-project template").action((options) => {
3630
4029
  initCommand({ examples: options.examples ?? false });
3631
4030
  });
4031
+ const draft = program.command("draft").description("build a stack step by step in the current folder, then publish it").argument("[name]", "start a draft with this stack name, or show it if it exists; omit to show the only draft here").option("--force", "start over when a draft of that name already exists here").action((name, options) => {
4032
+ process.exitCode = draftCommand(name, options);
4033
+ });
4034
+ draft.command("add").description("append a service to the draft in this folder").requiredOption("--command, --cmd <line>", 'what to run, e.g. "php artisan queue:work"').option("--project <key>", "the project it runs in \u2014 any short key, e.g. api").option("--path <dir>", "the project's folder, or where the command runs when there is no --project; defaults to this folder").option("--name <service>", "service name (default: project + command, e.g. api:queue:work)").option("--default-state <state>", '"running" (default) or "stopped": defined, but idle until you start it').option("--draft <name>", "which draft, when this folder holds several").action((options) => {
4035
+ process.exitCode = draftAddCommand({
4036
+ command: options.command ?? options.cmd,
4037
+ project: options.project,
4038
+ path: options.path,
4039
+ name: options.name,
4040
+ state: options.defaultState,
4041
+ draft: options.draft
4042
+ });
4043
+ });
4044
+ draft.command("publish").description("validate the draft and install it as a stack in ~/.laracrew").option("--force", "replace a stack of the same name").option("--link", "also install the global command, as `laracrew link` would").option("--draft <name>", "which draft, when this folder holds several").action((options) => {
4045
+ process.exitCode = draftPublishCommand(options);
4046
+ });
3632
4047
  program.command("ls").alias("list").description("list stacks, projects and tasks").option("--json", "machine-readable output").action((options) => {
3633
4048
  process.exitCode = lsCommand(process.env, options.json ?? false);
3634
4049
  });
@@ -3672,13 +4087,19 @@ var buildProgram = () => {
3672
4087
  });
3673
4088
  return program;
3674
4089
  };
4090
+ var renderConfigError = (error) => {
4091
+ const lines = [`${marks().fail} ${color.red.bold("config error")}`];
4092
+ if (error.file) lines.push(` ${color.gray(error.file)}`);
4093
+ lines.push(` ${error.message}`);
4094
+ for (const detail of error.details) lines.push(` ${color.gray("-")} ${detail}`);
4095
+ return lines.join("\n");
4096
+ };
3675
4097
  var run = async (argv = process.argv) => {
3676
4098
  try {
3677
4099
  await buildProgram().parseAsync(argv);
3678
4100
  } catch (error) {
3679
4101
  if (error instanceof ConfigError) {
3680
- console.error(`${paint("red", "config error")}
3681
- ${error.format()}`);
4102
+ console.error(renderConfigError(error));
3682
4103
  process.exitCode = 1;
3683
4104
  return;
3684
4105
  }
@@ -3689,7 +4110,9 @@ ${error.format()}`);
3689
4110
  // src/index.ts
3690
4111
  installPipeGuards();
3691
4112
  run().catch((error) => {
3692
- console.error(paint("red", error instanceof Error ? error.stack ?? error.message : String(error)));
4113
+ const [head, ...trace] = (error instanceof Error ? error.stack ?? error.message : String(error)).split("\n");
4114
+ console.error(`${marks().fail} ${color.red.bold(head ?? "unexpected error")}`);
4115
+ if (trace.length > 0) console.error(color.gray(trace.join("\n")));
3693
4116
  process.exitCode = 1;
3694
4117
  });
3695
4118
  //# sourceMappingURL=index.js.map