laracrew 0.2.1 → 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";
@@ -1612,6 +1713,45 @@ var Supervisor = class {
1612
1713
  const process2 = this.#requireProcess(name);
1613
1714
  await process2.restart();
1614
1715
  }
1716
+ /**
1717
+ * Restarts every service that is `running` right now and nothing else: a stopped or idle
1718
+ * service stays where it is, a failed one is not resurrected, one still starting is left to
1719
+ * finish. Dependency order, parallel within a level, so a dependency is back and ready
1720
+ * before the services that need it are bounced. Returns the names restarted, in that order.
1721
+ */
1722
+ async restartRunning() {
1723
+ const isTarget = (process2) => !process2.service.external && process2.state === "running";
1724
+ const wanted = new Set(this.processes.filter(isTarget).map((process2) => process2.service.name));
1725
+ if (wanted.size === 0) return [];
1726
+ this.#bus.emit({
1727
+ type: "notice",
1728
+ level: "info",
1729
+ message: `restarting ${wanted.size} running service${wanted.size === 1 ? "" : "s"}: ${[...wanted].join(", ")}`,
1730
+ at: Date.now()
1731
+ });
1732
+ const restarted = /* @__PURE__ */ new Set();
1733
+ for (const level of this.stack.levels) {
1734
+ await Promise.all(
1735
+ level.filter((name) => wanted.has(name)).map(async (name) => {
1736
+ const process2 = this.#processes.get(name);
1737
+ if (!isTarget(process2)) return;
1738
+ try {
1739
+ await process2.restart();
1740
+ restarted.add(name);
1741
+ } catch (error) {
1742
+ this.#bus.emit({
1743
+ type: "notice",
1744
+ level: "error",
1745
+ service: name,
1746
+ message: `restart failed: ${error instanceof Error ? error.message : String(error)}`,
1747
+ at: Date.now()
1748
+ });
1749
+ }
1750
+ })
1751
+ );
1752
+ }
1753
+ return this.stack.levels.flat().filter((name) => restarted.has(name));
1754
+ }
1615
1755
  async stopService(name) {
1616
1756
  await this.#requireProcess(name).stop();
1617
1757
  }
@@ -1682,6 +1822,7 @@ var time = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.
1682
1822
  var attachPlainRenderer = (bus, options) => {
1683
1823
  const write2 = options.write ?? ((text) => write(text));
1684
1824
  const painter = options.painter ?? paint;
1825
+ const glyph = options.glyph ?? glyphs();
1685
1826
  const showTime = options.timestamps ?? true;
1686
1827
  const now = options.now ?? (() => /* @__PURE__ */ new Date());
1687
1828
  const colorOf = new Map(options.stack.services.map((service) => [service.name, asColorName(service.color)]));
@@ -1692,10 +1833,19 @@ var attachPlainRenderer = (bus, options) => {
1692
1833
  write2(`${stamp2}${prefix} ${painter("gray", "|")} ${text}
1693
1834
  `);
1694
1835
  };
1695
- const system = (text, level = "info") => {
1696
- const color = level === "error" ? "red" : level === "warn" ? "yellow" : "gray";
1697
- 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
+ }
1698
1847
  };
1848
+ const system = (text, tone = "info") => line("laracrew", note(tone, text), "bold");
1699
1849
  const handle = (event) => {
1700
1850
  switch (event.type) {
1701
1851
  case "service:log":
@@ -1710,7 +1860,7 @@ var attachPlainRenderer = (bus, options) => {
1710
1860
  break;
1711
1861
  }
1712
1862
  case "stack:ready":
1713
- system(`${event.stack} ready in ${(event.durationMs / 1e3).toFixed(1)}s`);
1863
+ system(`${event.stack} ready in ${(event.durationMs / 1e3).toFixed(1)}s`, "ok");
1714
1864
  break;
1715
1865
  case "stack:stopping":
1716
1866
  system(`stopping ${event.stack}\u2026`);
@@ -1722,30 +1872,32 @@ var attachPlainRenderer = (bus, options) => {
1722
1872
  line(event.service, painter("gray", `$ ${event.command}`));
1723
1873
  break;
1724
1874
  case "service:ready":
1725
- 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`));
1726
1876
  break;
1727
1877
  case "service:exit":
1728
1878
  if (!event.intentional) {
1729
- line(
1730
- event.service,
1731
- painter("red", `exited (${event.signal ? `signal ${event.signal}` : `code ${event.code}`})`)
1732
- );
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})`));
1733
1882
  }
1734
1883
  break;
1735
1884
  case "service:restart":
