ework-aio 0.5.2 → 0.5.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-aio",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "All-in-one installer for ework (issue tracker) + ework-daemon (AI bridge) + opencode-ework (plugin). One command: npm i -g ework-aio && ework-aio install.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,7 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "ework-daemon": "^0.4.1",
50
- "ework-web": "^0.7.0",
50
+ "ework-web": "^0.8.0",
51
51
  "opencode-ework": "^0.1.0",
52
52
  "zod": "^3.23.0"
53
53
  },
@@ -709,6 +709,127 @@ case "$DELIVERIES_CODE" in
709
709
  ;;
710
710
  esac
711
711
 
712
+ # Phase 7: multi-daemon coordination (requires MySQL shared DB)
713
+ # -----------------------------------------------------------------------------
714
+ if grep -q 'WORK_DB_DRIVER=mysql' "$DATA_DIR/ework-daemon/.env" 2>/dev/null; then
715
+ echo
716
+ echo "====================================================="
717
+ echo "Phase 7: multi-daemon coordination"
718
+ echo "====================================================="
719
+
720
+ info "restarting daemon A on MySQL (ework-aio restart may not reload .env)"
721
+ info "daemon .env WORK_DB_* lines:"
722
+ grep WORK_DB "$DATA_DIR/ework-daemon/.env" 2>/dev/null || echo " (none found)"
723
+ ework-aio stop daemon --data-dir "$DATA_DIR" 2>&1 | tail -1
724
+ sleep 1
725
+ ework-aio start daemon --data-dir "$DATA_DIR" 2>&1 | tail -2
726
+ sleep 3
727
+ timeout 3 curl -sf "http://127.0.0.1:$DAEMON_PORT/healthz" >/dev/null 2>&1 \
728
+ || fail "daemon A did not respond after restart"
729
+ DAEMON_A_DB=$(grep '"msg":" db:' "$DATA_DIR/run/daemon.log" 2>/dev/null | tail -1 | sed 's/.*"msg":"//' | sed 's/".*//' || echo "?")
730
+ info "daemon A startup log: $DAEMON_A_DB"
731
+ [[ "$DAEMON_A_DB" == *"3312"* || "$DAEMON_A_DB" == *"mysql"* ]] \
732
+ || fail "daemon A is NOT on MySQL (log: $DAEMON_A_DB) — coordination requires shared DB"
733
+ pass "daemon A on MySQL"
734
+
735
+ DAEMON2_PORT=$((DAEMON_PORT + 1))
736
+ DAEMON2_DIR="$DATA_DIR/ework-daemon-2"
737
+ DAEMON2_BIN="$(npm root -g)/ework-aio/node_modules/ework-daemon/bin/ework-daemon-server.js"
738
+
739
+ mkdir -p "$DAEMON2_DIR"
740
+ cp "$DATA_DIR/ework-daemon/.env" "$DAEMON2_DIR/.env"
741
+ sed -i "s/^DAEMON_PORT=.*/DAEMON_PORT=$DAEMON2_PORT/" "$DAEMON2_DIR/.env"
742
+ echo "WORK_DAEMON_LEASE_TTL_MS=15000" >> "$DAEMON2_DIR/.env"
743
+
744
+ info "starting daemon B on port $DAEMON2_PORT (short TTL=15s for failover test)"
745
+ ( set -a; . "$DAEMON2_DIR/.env" 2>/dev/null; set +a
746
+ cd "$DAEMON2_DIR"
747
+ DAEMON_PORT=$DAEMON2_PORT WORK_DAEMON_LEASE_TTL_MS=15000 \
748
+ exec bun "$DAEMON2_BIN" >> "$DATA_DIR/run/daemon-2.log" 2>&1 ) &
749
+ DAEMON2_PID=$!
750
+
751
+ for i in $(seq 1 15); do
752
+ curl -sf "http://127.0.0.1:$DAEMON2_PORT/healthz" >/dev/null 2>&1 && break
753
+ sleep 1
754
+ done
755
+ curl -sf "http://127.0.0.1:$DAEMON2_PORT/healthz" >/dev/null 2>&1 \
756
+ || { echo "=== daemon-2.log ==="; cat "$DATA_DIR/run/daemon-2.log" 2>/dev/null | tail -20; echo "=== bin check ==="; ls -la "$DAEMON2_BIN" 2>&1; fail "daemon B did not start on port $DAEMON2_PORT"; }
757
+ pass "daemon B started (pid $DAEMON2_PID, port $DAEMON2_PORT)"
758
+
759
+ info "daemon B startup config:"
760
+ head -8 "$DATA_DIR/run/daemon-2.log" 2>/dev/null | while read -r line; do echo " $line"; done
761
+
762
+ WEBHOOK_SECRET=$(grep GITEA_WEBHOOK_SECRET "$DATA_DIR/ework-daemon/.env" 2>/dev/null | cut -d= -f2)
763
+
764
+ make_hook() {
765
+ local payload="$1"
766
+ if [[ -n "$WEBHOOK_SECRET" ]]; then
767
+ local sig=$(printf '%s' "$payload" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | awk '{print $NF}')
768
+ echo "-H x-gitea-signature:$sig"
769
+ fi
770
+ }
771
+
772
+ info "test 1: no-double-spawn — send identical webhook to both daemons"
773
+ ISSUE_HOOK='{"action":"opened","issue":{"number":888,"title":"multi-daemon test","body":"coordination","state":"open","user":{"login":"e2e"}},"repository":{"owner":{"login":"e2e"},"name":"test"}}'
774
+ HOOK_HDR=$(make_hook "$ISSUE_HOOK")
775
+
776
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$DAEMON_PORT/webhook/gitea" \
777
+ -H "Content-Type: application/json" $HOOK_HDR -d "$ISSUE_HOOK" 2>/dev/null || true
778
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$DAEMON2_PORT/webhook/gitea" \
779
+ -H "Content-Type: application/json" $HOOK_HDR -d "$ISSUE_HOOK" 2>/dev/null || true
780
+ sleep 3
781
+
782
+ A_SPAWNED=$(grep -c 'session.*created.*888\|observer started.*888' "$DATA_DIR/run/daemon.log" 2>/dev/null); A_SPAWNED=${A_SPAWNED:-0}
783
+ B_SPAWNED=$(grep -c 'session.*created.*888\|observer started.*888' "$DATA_DIR/run/daemon-2.log" 2>/dev/null); B_SPAWNED=${B_SPAWNED:-0}
784
+ TOTAL=$((A_SPAWNED + B_SPAWNED))
785
+ info "daemon A spawned=$A_SPAWNED daemon B spawned=$B_SPAWNED total=$TOTAL"
786
+ [[ "$TOTAL" -eq 2 ]] \
787
+ || { echo "--- daemon A 888 lines ---"; grep '888' "$DATA_DIR/run/daemon.log" 2>/dev/null | tail -5; echo "--- daemon B 888 lines ---"; grep '888' "$DATA_DIR/run/daemon-2.log" 2>/dev/null | tail -5; fail "no-double-spawn failed: expected 2 log lines (1 spawn), got $TOTAL"; }
788
+ pass "no-double-spawn: exactly 1 daemon spawned for issue 888"
789
+
790
+ info "test 2: failover — kill owner, verify survivor takes over"
791
+ if [[ "$A_SPAWNED" -eq 1 ]]; then
792
+ OWNER_PID=$(cat "$DATA_DIR/run/daemon.pid" 2>/dev/null)
793
+ SURVIVOR_PORT=$DAEMON2_PORT
794
+ SURVIVOR_LOG="$DATA_DIR/run/daemon-2.log"
795
+ else
796
+ OWNER_PID=$DAEMON2_PID
797
+ SURVIVOR_PORT=$DAEMON_PORT
798
+ SURVIVOR_LOG="$DATA_DIR/run/daemon.log"
799
+ fi
800
+
801
+ info "killing owner (pid $OWNER_PID)"
802
+ kill "$OWNER_PID" 2>/dev/null
803
+ sleep 1
804
+ pass "owner killed"
805
+
806
+ info "waiting 18s for lease expiry (TTL=15s)..."
807
+ sleep 18
808
+
809
+ COMMENT_HOOK='{"action":"created","issue":{"number":888,"title":"multi-daemon test","body":"coordination","state":"open","user":{"login":"e2e"}},"comment":{"id":99999,"body":"failover test","user":{"login":"e2e"}},"repository":{"owner":{"login":"e2e"},"name":"test"}}'
810
+ COMMENT_HDR=$(make_hook "$COMMENT_HOOK")
811
+
812
+ info "sending comment webhook to survivor (port $SURVIVOR_PORT)"
813
+ timeout 5 curl -sS -o /dev/null -X POST "http://127.0.0.1:$SURVIVOR_PORT/webhook/gitea" \
814
+ -H "Content-Type: application/json" $COMMENT_HDR -d "$COMMENT_HOOK" 2>/dev/null || true
815
+ sleep 3
816
+
817
+ SURVIVOR_CLAIMED=$(grep -c "observer started.*888\|session.*created.*888\|claimed.*888\|absorb" "$SURVIVOR_LOG" 2>/dev/null | tail -1 || echo 0)
818
+ SURVIVOR_CLAIMED_AFTER=$(grep "observer started.*888\|session.*created.*888" "$SURVIVOR_LOG" 2>/dev/null | wc -l || echo 0)
819
+ info "survivor log shows $SURVIVOR_CLAIMED_AFTER session creation(s) for 888"
820
+ [[ "$SURVIVOR_CLAIMED_AFTER" -ge 1 ]] \
821
+ || fail "failover failed: survivor did not claim issue 888"
822
+ pass "failover: survivor claimed issue 888"
823
+
824
+ info "cleanup: stop daemon B + restart main daemon"
825
+ kill "$DAEMON2_PID" 2>/dev/null
826
+ ework-aio start daemon --data-dir "$DATA_DIR" 2>&1 | tail -3
827
+ sleep 2
828
+
829
+ else
830
+ info "skipping Phase 7 (multi-daemon requires MySQL)"
831
+ fi
832
+
712
833
  # Phase 6: uninstall
