@gachlab/devup 0.10.1 → 0.11.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,15 +3,15 @@
3
3
  // src/index.ts
4
4
  import React7 from "react";
5
5
  import { render } from "ink";
6
- import { readFileSync as readFileSync4, realpathSync } from "fs";
7
- import { dirname as dirname7, join as join10 } from "path";
6
+ import { readFileSync as readFileSync5, realpathSync } from "fs";
7
+ import { dirname as dirname8, join as join10 } from "path";
8
8
  import { fileURLToPath as fileURLToPath2 } from "url";
9
9
  import { homedir as homedir5 } from "os";
10
10
 
11
11
  // src/config/loader.ts
12
- import { existsSync } from "fs";
12
+ import { existsSync, readFileSync } from "fs";
13
13
  import { readFile } from "fs/promises";
14
- import { resolve, join } from "path";
14
+ import { resolve, join, dirname } from "path";
15
15
  import { pathToFileURL } from "url";
16
16
  var CONFIG_NAMES = [
17
17
  "devup.config.ts",
@@ -33,7 +33,43 @@ function findConfigFile(cwd, explicit) {
33
33
  Or use --config <path>`
34
34
  );
35
35
  }
36
+ function extractEnvFilePath(configPath, raw) {
37
+ if (configPath.endsWith(".json")) {
38
+ try {
39
+ return JSON.parse(raw).envFile ?? null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+ const m = raw.match(/envFile\s*:\s*['"`]([^'"`]+)['"`]/);
45
+ return m ? m[1] : null;
46
+ }
47
+ function loadEnvFile(envFilePath) {
48
+ if (!existsSync(envFilePath)) return;
49
+ try {
50
+ const content = readFileSync(envFilePath, "utf8");
51
+ for (const line of content.split("\n")) {
52
+ const trimmed = line.trim();
53
+ if (!trimmed || trimmed.startsWith("#")) continue;
54
+ const eq = trimmed.indexOf("=");
55
+ if (eq < 1) continue;
56
+ const key = trimmed.slice(0, eq).trim();
57
+ const val = trimmed.slice(eq + 1).trim().replace(/^(['"`])(.*)\1$/, "$2");
58
+ if (!(key in process.env)) process.env[key] = val;
59
+ }
60
+ } catch {
61
+ }
62
+ }
36
63
  async function loadConfig(configPath) {
64
+ const configDir = dirname(configPath);
65
+ try {
66
+ const raw = readFileSync(configPath, "utf8");
67
+ const envFileRelPath = extractEnvFilePath(configPath, raw);
68
+ if (envFileRelPath) {
69
+ loadEnvFile(resolve(configDir, envFileRelPath));
70
+ }
71
+ } catch {
72
+ }
37
73
  if (configPath.endsWith(".json")) {
38
74
  const raw = await readFile(configPath, "utf8");
39
75
  return JSON.parse(raw);
@@ -439,9 +475,9 @@ function filterServices(services, args, config) {
439
475
 
440
476
  // src/orchestrator/subcommands.ts
441
477
  import { spawn as spawn5 } from "child_process";
442
- import { createReadStream as createReadStream2, watchFile as watchFile2, unwatchFile as unwatchFile2, existsSync as existsSync11, statSync as statSync2 } from "fs";
478
+ import { createReadStream as createReadStream2, watchFile as watchFile2, unwatchFile as unwatchFile2, existsSync as existsSync11, statSync as statSync3 } from "fs";
443
479
  import { readFile as readFile2 } from "fs/promises";
444
- import { join as join9, dirname as dirname3 } from "path";
480
+ import { join as join9, dirname as dirname4 } from "path";
445
481
  import { fileURLToPath } from "url";
446
482
  import { homedir as homedir4 } from "os";
447
483
  import { createInterface as createInterface4 } from "readline";
@@ -496,27 +532,24 @@ function checkHttp(port, opts = {}) {
496
532
  };
497
533
  return new Promise((resolve4) => {
498
534
  const req = http.get({ host, port, path, timeout: timeoutMs }, (res) => {
499
- const ok = typeof res.statusCode === "number" && accept(res.statusCode);
535
+ const code = res.statusCode ?? 0;
536
+ const ok = typeof res.statusCode === "number" && accept(code);
500
537
  res.resume();
501
- resolve4(ok);
538
+ resolve4(ok ? { ok: true } : { ok: false, reason: `HTTP ${code}` });
502
539
  });
503
- req.on("error", () => resolve4(false));
540
+ req.on("error", (e) => resolve4({ ok: false, reason: e.code ?? e.message }));
504
541
  req.on("timeout", () => {
505
542
  req.destroy();
506
- resolve4(false);
543
+ resolve4({ ok: false, reason: `timed out after ${timeoutMs}ms` });
507
544
  });
508
545
  });
509
546
  }
510
- function checkHealth(port, hc) {
547
+ async function checkHealth(port, hc) {
511
548
  if (hc?.type === "http") {
512
- return checkHttp(port, {
513
- path: hc.path,
514
- expect: hc.expect,
515
- host: hc.host,
516
- timeoutMs: hc.timeoutMs
517
- });
549
+ return checkHttp(port, { path: hc.path, expect: hc.expect, host: hc.host, timeoutMs: hc.timeoutMs });
518
550
  }
519
- return checkPort(port, "127.0.0.1", hc?.timeoutMs);
551
+ const ok = await checkPort(port, "127.0.0.1", hc?.timeoutMs);
552
+ return ok ? { ok: true } : { ok: false, reason: `connection refused on :${port}` };
520
553
  }
521
554
  function waitForPort(port, opts = {}) {
522
555
  const { timeout = 45e3, interval = 1e3 } = opts;
@@ -539,11 +572,11 @@ function deriveHealth(isUp, currentStatus) {
539
572
  }
540
573
 
541
574
  // src/utils/env.ts
542
- import { existsSync as existsSync3, readFileSync } from "fs";
575
+ import { existsSync as existsSync3, readFileSync as readFileSync2 } from "fs";
543
576
  function parseEnvFile(filePath, baseEnv = {}) {
544
577
  const env = { ...baseEnv };
545
578
  if (!existsSync3(filePath)) return env;
546
- for (const line of readFileSync(filePath, "utf8").split("\n")) {
579
+ for (const line of readFileSync2(filePath, "utf8").split("\n")) {
547
580
  const trimmed = line.trim();
548
581
  if (!trimmed || trimmed.startsWith("#")) continue;
549
582
  const eqIdx = trimmed.indexOf("=");
@@ -606,23 +639,23 @@ function redactSecrets(env) {
606
639
  }
607
640
 
608
641
  // src/utils/install-stamp.ts
609
- import { existsSync as existsSync4, readFileSync as readFileSync2, writeFileSync } from "fs";
642
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync } from "fs";
610
643
  import { createHash } from "crypto";
611
644
  import { join as join2 } from "path";
612
645
  function needsInstall(fullCwd) {
613
646
  const nm = join2(fullCwd, "node_modules");
614
647
  if (!existsSync4(nm)) return true;
615
648
  try {
616
- const pkgHash = createHash("md5").update(readFileSync2(join2(fullCwd, "package.json"))).digest("hex");
649
+ const pkgHash = createHash("md5").update(readFileSync3(join2(fullCwd, "package.json"))).digest("hex");
617
650
  const stampFile = join2(nm, ".install-stamp");
618
- if (existsSync4(stampFile) && readFileSync2(stampFile, "utf8") === pkgHash) return false;
651
+ if (existsSync4(stampFile) && readFileSync3(stampFile, "utf8") === pkgHash) return false;
619
652
  } catch {
620
653
  }
621
654
  return true;
622
655
  }
623
656
  function writeInstallStamp(fullCwd) {
624
657
  try {
625
- const pkgHash = createHash("md5").update(readFileSync2(join2(fullCwd, "package.json"))).digest("hex");
658
+ const pkgHash = createHash("md5").update(readFileSync3(join2(fullCwd, "package.json"))).digest("hex");
626
659
  writeFileSync(join2(fullCwd, "node_modules", ".install-stamp"), pkgHash);
627
660
  } catch {
628
661
  }
@@ -703,7 +736,7 @@ import { existsSync as existsSync6 } from "fs";
703
736
  import { createServer } from "net";
704
737
  import { createInterface } from "readline";
705
738
  import { existsSync as existsSync5, unlinkSync, chmodSync, mkdirSync, statSync } from "fs";
706
- import { dirname } from "path";
739
+ import { dirname as dirname2 } from "path";
707
740
  import { join as join3 } from "path";
708
741
  import { homedir } from "os";
709
742
  function defaultSocketPath(projectName) {
@@ -712,7 +745,7 @@ function defaultSocketPath(projectName) {
712
745
  }
713
746
  async function startSocketServer(projectName, ctx, opts = {}) {
714
747
  const path = opts.path ?? defaultSocketPath(projectName);
715
- mkdirSync(dirname(path), { recursive: true });
748
+ mkdirSync(dirname2(path), { recursive: true });
716
749
  if (existsSync5(path)) {
717
750
  try {
718
751
  const st = statSync(path);
@@ -835,7 +868,8 @@ function serializeState(name, st) {
835
868
  errors: st.errors,
836
869
  restarts: st.restarts,
837
870
  pid: st.pid,
838
- startedAt: st.startedAt
871
+ startedAt: st.startedAt,
872
+ crashLog: st.crashLog ?? null
839
873
  };
840
874
  }
841
875
  function respond(socket, payload) {
@@ -954,7 +988,7 @@ function openStream(socketPath, method, params, onFrame, onError) {
954
988
 
955
989
  // src/orchestrator/daemon.ts
956
990
  import { spawn as spawn4 } from "child_process";
957
- import { writeFileSync as writeFileSync2, readFileSync as readFileSync3, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
991
+ import { writeFileSync as writeFileSync2, readFileSync as readFileSync4, existsSync as existsSync10, unlinkSync as unlinkSync2, mkdirSync as mkdirSync3, createReadStream } from "fs";
958
992
  import { join as join8 } from "path";
959
993
  import { homedir as homedir3, totalmem, freemem, cpus } from "os";
960
994
  import { setTimeout as sleep } from "timers/promises";
@@ -1060,6 +1094,8 @@ var MAX_RESTARTS = 3;
1060
1094
  var BACKOFF_BASE_MS = 2e3;
1061
1095
 
1062
1096
  // src/process/spawner.ts
1097
+ var CRASH_LOG_LINES = 20;
1098
+ var STARTUP_TIMEOUT_DEFAULT_MS = 45e3;
1063
1099
  var Spawner = class {
1064
1100
  baseCwd;
1065
1101
  env;
@@ -1068,6 +1104,7 @@ var Spawner = class {
1068
1104
  events;
1069
1105
  lifecycle;
1070
1106
  onCrash;
1107
+ startupTimers = /* @__PURE__ */ new Map();
1071
1108
  constructor(opts) {
1072
1109
  this.baseCwd = opts.baseCwd;
1073
1110
  this.env = opts.env;
@@ -1079,6 +1116,7 @@ var Spawner = class {
1079
1116
  }
1080
1117
  async start(svc, colorIdx, isRestart = false) {
1081
1118
  const cwd = join4(this.baseCwd, svc.cwd);
1119
+ this.clearStartupTimer(svc.name);
1082
1120
  if (svc.type === "api") {
1083
1121
  const bindable = await isPortBindable(svc.port);
1084
1122
  if (!bindable && !isRestart) {
@@ -1114,18 +1152,41 @@ var Spawner = class {
1114
1152
  restarts: prev?.restarts ?? 0,
1115
1153
  startedAt: Date.now(),
1116
1154
  intentionalStop: false,
1117
- colorIdx
1155
+ colorIdx,
1156
+ crashLog: null
1118
1157
  };
1119
1158
  this.state.set(svc.name, state);
1120
1159
  this.procs.add(proc);
1121
1160
  this.events.onStateChange(svc.name, state);
1122
1161
  this.wireStdio(proc, svc, state, colorIdx);
1123
1162
  this.wireCloseHandler(proc, svc, state, colorIdx);
1163
+ this.scheduleStartupTimeout(svc, state, colorIdx);
1124
1164
  if (svc.watchBuild) {
1125
1165
  state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1126
1166
  }
1127
1167
  this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1128
1168
  }
1169
+ scheduleStartupTimeout(svc, state, colorIdx) {
1170
+ const timeoutMs = svc.healthCheck?.startupTimeoutMs ?? STARTUP_TIMEOUT_DEFAULT_MS;
1171
+ if (timeoutMs <= 0) return;
1172
+ const timer = setTimeout(() => {
1173
+ this.startupTimers.delete(svc.name);
1174
+ const current = this.state.get(svc.name);
1175
+ if (!current || current !== state || current.health === "up") return;
1176
+ current.status = "timeout";
1177
+ current.health = "down";
1178
+ this.log(svc.name, `\u23F1 startup timeout after ${timeoutMs / 1e3}s \u2014 service never became healthy`, colorIdx);
1179
+ this.events.onStateChange(svc.name, current);
1180
+ }, timeoutMs);
1181
+ this.startupTimers.set(svc.name, timer);
1182
+ }
1183
+ clearStartupTimer(name) {
1184
+ const t = this.startupTimers.get(name);
1185
+ if (t) {
1186
+ clearTimeout(t);
1187
+ this.startupTimers.delete(name);
1188
+ }
1189
+ }
1129
1190
  wireStdio(proc, svc, state, colorIdx) {
1130
1191
  const readyRegex = compileReadyPattern(svc.readyPattern);
1131
1192
  const markReadyIfMatch = (line) => {
@@ -1133,29 +1194,37 @@ var Spawner = class {
1133
1194
  if (readyRegex.test(line)) {
1134
1195
  state.health = "up";
1135
1196
  if (state.status === "starting") state.status = "running";
1197
+ this.clearStartupTimer(svc.name);
1136
1198
  this.events.onStateChange(svc.name, state);
1137
1199
  }
1138
1200
  };
1139
1201
  const errorRegex = compileReadyPattern(svc.errorPattern);
1140
1202
  const countsAsError = (line) => errorRegex ? errorRegex.test(line) : true;
1203
+ const stderrBuf = [];
1141
1204
  const stdoutBuf = lineBuffer((line) => {
1142
1205
  markReadyIfMatch(line);
1143
1206
  this.log(svc.name, line, colorIdx);
1144
1207
  });
1145
- const stderrBuf = lineBuffer((line) => {
1208
+ const stderrLineBuf = lineBuffer((line) => {
1146
1209
  if (countsAsError(line)) state.errors += 1;
1147
1210
  markReadyIfMatch(line);
1211
+ stderrBuf.push(line);
1212
+ if (stderrBuf.length > CRASH_LOG_LINES) stderrBuf.shift();
1148
1213
  this.log(svc.name, line, colorIdx);
1149
1214
  });
1150
1215
  proc.stdout?.on("data", (d) => stdoutBuf.push(d));
1151
- proc.stderr?.on("data", (d) => stderrBuf.push(d));
1216
+ proc.stderr?.on("data", (d) => stderrLineBuf.push(d));
1152
1217
  proc.stdout?.on("end", () => stdoutBuf.flush());
1153
- proc.stderr?.on("end", () => stderrBuf.flush());
1218
+ proc.stderr?.on("end", () => {
1219
+ stderrLineBuf.flush();
1220
+ state._stderrBuf = stderrBuf;
1221
+ });
1154
1222
  }
1155
1223
  wireCloseHandler(proc, svc, state, colorIdx) {
1156
1224
  proc.on("close", (code) => {
1157
1225
  this.procs.delete(proc);
1158
1226
  this.lifecycle.stopWatchProc(state);
1227
+ this.clearStartupTimer(svc.name);
1159
1228
  if (state.intentionalStop) {
1160
1229
  state.intentionalStop = false;
1161
1230
  return;
@@ -1168,6 +1237,8 @@ var Spawner = class {
1168
1237
  }
1169
1238
  state.status = "crashed";
1170
1239
  state.health = "down";
1240
+ const buf = state._stderrBuf;
1241
+ state.crashLog = buf && buf.length ? [...buf] : null;
1171
1242
  this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
1172
1243
  this.events.onStateChange(svc.name, state);
1173
1244
  this.onCrash(svc, state, colorIdx);
@@ -1205,9 +1276,7 @@ var Spawner = class {
1205
1276
  spawnWatchBuild(svc, cwd, env, colorIdx) {
1206
1277
  this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
1207
1278
  const isWin = process.platform === "win32";
1208
- const shell = isWin ? "cmd.exe" : "sh";
1209
- const shellFlag = isWin ? "/c" : "-c";
1210
- const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1279
+ const child = spawn2(isWin ? "cmd.exe" : "sh", [isWin ? "/c" : "-c", svc.watchBuild], {
1211
1280
  cwd,
1212
1281
  env,
1213
1282
  detached: true,
@@ -1220,8 +1289,6 @@ var Spawner = class {
1220
1289
  child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1221
1290
  return child;
1222
1291
  }
1223
- /** Create a state entry in 'crashed' status without spawning a process
1224
- * (used when preBuild fails or pre-flight checks fail). */
1225
1292
  recordCrashedState(svc, colorIdx) {
1226
1293
  const prev = this.state.get(svc.name);
1227
1294
  this.state.set(svc.name, {
@@ -1234,7 +1301,8 @@ var Spawner = class {
1234
1301
  restarts: prev?.restarts ?? 0,
1235
1302
  startedAt: null,
1236
1303
  intentionalStop: false,
1237
- colorIdx
1304
+ colorIdx,
1305
+ crashLog: null
1238
1306
  });
1239
1307
  this.events.onStateChange(svc.name, this.state.get(svc.name));
1240
1308
  }
@@ -1281,13 +1349,14 @@ var Restarter = class {
1281
1349
  var HealthPoller = class {
1282
1350
  state;
1283
1351
  events;
1352
+ failureCounts = /* @__PURE__ */ new Map();
1284
1353
  constructor(opts) {
1285
1354
  this.state = opts.state;
1286
1355
  this.events = opts.events;
1287
1356
  }
1288
1357
  async checkAll() {
1289
1358
  for (const [name, st] of this.state) {
1290
- if (!st.pid || st.status === "idle") {
1359
+ if (!st.pid || st.status === "idle" || st.status === "timeout") {
1291
1360
  st.health = st.status === "idle" ? "idle" : "down";
1292
1361
  continue;
1293
1362
  }
@@ -1295,10 +1364,22 @@ var HealthPoller = class {
1295
1364
  if (startPeriodMs > 0 && st.startedAt && Date.now() - st.startedAt < startPeriodMs) {
1296
1365
  continue;
1297
1366
  }
1298
- const isUp = await checkHealth(st.svc.port, st.svc.healthCheck);
1367
+ const result = await checkHealth(st.svc.port, st.svc.healthCheck);
1368
+ const threshold = st.svc.healthCheck?.failureThreshold ?? 2;
1299
1369
  const prev = st.health;
1300
- st.health = deriveHealth(isUp, st.status);
1301
- if (st.health === "up" && st.status === "starting") st.status = "running";
1370
+ if (result.ok) {
1371
+ this.failureCounts.delete(name);
1372
+ st.health = deriveHealth(true, st.status);
1373
+ if (st.health === "up" && st.status === "starting") st.status = "running";
1374
+ } else {
1375
+ const count = (this.failureCounts.get(name) ?? 0) + 1;
1376
+ this.failureCounts.set(name, count);
1377
+ if (count >= threshold) {
1378
+ const reason = result.reason ?? "probe failed";
1379
+ this.events.onLog(name, `[health] \u2717 ${name}: ${reason} (${count} consecutive failure${count > 1 ? "s" : ""})`, st.colorIdx);
1380
+ st.health = deriveHealth(false, st.status);
1381
+ }
1382
+ }
1302
1383
  if (prev !== st.health) this.events.onStateChange(name, st);
1303
1384
  }
1304
1385
  }
@@ -1444,44 +1525,82 @@ var ProcessManager = class {
1444
1525
  };
1445
1526
 
1446
1527
  // src/process/log-sink.ts
1447
- import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream } from "fs";
1448
- import { join as join6, dirname as dirname2 } from "path";
1528
+ import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream, statSync as statSync2 } from "fs";
1529
+ import { join as join6, dirname as dirname3 } from "path";
1449
1530
  import { homedir as homedir2 } from "os";
1531
+ var DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024;
1450
1532
  var LogSink = class {
1451
1533
  dir;
1452
1534
  rotateOnStart;
1535
+ maxLogBytes;
1453
1536
  streams = /* @__PURE__ */ new Map();
1454
1537
  seen = /* @__PURE__ */ new Set();
1538
+ bytesWritten = /* @__PURE__ */ new Map();
1455
1539
  constructor(opts) {
1456
1540
  const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
1457
1541
  this.dir = join6(root, sanitize(opts.projectName));
1458
1542
  this.rotateOnStart = opts.rotateOnStart ?? true;
1543
+ this.maxLogBytes = opts.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
1459
1544
  mkdirSync2(this.dir, { recursive: true });
1460
1545
  }
1461
- /** Returns the file path for a service log (useful for tests / UI). */
1462
1546
  pathFor(svcName) {
1463
1547
  return join6(this.dir, `${sanitize(svcName)}.log`);
1464
1548
  }
1465
1549
  write(svcName, line) {
1550
+ const entry = `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1551
+ `;
1552
+ const bytes = Buffer.byteLength(entry, "utf8");
1553
+ const current = this.bytesWritten.get(svcName) ?? this.estimateCurrentSize(svcName);
1554
+ if (current + bytes > this.maxLogBytes) {
1555
+ this.rotateFile(svcName);
1556
+ }
1466
1557
  const stream = this.streamFor(svcName);
1467
- stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1468
- `);
1558
+ stream.write(entry, (err) => {
1559
+ if (err && svcName !== "devup") {
1560
+ this.streamFor("devup").write(
1561
+ `${(/* @__PURE__ */ new Date()).toISOString()} \u26A0 log write error for "${svcName}": ${err.message}
1562
+ `
1563
+ );
1564
+ }
1565
+ });
1566
+ this.bytesWritten.set(svcName, (this.bytesWritten.get(svcName) ?? 0) + bytes);
1469
1567
  }
1470
1568
  async close() {
1471
- const closes = [...this.streams.values()].map(
1472
- (s) => new Promise((r) => s.end(() => r()))
1473
- );
1569
+ const closes = [...this.streams.values()].map((s) => new Promise((r) => s.end(() => r())));
1474
1570
  this.streams.clear();
1475
1571
  this.seen.clear();
1572
+ this.bytesWritten.clear();
1476
1573
  await Promise.all(closes);
1477
1574
  }
1575
+ rotateFile(svcName) {
1576
+ const existing = this.streams.get(svcName);
1577
+ if (existing) {
1578
+ existing.destroy();
1579
+ this.streams.delete(svcName);
1580
+ }
1581
+ const file = this.pathFor(svcName);
1582
+ if (existsSync9(file)) {
1583
+ try {
1584
+ renameSync(file, file + ".prev");
1585
+ } catch {
1586
+ }
1587
+ }
1588
+ this.bytesWritten.set(svcName, 0);
1589
+ }
1590
+ estimateCurrentSize(svcName) {
1591
+ try {
1592
+ return statSync2(this.pathFor(svcName)).size;
1593
+ } catch {
1594
+ return 0;
1595
+ }
1596
+ }
1478
1597
  streamFor(svcName) {
1479
1598
  let s = this.streams.get(svcName);
1480
1599
  if (s) return s;
1481
1600
  const file = this.pathFor(svcName);
1482
1601
  if (this.rotateOnStart && !this.seen.has(svcName) && existsSync9(file)) {
1483
1602
  try {
1484
- mkdirSync2(dirname2(file), { recursive: true });
1603
+ mkdirSync2(dirname3(file), { recursive: true });
1485
1604
  renameSync(file, file + ".prev");
1486
1605
  } catch {
1487
1606
  }
@@ -1589,7 +1708,7 @@ async function waitHealthy(svc, timeoutMs) {
1589
1708
  const deadline = Date.now() + timeoutMs;
1590
1709
  const port = svc.port;
1591
1710
  while (Date.now() < deadline) {
1592
- if (await checkHealth(port, svc.healthCheck)) return true;
1711
+ if ((await checkHealth(port, svc.healthCheck)).ok) return true;
1593
1712
  await new Promise((r) => setTimeout(r, 500));
1594
1713
  }
1595
1714
  return false;
@@ -1856,7 +1975,7 @@ function isDaemonRunning(projectName) {
1856
1975
  if (!existsSync10(path)) return { pid: null, stale: false };
1857
1976
  let pid;
1858
1977
  try {
1859
- pid = Number(readFileSync3(path, "utf8").trim());
1978
+ pid = Number(readFileSync4(path, "utf8").trim());
1860
1979
  if (!pid || !Number.isFinite(pid)) return { pid: null, stale: true };
1861
1980
  } catch {
1862
1981
  return { pid: null, stale: true };
@@ -2133,7 +2252,8 @@ async function bootLazy(mgr, services, lazyCfg, lazyTimeout, lazyProxies) {
2133
2252
  restarts: 0,
2134
2253
  startedAt: null,
2135
2254
  intentionalStop: false,
2136
- colorIdx: ci
2255
+ colorIdx: ci,
2256
+ crashLog: null
2137
2257
  });
2138
2258
  const proxy = createLazyProxy({
2139
2259
  listenPort: svc.port,
@@ -2212,7 +2332,7 @@ async function runDetached(opts) {
2212
2332
  const deadline = Date.now() + 9e4;
2213
2333
  while (Date.now() < deadline) {
2214
2334
  if (existsSync10(pidPath)) {
2215
- const pid = Number(readFileSync3(pidPath, "utf8").trim());
2335
+ const pid = Number(readFileSync4(pidPath, "utf8").trim());
2216
2336
  out("");
2217
2337
  out(`\u{1F680} devup detached (PID ${pid})`);
2218
2338
  out(" inspect: devup ctl status");
@@ -2221,7 +2341,7 @@ async function runDetached(opts) {
2221
2341
  return 0;
2222
2342
  }
2223
2343
  if (existsSync10(errPath)) {
2224
- const msg = readFileSync3(errPath, "utf8").trim();
2344
+ const msg = readFileSync4(errPath, "utf8").trim();
2225
2345
  out(`\u274C daemon boot failed: ${msg}`);
2226
2346
  try {
2227
2347
  unlinkSync2(errPath);
@@ -2294,7 +2414,7 @@ async function stopDaemon(projectName, opts = {}) {
2294
2414
  }
2295
2415
 
2296
2416
  // src/orchestrator/subcommands.ts
2297
- var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help", "ctl", "up", "down"]);
2417
+ var KNOWN = /* @__PURE__ */ new Set(["logs", "install", "status", "help", "ctl", "up", "down", "config"]);
2298
2418
  function detectSubcommand(argv) {
2299
2419
  const first = argv[0];
2300
2420
  return first && KNOWN.has(first) ? first : null;
@@ -2326,7 +2446,7 @@ async function runLogs(argv, opts) {
2326
2446
  }
2327
2447
  await streamFile(file, out);
2328
2448
  if (!follow) return 0;
2329
- return await followFile(file, out, statSync2(file).size);
2449
+ return await followFile(file, out, statSync3(file).size);
2330
2450
  }
2331
2451
  async function streamFile(file, out) {
2332
2452
  return new Promise((resolve4, reject) => {
@@ -2341,7 +2461,7 @@ async function followFile(file, out, startAt = 0) {
2341
2461
  while (!existsSync11(file)) await new Promise((r) => setTimeout(r, 500));
2342
2462
  return new Promise((resolve4) => {
2343
2463
  const tick = async () => {
2344
- const size = statSync2(file).size;
2464
+ const size = statSync3(file).size;
2345
2465
  if (size > pos) {
2346
2466
  await new Promise((res) => {
2347
2467
  const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8", start: pos, end: size - 1 }) });
@@ -2422,7 +2542,7 @@ async function runStatus(opts) {
2422
2542
  out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
2423
2543
  out("-".repeat(maxLen + 24));
2424
2544
  for (const svc of opts.config.services) {
2425
- const up = await checkHealth(svc.port, svc.healthCheck);
2545
+ const { ok: up } = await checkHealth(svc.port, svc.healthCheck);
2426
2546
  const health = up ? "\u2713 up" : "\u2717 down";
2427
2547
  out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
2428
2548
  }
@@ -2465,15 +2585,6 @@ async function runCtl(argv, opts) {
2465
2585
  out(`pong ts=${res.ts}`);
2466
2586
  return 0;
2467
2587
  }
2468
- if (method === "status" && !follow) {
2469
- const res = await sendRpc(socketPath, "status");
2470
- if (!res.services.length) {
2471
- out("(no services)");
2472
- return 0;
2473
- }
2474
- fmtStatus(res.services, out);
2475
- return 0;
2476
- }
2477
2588
  if (method === "status" && follow) {
2478
2589
  return await new Promise((resolve4) => {
2479
2590
  const abort = openStream(socketPath, "status.follow", {}, (frame) => {
@@ -2516,15 +2627,47 @@ async function runCtl(argv, opts) {
2516
2627
  });
2517
2628
  });
2518
2629
  }
2630
+ if (method === "status" && !follow) {
2631
+ const json = argv.includes("--json");
2632
+ const res = await sendRpc(socketPath, "status");
2633
+ if (json) {
2634
+ out(JSON.stringify(res.services, null, 2));
2635
+ } else {
2636
+ if (!res.services.length) {
2637
+ out("(no services)");
2638
+ return 0;
2639
+ }
2640
+ fmtStatus(res.services, out);
2641
+ }
2642
+ return 0;
2643
+ }
2519
2644
  if (method === "restart") {
2520
2645
  const svc = argv[1];
2521
2646
  if (!svc) {
2522
- out("usage: devup ctl restart <service>");
2647
+ out("usage: devup ctl restart <service> [--wait] [--timeout <s>]");
2523
2648
  return 1;
2524
2649
  }
2650
+ const wait = argv.includes("--wait");
2651
+ const timeoutIdx = argv.indexOf("--timeout");
2652
+ const timeoutSec = timeoutIdx >= 0 ? Number(argv[timeoutIdx + 1] ?? 60) : 60;
2525
2653
  await sendRpc(socketPath, "restart", { svc });
2526
- out(`\u2713 restart sent to ${svc}`);
2527
- return 0;
2654
+ if (!wait) {
2655
+ out(`\u2713 restart sent to ${svc}`);
2656
+ return 0;
2657
+ }
2658
+ out(`\u23F3 waiting for ${svc} to become healthy\u2026`);
2659
+ const deadline = Date.now() + timeoutSec * 1e3;
2660
+ while (Date.now() < deadline) {
2661
+ await new Promise((r) => setTimeout(r, 500));
2662
+ const status = await sendRpc(socketPath, "status");
2663
+ const row = status.services.find((s) => s.name === svc);
2664
+ if (row?.health === "up") {
2665
+ out(`\u2713 ${svc} is healthy`);
2666
+ return 0;
2667
+ }
2668
+ }
2669
+ out(`\u2717 ${svc} did not become healthy within ${timeoutSec}s`);
2670
+ return 1;
2528
2671
  }
2529
2672
  if (method === "stop") {
2530
2673
  const svc = argv[1];
@@ -2543,6 +2686,63 @@ async function runCtl(argv, opts) {
2543
2686
  return 1;
2544
2687
  }
2545
2688
  }
2689
+ async function runConfig(argv, opts) {
2690
+ const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2691
+ const subcmd = argv[0];
2692
+ const json = argv.includes("--json");
2693
+ if (!subcmd || subcmd === "help") {
2694
+ out("Usage: devup config <subcommand>");
2695
+ out(" validate [--json] Validate the config and print errors/warnings");
2696
+ out(" show [--no-redact] Print the fully-resolved config as JSON");
2697
+ return 0;
2698
+ }
2699
+ let cfgPath;
2700
+ try {
2701
+ cfgPath = findConfigFile(opts.cwd, opts.configPath);
2702
+ } catch (e) {
2703
+ out(`\u274C ${e.message}`);
2704
+ return 1;
2705
+ }
2706
+ let config;
2707
+ try {
2708
+ config = await loadConfig(cfgPath);
2709
+ } catch (e) {
2710
+ out(`\u274C failed to load config: ${e.message}`);
2711
+ return 1;
2712
+ }
2713
+ if (subcmd === "validate") {
2714
+ const errors = validateConfig(config, opts.cwd);
2715
+ const warnings = collectWarnings(config);
2716
+ if (json) {
2717
+ out(JSON.stringify({ valid: errors.length === 0, errors: errors.map((e) => `${e.field}: ${e.message}`), warnings: warnings.map((w) => `${w.field}: ${w.message}`) }, null, 2));
2718
+ } else {
2719
+ if (errors.length) {
2720
+ out(formatValidationErrors(errors));
2721
+ }
2722
+ if (warnings.length) {
2723
+ out(formatValidationWarnings(warnings));
2724
+ }
2725
+ if (!errors.length) out(`\u2713 config is valid (${config.services.length} services${warnings.length ? `, ${warnings.length} warning${warnings.length > 1 ? "s" : ""}` : ""})`);
2726
+ }
2727
+ return errors.length ? 1 : 0;
2728
+ }
2729
+ if (subcmd === "show") {
2730
+ const noRedact = argv.includes("--no-redact");
2731
+ const resolved = noRedact ? config : redactConfig(config);
2732
+ out(JSON.stringify(resolved, null, 2));
2733
+ return 0;
2734
+ }
2735
+ out(`unknown config subcommand: ${subcmd}`);
2736
+ return 1;
2737
+ }
2738
+ function redactConfig(config) {
2739
+ const clone = JSON.parse(JSON.stringify(config));
2740
+ for (const svc of clone.services ?? []) {
2741
+ if (svc.extraEnv) svc.extraEnv = redactSecrets(svc.extraEnv);
2742
+ }
2743
+ if (clone.env) clone.env = redactSecrets(clone.env);
2744
+ return clone;
2745
+ }
2546
2746
  async function runDown(opts) {
2547
2747
  const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2548
2748
  return stopDaemon(opts.config.name, { out });
@@ -2741,7 +2941,7 @@ async function detectPlatform() {
2741
2941
 
2742
2942
  // src/proxy-config/traefik.ts
2743
2943
  import { existsSync as existsSync12, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs";
2744
- import { dirname as dirname4 } from "path";
2944
+ import { dirname as dirname5 } from "path";
2745
2945
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
2746
2946
  var TraefikProvider = class {
2747
2947
  name = "traefik";
@@ -2778,7 +2978,7 @@ ${svcs.join("\n")}
2778
2978
  `;
2779
2979
  }
2780
2980
  write(content, opts) {
2781
- const dir = dirname4(opts.confPath);
2981
+ const dir = dirname5(opts.confPath);
2782
2982
  if (!existsSync12(dir)) mkdirSync4(dir, { recursive: true });
2783
2983
  writeFileSync3(opts.confPath, content);
2784
2984
  }
@@ -2789,7 +2989,7 @@ ${svcs.join("\n")}
2789
2989
 
2790
2990
  // src/proxy-config/nginx.ts
2791
2991
  import { existsSync as existsSync13, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2792
- import { dirname as dirname5 } from "path";
2992
+ import { dirname as dirname6 } from "path";
2793
2993
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
2794
2994
  var NginxProvider = class {
2795
2995
  name = "nginx";
@@ -2826,7 +3026,7 @@ var NginxProvider = class {
2826
3026
  return blocks.join("\n\n") + "\n";
2827
3027
  }
2828
3028
  write(content, opts) {
2829
- const dir = dirname5(opts.confPath);
3029
+ const dir = dirname6(opts.confPath);
2830
3030
  if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
2831
3031
  writeFileSync4(opts.confPath, content);
2832
3032
  }
@@ -2837,7 +3037,7 @@ var NginxProvider = class {
2837
3037
 
2838
3038
  // src/proxy-config/caddy.ts
2839
3039
  import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
2840
- import { dirname as dirname6 } from "path";
3040
+ import { dirname as dirname7 } from "path";
2841
3041
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
2842
3042
  var CaddyProvider = class {
2843
3043
  name = "caddy";
@@ -2860,7 +3060,7 @@ var CaddyProvider = class {
2860
3060
  return blocks.join("\n\n") + "\n";
2861
3061
  }
2862
3062
  write(content, opts) {
2863
- const dir = dirname6(opts.confPath);
3063
+ const dir = dirname7(opts.confPath);
2864
3064
  if (!existsSync14(dir)) mkdirSync6(dir, { recursive: true });
2865
3065
  writeFileSync5(opts.confPath, content);
2866
3066
  }
@@ -3574,7 +3774,8 @@ function useBootSequence(manager, config, services, cliArgs, platform, env, base
3574
3774
  restarts: 0,
3575
3775
  startedAt: null,
3576
3776
  intentionalStop: false,
3577
- colorIdx: ci
3777
+ colorIdx: ci,
3778
+ crashLog: null
3578
3779
  };
3579
3780
  mgr.state.set(svc.name, idleState);
3580
3781
  const proxy = createLazyProxy({
@@ -4108,7 +4309,7 @@ async function runOnce(opts) {
4108
4309
  }
4109
4310
  async function waitHealthy2(svc, deadline) {
4110
4311
  while (Date.now() < deadline) {
4111
- const ok = await checkHealth(svc.port, svc.healthCheck);
4312
+ const { ok } = await checkHealth(svc.port, svc.healthCheck);
4112
4313
  if (ok) return true;
4113
4314
  await new Promise((r) => setTimeout(r, 500));
4114
4315
  }
@@ -4123,9 +4324,9 @@ function defineConfig(config) {
4123
4324
  // src/index.ts
4124
4325
  function readVersion() {
4125
4326
  try {
4126
- const here = dirname7(fileURLToPath2(import.meta.url));
4327
+ const here = dirname8(fileURLToPath2(import.meta.url));
4127
4328
  const pkgPath = join10(here, "..", "package.json");
4128
- return JSON.parse(readFileSync4(pkgPath, "utf8")).version ?? "unknown";
4329
+ return JSON.parse(readFileSync5(pkgPath, "utf8")).version ?? "unknown";
4129
4330
  } catch {
4130
4331
  return "unknown";
4131
4332
  }
@@ -4162,6 +4363,7 @@ async function main() {
4162
4363
  if (subcmd === "status") process.exit(await runStatus(subOpts));
4163
4364
  if (subcmd === "ctl") process.exit(await runCtl(subArgs, subOpts));
4164
4365
  if (subcmd === "down") process.exit(await runDown(subOpts));
4366
+ if (subcmd === "config") process.exit(await runConfig(subArgs, { cwd, configPath: cliArgs.configPath }));
4165
4367
  }
4166
4368
  let configPath;
4167
4369
  try {