1736
1885
  line(
1737
1886
  event.service,
1738
- 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
+ )}`
1739
1891
  );
1740
1892
  break;
1741
1893
  case "service:state":
1742
1894
  if (event.to === "failed") {
1743
- line(event.service, painter("red", `failed${event.detail ? `: ${event.detail}` : ""}`));
1895
+ line(event.service, note("error", `failed${event.detail ? `: ${event.detail}` : ""}`));
1744
1896
  }
1745
1897
  break;
1746
1898
  case "notice":
1747
1899
  if (event.service) {
1748
- line(event.service, painter(event.level === "error" ? "red" : "yellow", event.message));
1900
+ line(event.service, note(event.level, event.message));
1749
1901
  } else {
1750
1902
  system(event.message, event.level);
1751
1903
  }
@@ -1834,6 +1986,7 @@ var handleKey = (ui, key, rowCount) => {
1834
1986
  case "t":
1835
1987
  return { ui, command: { type: "toggle", index: ui.selected } };
1836
1988
  case "r":
1989
+ if (key.shift) return { ui, command: { type: "restart-running" } };
1837
1990
  return { ui, command: { type: "restart", index: ui.selected } };
1838
1991
  case "a":
1839
1992
  return none({ ...ui, view: ui.view === "merged" ? "overview" : "merged", scrollBack: 0, follow: true });
@@ -1885,92 +2038,23 @@ var handleKey = (ui, key, rowCount) => {
1885
2038
  }
1886
2039
  };
1887
2040
 
1888
- // src/tui/theme.ts
1889
- var asciiOnly = (env = process.env) => env.LARACREW_ASCII === "1" || env.TERM === "dumb";
1890
- var UNICODE = {
1891
- running: "\u25CF",
1892
- ready: "\u25CF",
1893
- starting: "\u25D0",
1894
- stopped: "\u25FC",
1895
- failed: "\u2716",
1896
- queued: "\u25CB",
1897
- branch: "\u251C\u2500",
1898
- lastBranch: "\u2514\u2500",
1899
- pipe: "\u2502",
1900
- pointer: "\u25B8",
1901
- restart: "\u27F3",
1902
- rule: "\u2500",
1903
- dot: "\xB7",
1904
- tick: "\u2714"
2041
+ // src/tui/screen.ts
2042
+ var initialUiState = () => ({
2043
+ view: "overview",
2044
+ selected: 0,
2045
+ follow: true,
2046
+ scrollBack: 0,
2047
+ confirmQuit: false,
2048
+ phase: "booting",
2049
+ note: "Booting\u2026"
2050
+ });
2051
+ var pad = (text, width) => {
2052
+ const visible = stripAnsi(text).length;
2053
+ return visible >= width ? text : text + " ".repeat(width - visible);
1905
2054
  };
1906
- var ASCII = {
1907
- running: "*",
1908
- ready: "*",
1909
- starting: "o",
1910
- stopped: "#",
1911
- failed: "X",
1912
- queued: ".",
1913
- branch: "|-",
1914
- lastBranch: "`-",
1915
- pipe: "|",
1916
- pointer: ">",
1917
- restart: "r",
1918
- rule: "-",
1919
- dot: ".",
1920
- tick: "v"
1921
- };
1922
- var glyphs = (env = process.env) => asciiOnly(env) ? ASCII : UNICODE;
1923
- var STATE_STYLE = {
1924
- queued: { glyph: "queued", color: "gray", label: "QUEUED" },
1925
- starting: { glyph: "starting", color: "yellow", label: "STARTING" },
1926
- ready: { glyph: "ready", color: "cyan", label: "READY" },
1927
- running: { glyph: "running", color: "green", label: "RUNNING" },
1928
- stopping: { glyph: "starting", color: "yellow", label: "STOPPING" },
1929
- stopped: { glyph: "stopped", color: "gray", label: "STOPPED" },
1930
- crashed: { glyph: "failed", color: "red", label: "CRASHED" },
1931
- backoff: { glyph: "starting", color: "yellow", label: "RESTARTING" },
1932
- failed: { glyph: "failed", color: "red", label: "FAILED" }
1933
- };
1934
- var formatUptime = (ms) => {
1935
- if (ms === void 0 || ms < 0) return "--:--:--";
1936
- const total = Math.floor(ms / 1e3);
1937
- const hours = Math.floor(total / 3600);
1938
- const minutes = Math.floor(total % 3600 / 60);
1939
- const seconds = total % 60;
1940
- return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
1941
- };
1942
- var formatDuration = (ms) => {
1943
- if (ms === void 0 || ms < 0) return "\u2014";
1944
- const total = Math.floor(ms / 1e3);
1945
- if (total < 60) return `${total}s`;
1946
- const minutes = Math.floor(total / 60);
1947
- const seconds = total % 60;
1948
- if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1949
- const hours = Math.floor(minutes / 60);
1950
- return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
1951
- };
1952
- var formatClock = (at) => {
1953
- const date = new Date(at);
1954
- return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
1955
- };
1956
-
1957
- // src/tui/screen.ts
1958
- var initialUiState = () => ({
1959
- view: "overview",
1960
- selected: 0,
1961
- follow: true,
1962
- scrollBack: 0,
1963
- confirmQuit: false,
1964
- phase: "booting",
1965
- note: "Booting\u2026"
1966
- });
1967
- var pad = (text, width) => {
1968
- const visible = stripAnsi(text).length;
1969
- return visible >= width ? text : text + " ".repeat(width - visible);
1970
- };
1971
- var truncate = (text, width) => {
1972
- if (width <= 1) return "";
1973
- return stripAnsi(text).length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}\u2026`;
2055
+ var truncate = (text, width) => {
2056
+ if (width <= 1) return "";
2057
+ return stripAnsi(text).length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}\u2026`;
1974
2058
  };
1975
2059
  var rule = (width, glyph) => glyph.rule.repeat(Math.max(0, width));
1976
2060
  var header = ({ stack, ui, size, paint: paint2, glyph, uptimeMs }) => {
@@ -1997,6 +2081,7 @@ var KEYS = {
1997
2081
  ["enter", "inspect log"],
1998
2082
  ["0-9", "jump"],
1999
2083
  ["r", "restart"],
2084
+ ["R", "restart all running"],
2000
2085
  ["s", "stop/start"],
2001
2086
  ["a", "all logs"],
2002
2087
  ["?", "help"],
@@ -2008,6 +2093,7 @@ var KEYS = {
2008
2093
  ["g/G", "top/bottom"],
2009
2094
  ["f", "follow"],
2010
2095
  ["r", "restart"],
2096
+ ["R", "restart all running"],
2011
2097
  ["q", "quit"]
2012
2098
  ],
2013
2099
  merged: [
@@ -2015,6 +2101,7 @@ var KEYS = {
2015
2101
  ["up/down", "scroll"],
2016
2102
  ["g/G", "top/bottom"],
2017
2103
  ["f", "follow"],
2104
+ ["R", "restart all running"],
2018
2105
  ["q", "quit"]
2019
2106
  ],
2020
2107
  help: [["any key", "back"]]
@@ -2053,8 +2140,8 @@ var processLine = (row, isSelected, isLast, nameWidth, { paint: paint2, glyph, s
2053
2140
  var dependencyLine = (row, nameWidth, { paint: paint2, glyph, size }) => {
2054
2141
  const style = STATE_STYLE[row.state];
2055
2142
  const label = row.state === "ready" || row.state === "running" ? "ready" : style.label.toLowerCase();
2056
- const color = row.state === "ready" || row.state === "running" ? "green" : style.color;
2057
- 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 ?? "")}`;
2058
2145
  return truncate(line, size.columns + (line.length - stripAnsi(line).length));
2059
2146
  };
2060
2147
  var dependencies = (input, nameWidth) => {
@@ -2141,6 +2228,7 @@ var help = ({ paint: paint2, glyph }) => [
2141
2228
  ["esc, s", "back to the stack overview"],
2142
2229
  ["a", "merged log across every service"],
2143
2230
  ["r", "restart the selected process (graceful)"],
2231
+ ["R", "restart every running process, in dependency order; stopped and idle stay put"],
2144
2232
  ["t", "toggle the selected process: stop if running, start if stopped"],
2145
2233
  ["f", "follow / pause tailing in a log view"],
2146
2234
  ["g / G", "jump to the top / bottom of a log"],
@@ -2311,7 +2399,7 @@ var runTui = async (supervisor, bus, options = {}) => {
2311
2399
  });
2312
2400
  const basePainter = createPainter(supportsColor(stdout, env));
2313
2401
  const paint2 = Object.assign(
2314
- (color, text) => basePainter(color, text),
2402
+ (color2, text) => basePainter(color2, text),
2315
2403
  { enabled: basePainter.enabled }
2316
2404
  );
2317
2405
  const glyph = glyphs(env);
@@ -2370,7 +2458,7 @@ var runTui = async (supervisor, bus, options = {}) => {
2370
2458
  };
2371
2459
  const perform = (command) => {
2372
2460
  const stack = buildStackView(supervisor);
2373
- const row = stack.rows[command.type === "none" || command.type === "quit" || command.type === "force-quit" ? ui.selected : command.index];
2461
+ const row = stack.rows["index" in command ? command.index : ui.selected];
2374
2462
  switch (command.type) {
2375
2463
  case "quit":
2376
2464
  void shutdown("user quit");
@@ -2384,6 +2472,14 @@ var runTui = async (supervisor, bus, options = {}) => {
2384
2472
  setNote(`restarting ${row.name}\u2026`);
2385
2473
  void supervisor.restart(row.name).then(() => setNote(`${row.name} restarted`)).catch((error) => setNote(`restart failed: ${error instanceof Error ? error.message : String(error)}`));
2386
2474
  break;
2475
+ case "restart-running":
2476
+ setNote("restarting every running process\u2026");
2477
+ void supervisor.restartRunning().then(
2478
+ (names) => setNote(
2479
+ names.length === 0 ? "nothing is running \u2014 nothing to restart" : `restarted ${names.length} process${names.length === 1 ? "" : "es"}: ${names.join(", ")}`
2480
+ )
2481
+ ).catch((error) => setNote(`restart failed: ${error instanceof Error ? error.message : String(error)}`));
2482
+ break;
2387
2483
  case "toggle": {
2388
2484
  if (!row) break;
2389
2485
  if (row.external) {
@@ -2471,6 +2567,7 @@ var shouldUseTui = (options, stdout = process.stdout) => !options.plain && !opti
2471
2567
  var upCommand = async (name, options = {}) => {
2472
2568
  const stack = prepareStack(name, options);
2473
2569
  const bus = new EventBus();
2570
+ const mark = marks(options.env);
2474
2571
  if (shouldUseTui(options)) {
2475
2572
  const supervisor2 = new Supervisor(stack, bus, options.env ? { env: options.env } : {});
2476
2573
  return runTui(supervisor2, bus, options.env ? { env: options.env } : {});
@@ -2486,7 +2583,8 @@ var upCommand = async (name, options = {}) => {
2486
2583
  };
2487
2584
  const onSignal = (signal) => {
2488
2585
  if (stopping) {
2489
- 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")}`);
2490
2588
  process.exit(130);
2491
2589
  }
2492
2590
  void shutdown(`received ${signal}`);
@@ -2500,7 +2598,7 @@ var upCommand = async (name, options = {}) => {
2500
2598
  await supervisor.up();
2501
2599
  await waitUntilIdle(supervisor, () => stopping);
2502
2600
  } catch (error) {
2503
- 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))}`);
2504
2602
  exitCode = 1;
2505
2603
  } finally {
2506
2604
  await shutdown("done");
@@ -2816,303 +2914,67 @@ var runDoctor = async (stackName, env = process.env) => {
2816
2914
  };
2817
2915
  var doctorCommand = async (stackName, env = process.env) => {
2818
2916
  const findings = await runDoctor(stackName, env);
2819
- 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 };
2820
2922
  for (const finding of findings) {
2821
- console.log(`${glyph[finding.level]} ${finding.message}`);
2822
- 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)}`);
2823
2925
  }