713
834
  # -----------------------------------------------------------------------------
714
835
  echo
package/src/cli.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  } from "./commands/lifecycle.ts";
32
32
  import { runLogs } from "./commands/logs.ts";
33
33
  import { runEnv } from "./commands/env.ts";
34
+ import { runAddDaemon } from "./commands/add-daemon.ts";
34
35
  import {
35
36
  runConfig,
36
37
  printConfigHelp,
@@ -67,6 +68,7 @@ Commands:
67
68
  start [web|daemon|both] Start services in PID-file mode (default both)
68
69
  stop [web|daemon|both] Stop services (SIGTERM, 5s grace, then SIGKILL)
69
70
  restart [web|daemon|both] Stop + start
71
+ add-daemon [port] Start an additional daemon instance
70
72
  ps Show PID-file mode status (alias for 'status')
71
73
 
72
74
  migrate [options] Migrate issues from a Gitea instance
@@ -480,6 +482,16 @@ export async function main(
480
482
  await runRestart(opts, logger, target);
481
483
  return 0;
482
484
  }
485
+ case "add-daemon": {
486
+ const port = positionals[1] ? Number.parseInt(positionals[1], 10) : undefined;
487
+ if (port !== undefined && (!Number.isFinite(port) || port <= 0 || port > 65535)) {
488
+ throw new InstallError(
489
+ `add-daemon: invalid port '${positionals[1]}' (must be 1-65535)`,
490
+ );
491
+ }
492
+ await runAddDaemon(opts, logger, port);
493
+ return 0;
494
+ }
483
495
  case "migrate": {
484
496
  return runDelegateScript("migrate-from-gitea.ts", argv.slice(1));
485
497
  }
@@ -0,0 +1,86 @@
1
+ import path from "node:path";
2
+ import { mkdirSync } from "node:fs";
3
+ import { Logger, InstallError } from "../log.ts";
4
+ import { resolvePaths, daemonInstancePaths } from "../paths.ts";
5
+ import { parseEnvFile, serializeEnvFile, writeEnvFileAtomic } from "../env.ts";
6
+ import { startFromSp, type ServicePaths } from "./lifecycle.ts";
7
+ import type { GlobalOptions } from "../types.ts";
8
+
9
+ export async function runAddDaemon(
10
+ opts: GlobalOptions,
11
+ logger: Logger,
12
+ port?: number,
13
+ ): Promise<void> {
14
+ const paths = resolvePaths({
15
+ dataDir: opts.dataDir,
16
+ scope: opts.scope,
17
+ useSystemd: false,
18
+ });
19
+ const instances = daemonInstancePaths(paths.dataDir, paths.runDir);
20
+ const primary = instances.find((i) => i.num === 1);
21
+ if (!primary) {
22
+ throw new InstallError(
23
+ `No primary daemon found at ${paths.daemonDataDir}. Run 'ework-aio install' first.`,
24
+ );
25
+ }
26
+
27
+ let primaryContent: string;
28
+ try {
29
+ primaryContent = await Bun.file(primary.envFile).text();
30
+ } catch (err) {
31
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
32
+ throw new InstallError(
33
+ `Primary daemon .env not found at ${primary.envFile}. Run 'ework-aio install' first.`,
34
+ );
35
+ }
36
+ throw err;
37
+ }
38
+
39
+ const parsed = parseEnvFile(primaryContent);
40
+ const basePortStr = parsed.entries.get("DAEMON_PORT");
41
+ const basePort = basePortStr ? Number.parseInt(basePortStr, 10) : NaN;
42
+ if (!Number.isFinite(basePort)) {
43
+ throw new InstallError(
44
+ `Primary daemon .env has no valid DAEMON_PORT (got '${basePortStr ?? "<unset>"}')`,
45
+ );
46
+ }
47
+ const newPort = port ?? (basePort + instances.length);
48
+
49
+ const nextNum = instances.length + 1;
50
+ const newDataDir = nextNum === 1
51
+ ? paths.daemonDataDir
52
+ : path.join(paths.dataDir, `ework-daemon-${nextNum}`);
53
+ const newEnvFile = path.join(newDataDir, ".env");
54
+ const newPidFile = path.join(paths.runDir, `daemon-${nextNum}.pid`);
55
+ const newLogFile = path.join(paths.runDir, `daemon-${nextNum}.log`);
56
+
57
+ mkdirSync(newDataDir, { recursive: true });
58
+
59
+ const patchedRaw = parsed.rawLines.map((line) => {
60
+ const eqIdx = line.indexOf("=");
61
+ if (eqIdx === -1) return line;
62
+ if (line.slice(0, eqIdx).trim().startsWith("#")) return line;
63
+ if (line.slice(0, eqIdx).trim() === "DAEMON_PORT") {
64
+ return `DAEMON_PORT=${newPort}`;
65
+ }
66
+ return line;
67
+ });
68
+ const body = serializeEnvFile(
69
+ { entries: parsed.entries, rawLines: patchedRaw },
70
+ [],
71
+ );
72
+ await writeEnvFileAtomic(newEnvFile, body);
73
+
74
+ const sp: ServicePaths = {
75
+ bin: "ework-daemon-server",
76
+ pkg: "ework-daemon",
77
+ binRelPath: "bin/ework-daemon-server.js",
78
+ dataDir: newDataDir,
79
+ envFile: newEnvFile,
80
+ pidFile: newPidFile,
81
+ logFile: newLogFile,
82
+ portKey: "DAEMON_PORT",
83
+ };
84
+ const label = `daemon-${nextNum}`;
85
+ await startFromSp(sp, label, logger);
86
+ }
@@ -9,7 +9,12 @@
9
9
  // responding" without having to curl themselves).
