@gachlab/devup 0.2.0 → 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
@@ -3,7 +3,7 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { join as join5 } from "path";
6
+ import { join as join6 } from "path";
7
7
  import { homedir as homedir2 } from "os";
8
8
 
9
9
  // src/config/loader.ts
@@ -112,6 +112,25 @@ function validateConfig(config, cwd) {
112
112
  if (svc.cwd && !existsSync2(resolve2(cwd, svc.cwd))) {
113
113
  errors.push({ field: `services[${svc.name}].cwd`, message: `Directory not found: ${svc.cwd}` });
114
114
  }
115
+ if (svc.readyPattern !== void 0) {
116
+ if (typeof svc.readyPattern !== "string" || !svc.readyPattern.length) {
117
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `readyPattern must be a non-empty string` });
118
+ } else {
119
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(svc.readyPattern);
120
+ try {
121
+ if (slashed) new RegExp(slashed[1], slashed[2] || "i");
122
+ else new RegExp(svc.readyPattern, "i");
123
+ } catch (e) {
124
+ errors.push({ field: `services[${svc.name}].readyPattern`, message: `Invalid regex: ${e.message}` });
125
+ }
126
+ }
127
+ }
128
+ if (svc.preBuild !== void 0 && (typeof svc.preBuild !== "string" || !svc.preBuild.trim())) {
129
+ errors.push({ field: `services[${svc.name}].preBuild`, message: `preBuild must be a non-empty string` });
130
+ }
131
+ if (svc.watchBuild !== void 0 && (typeof svc.watchBuild !== "string" || !svc.watchBuild.trim())) {
132
+ errors.push({ field: `services[${svc.name}].watchBuild`, message: `watchBuild must be a non-empty string` });
133
+ }
115
134
  if (svc.healthCheck) {
116
135
  const hc = svc.healthCheck;
117
136
  if (hc.type !== "tcp" && hc.type !== "http") {
@@ -145,6 +164,34 @@ function validateConfig(config, cwd) {
145
164
  }
146
165
  }
147
166
  }
167
+ if (config.external) {
168
+ const extNames = /* @__PURE__ */ new Set();
169
+ for (const ext of config.external) {
170
+ if (!ext.name?.trim()) {
171
+ errors.push({ field: "external[].name", message: "External service name is required" });
172
+ continue;
173
+ }
174
+ if (extNames.has(ext.name)) {
175
+ errors.push({ field: `external[${ext.name}].name`, message: `Duplicate external name: ${ext.name}` });
176
+ }
177
+ extNames.add(ext.name);
178
+ if (!ext.cmd?.trim()) {
179
+ errors.push({ field: `external[${ext.name}].cmd`, message: "cmd is required" });
180
+ }
181
+ if (ext.healthCheck) {
182
+ const hc = ext.healthCheck;
183
+ if (hc.type !== "tcp" && hc.type !== "http") {
184
+ errors.push({ field: `external[${ext.name}].healthCheck.type`, message: `Invalid healthCheck.type: ${hc.type}` });
185
+ }
186
+ if ((hc.type === "tcp" || hc.type === "http") && (typeof ext.port !== "number" || ext.port <= 0)) {
187
+ errors.push({ field: `external[${ext.name}].port`, message: `port is required when healthCheck is set (got ${ext.port})` });
188
+ }
189
+ if (hc.type === "http" && hc.path && !hc.path.startsWith("/")) {
190
+ errors.push({ field: `external[${ext.name}].healthCheck.path`, message: `must start with "/"` });
191
+ }
192
+ }
193
+ }
194
+ }
148
195
  if (config.proxy?.routes) {
149
196
  for (const ref of Object.keys(config.proxy.routes)) {
150
197
  if (!names.has(ref)) {
@@ -152,6 +199,19 @@ function validateConfig(config, cwd) {
152
199
  }
153
200
  }
154
201
  }
202
+ if (config.profiles) {
203
+ for (const [profile, svcNames] of Object.entries(config.profiles)) {
204
+ if (!Array.isArray(svcNames) || !svcNames.length) {
205
+ errors.push({ field: `profiles.${profile}`, message: `Profile "${profile}" must be a non-empty array of service names` });
206
+ continue;
207
+ }
208
+ for (const ref of svcNames) {
209
+ if (!names.has(ref)) {
210
+ errors.push({ field: `profiles.${profile}`, message: `Unknown service: ${ref}` });
211
+ }
212
+ }
213
+ }
214
+ }
155
215
  return errors;
156
216
  }