2824
2926
  const errors = findings.filter((finding) => finding.level === "error").length;
2825
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")}`) : "";
2826
2930
  console.log(
2827
- `
2828
- ${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}`
2829
2934
  );
2830
2935
  return errors > 0 ? 1 : 0;
2831
2936
  };
2832
2937
 
2833
- // src/cli/commands/init.ts
2834
- import { mkdirSync as mkdirSync2, writeFileSync, existsSync as existsSync5 } from "fs";
2835
- import path8 from "path";
2836
-
2837
- // src/cli/commands/templates.ts
2838
- var CONFIG_YAML = `# laracrew global preferences
2839
- # theme: auto | dark | light | mono | high-contrast
2840
- theme: auto
2841
-
2842
- # Polling interval for queue depth and stream lag, in milliseconds.
2843
- metricsIntervalMs: 2000
2844
-
2845
- # The stack \`laracrew up\` boots when you don't name one.
2846
- # defaultStack: dual
2847
- `;
2848
- var PROJECTS_YAML = `# Your projects, referenced by key from any stack. A project is just a root directory
2849
- # plus the defaults its services inherit \u2014 cwd, .env and colour. Any language.
2850
- # Paths may use forward slashes on Windows.
2851
- #
2852
- # Declaring projects here is optional: a service can carry its own \`cwd\` instead.
2853
- projects: {}
2854
-
2855
- # Example \u2014 delete the \`{}\` above and uncomment:
2856
- #
2857
- # projects:
2858
- # api:
2859
- # path: D:/work/api
2860
- # php: php # only used by \`stop: { artisan: ... }\`; ignore it for non-PHP projects
2861
- # envFile: .env # read for \${project.env:KEY} tokens, and by \`doctor\`
2862
- # color: cyan
2863
- # web:
2864
- # path: D:/work/web
2865
- # envFile: .env
2866
- # color: magenta
2867
- `;
2868
- var EXAMPLE_STACK_YAML = `# A runnable demo stack \u2014 no Laravel project required.
2869
- # Try: laracrew up example
2870
- # Then: laracrew up example --only workers
2871
- name: example
2872
- description: Three fake services that prove laracrew works on this machine
2873
-
2874
- # \`laracrew link example\` installs this as a global command you can run from anywhere.
2875
- command: demo
2876
-
2877
- defaults:
2878
- restart: on-failure
2879
- backoff: { initialMs: 500, maxMs: 5000, factor: 2, maxRestarts: 5 }
2880
- stop: { graceMs: 5000 }
2881
-
2882
- services:
2883
- - name: demo:heartbeat
2884
- cmd: ["node", "-e", "let n=0; setInterval(() => console.log('heartbeat ' + (++n)), 1000)"]
2885
- groups: [workers]
2886
- color: cyan
2887
-
2888
- - name: demo:worker
2889
- cmd: ["node", "-e", "let n=0; setInterval(() => console.log('processed job #' + (++n)), 1700)"]
2890
- groups: [workers]
2891
- needs: [demo:heartbeat]
2892
- color: magenta
2893
-
2894
- - name: demo:slow-starter
2895
- cmd: ["node", "-e", "setTimeout(() => console.log('listening on 9'), 1200); setInterval(() => {}, 1000)"]
2896
- groups: [http]
2897
- ready: { logMatch: "listening on", timeoutMs: 10000 }
2898
- color: green
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";
2899
2942
 
2900
- profiles:
2901
- quiet:
2902
- except: [http]
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} "$@"
2903
2969
  `;
