@gachlab/devup 0.10.0 → 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);
@@ -831,10 +864,12 @@ function serializeState(name, st) {
831
864
  health: st.health,
832
865
  port: st.svc.port,
833
866
  type: st.svc.type,
867
+ phase: st.svc.phase,
834
868
  errors: st.errors,
835
869
  restarts: st.restarts,
836
870
  pid: st.pid,
837
- startedAt: st.startedAt
871
+ startedAt: st.startedAt,
872
+ crashLog: st.crashLog ?? null
838
873
  };
839
874
  }
840
875
  function respond(socket, payload) {
@@ -851,6 +886,8 @@ async function dispatch(method, params, ctx) {
851
886
  }
852
887
  case "stats":
853
888
  return await ctx.getStats();
889
+ case "info":
890
+ return ctx.getInfo();
854
891
  case "restart": {
855
892
  const svc = stringOrThrow(params["svc"] ?? params["service"], "svc");
856
893
  await ctx.restart(svc);
@@ -951,7 +988,7 @@ function openStream(socketPath, method, params, onFrame, onError) {
951
988
 
952
989
  // src/orchestrator/daemon.ts
953
990
  import { spawn as spawn4 } from "child_process";
954
- 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";
955
992
  import { join as join8 } from "path";
956
993
  import { homedir as homedir3, totalmem, freemem, cpus } from "os";
957
994
  import { setTimeout as sleep } from "timers/promises";
@@ -1057,6 +1094,8 @@ var MAX_RESTARTS = 3;
1057
1094
  var BACKOFF_BASE_MS = 2e3;
1058
1095
 
1059
1096
  // src/process/spawner.ts
1097
+ var CRASH_LOG_LINES = 20;
1098
+ var STARTUP_TIMEOUT_DEFAULT_MS = 45e3;
1060
1099
  var Spawner = class {
1061
1100
  baseCwd;
1062
1101
  env;
@@ -1065,6 +1104,7 @@ var Spawner = class {
1065
1104
  events;
1066
1105
  lifecycle;
1067
1106
  onCrash;
1107
+ startupTimers = /* @__PURE__ */ new Map();
1068
1108
  constructor(opts) {
1069
1109
  this.baseCwd = opts.baseCwd;
1070
1110
  this.env = opts.env;
@@ -1076,6 +1116,7 @@ var Spawner = class {
1076
1116
  }
1077
1117
  async start(svc, colorIdx, isRestart = false) {
1078
1118
  const cwd = join4(this.baseCwd, svc.cwd);
1119
+ this.clearStartupTimer(svc.name);
1079
1120
  if (svc.type === "api") {
1080
1121
  const bindable = await isPortBindable(svc.port);
1081
1122
  if (!bindable && !isRestart) {
@@ -1111,18 +1152,41 @@ var Spawner = class {
1111
1152
  restarts: prev?.restarts ?? 0,
1112
1153
  startedAt: Date.now(),
1113
1154
  intentionalStop: false,
1114
- colorIdx
1155
+ colorIdx,
1156
+ crashLog: null
1115
1157
  };
1116
1158
  this.state.set(svc.name, state);
1117
1159
  this.procs.add(proc);
1118
1160
  this.events.onStateChange(svc.name, state);
1119
1161
  this.wireStdio(proc, svc, state, colorIdx);
1120
1162
  this.wireCloseHandler(proc, svc, state, colorIdx);
1163
+ this.scheduleStartupTimeout(svc, state, colorIdx);
1121
1164
  if (svc.watchBuild) {
1122
1165
  state.watchProc = this.spawnWatchBuild(svc, cwd, env, colorIdx);
1123
1166
  }
1124
1167
  this.log(svc.name, isRestart ? `\u{1F504} restarted (:${svc.port})` : `\u{1F680} started (:${svc.port})`, colorIdx);
1125
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
+ }
1126
1190
  wireStdio(proc, svc, state, colorIdx) {
1127
1191
  const readyRegex = compileReadyPattern(svc.readyPattern);
1128
1192
  const markReadyIfMatch = (line) => {
@@ -1130,29 +1194,37 @@ var Spawner = class {
1130
1194
  if (readyRegex.test(line)) {
1131
1195
  state.health = "up";
1132
1196
  if (state.status === "starting") state.status = "running";
1197
+ this.clearStartupTimer(svc.name);
1133
1198
  this.events.onStateChange(svc.name, state);
1134
1199
  }
1135
1200
  };
1136
1201
  const errorRegex = compileReadyPattern(svc.errorPattern);
1137
1202
  const countsAsError = (line) => errorRegex ? errorRegex.test(line) : true;
1203
+ const stderrBuf = [];
1138
1204
  const stdoutBuf = lineBuffer((line) => {
1139
1205
  markReadyIfMatch(line);
1140
1206
  this.log(svc.name, line, colorIdx);
1141
1207
  });
1142
- const stderrBuf = lineBuffer((line) => {
1208
+ const stderrLineBuf = lineBuffer((line) => {
1143
1209
  if (countsAsError(line)) state.errors += 1;
1144
1210
  markReadyIfMatch(line);
1211
+ stderrBuf.push(line);
1212
+ if (stderrBuf.length > CRASH_LOG_LINES) stderrBuf.shift();
1145
1213
  this.log(svc.name, line, colorIdx);
1146
1214
  });
1147
1215
  proc.stdout?.on("data", (d) => stdoutBuf.push(d));
1148
- proc.stderr?.on("data", (d) => stderrBuf.push(d));
1216
+ proc.stderr?.on("data", (d) => stderrLineBuf.push(d));
1149
1217
  proc.stdout?.on("end", () => stdoutBuf.flush());
1150
- proc.stderr?.on("end", () => stderrBuf.flush());
1218
+ proc.stderr?.on("end", () => {
1219
+ stderrLineBuf.flush();
1220
+ state._stderrBuf = stderrBuf;
1221
+ });
1151
1222
  }
1152
1223
  wireCloseHandler(proc, svc, state, colorIdx) {
1153
1224
  proc.on("close", (code) => {
1154
1225
  this.procs.delete(proc);
1155
1226
  this.lifecycle.stopWatchProc(state);
1227
+ this.clearStartupTimer(svc.name);
1156
1228
  if (state.intentionalStop) {
1157
1229
  state.intentionalStop = false;
1158
1230
  return;
@@ -1165,6 +1237,8 @@ var Spawner = class {
1165
1237
  }
1166
1238
  state.status = "crashed";
1167
1239
  state.health = "down";
1240
+ const buf = state._stderrBuf;
1241
+ state.crashLog = buf && buf.length ? [...buf] : null;
1168
1242
  this.log(svc.name, `\u274C exited with code ${code}`, colorIdx);
1169
1243
  this.events.onStateChange(svc.name, state);
1170
1244
  this.onCrash(svc, state, colorIdx);
@@ -1202,9 +1276,7 @@ var Spawner = class {
1202
1276
  spawnWatchBuild(svc, cwd, env, colorIdx) {
1203
1277
  this.log(svc.name, `\u{1F440} watchBuild: ${svc.watchBuild}`, colorIdx);
1204
1278
  const isWin = process.platform === "win32";
1205
- const shell = isWin ? "cmd.exe" : "sh";
1206
- const shellFlag = isWin ? "/c" : "-c";
1207
- const child = spawn2(shell, [shellFlag, svc.watchBuild], {
1279
+ const child = spawn2(isWin ? "cmd.exe" : "sh", [isWin ? "/c" : "-c", svc.watchBuild], {
1208
1280
  cwd,
1209
1281
  env,
1210
1282
  detached: true,
@@ -1217,8 +1289,6 @@ var Spawner = class {
1217
1289
  child.on("error", (err) => this.log(svc.name, `[watch] \u274C ${err.message}`, colorIdx));
1218
1290
  return child;
1219
1291
  }
1220
- /** Create a state entry in 'crashed' status without spawning a process
1221
- * (used when preBuild fails or pre-flight checks fail). */
1222
1292
  recordCrashedState(svc, colorIdx) {
1223
1293
  const prev = this.state.get(svc.name);
1224
1294
  this.state.set(svc.name, {
@@ -1231,7 +1301,8 @@ var Spawner = class {
1231
1301
  restarts: prev?.restarts ?? 0,
1232
1302
  startedAt: null,
1233
1303
  intentionalStop: false,
1234
- colorIdx
1304
+ colorIdx,
1305
+ crashLog: null
1235
1306
  });
1236
1307
  this.events.onStateChange(svc.name, this.state.get(svc.name));
1237
1308
  }
@@ -1278,13 +1349,14 @@ var Restarter = class {
1278
1349
  var HealthPoller = class {
1279
1350
  state;
1280
1351
  events;
1352
+ failureCounts = /* @__PURE__ */ new Map();
1281
1353
  constructor(opts) {
1282
1354
  this.state = opts.state;
1283
1355
  this.events = opts.events;
1284
1356
  }
1285
1357
  async checkAll() {
1286
1358
  for (const [name, st] of this.state) {
1287
- if (!st.pid || st.status === "idle") {
1359
+ if (!st.pid || st.status === "idle" || st.status === "timeout") {
1288
1360
  st.health = st.status === "idle" ? "idle" : "down";
1289
1361
  continue;
1290
1362
  }
@@ -1292,10 +1364,22 @@ var HealthPoller = class {
1292
1364
  if (startPeriodMs > 0 && st.startedAt && Date.now() - st.startedAt < startPeriodMs) {
1293
1365
  continue;
1294
1366
  }
1295
- 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;
1296
1369
  const prev = st.health;
1297
- st.health = deriveHealth(isUp, st.status);
1298
- 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
+ }
1299
1383
  if (prev !== st.health) this.events.onStateChange(name, st);
1300
1384
  }
1301
1385
  }
@@ -1441,44 +1525,82 @@ var ProcessManager = class {
1441
1525
  };
1442
1526
 
1443
1527
  // src/process/log-sink.ts
1444
- import { existsSync as existsSync9, mkdirSync as mkdirSync2, renameSync, createWriteStream } from "fs";
1445
- 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";
1446
1530
  import { homedir as homedir2 } from "os";
1531
+ var DEFAULT_MAX_LOG_BYTES = 10 * 1024 * 1024;
1447
1532
  var LogSink = class {
1448
1533
  dir;
1449
1534
  rotateOnStart;
1535
+ maxLogBytes;
1450
1536
  streams = /* @__PURE__ */ new Map();
1451
1537
  seen = /* @__PURE__ */ new Set();
1538
+ bytesWritten = /* @__PURE__ */ new Map();
1452
1539
  constructor(opts) {
1453
1540
  const root = opts.rootDir ?? join6(homedir2(), ".devup", "logs");
1454
1541
  this.dir = join6(root, sanitize(opts.projectName));
1455
1542
  this.rotateOnStart = opts.rotateOnStart ?? true;
1543
+ this.maxLogBytes = opts.maxLogBytes ?? DEFAULT_MAX_LOG_BYTES;
1456
1544
  mkdirSync2(this.dir, { recursive: true });
1457
1545
  }
1458
- /** Returns the file path for a service log (useful for tests / UI). */
1459
1546
  pathFor(svcName) {
1460
1547
  return join6(this.dir, `${sanitize(svcName)}.log`);
1461
1548
  }
1462
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
+ }
1463
1557
  const stream = this.streamFor(svcName);
1464
- stream.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
1465
- `);
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);
1466
1567
  }
1467
1568
  async close() {
1468
- const closes = [...this.streams.values()].map(
1469
- (s) => new Promise((r) => s.end(() => r()))
1470
- );
1569
+ const closes = [...this.streams.values()].map((s) => new Promise((r) => s.end(() => r())));
1471
1570
  this.streams.clear();
1472
1571
  this.seen.clear();
1572
+ this.bytesWritten.clear();
1473
1573
  await Promise.all(closes);
1474
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
+ }
1475
1597
  streamFor(svcName) {
1476
1598
  let s = this.streams.get(svcName);
1477
1599
  if (s) return s;
1478
1600
  const file = this.pathFor(svcName);
1479
1601
  if (this.rotateOnStart && !this.seen.has(svcName) && existsSync9(file)) {
1480
1602
  try {
1481
- mkdirSync2(dirname2(file), { recursive: true });
1603
+ mkdirSync2(dirname3(file), { recursive: true });
1482
1604
  renameSync(file, file + ".prev");
1483
1605
  } catch {
1484
1606
  }
@@ -1586,7 +1708,7 @@ async function waitHealthy(svc, timeoutMs) {
1586
1708
  const deadline = Date.now() + timeoutMs;
1587
1709
  const port = svc.port;
1588
1710
  while (Date.now() < deadline) {
1589
- if (await checkHealth(port, svc.healthCheck)) return true;
1711
+ if ((await checkHealth(port, svc.healthCheck)).ok) return true;
1590
1712
  await new Promise((r) => setTimeout(r, 500));
1591
1713
  }
1592
1714
  return false;
@@ -1853,7 +1975,7 @@ function isDaemonRunning(projectName) {
1853
1975
  if (!existsSync10(path)) return { pid: null, stale: false };
1854
1976
  let pid;
1855
1977
  try {
1856
- pid = Number(readFileSync3(path, "utf8").trim());
1978
+ pid = Number(readFileSync4(path, "utf8").trim());
1857
1979
  if (!pid || !Number.isFinite(pid)) return { pid: null, stale: true };
1858
1980
  } catch {
1859
1981
  return { pid: null, stale: true };
@@ -2027,6 +2149,9 @@ async function daemonBody(opts) {
2027
2149
  tls: proxyOpts.tls,
2028
2150
  routes: proxyOpts.routes
2029
2151
  };
2152
+ },
2153
+ getInfo() {
2154
+ return { project: projectName, profiles: config.profiles ?? {} };
2030
2155
  }
2031
2156
  }, { onLog: (msg) => writeDevupLog(msg) });
2032
2157
  healthTimer = setInterval(() => {
@@ -2127,7 +2252,8 @@ async function bootLazy(mgr, services, lazyCfg, lazyTimeout, lazyProxies) {
2127
2252
  restarts: 0,
2128
2253
  startedAt: null,
2129
2254
  intentionalStop: false,
2130
- colorIdx: ci
2255
+ colorIdx: ci,
2256
+ crashLog: null
2131
2257
  });
2132
2258
  const proxy = createLazyProxy({
2133
2259
  listenPort: svc.port,
@@ -2206,7 +2332,7 @@ async function runDetached(opts) {
2206
2332
  const deadline = Date.now() + 9e4;
2207
2333
  while (Date.now() < deadline) {
2208
2334
  if (existsSync10(pidPath)) {
2209
- const pid = Number(readFileSync3(pidPath, "utf8").trim());
2335
+ const pid = Number(readFileSync4(pidPath, "utf8").trim());
2210
2336
  out("");
2211
2337
  out(`\u{1F680} devup detached (PID ${pid})`);
2212
2338
  out(" inspect: devup ctl status");
@@ -2215,7 +2341,7 @@ async function runDetached(opts) {
2215
2341
  return 0;
2216
2342
  }
2217
2343
  if (existsSync10(errPath)) {
2218
- const msg = readFileSync3(errPath, "utf8").trim();
2344
+ const msg = readFileSync4(errPath, "utf8").trim();
2219
2345
  out(`\u274C daemon boot failed: ${msg}`);
2220
2346
  try {
2221
2347
  unlinkSync2(errPath);
@@ -2288,7 +2414,7 @@ async function stopDaemon(projectName, opts = {}) {
2288
2414
  }
2289
2415
 
2290
2416
  // src/orchestrator/subcommands.ts
2291
- 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"]);
2292
2418
  function detectSubcommand(argv) {
2293
2419
  const first = argv[0];
2294
2420
  return first && KNOWN.has(first) ? first : null;
@@ -2320,7 +2446,7 @@ async function runLogs(argv, opts) {
2320
2446
  }
2321
2447
  await streamFile(file, out);
2322
2448
  if (!follow) return 0;
2323
- return await followFile(file, out, statSync2(file).size);
2449
+ return await followFile(file, out, statSync3(file).size);
2324
2450
  }
2325
2451
  async function streamFile(file, out) {
2326
2452
  return new Promise((resolve4, reject) => {
@@ -2335,7 +2461,7 @@ async function followFile(file, out, startAt = 0) {
2335
2461
  while (!existsSync11(file)) await new Promise((r) => setTimeout(r, 500));
2336
2462
  return new Promise((resolve4) => {
2337
2463
  const tick = async () => {
2338
- const size = statSync2(file).size;
2464
+ const size = statSync3(file).size;
2339
2465
  if (size > pos) {
2340
2466
  await new Promise((res) => {
2341
2467
  const rl = createInterface4({ input: createReadStream2(file, { encoding: "utf8", start: pos, end: size - 1 }) });
@@ -2416,7 +2542,7 @@ async function runStatus(opts) {
2416
2542
  out(`${"Service".padEnd(maxLen)} ${"Port".padStart(5)} ${"Type".padEnd(4)} Health`);
2417
2543
  out("-".repeat(maxLen + 24));
2418
2544
  for (const svc of opts.config.services) {
2419
- const up = await checkHealth(svc.port, svc.healthCheck);
2545
+ const { ok: up } = await checkHealth(svc.port, svc.healthCheck);
2420
2546
  const health = up ? "\u2713 up" : "\u2717 down";
2421
2547
  out(`${svc.name.padEnd(maxLen)} ${String(svc.port).padStart(5)} ${svc.type.padEnd(4)} ${health}`);
2422
2548
  }
@@ -2459,15 +2585,6 @@ async function runCtl(argv, opts) {
2459
2585
  out(`pong ts=${res.ts}`);
2460
2586
  return 0;
2461
2587
  }
2462
- if (method === "status" && !follow) {
2463
- const res = await sendRpc(socketPath, "status");
2464
- if (!res.services.length) {
2465
- out("(no services)");
2466
- return 0;
2467
- }
2468
- fmtStatus(res.services, out);
2469
- return 0;
2470
- }
2471
2588
  if (method === "status" && follow) {
2472
2589
  return await new Promise((resolve4) => {
2473
2590
  const abort = openStream(socketPath, "status.follow", {}, (frame) => {
@@ -2510,15 +2627,47 @@ async function runCtl(argv, opts) {
2510
2627
  });
2511
2628
  });
2512
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
+ }
2513
2644
  if (method === "restart") {
2514
2645
  const svc = argv[1];
2515
2646
  if (!svc) {
2516
- out("usage: devup ctl restart <service>");
2647
+ out("usage: devup ctl restart <service> [--wait] [--timeout <s>]");
2517
2648
  return 1;
2518
2649
  }
2650
+ const wait = argv.includes("--wait");
2651
+ const timeoutIdx = argv.indexOf("--timeout");
2652
+ const timeoutSec = timeoutIdx >= 0 ? Number(argv[timeoutIdx + 1] ?? 60) : 60;
2519
2653
  await sendRpc(socketPath, "restart", { svc });
2520
- out(`\u2713 restart sent to ${svc}`);
2521
- 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;
2522
2671
  }
2523
2672
  if (method === "stop") {
2524
2673
  const svc = argv[1];
@@ -2537,6 +2686,63 @@ async function runCtl(argv, opts) {
2537
2686
  return 1;
2538
2687
  }
2539
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
+ }
2540
2746
  async function runDown(opts) {
2541
2747
  const out = opts.out ?? ((l) => process.stdout.write(l + "\n"));
2542
2748
  return stopDaemon(opts.config.name, { out });
@@ -2735,7 +2941,7 @@ async function detectPlatform() {
2735
2941
 
2736
2942
  // src/proxy-config/traefik.ts
2737
2943
  import { existsSync as existsSync12, mkdirSync as mkdirSync4, writeFileSync as writeFileSync3 } from "fs";
2738
- import { dirname as dirname4 } from "path";
2944
+ import { dirname as dirname5 } from "path";
2739
2945
  var EMPTY_CONFIG = "http:\n routers: {}\n services: {}\n";
2740
2946
  var TraefikProvider = class {
2741
2947
  name = "traefik";
@@ -2772,7 +2978,7 @@ ${svcs.join("\n")}
2772
2978
  `;
2773
2979
  }
2774
2980
  write(content, opts) {
2775
- const dir = dirname4(opts.confPath);
2981
+ const dir = dirname5(opts.confPath);
2776
2982
  if (!existsSync12(dir)) mkdirSync4(dir, { recursive: true });
2777
2983
  writeFileSync3(opts.confPath, content);
2778
2984
  }
@@ -2783,7 +2989,7 @@ ${svcs.join("\n")}
2783
2989
 
2784
2990
  // src/proxy-config/nginx.ts
2785
2991
  import { existsSync as existsSync13, mkdirSync as mkdirSync5, writeFileSync as writeFileSync4 } from "fs";
2786
- import { dirname as dirname5 } from "path";
2992
+ import { dirname as dirname6 } from "path";
2787
2993
  var EMPTY_CONFIG2 = "# devup: no healthy services\n";
2788
2994
  var NginxProvider = class {
2789
2995
  name = "nginx";
@@ -2820,7 +3026,7 @@ var NginxProvider = class {
2820
3026
  return blocks.join("\n\n") + "\n";
2821
3027
  }
2822
3028
  write(content, opts) {
2823
- const dir = dirname5(opts.confPath);
3029
+ const dir = dirname6(opts.confPath);
2824
3030
  if (!existsSync13(dir)) mkdirSync5(dir, { recursive: true });
2825
3031
  writeFileSync4(opts.confPath, content);
2826
3032
  }
@@ -2831,7 +3037,7 @@ var NginxProvider = class {
2831
3037
 
2832
3038
  // src/proxy-config/caddy.ts
2833
3039
  import { existsSync as existsSync14, mkdirSync as mkdirSync6, writeFileSync as writeFileSync5 } from "fs";
2834
- import { dirname as dirname6 } from "path";
3040
+ import { dirname as dirname7 } from "path";
2835
3041
  var EMPTY_CONFIG3 = "# devup: no healthy services\n";
2836
3042
  var CaddyProvider = class {
2837
3043
  name = "caddy";
@@ -2854,7 +3060,7 @@ var CaddyProvider = class {
2854
3060
  return blocks.join("\n\n") + "\n";
2855
3061
  }
2856
3062
  write(content, opts) {
2857
- const dir = dirname6(opts.confPath);
3063
+ const dir = dirname7(opts.confPath);
2858
3064
  if (!existsSync14(dir)) mkdirSync6(dir, { recursive: true });
2859
3065
  writeFileSync5(opts.confPath, content);
2860
3066
  }
@@ -3147,7 +3353,7 @@ import { useEffect as useEffect5, useRef as useRef3 } from "react";
3147
3353
  import { createInterface as createInterface5 } from "readline";
3148
3354
  import { createReadStream as createReadStream3, existsSync as existsSync15 } from "fs";
3149
3355
  import { totalmem as totalmem2, freemem as freemem2, cpus as cpus2 } from "os";
3150
- function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy) {
3356
+ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy, profiles) {
3151
3357
  const handleRef = useRef3(null);
3152
3358
  const prevCpuMap = useRef3(/* @__PURE__ */ new Map());
3153
3359
  useEffect5(() => {
@@ -3222,6 +3428,9 @@ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBu
3222
3428
  tls: proxy.opts.tls,
3223
3429
  routes: proxy.opts.routes
3224
3430
  };
3431
+ },
3432
+ getInfo() {
3433
+ return { project: projectName, profiles };
3225
3434
  }
3226
3435
  }, { onLog: (msg) => pushLog("devup", msg, 12) });
3227
3436
  handleRef.current = handle;
@@ -3233,7 +3442,7 @@ function useControlPlane(manager, projectName, logSink, pushLog, logBus, stateBu
3233
3442
  void handle?.close();
3234
3443
  handleRef.current = null;
3235
3444
  };
3236
- }, [manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy]);
3445
+ }, [manager, projectName, logSink, pushLog, logBus, stateBus, platform, proxy, profiles]);
3237
3446
  return handleRef;
3238
3447
  }
3239
3448
 
@@ -3565,7 +3774,8 @@ function useBootSequence(manager, config, services, cliArgs, platform, env, base
3565
3774
  restarts: 0,
3566
3775
  startedAt: null,
3567
3776
  intentionalStop: false,
3568
- colorIdx: ci
3777
+ colorIdx: ci,
3778
+ crashLog: null
3569
3779
  };
3570
3780
  mgr.state.set(svc.name, idleState);
3571
3781
  const proxy = createLazyProxy({
@@ -3842,7 +4052,7 @@ function App({ config, services, cliArgs, platform, env, baseCwd, proxyProvider,
3842
4052
  }
3843
4053
  });
3844
4054
  const proxyCtx = proxyProvider && proxyOpts ? { provider: proxyProvider, opts: proxyOpts } : null;
3845
- const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus, platform, proxyCtx);
4055
+ const socketServer = useControlPlane(pm.manager, config.name, logSink, pm.pushLog, pm.logBus, pm.stateBus, platform, proxyCtx, config.profiles ?? {});
3846
4056
  const shutdown = useCallback3(async () => {
3847
4057
  lazyProxies.current.forEach((p) => p.destroy());
3848
4058
  await socketServer.current?.close();
@@ -4099,7 +4309,7 @@ async function runOnce(opts) {
4099
4309
  }
4100
4310
  async function waitHealthy2(svc, deadline) {
4101
4311
  while (Date.now() < deadline) {
4102
- const ok = await checkHealth(svc.port, svc.healthCheck);
4312
+ const { ok } = await checkHealth(svc.port, svc.healthCheck);
4103
4313
  if (ok) return true;
4104
4314
  await new Promise((r) => setTimeout(r, 500));
4105
4315
  }
@@ -4114,9 +4324,9 @@ function defineConfig(config) {
4114
4324
  // src/index.ts
4115
4325
  function readVersion() {
4116
4326
  try {
4117
- const here = dirname7(fileURLToPath2(import.meta.url));
4327
+ const here = dirname8(fileURLToPath2(import.meta.url));
4118
4328
  const pkgPath = join10(here, "..", "package.json");
4119
- return JSON.parse(readFileSync4(pkgPath, "utf8")).version ?? "unknown";
4329
+ return JSON.parse(readFileSync5(pkgPath, "utf8")).version ?? "unknown";
4120
4330
  } catch {
4121
4331
  return "unknown";
4122
4332
  }
@@ -4153,6 +4363,7 @@ async function main() {
4153
4363
  if (subcmd === "status") process.exit(await runStatus(subOpts));
4154
4364
  if (subcmd === "ctl") process.exit(await runCtl(subArgs, subOpts));
4155
4365
  if (subcmd === "down") process.exit(await runDown(subOpts));
4366
+ if (subcmd === "config") process.exit(await runConfig(subArgs, { cwd, configPath: cliArgs.configPath }));
4156
4367
  }
4157
4368
  let configPath;
4158
4369
  try {