10
10
 
11
11
  import { Logger, InstallError } from "../log.ts";
12
- import { resolvePaths, type PathConfig } from "../paths.ts";
12
+ import {
13
+ resolvePaths,
14
+ daemonInstancePaths,
15
+ type PathConfig,
16
+ type DaemonInstance,
17
+ } from "../paths.ts";
13
18
  import {
14
19
  startProcess,
15
20
  stopProcess,
@@ -20,7 +25,7 @@ import { parseEnvFile } from "../env.ts";
20
25
  import { resolveCommand, resolveBundledBin } from "../preflight.ts";
21
26
  import type { GlobalOptions, ServiceTarget } from "../types.ts";
22
27
 
23
- interface ServicePaths {
28
+ export interface ServicePaths {
24
29
  bin: string;
25
30
  pkg: string;
26
31
  binRelPath: string;
@@ -95,13 +100,21 @@ async function startOne(
95
100
  logger: Logger,
96
101
  ): Promise<boolean> {
97
102
  const sp = servicePaths(paths, svc);
103
+ return startFromSp(sp, svc, logger);
104
+ }
105
+
106
+ export async function startFromSp(
107
+ sp: ServicePaths,
108
+ label: string,
109
+ logger: Logger,
110
+ ): Promise<boolean> {
98
111
  const binPath = resolveBundledBin(sp.pkg, sp.binRelPath) ?? resolveCommand(sp.bin);
99
112
  if (!binPath) {
100
113
  throw new InstallError(`${sp.bin} not found — update with: npm install -g ework-aio@latest`);
101
114
  }
102
115
  const existingPid = await readPidFile(sp.pidFile);
103
116
  if (existingPid !== null && isProcessRunning(existingPid)) {
104
- logger.log(`ework-${svc} already running (pid ${existingPid})`);
117
+ logger.log(`ework-${label} already running (pid ${existingPid})`);
105
118
  return false;
106
119
  }
107
120
  const env = await loadEnv(sp.envFile);
@@ -118,46 +131,101 @@ async function startOne(
118
131
  if (!isProcessRunning(pid)) {
119
132
  const tail = await readLogTail(sp.logFile, 10);
120
133
  const detail = tail ? `\n${tail}` : "";
121
- throw new InstallError(`ework-${svc} failed to start (pid ${pid} exited within 1.5s)${detail}`);
134
+ throw new InstallError(`ework-${label} failed to start (pid ${pid} exited within 1.5s)${detail}`);
122
135
  }
123
136
 
124
137
  const port = await readPort(sp);
125
138
  const portStr = port !== null ? `, http://127.0.0.1:${port}` : "";
126
- logger.ok(`ework-${svc} started (pid ${pid}${portStr}, log ${sp.logFile})`);
139
+ logger.ok(`ework-${label} started (pid ${pid}${portStr}, log ${sp.logFile})`);
127
140
  return true;
128
141
  }
129
142
 
130
143
  async function stopOne(svc: "web" | "daemon", paths: PathConfig, logger: Logger): Promise<boolean> {
131
144
  const sp = servicePaths(paths, svc);
145
+ return stopFromSp(sp, svc, logger);
146
+ }
147
+
148
+ export async function stopFromSp(
149
+ sp: ServicePaths,
150
+ label: string,
151
+ logger: Logger,
152
+ ): Promise<boolean> {
132
153
  try {
133
154
  const result = await stopProcess(sp.pidFile, { graceMs: 5000, sigkillAfter: true });
134
155
  if (result.killed) {
135
- logger.ok(`ework-${svc} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
156
+ logger.ok(`ework-${label} stopped (pid ${result.pid}${result.timedOut ? ", SIGKILL after timeout" : ""})`);
136
157
  } else {
137
- logger.log(`ework-${svc} was not running (stale pidfile cleaned)`);
158
+ logger.log(`ework-${label} was not running (stale pidfile cleaned)`);
138
159
  }
139
160
  return result.killed;
140
161
  } catch (err) {
141
162
  if (err instanceof Error && /not found or empty/.test(err.message)) {
142
- logger.log(`ework-${svc} not running (no pidfile at ${sp.pidFile})`);
163
+ logger.log(`ework-${label} not running (no pidfile at ${sp.pidFile})`);
143
164
  return false;
144
165
  }
145
166
  throw err;
146
167
  }
147
168
  }
148
169
 
170
+ function daemonSpFromInstance(inst: DaemonInstance): ServicePaths {
171
+ return {
172
+ bin: "ework-daemon-server",
173
+ pkg: "ework-daemon",
174
+ binRelPath: "bin/ework-daemon-server.js",
175
+ dataDir: inst.dataDir,
176
+ envFile: inst.envFile,
177
+ pidFile: inst.pidFile,
178
+ logFile: inst.logFile,
179
+ portKey: "DAEMON_PORT",
180
+ };
181
+ }
182
+
183
+ function instanceLabel(num: number): string {
184
+ return num === 1 ? "daemon" : `daemon-${num}`;
185
+ }
186
+
187
+ function iterDaemonInstances(
188
+ paths: PathConfig,
189
+ ): Array<{ sp: ServicePaths; label: string }> {
190
+ const instances = daemonInstancePaths(paths.dataDir, paths.runDir);
191
+ return instances.map((inst) => ({
192
+ sp: daemonSpFromInstance(inst),
193
+ label: instanceLabel(inst.num),
194
+ }));
195
+ }
196
+
149
197
  function targets(target: ServiceTarget): Array<"web" | "daemon"> {
150
198
  return target === "both" ? ["web", "daemon"] : [target];
151
199
  }
152
200
 
201
+ function iterTargets(
202
+ paths: PathConfig,
203
+ target: ServiceTarget,
204
+ ): Array<{ sp: ServicePaths; label: string }> {
205
+ const out: Array<{ sp: ServicePaths; label: string }> = [];
206
+ for (const svc of targets(target)) {
207
+ if (svc === "web") {
208
+ out.push({ sp: servicePaths(paths, "web"), label: "web" });
209
+ } else {
210
+ const insts = iterDaemonInstances(paths);
211
+ if (insts.length === 0) {
212
+ out.push({ sp: servicePaths(paths, "daemon"), label: "daemon" });
213
+ } else {
214
+ out.push(...insts);
215
+ }
216
+ }
217
+ }
218
+ return out;
219
+ }
220
+
153
221
  export async function runStart(
154
222
  opts: GlobalOptions,
155
223
  logger: Logger,
156
224
  target: ServiceTarget,
157
225
  ): Promise<void> {
158
226
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
159
- for (const svc of targets(target)) {
160
- await startOne(svc, paths, logger);
227
+ for (const { sp, label } of iterTargets(paths, target)) {
228
+ await startFromSp(sp, label, logger);
161
229
  }
162
230
  }
163
231
 
@@ -167,8 +235,8 @@ export async function runStop(
167
235
  target: ServiceTarget,
168
236
  ): Promise<void> {
169
237
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
170
- for (const svc of targets(target)) {
171
- await stopOne(svc, paths, logger);
238
+ for (const { sp, label } of iterTargets(paths, target)) {
239
+ await stopFromSp(sp, label, logger);
172
240
  }
173
241
  }
174
242
 
@@ -178,14 +246,14 @@ export async function runRestart(
178
246
  target: ServiceTarget,
179
247
  ): Promise<void> {
180
248
  const paths = resolvePaths({ dataDir: opts.dataDir, scope: opts.scope, useSystemd: false });
181
- for (const svc of targets(target)) {
182
- await stopOne(svc, paths, logger);
183
- await startOne(svc, paths, logger);
249
+ for (const { sp, label } of iterTargets(paths, target)) {
250
+ await stopFromSp(sp, label, logger);
251
+ await startFromSp(sp, label, logger);
184
252
  }
185
253
  }
186
254
 
187
255
  export interface StatusEntry {
188
- svc: "web" | "daemon";
256
+ svc: string;
189
257
  pid: number | null;
190
258
  alive: boolean;
191
259
  port: number | null;
@@ -199,16 +267,25 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
199
267
  logger.log("ework-aio status (PID-file mode)");
200
268
  logger.hr();
201
269
 
270
+ const probeTargets: Array<{ sp: ServicePaths; label: string }> = [
271
+ { sp: servicePaths(paths, "web"), label: "web" },
272
+ ];
273
+ const daemonInsts = iterDaemonInstances(paths);
274
+ if (daemonInsts.length === 0) {
275
+ probeTargets.push({ sp: servicePaths(paths, "daemon"), label: "daemon" });
276
+ } else {
277
+ probeTargets.push(...daemonInsts);
278
+ }
279
+
202
280
  const entries: StatusEntry[] = [];
203
- for (const svc of ["web", "daemon"] as const) {
204
- const sp = servicePaths(paths, svc);
281
+ for (const { sp, label } of probeTargets) {
205
282
  const pid = await readPidFile(sp.pidFile);
206
283
  const alive = pid !== null && isProcessRunning(pid);
207
284
 
208
- let port = await readPort(sp);
285
+ const port = await readPort(sp);
209
286
  let listening: boolean | null = null;
210
287
  if (port !== null) {
211
- const probeUrl = svc === "web"
288
+ const probeUrl = label === "web"
212
289
  ? `http://127.0.0.1:${port}/login`
213
290
  : `http://127.0.0.1:${port}/`;
214
291
  try {
@@ -219,13 +296,13 @@ export async function runStatus(opts: GlobalOptions, logger: Logger): Promise<St
219
296
  }
220
297
  }
221
298
 
222
- entries.push({ svc, pid, alive, port, listening });
299
+ entries.push({ svc: label, pid, alive, port, listening });
223
300
 
224
301
  const pidStr = pid === null ? "—" : `pid ${pid}`;
225
302
  const aliveStr = alive ? "✓ running" : "✗ not running";
226
303
  const portStr = port === null ? "(no port in .env)" : `:${port}`;
227
304
  const listenStr = listening === null ? "" : listening ? " ✓ listening" : " ✗ not responding";
228
- logger.log(` ework-${svc.padEnd(8)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
305
+ logger.log(` ework-${label.padEnd(10)} ${pidStr.padEnd(10)} ${aliveStr} ${portStr}${listenStr}`);
229
306
  }
230
307
  logger.hr();
231
308
  return entries;
package/src/paths.ts CHANGED
@@ -18,6 +18,7 @@
18
18
 
19
19
  import path from "node:path";
20
20
  import os from "node:os";
21
+ import { existsSync } from "node:fs";
21
22
 
22
23
  export interface PathConfig {
23
24
  dataDir: string;
@@ -93,3 +94,37 @@ export function resolvePaths(opts: ResolvePathsOptions): PathConfig {
93
94
  daemonUnitFile: unitDir ? path.join(unitDir, "ework-daemon.service") : null,
94
95
  };
95
96
  }
97
+
98
+ export interface DaemonInstance {
99
+ num: number;
100
+ dataDir: string;
101
+ envFile: string;
102
+ pidFile: string;
103
+ logFile: string;
104
+ }
105
+
106
+ export function daemonInstancePaths(dataDir: string, runDir: string): DaemonInstance[] {
107
+ const instances: DaemonInstance[] = [];
108
+ const primaryDir = path.join(dataDir, "ework-daemon");
109
+ if (existsSync(primaryDir)) {
110
+ instances.push({
111
+ num: 1,
112
+ dataDir: primaryDir,
113
+ envFile: path.join(primaryDir, ".env"),
114
+ pidFile: path.join(runDir, "daemon.pid"),
115
+ logFile: path.join(runDir, "daemon.log"),
116
+ });
117
+ }
118
+ for (let i = 2; i <= 100; i++) {
119
+ const dir = path.join(dataDir, `ework-daemon-${i}`);
120
+ if (!existsSync(dir)) break;
121
+ instances.push({
122
+ num: i,
123
+ dataDir: dir,
124
+ envFile: path.join(dir, ".env"),
125
+ pidFile: path.join(runDir, `daemon-${i}.pid`),
126
+ logFile: path.join(runDir, `daemon-${i}.log`),
127
+ });
128
+ }
129
+ return instances;
130
+ }