2904
- var REAL_STACK_TEMPLATE = `# Two interconnected Laravel projects: queues, Redis stream listeners, schedulers.
2905
- # Fill in the project paths in ~/.laracrew/projects.yaml first, then: laracrew up dual
2906
- name: dual
2907
- description: API + Portal with queues, streams and schedulers
2908
-
2909
- # Run \`laracrew link dual\` and this whole set boots by typing \`dual\` from anywhere.
2910
- command: dual
2911
-
2912
- use: [api, portal]
2913
-
2914
- defaults:
2915
- restart: on-failure
2916
- backoff: { initialMs: 1000, maxMs: 30000, factor: 2, maxRestarts: 10 }
2917
- stop: { graceMs: 10000 }
2918
- logs: { maxLines: 5000, toFile: false }
2919
-
2920
- services:
2921
- - name: redis
2922
- external: true # laracrew checks it, never starts it
2923
- ready: { tcp: "127.0.0.1:6379" }
2924
-
2925
- - name: api:serve
2926
- project: api
2927
- cmd: php artisan serve --port=\${port:8000}
2928
- groups: [http]
2929
- needs: [redis]
2930
- ready: { http: "http://127.0.0.1:8000/up", timeoutMs: 20000 }
2931
- url: http://127.0.0.1:8000
2932
-
2933
- - name: api:queue
2934
- project: api
2935
- cmd: php artisan queue:work redis --queue=high,default --tries=3 --timeout=90
2936
- groups: [workers]
2937
- needs: [redis]
2938
- stop: { artisan: "queue:restart", graceMs: 15000 }
2939
- watch:
2940
- paths: [app, config, routes, database]
2941
- strategy: artisan-queue-restart
2942
- debounceMs: 800
2943
- metrics:
2944
- queues: [high, default]
2945
-
2946
- - name: api:streams
2947
- project: api
2948
- cmd: php artisan streams:listen orders
2949
- groups: [workers, streams]
2950
- needs: [redis]
2951
- metrics:
2952
- streams:
2953
- - { key: orders, group: api-consumers }
2954
-
2955
- - name: api:schedule
2956
- project: api
2957
- cmd: php artisan schedule:work
2958
- groups: [workers]
2959
- needs: [redis]
2960
-
2961
- - name: portal:serve
2962
- project: portal
2963
- cmd: php artisan serve --port=\${port:8001}
2964
- groups: [http]
2965
- needs: [redis, api:serve] # don't boot until the API answers
2966
- ready: { http: "http://127.0.0.1:8001/up", timeoutMs: 20000 }
2967
- url: http://127.0.0.1:8001
2968
-
2969
- - name: portal:queue
2970
- project: portal
2971
- cmd: php artisan queue:work redis --queue=default --tries=3
2972
- groups: [workers]
2973
- needs: [redis, api:serve]
2974
- stop: { artisan: "queue:restart" }
2975
- watch: { paths: [app, config], strategy: artisan-queue-restart }
2976
- metrics: { queues: [default] }
2977
-
2978
- - name: portal:streams
2979
- project: portal
2980
- cmd: php artisan streams:listen inventory
2981
- groups: [workers, streams]
2982
- needs: [redis]
2983
- metrics:
2984
- streams:
2985
- - { key: inventory, group: portal-consumers }
2986
-
2987
- profiles:
2988
- light:
2989
- except: [assets]
2990
- workers-only:
2991
- only: [workers]
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
2992
2973
  `;
2993
- var RESET_TASK_YAML = `# laracrew run reset
2994
- name: reset
2995
- description: Wipe and reseed both databases in the right order
2996
- pauseServices: [workers] # stopped before the steps, restarted after
2997
- steps:
2998
- - { project: api, cmd: php artisan migrate:fresh --seed }
2999
- - { project: portal, cmd: php artisan migrate:fresh --seed }
3000
- - parallel:
3001
- - { project: api, cmd: php artisan cache:clear }
3002
- - { project: portal, cmd: php artisan cache:clear }
3003
- `;
3004
- var HOME_README = `# ~/.laracrew
3005
-
3006
- Your laracrew configuration. Safe to keep in git \u2014 nothing here is secret unless you put
3007
- secrets in it.
3008
-
3009
- config.yaml global preferences
3010
- projects.yaml your Laravel projects, referenced by key
3011
- stacks/<name>/ one folder per stack; stack.yaml is the definition
3012
- tasks/<name>.yaml one-shot sequences (laracrew run <name>)
3013
- fragments/ shared YAML any stack can reuse
3014
- logs/ per-run log files, when defaults.logs.toFile is on
3015
-
3016
- Start here:
3017
-
3018
- laracrew init --examples # add a demo stack and a two-project template
3019
- laracrew ls # what is defined
3020
- laracrew doctor <stack> # check a stack before booting it
3021
- laracrew link <stack> # give a stack its own global command
3022
- `;
3023
-
3024
- // src/cli/commands/init.ts
3025
- var runInit = (env = process.env, options = {}) => {
3026
- const home2 = paths(env);
3027
- const created = [];
3028
- const skipped = [];
3029
- for (const dir of [home2.root, home2.stacksDir, home2.tasksDir, home2.fragmentsDir, home2.logsDir, home2.runDir]) {
3030
- mkdirSync2(dir, { recursive: true });
3031
- }
3032
- const files = [
3033
- [home2.configFile, CONFIG_YAML],
3034
- [home2.projectsFile, PROJECTS_YAML],
3035
- [path8.join(home2.root, "README.md"), HOME_README]
3036
- ];
3037
- if (options.examples) {
3038
- mkdirSync2(path8.join(home2.stacksDir, "example"), { recursive: true });
3039
- mkdirSync2(path8.join(home2.stacksDir, "dual"), { recursive: true });
3040
- files.push(
3041
- [path8.join(home2.stacksDir, "example", "stack.yaml"), EXAMPLE_STACK_YAML],
3042
- [path8.join(home2.stacksDir, "dual", "stack.yaml"), REAL_STACK_TEMPLATE],
3043
- [path8.join(home2.tasksDir, "reset.yaml"), RESET_TASK_YAML]
3044
- );
3045
- }
3046
- for (const [file, contents] of files) {
3047
- if (existsSync5(file)) {
3048
- skipped.push(file);
3049
- continue;
3050
- }
3051
- writeFileSync(file, contents, "utf8");
3052
- created.push(file);
3053
- }
3054
- return { root: home2.root, created, skipped, examples: options.examples ?? false };
3055
- };
3056
- var initCommand = (options = {}, env = process.env) => {
3057
- const result = runInit(env, options);
3058
- console.log(`${paint("bold", "laracrew")} home: ${result.root}`);
3059
- for (const file of result.created) console.log(` ${paint("green", "+")} ${path8.relative(result.root, file)}`);
3060
- for (const file of result.skipped) {
3061
- console.log(` ${paint("gray", "\xB7")} ${path8.relative(result.root, file)} ${paint("gray", "(kept)")}`);
3062
- }
3063
- if (result.examples) {
3064
- console.log(`
3065
- ${paint("bold", "Next:")}
3066
- ${paint("cyan", "laracrew up example")} run the demo stack \u2014 proves it works, no Laravel needed
3067
- ${paint("cyan", "laracrew link example")} install it as a global command you can type from anywhere
3068
- edit ${paint("gray", path8.join(result.root, "projects.yaml"))} with your project paths,
3069
- then ${paint("cyan", "laracrew doctor dual")} and ${paint("cyan", "laracrew up dual")}
3070
- `);
3071
- return;
3072
- }
3073
- console.log(`
3074
- ${paint("bold", "Next:")}
3075
- ${paint("cyan", "laracrew init --examples")} add a runnable demo stack and a two-project template
3076
- or write your own at ${paint("gray", path8.join(result.root, "stacks", "<name>", "stack.yaml"))}
3077
- then ${paint("cyan", "laracrew ls")} and ${paint("cyan", "laracrew up <name>")}
3078
- `);
3079
- };
3080
-
3081
- // src/cli/commands/link.ts
3082
- import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
3083
- import path9 from "path";
3084
- import { fileURLToPath } from "url";
3085
- var IS_WINDOWS3 = process.platform === "win32";
3086
- var MARKER = "laracrew-generated";
3087
- var RESERVED = /* @__PURE__ */ new Set(["laracrew", "node", "npm", "npx", "php", "git", "cd", "ls", "rm", "sh", "bash"]);
3088
- var VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
3089
- var entryScript = (argv = process.argv) => {
3090
- const main = argv[1];
3091
- if (!main) return fileURLToPath(import.meta.url);
3092
- const resolved = path9.resolve(main);
3093
- if (!resolved.endsWith(".ts")) return resolved;
3094
- const dist = path9.join(path9.dirname(path9.dirname(resolved)), "dist", "index.js");
3095
- if (existsSync6(dist)) return dist;
3096
- throw new ConfigError("run `npm run build` first \u2014 a generated command has to point at dist/index.js");
3097
- };
3098
- var currentTarget = (stack) => ({
3099
- node: process.execPath,
3100
- entry: entryScript(),
3101
- stack
3102
- });
3103
- var forward = (value) => value.replace(/\\/g, "/");
3104
- var shShim = ({ node, entry, stack }) => `#!/bin/sh
3105
- # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
3106
- exec "${forward(node)}" "${forward(entry)}" up ${stack} "$@"
3107
- `;
3108
- var cmdShim = ({ node, entry, stack }) => `@ECHO OFF\r
3109
- REM ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.\r
3110
- "${node}" "${entry}" up ${stack} %*\r
3111
- `;
3112
- var ps1Shim = ({ node, entry, stack }) => `#!/usr/bin/env pwsh
3113
- # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
3114
- & "${node}" "${entry}" up ${stack} @args
3115
- exit $LASTEXITCODE
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
3116
2978
  `;