157
217
  function formatValidationErrors(errors) {
@@ -194,6 +254,10 @@ function parseCliArgs(argv) {
194
254
  args.services = next?.split(",");
195
255
  i++;
196
256
  break;
257
+ case "--profile":
258
+ args.profile = next;
259
+ i++;
260
+ break;
197
261
  case "--lazy":
198
262
  args.lazy = true;
199
263
  break;
@@ -246,9 +310,18 @@ function parseCliArgs(argv) {
246
310
  }
247
311
  return args;
248
312
  }
249
- function filterServices(services, args) {
313
+ function filterServices(services, args, config) {
250
314
  let result = services;
251
- if (args.services) {
315
+ if (args.profile) {
316
+ const profileNames = config?.profiles?.[args.profile];
317
+ if (!profileNames) {
318
+ const available = Object.keys(config?.profiles ?? {});
319
+ const hint = available.length ? `Available: ${available.join(", ")}` : "No profiles defined in config.";
320
+ throw new Error(`Unknown profile: "${args.profile}". ${hint}`);
321
+ }
322
+ const set = new Set(profileNames);
323
+ result = result.filter((s) => set.has(s.name));
324
+ } else if (args.services) {
252
325
  const explicit = new Set(args.services);
253
326
  result = result.filter((s) => explicit.has(s.name));
254
327
  } else if (args.only) {
@@ -666,6 +739,16 @@ function installService(cwd, env, onLog) {
666
739
  // src/process/manager.ts
667
740
  var MAX_RESTARTS = 3;
668
741
  var BACKOFF_BASE_MS = 2e3;
742
+ function compileReadyPattern(pattern) {
743
+ if (!pattern) return null;
744
+ const slashed = /^\/(.+)\/([gimsuy]*)$/.exec(pattern);
745
+ try {
746
+ if (slashed) return new RegExp(slashed[1], slashed[2] || "i");
747
+ return new RegExp(pattern, "i");
748
+ } catch {
749
+ return null;
750
+ }
751
+ }
669
752
  function lineBuffer(onLine) {
670
753
  let buf = "";
671
754
  return {
@@ -713,6 +796,13 @@ var ProcessManager = class {
713
796
  return;
714
797
  }
715
798
  }
799
+ if (svc.preBuild) {
800
+ const built = await this.runPreBuild(svc, cwd, colorIdx);
801
+ if (!built) {
802
+ this.recordCrashedState(svc, colorIdx);
803
+ return;
804
+ }
805
+ }
716
806
  const args = buildProcessArgs(svc);
717
807
  const env = buildProcessEnv(svc, this.env);
718
808
  const proc = spawn2(svc.cmd, args, { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
@@ -732,9 +822,22 @@ var ProcessManager = class {
732
822
  this.state.set(svc.name, state);
733
823
  this.procs.add(proc);
734
824
  this.events.onStateChange(svc.name, state);
735
- const stdoutBuf = lineBuffer((line) => this.log(svc.name, line, colorIdx));
825
+ const readyRegex = compileReadyPattern(svc.readyPattern);
826
+ const markReadyIfMatch = (line) => {
827
+ if (!readyRegex || state.health === "up") return;
828
+ if (readyRegex.test(line)) {
829
+ state.health = "up";
830
+ if (state.status === "starting") state.status = "running";
831
+ this.events.onStateChange(svc.name, state);
832
+ }
833
+ };
834
+ const stdoutBuf = lineBuffer((line) => {
835
+ markReadyIfMatch(line);
836
+ this.log(svc.name, line, colorIdx);
837
+ });
736
838
  const stderrBuf = lineBuffer((line) => {
737
839
  state.errors += 1;
840
+ markReadyIfMatch(line);
738
841
  this.log(svc.name, line, colorIdx);
739
842
  });
740
843
  proc.stdout?.on("data", (d) => stdoutBuf.push(d));
@@ -743,6 +846,7 @@ var ProcessManager = class {
743
846
  proc.stderr?.on("end", () => stderrBuf.flush());
744
847
  proc.on("close", (code) => {
745
848
  this.procs.delete(proc);
849
+ this.stopWatchProc(state);
746
850
  if (state.intentionalStop) {
747
851
  state.intentionalStop = false;
748
852
  return;
@@ -766,13 +870,90 @@ var ProcessManager = class {
766
870
  this.log(svc.name, "\u26D4 max restarts reached", colorIdx);
767
871
  }
768
872
  });
873
+ if (svc.watchBuild) {
874
+ state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
875
+ }
769
876
  this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
770
877
  }
878
+ runPreBuild(svc, cwd, colorIdx) {
879
+ this.log(svc.name, `\u{1F528} preBuild: ${svc.preBuild}`, colorIdx);
880
+ return new Promise((resolve3) => {
881
+ const isWin = process.platform === "win32";
882
+ const shell = isWin ? "cmd.exe" : "sh";
883
+ const shellFlag = isWin ? "/c" : "-c";
884
+ const env = buildProcessEnv(svc, this.env);
885
+ const child = spawn2(shell, [shellFlag, svc.preBuild], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
886
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
887
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[build] ${line}`, colorIdx));
888
+ child.stdout?.on("data", (d) => outBuf.push(d));
889
+ child.stderr?.on("data", (d) => errBuf.push(d));
890
+ child.on("error", (err) => {
891
+ this.log(svc.name, `[build] \u274C ${err.message}`, colorIdx);
892
+ resolve3(false);
893
+ });
894
+ child.on("close", (code) => {
895
+ outBuf.flush();
896
+ errBuf.flush();
897
+ if (code === 0) {
898
+ this.log(svc.name, `[build] \u2705 done`, colorIdx);
899
+ resolve3(true);
900
+ } else {
901
+ this.log(svc.name, `[build] \u274C exited with code ${code}`, colorIdx);
902
+ resolve3(false);
903
+ }
904
+ });
905
+ });
906
+ }
907
+ spawnWatchBuild(svc, cwd, env, colorIdx) {
908
+ this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
909
+ const isWin = process.platform === "win32";
910
+ const shell = isWin ? "cmd.exe" : "sh";
911
+ const shellFlag = isWin ? "/c" : "-c";
912
+ const child = spawn2(shell, [shellFlag, svc.watchBuild], {
913
+ cwd,
914
+ env,
915
+ detached: true,
916
+ stdio: ["ignore", "pipe", "pipe"]
917
+ });
918
+ const outBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
919
+ const errBuf = lineBuffer((line) => this.log(svc.name, `[watch] ${line}`, colorIdx));
920
+ child.stdout?.on("data", (d) => outBuf.push(d));
921
+ child.stderr?.on("data", (d) => errBuf.push(d));
922
+ child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
923
+ return child;
924
+ }
925
+ /** Create a state entry in 'crashed' status without spawning a process (used when preBuild fails). */
926
+ recordCrashedState(svc, colorIdx) {
927
+ const prev = this.state.get(svc.name);
928
+ this.state.set(svc.name, {
929
+ svc,
930
+ proc: null,
931
+ pid: null,
932
+ status: "crashed",
933
+ health: "down",
934
+ errors: prev?.errors ?? 0,
935
+ restarts: prev?.restarts ?? 0,
936
+ startedAt: null,
937
+ intentionalStop: false,
938
+ colorIdx
939
+ });
940
+ this.events.onStateChange(svc.name, this.state.get(svc.name));
941
+ }
771
942
  stop(name) {
772
943
  const st = this.state.get(name);
773
944
  if (!st?.proc || !st.pid) return;
774
945
  st.intentionalStop = true;
775
946
  this.platform.killTree(st.pid);
947
+ this.stopWatchProc(st);
948
+ }
949
+ stopWatchProc(state) {
950
+ const wp = state.watchProc;
951
+ if (!wp || !wp.pid) return;
952
+ try {
953
+ this.platform.killTree(wp.pid);
954
+ } catch {
955
+ }
956
+ state.watchProc = null;
776
957
  }
777
958
  async restart(name) {
778
959
  const st = this.state.get(name);
@@ -803,9 +984,13 @@ var ProcessManager = class {
803
984
  if (!procs.length) return;
804
985
  for (const proc of procs) {
805
986
  const st = this.findStateByProc(proc);
806
- if (st) st.intentionalStop = true;
987
+ if (st) {
988
+ st.intentionalStop = true;
989
+ this.stopWatchProc(st);
990
+ }
807
991
  if (proc.pid) this.platform.killTree(proc.pid);
808
992
  }
993
+ for (const st of this.state.values()) this.stopWatchProc(st);
809
994
  const waits = procs.map(
810
995
  (p) => p.exitCode !== null || p.signalCode !== null ? Promise.resolve() : new Promise((resolve3) => p.once("close", () => resolve3()))
811
996
  );
@@ -913,6 +1098,21 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
913
1098
  pendingLogsRef.current = [];
914
1099
  setLogs([]);
915
1100
  }, []);
1101
+ const pushLog = useCallback((svcName, text, colorIdx = 0) => {
1102
+ sinkRef.current?.write(svcName, text);
1103
+ const entry = { svcName, text, colorIdx, ts: Date.now() };
1104
+ if (pausedRef.current) {
1105
+ pendingLogsRef.current.push(entry);
1106
+ if (pendingLogsRef.current.length > 5e3) {
1107
+ pendingLogsRef.current = pendingLogsRef.current.slice(-5e3);
1108
+ }
1109
+ return;
1110
+ }
1111
+ setLogs((prev) => {
1112
+ const next = prev.concat(entry);
1113
+ return next.length > 5e3 ? next.slice(-5e3) : next;
1114
+ });
1115
+ }, []);
916
1116
  const setPaused = useCallback((paused) => {
917
1117
  pausedRef.current = paused;
918
1118
  if (!paused && pendingLogsRef.current.length) {
@@ -935,6 +1135,7 @@ function useProcessManager(platform, baseCwd, env, logSink = null) {
935
1135
  cleanup: useCallback(() => mgr?.cleanup(), [mgr]),
936
1136
  clearLogs,
937
1137
  setPaused,
1138
+ pushLog,
938
1139
  manager: mgr
939
1140
  };
940
1141
  }
@@ -1445,6 +1646,86 @@ function createLazyProxy(opts) {
1445
1646
  };
1446
1647
  }
1447
1648
 
1649
+ // src/process/external.ts
1650
+ import { spawn as spawn3 } from "child_process";
1651
+ import { join as join4 } from "path";
1652
+ var DEFAULT_START_TIMEOUT_S = 60;
1653
+ async function startExternals(externals, opts) {
1654
+ const procs = [];
1655
+ const failed = [];
1656
+ for (const svc of externals) {
1657
+ const proc = spawnExternal(svc, opts);
1658
+ procs.push({ svc, proc, pid: proc.pid ?? null });
1659
+ if (!svc.healthCheck) {
1660
+ opts.onLog?.(svc.name, "\u2705 started (no healthCheck)");
1661
+ continue;
1662
+ }
1663
+ if (svc.healthCheck.type === "tcp" && !svc.port) {
1664
+ opts.onLog?.(svc.name, "\u26A0 tcp healthCheck requires `port` \u2014 skipping wait");
1665
+ continue;
1666
+ }
1667
+ const timeoutMs = (svc.startTimeout ?? DEFAULT_START_TIMEOUT_S) * 1e3;
1668
+ const ok = await waitHealthy(svc, timeoutMs);
1669
+ if (ok) {
1670
+ opts.onLog?.(svc.name, "\u2705 healthy");
1671
+ } else {
1672
+ opts.onLog?.(svc.name, `\u274C never became healthy (timeout ${timeoutMs / 1e3}s)`);
1673
+ failed.push(svc.name);
1674
+ }
1675
+ }
1676
+ return { procs, allHealthy: failed.length === 0, failed };
1677
+ }
1678
+ async function stopExternals(procs, platform, opts = {}) {
1679
+ for (const { svc, proc, pid } of procs) {
1680
+ try {
1681
+ if (pid) platform.killTree(pid);
1682
+ if (svc.stopCmd) {
1683
+ opts.onLog?.(svc.name, `\u{1F9F9} ${svc.stopCmd}`);
1684
+ await new Promise((resolve3) => {
1685
+ const isWin = process.platform === "win32";
1686
+ const shell = isWin ? "cmd.exe" : "sh";
1687
+ const flag = isWin ? "/c" : "-c";
1688
+ const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1689
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1690
+ const child = spawn3(shell, [flag, svc.stopCmd], { cwd, env, stdio: "ignore" });
1691
+ child.on("close", () => resolve3());
1692
+ child.on("error", () => resolve3());
1693
+ setTimeout(() => resolve3(), 1e4);
1694
+ });
1695
+ }
1696
+ } catch {
1697
+ }
1698
+ void proc;
1699
+ }
1700
+ }
1701
+ function spawnExternal(svc, opts) {
1702
+ const isWin = process.platform === "win32";
1703
+ const shell = isWin ? "cmd.exe" : "sh";
1704
+ const flag = isWin ? "/c" : "-c";
1705
+ const cwd = svc.cwd ? join4(opts.baseCwd, svc.cwd) : opts.baseCwd;
1706
+ const env = { ...opts.env, ...svc.extraEnv ?? {} };
1707
+ opts.onLog?.(svc.name, `\u{1F680} ${svc.cmd}`);
1708
+ const child = spawn3(shell, [flag, svc.cmd], {
1709
+ cwd,
1710
+ env,
1711
+ detached: true,
1712
+ stdio: ["ignore", "pipe", "pipe"]
1713
+ });
1714
+ child.stdout?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1715
+ child.stderr?.on("data", (d) => opts.onLog?.(svc.name, d.toString().trimEnd()));
1716
+ child.on("error", (err) => opts.onLog?.(svc.name, `\u274C spawn error: ${err.message}`));
1717
+ return child;
1718
+ }
1719
+ async function waitHealthy(svc, timeoutMs) {
1720
+ const deadline = Date.now() + timeoutMs;
1721
+ const port = svc.port;
1722
+ while (Date.now() < deadline) {
1723
+ if (await checkHealth(port, svc.healthCheck)) return true;
1724
+ await new Promise((r) => setTimeout(r, 500));
1725
+ }
1726
+ return false;
1727
+ }
1728
+
1448
1729
  // src/tui/App.tsx
1449
1730
  import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1450
1731
  function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider, proxyOpts, logSink }) {
@@ -1464,6 +1745,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1464
1745
  const pm = useProcessManager(platform, baseCwd, env, logSink);
1465
1746
  const [booted, setBooted] = useState5(false);
1466
1747
  const lazyProxies = useRef3(/* @__PURE__ */ new Map());
1748
+ const externals = useRef3([]);
1467
1749
  const kb = useKeyBindings({
1468
1750
  onQuit: () => {
1469
1751
  void shutdown();
@@ -1475,9 +1757,17 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1475
1757
  const shutdown = useCallback3(async () => {
1476
1758
  lazyProxies.current.forEach((p) => p.destroy());
1477
1759
  await pm.cleanup();
1760
+ if (externals.current.length) {
1761
+ await stopExternals(externals.current, platform, {
1762
+ baseCwd,
1763
+ env,
1764
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
1765
+ });
1766
+ externals.current = [];
1767
+ }
1478
1768
  await logSink?.close();
1479
1769
  process.exit(0);
1480
- }, [pm, logSink]);
1770
+ }, [pm, logSink, platform, baseCwd, env]);
1481
1771
  useEffect5(() => {
1482
1772
  pm.setPaused(kb.logsPaused || kb.logsScrollOffset > 0);
1483
1773
  }, [kb.logsPaused, kb.logsScrollOffset, pm]);
@@ -1489,6 +1779,19 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1489
1779
  (async () => {
1490
1780
  const lazyMode = cliArgs.lazy;
1491
1781
  const lazyTimeout = cliArgs.lazyTimeout;
1782
+ if (config.external?.length) {
1783
+ const result = await startExternals(config.external, {
1784
+ baseCwd,
1785
+ env,
1786
+ platform,
1787
+ onLog: (svc, msg) => pm.pushLog(`ext:${svc}`, msg, 12)
1788
+ });
1789
+ externals.current = result.procs;
1790
+ if (!result.allHealthy) {
1791
+ pm.pushLog("devup", `\u274C external(s) failed: ${result.failed.join(", ")}. Aborting boot.`, 5);
1792
+ return;
1793
+ }
1794
+ }
1492
1795
  if (lazyMode && config.lazy) {
1493
1796
  const { alwaysOn, lazy } = classifyServices(services, config.lazy);
1494
1797
  const aoPhases = groupByPhase(alwaysOn);
@@ -1637,7 +1940,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
1637
1940
 
1638
1941
  // src/process/log-sink.ts
1639
1942
  import { existsSync as existsSync8, mkdirSync as mkdirSync4, renameSync, createWriteStream } from "fs";
1640
- import { join as join4, dirname as dirname4 } from "path";
1943
+ import { join as join5, dirname as dirname4 } from "path";
1641
1944
  import { homedir } from "os";
1642
1945
  var LogSink = class {
1643
1946
  dir;
@@ -1645,14 +1948,14 @@ var LogSink = class {
1645
1948
  streams = /* @__PURE__ */ new Map();
1646
1949
  seen = /* @__PURE__ */ new Set();
1647
1950
  constructor(opts) {
1648
- const root = opts.rootDir ?? join4(homedir(), ".devup", "logs");
1649
- this.dir = join4(root, sanitize(opts.projectName));
1951
+ const root = opts.rootDir ?? join5(homedir(), ".devup", "logs");
1952
+ this.dir = join5(root, sanitize(opts.projectName));
1650
1953
  this.rotateOnStart = opts.rotateOnStart ?? true;
1651
1954
  mkdirSync4(this.dir, { recursive: true });
1652
1955
  }
1653
1956
  /** Returns the file path for a service log (useful for tests / UI). */
1654
1957
  pathFor(svcName) {
1655
- return join4(this.dir, `${sanitize(svcName)}.log`);
1958
+ return join5(this.dir, `${sanitize(svcName)}.log`);
1656
1959
  }
1657
1960
  write(svcName, line) {
1658
1961
  const stream = this.streamFor(svcName);
@@ -1696,8 +1999,18 @@ function renderDryRun(opts) {
1696
1999
  const lines = [];
1697
2000
  lines.push(`Project: ${config.icon ?? "\u{1F4E6}"} ${config.name}`);
1698
2001
  lines.push(`Mode: ${cliArgs.lazy && config.lazy ? "lazy" : "normal"}`);
2002
+ if (cliArgs.profile) lines.push(`Profile: ${cliArgs.profile}`);
1699
2003
  lines.push(`Services: ${services.length}`);
1700
2004
  lines.push("");
2005
+ if (config.external?.length) {
2006
+ lines.push(`Externals (${config.external.length}):`);
2007
+ for (const ext of config.external) {
2008
+ const hc = ext.healthCheck;
2009
+ const hcTag = hc ? ` health=${hc.type}${hc.type === "http" ? " " + (hc.path ?? "/") : ""} :${ext.port ?? "?"}` : "";
2010
+ lines.push(` - ${ext.name.padEnd(20)} ${ext.cmd}${hcTag}`);
2011
+ }
2012
+ lines.push("");
2013
+ }
1701
2014
  const lazyMode = cliArgs.lazy && !!config.lazy;
1702
2015
  let alwaysOn = services;
1703
2016
  let lazy = [];
@@ -1771,6 +2084,26 @@ async function runOnce(opts) {
1771
2084
  }
1772
2085
  }
1773
2086
  });
2087
+ let externals = [];
2088
+ if (config.external?.length) {
2089
+ out(`\u25B6 externals (${config.external.length})`);
2090
+ const result = await startExternals(config.external, {
2091
+ baseCwd,
2092
+ env,
2093
+ platform,
2094
+ onLog: (svc, msg) => {
2095
+ logSink?.write(`ext:${svc}`, msg);
2096
+ out(`[ext:${svc}] ${msg}`);
2097
+ }
2098
+ });
2099
+ externals = result.procs;
2100
+ if (!result.allHealthy) {
2101
+ out(`\u2717 externals failed: ${result.failed.join(", ")}`);
2102
+ await stopExternals(externals, platform, { baseCwd, env });
2103
+ await mgr.cleanup();
2104
+ return 1;
2105
+ }
2106
+ }
1774
2107
  const phases = groupByPhase(services);
1775
2108
  const phaseNums = Object.keys(phases).map(Number).sort((a, b) => a - b);
1776
2109
  const apiNames = services.filter((s) => s.type === "api").map((s) => s.name);
@@ -1784,16 +2117,18 @@ async function runOnce(opts) {
1784
2117
  if (!installed) {
1785
2118
  out(`\u2717 install failed for ${svc.name}`);
1786
2119
  await mgr.cleanup();
2120
+ await stopExternals(externals, platform, { baseCwd, env });
1787
2121
  return 1;
1788
2122
  }
1789
2123
  await mgr.start(svc, ci);
1790
2124
  }
1791
2125
  const apis = phases[num].filter((s) => s.type === "api");
1792
2126
  for (const api of apis) {
1793
- const ok = await waitHealthy(api, deadline);
2127
+ const ok = await waitHealthy2(api, deadline);
1794
2128
  if (!ok) {
1795
2129
  out(`\u2717 ${api.name} did not become healthy within ${cliArgs.onceTimeout}s`);
1796
2130
  await mgr.cleanup();
2131
+ await stopExternals(externals, platform, { baseCwd, env });
1797
2132
  return 1;
1798
2133
  }
1799
2134
  out(`\u2713 ${api.name} ready`);
@@ -1807,9 +2142,10 @@ async function runOnce(opts) {
1807
2142
  const summary = `ready: ${apiNames.length} APIs in ${((cliArgs.onceTimeout * 1e3 - (deadline - Date.now())) / 1e3).toFixed(1)}s`;
1808
2143
  out(summary);
1809
2144
  await mgr.cleanup();
2145
+ await stopExternals(externals, platform, { baseCwd, env });
1810
2146
  return 0;
1811
2147
  }
1812
- async function waitHealthy(svc, deadline) {
2148
+ async function waitHealthy2(svc, deadline) {
1813
2149
  while (Date.now() < deadline) {
1814
2150
  const ok = await checkHealth(svc.port, svc.healthCheck);
1815
2151
  if (ok) return true;
@@ -1841,13 +2177,19 @@ async function main() {
1841
2177
  ${formatValidationErrors(errors)}`);
1842
2178
  process.exit(1);
1843
2179
  }
1844
- const services = filterServices(config.services, cliArgs);
2180
+ let services;
2181
+ try {
2182
+ services = filterServices(config.services, cliArgs, config);
2183
+ } catch (e) {
2184
+ console.error(`\u274C ${e.message}`);
2185
+ process.exit(1);
2186
+ }
1845
2187
  if (!services.length) {
1846
2188
  console.error("\u274C No services to run after filtering");
1847
2189
  process.exit(1);
1848
2190
  }
1849
2191
  const platform = await detectPlatform();
1850
- const envFile = config.envFile ? join5(cwd, config.envFile) : join5(cwd, ".env");
2192
+ const envFile = config.envFile ? join6(cwd, config.envFile) : join6(cwd, ".env");
1851
2193
  const env = parseEnvFile(envFile, process.env);
1852
2194
  if (config.env) {
1853
2195
  for (const [k, v] of Object.entries(config.env)) {
@@ -1864,7 +2206,7 @@ ${formatValidationErrors(errors)}`);
1864
2206
  routes: config.proxy.routes,
1865
2207
  tls: cliArgs.proxyTls ?? config.proxy.tls ?? true,
1866
2208
  entrypoint: cliArgs.proxyEntrypoint ?? config.proxy.entrypoint ?? "websecure",
1867
- confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join5(homedir2(), ".traefik", "traefik_conf.yaml")
2209
+ confPath: cliArgs.proxyConf ?? config.proxy.confPath ?? join6(homedir2(), ".traefik", "traefik_conf.yaml")
1868
2210
  };
1869
2211
  }
1870
2212
  if (cliArgs.dryRun) {