3117
2979
  var shimFiles = (name, target) => {
3118
2980
  const posix = { file: name, contents: shShim(target), exec: true };
@@ -3124,13 +2986,13 @@ var shimFiles = (name, target) => {
3124
2986
  { file: `${name}.ps1`, contents: ps1Shim(target), exec: true }
3125
2987
  ];
3126
2988
  };
3127
- var pathDirs = (env) => (env.PATH ?? env.Path ?? "").split(path9.delimiter).filter(Boolean);
2989
+ var pathDirs = (env) => (env.PATH ?? env.Path ?? "").split(path8.delimiter).filter(Boolean);
3128
2990
  var samePath = (a, b) => IS_WINDOWS3 ? a.toLowerCase() === b.toLowerCase() : a === b;
3129
2991
  var ownBinDir = (env) => {
3130
2992
  for (const dir of pathDirs(env)) {
3131
2993
  for (const candidate of IS_WINDOWS3 ? ["laracrew.cmd", "laracrew"] : ["laracrew"]) {
3132
2994
  try {
3133
- if (existsSync6(path9.join(dir, candidate))) return dir;
2995
+ if (existsSync5(path8.join(dir, candidate))) return dir;
3134
2996
  } catch {
3135
2997
  }
3136
2998
  }
@@ -3138,14 +3000,14 @@ var ownBinDir = (env) => {
3138
3000
  return void 0;
3139
3001
  };
3140
3002
  var resolveBinDir = (env = process.env) => {
3141
- if (env.LARACREW_BIN) return { dir: path9.resolve(env.LARACREW_BIN), onPath: true };
3003
+ if (env.LARACREW_BIN) return { dir: path8.resolve(env.LARACREW_BIN), onPath: true };
3142
3004
  const own = ownBinDir(env);
3143
3005
  if (own) return { dir: own, onPath: true };
3144
- const conventional = IS_WINDOWS3 ? env.APPDATA ? path9.join(env.APPDATA, "npm") : void 0 : path9.dirname(process.execPath);
3145
- if (conventional && existsSync6(conventional) && pathDirs(env).some((dir) => samePath(dir, conventional))) {
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))) {
3146
3008
  return { dir: conventional, onPath: true };
3147
3009
  }
3148
- return { dir: path9.join(paths(env).root, "bin"), onPath: false };
3010
+ return { dir: path8.join(paths(env).root, "bin"), onPath: false };
3149
3011
  };
3150
3012
  var isOurs = (file) => {
3151
3013
  try {
@@ -3168,14 +3030,14 @@ var linkStack = (stackName, options = {}) => {
3168
3030
  details: ["pick another with `--as <name>`, or pass --force if you really mean it"]
3169
3031
  });
3170
3032
  }
3171
- const resolved = options.dir ? { dir: path9.resolve(options.dir), onPath: true } : resolveBinDir(env);
3172
- mkdirSync3(resolved.dir, { recursive: true });
3033
+ const resolved = options.dir ? { dir: path8.resolve(options.dir), onPath: true } : resolveBinDir(env);
3034
+ mkdirSync2(resolved.dir, { recursive: true });
3173
3035
  const target = currentTarget(stackName);
3174
3036
  const planned = shimFiles(name, target);
3175
3037
  let replaced = false;
3176
3038
  for (const { file } of planned) {
3177
- const full = path9.join(resolved.dir, file);
3178
- if (!existsSync6(full)) continue;
3039
+ const full = path8.join(resolved.dir, file);
3040
+ if (!existsSync5(full)) continue;
3179
3041
  if (isOurs(full)) {
3180
3042
  replaced = true;
3181
3043
  continue;
@@ -3189,8 +3051,8 @@ var linkStack = (stackName, options = {}) => {
3189
3051
  }
3190
3052
  const written = [];
3191
3053
  for (const { file, contents, exec } of planned) {
3192
- const full = path9.join(resolved.dir, file);
3193
- writeFileSync2(full, contents, "utf8");
3054
+ const full = path8.join(resolved.dir, file);
3055
+ writeFileSync(full, contents, "utf8");
3194
3056
  if (exec && !IS_WINDOWS3) chmodSync(full, 493);
3195
3057
  else if (exec) {
3196
3058
  try {
@@ -3204,11 +3066,11 @@ var linkStack = (stackName, options = {}) => {
3204
3066
  };
3205
3067
  var unlinkCommandName = (name, options = {}) => {
3206
3068
  const env = options.env ?? process.env;
3207
- const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3069
+ const dir = options.dir ? path8.resolve(options.dir) : resolveBinDir(env).dir;
3208
3070
  const removed = [];
3209
3071
  for (const candidate of [name, `${name}.cmd`, `${name}.ps1`]) {
3210
- const full = path9.join(dir, candidate);
3211
- if (!existsSync6(full)) continue;
3072
+ const full = path8.join(dir, candidate);
3073
+ if (!existsSync5(full)) continue;
3212
3074
  if (!isOurs(full)) {
3213
3075
  throw new ConfigError(`"${full}" was not created by laracrew \u2014 leaving it alone`);
3214
3076
  }
@@ -3220,12 +3082,12 @@ var unlinkCommandName = (name, options = {}) => {
3220
3082
  };
3221
3083
  var listLinks = (options = {}) => {
3222
3084
  const env = options.env ?? process.env;
3223
- const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3224
- if (!existsSync6(dir)) return [];
3085
+ const dir = options.dir ? path8.resolve(options.dir) : resolveBinDir(env).dir;
3086
+ if (!existsSync5(dir)) return [];
3225
3087
  const found = [];
3226
3088
  for (const entry of readdirSync2(dir).sort()) {
3227
3089
  if (entry.endsWith(".cmd") || entry.endsWith(".ps1")) continue;
3228
- const full = path9.join(dir, entry);
3090
+ const full = path8.join(dir, entry);
3229
3091
  let contents;
3230
3092
  try {
3231
3093
  contents = readFileSync3(full, "utf8");
@@ -3258,47 +3120,621 @@ var linkCommand = (stackName, options, env = process.env) => {
3258
3120
  })
3259
3121
  );
3260
3122
  }
3123
+ const mark = marks(env);
3261
3124
  if (results.length === 0) {
3262
- console.log(paint("yellow", "nothing to link \u2014 add `command: <name>` to the stacks you want a command for"));
3125
+ console.log(
3126
+ `${mark.warn} ${color.yellow("nothing to link \u2014 add `command: <name>` to the stacks you want a command for")}`
3127
+ );
3263
3128
  return 0;
3264
3129
  }
3265
3130
  for (const result of results) {
3266
3131
  const verb = result.replaced ? "updated" : "created";
3267
3132
  console.log(
3268
- `${paint("green", verb)} ${paint("bold", result.name)} ${paint("gray", `-> laracrew up ${result.stack}`)}`
3133
+ `${mark.ok} ${color.green(verb)} ${color.bold(result.name)} ${mark.arrow} ${color.gray(`laracrew up ${result.stack}`)}`
3269
3134
  );
3270
3135
  }
3271
3136
  const first = results[0];
3272
- console.log(paint("gray", `
3273
- in ${first.dir}`));
3137
+ console.log(`
3138
+ ${color.gray(`in ${first.dir}`)}`);
3274
3139
  if (!first.onPath) {
3275
3140
  console.log(
3276
3141
  `
3277
- ${paint("yellow", "that directory is not on your PATH.")} Add it once:
3278
- ` + (IS_WINDOWS3 ? ` ${paint("cyan", `setx PATH "%PATH%;${first.dir}"`)} ${paint("gray", "(then reopen the terminal)")}` : ` ${paint("cyan", `echo 'export PATH="$PATH:${first.dir}"' >> ~/.bashrc`)}`)
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`)}`)
3279
3144
  );
3280
3145
  } else {
3281
3146
  console.log(`
3282
- Run it from anywhere: ${paint("cyan", first.name)}`);
3283
- console.log(paint("gray", `Flags pass straight through: ${first.name} --only workers`));
3147
+ Run it from anywhere: ${color.cyan.bold(first.name)}`);
3148
+ console.log(color.gray(`Flags pass straight through: ${first.name} --only workers`));
3284
3149
  }
3285
3150
  return 0;
3286
3151
  };
3287
3152
  var unlinkCommandAction = (name, env = process.env) => {
3288
3153
  const removed = unlinkCommandName(name, { env });
3289
- for (const file of removed) console.log(`${paint("red", "removed")} ${file}`);
3154
+ const mark = marks(env);
3155
+ for (const file of removed) console.log(`${mark.ok} removed ${color.gray(file)}`);
3290
3156
  return 0;
3291
3157
  };
3292
3158
 
3293
- // src/cli/commands/logs.ts
3294
- import { existsSync as existsSync8, readdirSync as readdirSync3 } from "fs";
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";
3295
3483
  import path10 from "path";
3296
3484
 
3485
+ // src/cli/commands/templates.ts
3486
+ var CONFIG_YAML = `# laracrew global preferences
3487
+ # theme: auto | dark | light | mono | high-contrast
3488
+ theme: auto
3489
+
3490
+ # Polling interval for queue depth and stream lag, in milliseconds.
3491
+ metricsIntervalMs: 2000
3492
+
3493
+ # The stack \`laracrew up\` boots when you don't name one.
3494
+ # defaultStack: dual
3495
+ `;
3496
+ var PROJECTS_YAML = `# Your projects, referenced by key from any stack. A project is just a root directory
3497
+ # plus the defaults its services inherit \u2014 cwd, .env and colour. Any language.
3498
+ # Paths may use forward slashes on Windows.
3499
+ #
3500
+ # Declaring projects here is optional: a service can carry its own \`cwd\` instead.
3501
+ projects: {}
3502
+
3503
+ # Example \u2014 delete the \`{}\` above and uncomment:
3504
+ #
3505
+ # projects:
3506
+ # api:
3507
+ # path: D:/work/api
3508
+ # php: php # only used by \`stop: { artisan: ... }\`; ignore it for non-PHP projects
3509
+ # envFile: .env # read for \${project.env:KEY} tokens, and by \`doctor\`
3510
+ # color: cyan
3511
+ # web:
3512
+ # path: D:/work/web
3513
+ # envFile: .env
3514
+ # color: magenta
3515
+ `;
3516
+ var EXAMPLE_STACK_YAML = `# A runnable demo stack \u2014 no Laravel project required.
3517
+ # Try: laracrew up example
3518
+ # Then: laracrew up example --only workers
3519
+ name: example
3520
+ description: Three fake services that prove laracrew works on this machine
3521
+
3522
+ # \`laracrew link example\` installs this as a global command you can run from anywhere.
3523
+ command: demo
3524
+
3525
+ defaults:
3526
+ restart: on-failure
3527
+ backoff: { initialMs: 500, maxMs: 5000, factor: 2, maxRestarts: 5 }
3528
+ stop: { graceMs: 5000 }
3529
+
3530
+ services:
3531
+ - name: demo:heartbeat
3532
+ cmd: ["node", "-e", "let n=0; setInterval(() => console.log('heartbeat ' + (++n)), 1000)"]
3533
+ groups: [workers]
3534
+ color: cyan
3535
+
3536
+ - name: demo:worker
3537
+ cmd: ["node", "-e", "let n=0; setInterval(() => console.log('processed job #' + (++n)), 1700)"]
3538
+ groups: [workers]
3539
+ needs: [demo:heartbeat]
3540
+ color: magenta
3541
+
3542
+ - name: demo:slow-starter
3543
+ cmd: ["node", "-e", "setTimeout(() => console.log('listening on 9'), 1200); setInterval(() => {}, 1000)"]
3544
+ groups: [http]
3545
+ ready: { logMatch: "listening on", timeoutMs: 10000 }
3546
+ color: green
3547
+
3548
+ profiles:
3549
+ quiet:
3550
+ except: [http]
3551
+ `;
3552
+ var REAL_STACK_TEMPLATE = `# Two interconnected Laravel projects: queues, Redis stream listeners, schedulers.
3553
+ # Fill in the project paths in ~/.laracrew/projects.yaml first, then: laracrew up dual
3554
+ name: dual
3555
+ description: API + Portal with queues, streams and schedulers
3556
+
3557
+ # Run \`laracrew link dual\` and this whole set boots by typing \`dual\` from anywhere.
3558
+ command: dual
3559
+
3560
+ use: [api, portal]
3561
+
3562
+ defaults:
3563
+ restart: on-failure
3564
+ backoff: { initialMs: 1000, maxMs: 30000, factor: 2, maxRestarts: 10 }
3565
+ stop: { graceMs: 10000 }
3566
+ logs: { maxLines: 5000, toFile: false }
3567
+
3568
+ services:
3569
+ - name: redis
3570
+ external: true # laracrew checks it, never starts it
3571
+ ready: { tcp: "127.0.0.1:6379" }
3572
+
3573
+ - name: api:serve
3574
+ project: api
3575
+ cmd: php artisan serve --port=\${port:8000}
3576
+ groups: [http]
3577
+ needs: [redis]
3578
+ ready: { http: "http://127.0.0.1:8000/up", timeoutMs: 20000 }
3579
+ url: http://127.0.0.1:8000
3580
+
3581
+ - name: api:queue
3582
+ project: api
3583
+ cmd: php artisan queue:work redis --queue=high,default --tries=3 --timeout=90
3584
+ groups: [workers]
3585
+ needs: [redis]
3586
+ stop: { artisan: "queue:restart", graceMs: 15000 }
3587
+ watch:
3588
+ paths: [app, config, routes, database]
3589
+ strategy: artisan-queue-restart
3590
+ debounceMs: 800
3591
+ metrics:
3592
+ queues: [high, default]
3593
+
3594
+ - name: api:streams
3595
+ project: api
3596
+ cmd: php artisan streams:listen orders
3597
+ groups: [workers, streams]
3598
+ needs: [redis]
3599
+ metrics:
3600
+ streams:
3601
+ - { key: orders, group: api-consumers }
3602
+
3603
+ - name: api:schedule
3604
+ project: api
3605
+ cmd: php artisan schedule:work
3606
+ groups: [workers]
3607
+ needs: [redis]
3608
+
3609
+ - name: portal:serve
3610
+ project: portal
3611
+ cmd: php artisan serve --port=\${port:8001}
3612
+ groups: [http]
3613
+ needs: [redis, api:serve] # don't boot until the API answers
3614
+ ready: { http: "http://127.0.0.1:8001/up", timeoutMs: 20000 }
3615
+ url: http://127.0.0.1:8001
3616
+
3617
+ - name: portal:queue
3618
+ project: portal
3619
+ cmd: php artisan queue:work redis --queue=default --tries=3
3620
+ groups: [workers]
3621
+ needs: [redis, api:serve]
3622
+ stop: { artisan: "queue:restart" }
3623
+ watch: { paths: [app, config], strategy: artisan-queue-restart }
3624
+ metrics: { queues: [default] }
3625
+
3626
+ - name: portal:streams
3627
+ project: portal
3628
+ cmd: php artisan streams:listen inventory
3629
+ groups: [workers, streams]
3630
+ needs: [redis]
3631
+ metrics:
3632
+ streams:
3633
+ - { key: inventory, group: portal-consumers }
3634
+
3635
+ profiles:
3636
+ light:
3637
+ except: [assets]
3638
+ workers-only:
3639
+ only: [workers]
3640
+ `;
3641
+ var RESET_TASK_YAML = `# laracrew run reset
3642
+ name: reset
3643
+ description: Wipe and reseed both databases in the right order
3644
+ pauseServices: [workers] # stopped before the steps, restarted after
3645
+ steps:
3646
+ - { project: api, cmd: php artisan migrate:fresh --seed }
3647
+ - { project: portal, cmd: php artisan migrate:fresh --seed }
3648
+ - parallel:
3649
+ - { project: api, cmd: php artisan cache:clear }
3650
+ - { project: portal, cmd: php artisan cache:clear }
3651
+ `;
3652
+ var HOME_README = `# ~/.laracrew
3653
+
3654
+ Your laracrew configuration. Safe to keep in git \u2014 nothing here is secret unless you put
3655
+ secrets in it.
3656
+
3657
+ config.yaml global preferences
3658
+ projects.yaml your Laravel projects, referenced by key
3659
+ stacks/<name>/ one folder per stack; stack.yaml is the definition
3660
+ tasks/<name>.yaml one-shot sequences (laracrew run <name>)
3661
+ fragments/ shared YAML any stack can reuse
3662
+ logs/ per-run log files, when defaults.logs.toFile is on
3663
+
3664
+ Start here:
3665
+
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
3668
+ laracrew ls # what is defined
3669
+ laracrew doctor <stack> # check a stack before booting it
3670
+ laracrew link <stack> # give a stack its own global command
3671
+ `;
3672
+
3673
+ // src/cli/commands/init.ts
3674
+ var runInit = (env = process.env, options = {}) => {
3675
+ const home2 = paths(env);
3676
+ const created = [];
3677
+ const skipped = [];
3678
+ for (const dir of [home2.root, home2.stacksDir, home2.tasksDir, home2.fragmentsDir, home2.logsDir, home2.runDir]) {
3679
+ mkdirSync4(dir, { recursive: true });
3680
+ }
3681
+ const files = [
3682
+ [home2.configFile, CONFIG_YAML],
3683
+ [home2.projectsFile, PROJECTS_YAML],
3684
+ [path10.join(home2.root, "README.md"), HOME_README]
3685
+ ];
3686
+ if (options.examples) {
3687
+ mkdirSync4(path10.join(home2.stacksDir, "example"), { recursive: true });
3688
+ mkdirSync4(path10.join(home2.stacksDir, "dual"), { recursive: true });
3689
+ files.push(
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]
3693
+ );
3694
+ }
3695
+ for (const [file, contents] of files) {
3696
+ if (existsSync7(file)) {
3697
+ skipped.push(file);
3698
+ continue;
3699
+ }
3700
+ writeFileSync3(file, contents, "utf8");
3701
+ created.push(file);
3702
+ }
3703
+ return { root: home2.root, created, skipped, examples: options.examples ?? false };
3704
+ };
3705
+ var initCommand = (options = {}, env = process.env) => {
3706
+ const result = runInit(env, options);
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)}`);
3710
+ for (const file of result.skipped) {
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
+ ];
3724
+ console.log(`
3725
+ ${nextSteps(steps, env)}
3726
+ `);
3727
+ };
3728
+
3729
+ // src/cli/commands/logs.ts
3730
+ import { existsSync as existsSync9, readdirSync as readdirSync4 } from "fs";
3731
+ import path11 from "path";
3732
+
3297
3733
  // src/core/logs/read.ts
3298
- 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";
3299
3735
  var CHUNK = 64 * 1024;
3300
3736
  var tailFile = (file, limit) => {
3301
- if (!existsSync7(file) || limit <= 0) return [];
3737
+ if (!existsSync8(file) || limit <= 0) return [];
3302
3738
  const size = statSync3(file).size;
3303
3739
  if (size === 0) return [];
3304
3740
  const handle = openSync2(file, "r");
@@ -3345,10 +3781,10 @@ var parseSince = (input) => {
3345
3781
  return Number.isNaN(absolute) ? void 0 : absolute;
3346
3782
  };
3347
3783
  var followFile = (file, options) => {
3348
- let position = existsSync7(file) ? statSync3(file).size : 0;
3784
+ let position = existsSync8(file) ? statSync3(file).size : 0;
3349
3785
  let carry = "";
3350
3786
  const read = () => {
3351
- if (!existsSync7(file)) return;
3787
+ if (!existsSync8(file)) return;
3352
3788
  const size = statSync3(file).size;
3353
3789
  if (size === position) return;
3354
3790
  if (size < position) {
@@ -3377,23 +3813,23 @@ var followFile = (file, options) => {
3377
3813
  };
3378
3814
 
3379
3815
  // src/cli/commands/logs.ts
3380
- 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);
3381
3817
  var targetsFor = (stackName, env = process.env) => {
3382
3818
  const stack = prepareStack(stackName, { env });
3383
3819
  const dir = logDirFor(stackName, env);
3384
3820
  const names = fileNamesFor(stack.services.map((service) => service.name));
3385
3821
  return stack.services.map((service) => ({
3386
3822
  service: service.name,
3387
- file: path10.join(dir, names.get(service.name)),
3823
+ file: path11.join(dir, names.get(service.name)),
3388
3824
  color: service.color
3389
- })).filter((target) => existsSync8(target.file));
3825
+ })).filter((target) => existsSync9(target.file));
3390
3826
  };
3391
3827
  var formatEntry = (entry, width, withName) => {
3392
3828
  const time2 = new Date(entry.at || Date.now());
3393
3829
  const clock = [time2.getHours(), time2.getMinutes(), time2.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
3394
- const who = withName ? `${paint(asColorName(void 0), padEnd(entry.service, width))} ` : "";
3395
- const body = entry.stream === "stderr" ? paint("red", entry.line) : entry.line;
3396
- 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}
3397
3833
  `;
3398
3834
  };
3399
3835
  var logsCommand = async (service, options = {}) => {
@@ -3402,25 +3838,24 @@ var logsCommand = async (service, options = {}) => {
3402
3838
  if (!stackName) throw new ConfigError("name a stack with --stack, or run `laracrew logs` from a known stack");
3403
3839
  const dir = logDirFor(stackName, env);
3404
3840
  const available = targetsFor(stackName, env);
3841
+ const mark = marks(env);
3405
3842
  if (options.list) {
3406
3843
  if (available.length === 0) {
3407
- console.log(paint("yellow", `no log files yet in ${dir}`));
3408
- 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")}`);
3409
3846
  return 0;
3410
3847
  }
3411
- 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)}`);
3412
3850
  for (const target of available) {
3413
- 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))}`);
3414
3852
  }
3415
3853
  return 0;
3416
3854
  }
3417
3855
  if (available.length === 0) {
3418
- console.error(paint("yellow", `no log files in ${dir}`));
3856
+ console.error(`${mark.warn} ${color.yellow(`no log files in ${dir}`)}`);
3419
3857
  console.error(
3420
- paint(
3421
- "gray",
3422
- "run the stack at least once, and check `defaults.logs.toFile` is not set to false"
3423
- )
3858
+ ` ${color.gray("run the stack at least once, and check `defaults.logs.toFile` is not set to false")}`
3424
3859
  );
3425
3860
  return 1;
3426
3861
  }
@@ -3482,11 +3917,12 @@ var logsCommand = async (service, options = {}) => {
3482
3917
  };
3483
3918
 
3484
3919
  // src/cli/commands/ls.ts
3485
- import { existsSync as existsSync9 } from "fs";
3920
+ import { existsSync as existsSync10 } from "fs";
3486
3921
  var lsCommand = (env = process.env, json = false) => {
3487
3922
  const home2 = paths(env);
3488
- if (!existsSync9(home2.root)) {
3489
- 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")}`);
3490
3926
  return 1;
3491
3927
  }
3492
3928
  const stackNames = listStacks(env);
@@ -3519,43 +3955,46 @@ var lsCommand = (env = process.env, json = false) => {
3519
3955
  console.log(JSON.stringify({ root: home2.root, stacks, tasks: taskNames, projects }, null, 2));
3520
3956
  return 0;
3521
3957
  }
3522
- console.log(paint("gray", home2.root));
3523
- console.log(`
3524
- ${paint("bold", "STACKS")}`);
3525
- 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
+ }
3526
3965
  const width = Math.max(4, ...stacks.map((stack) => stack.name.length));
3966
+ const indent = " ".repeat(width + 4);
3527
3967
  for (const stack of stacks) {
3528
- const label = stack.valid ? paint("cyan", padEnd(stack.name, width)) : paint("red", padEnd(stack.name, width));
3529
- const meta = stack.valid ? paint("gray", `${stack.services} services`) : paint("red", "invalid");
3530
- console.log(` ${label} ${meta} ${paint("gray", stack.description)}`);
3531
- if (stack.command) {
3532
- console.log(` ${" ".repeat(width)} ${paint("green", `$ ${stack.command}`)} ${paint("gray", "(global command)")}`);
3533
- }
3534
- if (stack.profiles.length > 0) {
3535
- 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
+ );
3536
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(", ")}`)}`);
3537
3978
  }
3538
- console.log(`
3539
- ${paint("bold", "PROJECTS")}`);
3540
3979
  const projectKeys = Object.keys(projects);
3541
- 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"));
3542
3982
  const projectWidth = Math.max(4, ...projectKeys.map((key) => key.length));
3543
3983
  for (const [key, project] of Object.entries(projects)) {
3544
- const exists = existsSync9(project.path);
3545
- console.log(
3546
- ` ${paint("magenta", padEnd(key, projectWidth))} ${project.path} ${exists ? "" : paint("red", "(missing)")}`
3547
- );
3548
- }
3549
- console.log(`
3550
- ${paint("bold", "TASKS")}`);
3551
- if (taskNames.length === 0) console.log(paint("gray", " none"));
3552
- 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)}`);
3553
3992
  console.log("");
3554
3993
  return 0;
3555
3994
  };
3556
3995
 
3557
3996
  // src/cli/program.ts
3558
- var VERSION = true ? "0.2.1" : versionFromPackageJson();
3997
+ var VERSION = true ? "0.3.0" : versionFromPackageJson();
3559
3998
  var resolveStackName = (explicit, env = process.env) => {
3560
3999
  if (explicit) return explicit;
3561
4000
  const configured = loadGlobalConfig(env).defaultStack;
@@ -3573,10 +4012,38 @@ var resolveStackName = (explicit, env = process.env) => {
3573
4012
  var collect = (value, previous) => [...previous, value];
3574
4013
  var buildProgram = () => {
3575
4014
  const program = new Command();
3576
- 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
+ });
3577
4028
  program.command("init").description("create ~/.laracrew").option("--examples", "also write a runnable demo stack and a two-project template").action((options) => {
3578
4029
  initCommand({ examples: options.examples ?? false });
3579
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
+ });
3580
4047
  program.command("ls").alias("list").description("list stacks, projects and tasks").option("--json", "machine-readable output").action((options) => {
3581
4048
  process.exitCode = lsCommand(process.env, options.json ?? false);
3582
4049
  });
@@ -3620,13 +4087,19 @@ var buildProgram = () => {
3620
4087
  });
3621
4088
  return program;
3622
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
+ };
3623
4097
  var run = async (argv = process.argv) => {
3624
4098
  try {
3625
4099
  await buildProgram().parseAsync(argv);
3626
4100
  } catch (error) {
3627
4101
  if (error instanceof ConfigError) {
3628
- console.error(`${paint("red", "config error")}
3629
- ${error.format()}`);
4102
+ console.error(renderConfigError(error));
3630
4103
  process.exitCode = 1;
3631
4104
  return;
3632
4105
  }
@@ -3637,7 +4110,9 @@ ${error.format()}`);
3637
4110
  // src/index.ts
3638
4111
  installPipeGuards();
3639
4112
  run().catch((error) => {
3640
- 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")));
3641
4116
  process.exitCode = 1;
3642
4117
  });
3643
4118
  //# sourceMappingURL=index.js.map