triflux 10.21.0 → 10.22.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/bin/triflux.mjs +552 -6
- package/hub/team/session.mjs +54 -2
- package/hub/team/terminal-opener.mjs +24 -4
- package/package.json +1 -1
- package/scripts/hub-ensure.mjs +689 -33
- package/scripts/test-lock.mjs +194 -26
package/bin/triflux.mjs
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readdirSync,
|
|
13
13
|
readFileSync,
|
|
14
14
|
readSync,
|
|
15
|
+
realpathSync,
|
|
15
16
|
renameSync,
|
|
16
17
|
statSync,
|
|
17
18
|
unlinkSync,
|
|
@@ -26,6 +27,7 @@ import {
|
|
|
26
27
|
validateRuntimeCachePaths,
|
|
27
28
|
} from "../hub/lib/cache-guard.mjs";
|
|
28
29
|
import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
|
|
30
|
+
import { getVersionHash } from "../hub/state.mjs";
|
|
29
31
|
import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
|
|
30
32
|
import {
|
|
31
33
|
detectMultiplexer,
|
|
@@ -119,6 +121,8 @@ const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
|
|
|
119
121
|
const _DOT = `${GRAY}·${RESET}`;
|
|
120
122
|
const STALE_TEAM_MAX_AGE_SEC = 3600;
|
|
121
123
|
const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
124
|
+
const HUB_DEFAULT_PORT = 27888;
|
|
125
|
+
const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
|
|
122
126
|
|
|
123
127
|
const _EXIT_SUCCESS = 0;
|
|
124
128
|
const EXIT_ERROR = 1;
|
|
@@ -151,7 +155,7 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
151
155
|
},
|
|
152
156
|
doctor: {
|
|
153
157
|
usage:
|
|
154
|
-
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--json]",
|
|
158
|
+
"tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--json]",
|
|
155
159
|
description: "설치 상태 진단 및 자동 복구",
|
|
156
160
|
options: [
|
|
157
161
|
{
|
|
@@ -187,6 +191,24 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
|
|
|
187
191
|
description:
|
|
188
192
|
"Phase 1 dynamic routing 상태 진단 (env / policy / snapshot cache / preview decision)",
|
|
189
193
|
},
|
|
194
|
+
{
|
|
195
|
+
name: "--cleanup-stale-hubs",
|
|
196
|
+
type: "boolean",
|
|
197
|
+
description:
|
|
198
|
+
"PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "--dry-run",
|
|
202
|
+
type: "boolean",
|
|
203
|
+
description:
|
|
204
|
+
"--cleanup-stale-hubs 와 함께 사용. active healthy hub를 제외하고 정리 후보만 표시",
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: "--apply",
|
|
208
|
+
type: "boolean",
|
|
209
|
+
description:
|
|
210
|
+
"--cleanup-stale-hubs 와 함께 사용. active healthy hub 제외 후 stale hub 종료",
|
|
211
|
+
},
|
|
190
212
|
{
|
|
191
213
|
name: "--json",
|
|
192
214
|
type: "boolean",
|
|
@@ -505,6 +527,403 @@ function createCliError(
|
|
|
505
527
|
return error;
|
|
506
528
|
}
|
|
507
529
|
|
|
530
|
+
function parseHubPort(value) {
|
|
531
|
+
const parsed = Number.parseInt(String(value ?? ""), 10);
|
|
532
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function isPidAliveForHub(pid, killFn = process.kill) {
|
|
536
|
+
const resolvedPid = Number(pid);
|
|
537
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
|
|
538
|
+
try {
|
|
539
|
+
killFn(resolvedPid, 0);
|
|
540
|
+
return true;
|
|
541
|
+
} catch (error) {
|
|
542
|
+
return error?.code === "EPERM";
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function isHubServerCommand(command) {
|
|
547
|
+
return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
|
|
548
|
+
String(command || ""),
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function parsePortFromAddress(address) {
|
|
553
|
+
const match = String(address || "").match(/:(\d+)(?:\s|$)/);
|
|
554
|
+
return parseHubPort(match?.[1]);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export function parseDetachedHubProcessRows(output) {
|
|
558
|
+
const rows = [];
|
|
559
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
560
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
|
|
561
|
+
if (!match) continue;
|
|
562
|
+
const [, pidText, ppidText, rssText, uptime, command] = match;
|
|
563
|
+
const pid = Number.parseInt(pidText, 10);
|
|
564
|
+
const ppid = Number.parseInt(ppidText, 10);
|
|
565
|
+
const rssKb = Number.parseInt(rssText, 10);
|
|
566
|
+
if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
|
|
567
|
+
if (ppid !== 1) continue;
|
|
568
|
+
if (!isHubServerCommand(command)) continue;
|
|
569
|
+
rows.push({
|
|
570
|
+
pid,
|
|
571
|
+
ppid,
|
|
572
|
+
rssKb: Number.isFinite(rssKb) ? rssKb : null,
|
|
573
|
+
uptime,
|
|
574
|
+
command: command.trim(),
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return rows;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function queryDetachedHubProcessRows({
|
|
581
|
+
platform = process.platform,
|
|
582
|
+
execFile = execFileSync,
|
|
583
|
+
} = {}) {
|
|
584
|
+
if (platform === "win32") return [];
|
|
585
|
+
try {
|
|
586
|
+
const output = execFile("ps", ["-axo", "pid=,ppid=,rss=,etime=,command="], {
|
|
587
|
+
encoding: "utf8",
|
|
588
|
+
timeout: 5000,
|
|
589
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
590
|
+
windowsHide: true,
|
|
591
|
+
});
|
|
592
|
+
return parseDetachedHubProcessRows(output);
|
|
593
|
+
} catch {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function parseLsofListeningPorts(output) {
|
|
599
|
+
const ports = new Set();
|
|
600
|
+
for (const line of String(output || "").split(/\r?\n/)) {
|
|
601
|
+
if (!/\(LISTEN\)/i.test(line)) continue;
|
|
602
|
+
const match = line.match(/TCP\s+\S+:(\d+)\s+\(LISTEN\)/i);
|
|
603
|
+
const port = parseHubPort(match?.[1]) ?? parsePortFromAddress(line);
|
|
604
|
+
if (port) ports.add(port);
|
|
605
|
+
}
|
|
606
|
+
return [...ports];
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function queryListeningPortsForPid(
|
|
610
|
+
pid,
|
|
611
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
612
|
+
) {
|
|
613
|
+
const resolvedPid = Number(pid);
|
|
614
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return [];
|
|
615
|
+
if (platform === "win32") return [];
|
|
616
|
+
try {
|
|
617
|
+
const output = execFile(
|
|
618
|
+
"lsof",
|
|
619
|
+
["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:LISTEN"],
|
|
620
|
+
{
|
|
621
|
+
encoding: "utf8",
|
|
622
|
+
timeout: 5000,
|
|
623
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
624
|
+
windowsHide: true,
|
|
625
|
+
},
|
|
626
|
+
);
|
|
627
|
+
return parseLsofListeningPorts(output);
|
|
628
|
+
} catch {
|
|
629
|
+
return [];
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function queryEstablishedCountForPid(
|
|
634
|
+
pid,
|
|
635
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
636
|
+
) {
|
|
637
|
+
const resolvedPid = Number(pid);
|
|
638
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return 0;
|
|
639
|
+
if (platform === "win32") return 0;
|
|
640
|
+
try {
|
|
641
|
+
const output = execFile(
|
|
642
|
+
"lsof",
|
|
643
|
+
["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:ESTABLISHED"],
|
|
644
|
+
{
|
|
645
|
+
encoding: "utf8",
|
|
646
|
+
timeout: 5000,
|
|
647
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
648
|
+
windowsHide: true,
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
return Math.max(0, output.trim().split(/\r?\n/).filter(Boolean).length - 1);
|
|
652
|
+
} catch {
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function queryPidCommand(
|
|
658
|
+
pid,
|
|
659
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
660
|
+
) {
|
|
661
|
+
const resolvedPid = Number(pid);
|
|
662
|
+
if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return "";
|
|
663
|
+
try {
|
|
664
|
+
if (platform === "win32") return "";
|
|
665
|
+
return execFile("ps", ["-p", String(resolvedPid), "-o", "command="], {
|
|
666
|
+
encoding: "utf8",
|
|
667
|
+
timeout: 5000,
|
|
668
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
669
|
+
windowsHide: true,
|
|
670
|
+
}).trim();
|
|
671
|
+
} catch {
|
|
672
|
+
return "";
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function queryListeningPidByPort(
|
|
677
|
+
port,
|
|
678
|
+
{ platform = process.platform, execFile = execFileSync } = {},
|
|
679
|
+
) {
|
|
680
|
+
const targetPort = parseHubPort(port);
|
|
681
|
+
if (!targetPort || platform === "win32") return null;
|
|
682
|
+
try {
|
|
683
|
+
const output = execFile(
|
|
684
|
+
"lsof",
|
|
685
|
+
["-nP", "-iTCP:" + targetPort, "-sTCP:LISTEN", "-t"],
|
|
686
|
+
{
|
|
687
|
+
encoding: "utf8",
|
|
688
|
+
timeout: 5000,
|
|
689
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
690
|
+
windowsHide: true,
|
|
691
|
+
},
|
|
692
|
+
);
|
|
693
|
+
const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
|
|
694
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
695
|
+
} catch {
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
async function fetchHubHealthForDoctor(
|
|
701
|
+
host,
|
|
702
|
+
port,
|
|
703
|
+
{ fetchImpl = fetch, timeoutMs = 1000 } = {},
|
|
704
|
+
) {
|
|
705
|
+
try {
|
|
706
|
+
const urlHost = String(host || "127.0.0.1").includes(":")
|
|
707
|
+
? `[${host}]`
|
|
708
|
+
: host || "127.0.0.1";
|
|
709
|
+
const response = await fetchImpl(`http://${urlHost}:${port}/health`, {
|
|
710
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
711
|
+
});
|
|
712
|
+
if (!response.ok) return { ok: false, version: null };
|
|
713
|
+
const body = await response.json().catch(() => null);
|
|
714
|
+
return {
|
|
715
|
+
ok: body?.ok === true,
|
|
716
|
+
version: typeof body?.version === "string" ? body.version : null,
|
|
717
|
+
raw: body,
|
|
718
|
+
};
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return { ok: false, version: null, error };
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function readHubPidInfo({
|
|
725
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
726
|
+
exists = existsSync,
|
|
727
|
+
readFile = readFileSync,
|
|
728
|
+
} = {}) {
|
|
729
|
+
if (!exists(pidFilePath)) return null;
|
|
730
|
+
try {
|
|
731
|
+
return JSON.parse(readFile(pidFilePath, "utf8"));
|
|
732
|
+
} catch {
|
|
733
|
+
return null;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
async function resolveActiveHealthyHub({
|
|
738
|
+
expectedVersion = getVersionHash(),
|
|
739
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
740
|
+
exists = existsSync,
|
|
741
|
+
readFile = readFileSync,
|
|
742
|
+
killFn = process.kill,
|
|
743
|
+
platform = process.platform,
|
|
744
|
+
execFile = execFileSync,
|
|
745
|
+
fetchImpl = fetch,
|
|
746
|
+
} = {}) {
|
|
747
|
+
const info = readHubPidInfo({ pidFilePath, exists, readFile });
|
|
748
|
+
const pid = Number(info?.pid);
|
|
749
|
+
const port = parseHubPort(info?.port);
|
|
750
|
+
const host =
|
|
751
|
+
typeof info?.host === "string" && info.host.trim()
|
|
752
|
+
? info.host.trim()
|
|
753
|
+
: "127.0.0.1";
|
|
754
|
+
if (pid && port && isPidAliveForHub(pid, killFn)) {
|
|
755
|
+
const command = queryPidCommand(pid, { platform, execFile });
|
|
756
|
+
const health = await fetchHubHealthForDoctor(host, port, { fetchImpl });
|
|
757
|
+
if (
|
|
758
|
+
isHubServerCommand(command) &&
|
|
759
|
+
health.ok &&
|
|
760
|
+
health.version === expectedVersion
|
|
761
|
+
) {
|
|
762
|
+
return { pid, port, host, version: health.version, source: "pid-file" };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
const defaultPid = queryListeningPidByPort(HUB_DEFAULT_PORT, {
|
|
767
|
+
platform,
|
|
768
|
+
execFile,
|
|
769
|
+
});
|
|
770
|
+
if (!defaultPid || !isPidAliveForHub(defaultPid, killFn)) return null;
|
|
771
|
+
const command = queryPidCommand(defaultPid, { platform, execFile });
|
|
772
|
+
if (!isHubServerCommand(command)) return null;
|
|
773
|
+
const health = await fetchHubHealthForDoctor("127.0.0.1", HUB_DEFAULT_PORT, {
|
|
774
|
+
fetchImpl,
|
|
775
|
+
});
|
|
776
|
+
if (!health.ok || health.version !== expectedVersion) return null;
|
|
777
|
+
return {
|
|
778
|
+
pid: defaultPid,
|
|
779
|
+
port: HUB_DEFAULT_PORT,
|
|
780
|
+
host: "127.0.0.1",
|
|
781
|
+
version: health.version,
|
|
782
|
+
source: "default-port",
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function inspectDetachedHubProcesses({
|
|
787
|
+
expectedVersion = getVersionHash(),
|
|
788
|
+
pidFilePath = DOCTOR_HUB_PID_FILE,
|
|
789
|
+
exists = existsSync,
|
|
790
|
+
readFile = readFileSync,
|
|
791
|
+
killFn = process.kill,
|
|
792
|
+
platform = process.platform,
|
|
793
|
+
execFile = execFileSync,
|
|
794
|
+
fetchImpl = fetch,
|
|
795
|
+
} = {}) {
|
|
796
|
+
const rows = queryDetachedHubProcessRows({ platform, execFile });
|
|
797
|
+
const activeHealthy = await resolveActiveHealthyHub({
|
|
798
|
+
expectedVersion,
|
|
799
|
+
pidFilePath,
|
|
800
|
+
exists,
|
|
801
|
+
readFile,
|
|
802
|
+
killFn,
|
|
803
|
+
platform,
|
|
804
|
+
execFile,
|
|
805
|
+
fetchImpl,
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const hubs = [];
|
|
809
|
+
for (const row of rows) {
|
|
810
|
+
const ports = queryListeningPortsForPid(row.pid, { platform, execFile });
|
|
811
|
+
const established = queryEstablishedCountForPid(row.pid, {
|
|
812
|
+
platform,
|
|
813
|
+
execFile,
|
|
814
|
+
});
|
|
815
|
+
let version = null;
|
|
816
|
+
let healthy = false;
|
|
817
|
+
for (const port of ports) {
|
|
818
|
+
const health = await fetchHubHealthForDoctor("127.0.0.1", port, {
|
|
819
|
+
fetchImpl,
|
|
820
|
+
});
|
|
821
|
+
if (!health.ok) continue;
|
|
822
|
+
version = health.version;
|
|
823
|
+
healthy = true;
|
|
824
|
+
break;
|
|
825
|
+
}
|
|
826
|
+
hubs.push({
|
|
827
|
+
...row,
|
|
828
|
+
ports,
|
|
829
|
+
established,
|
|
830
|
+
version,
|
|
831
|
+
healthy,
|
|
832
|
+
activeHealthy: activeHealthy?.pid === row.pid,
|
|
833
|
+
staleCandidate: activeHealthy?.pid !== row.pid,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return {
|
|
838
|
+
expectedVersion,
|
|
839
|
+
activeHealthy,
|
|
840
|
+
hubs,
|
|
841
|
+
staleCandidates: hubs.filter((hub) => hub.staleCandidate),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function waitForHubProcessExit(
|
|
846
|
+
pid,
|
|
847
|
+
{ killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
|
|
848
|
+
) {
|
|
849
|
+
const deadline = Date.now() + Math.max(0, graceMs);
|
|
850
|
+
while (Date.now() <= deadline) {
|
|
851
|
+
if (!isPidAliveForHub(pid, killFn)) return true;
|
|
852
|
+
await delay(pollMs);
|
|
853
|
+
}
|
|
854
|
+
return !isPidAliveForHub(pid, killFn);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
async function retireDetachedHubPid(
|
|
858
|
+
pid,
|
|
859
|
+
{ killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
|
|
860
|
+
) {
|
|
861
|
+
if (!isPidAliveForHub(pid, killFn)) return { ok: true, reason: "dead" };
|
|
862
|
+
try {
|
|
863
|
+
killFn(pid, "SIGTERM");
|
|
864
|
+
} catch (error) {
|
|
865
|
+
return { ok: false, reason: "sigterm_failed", error };
|
|
866
|
+
}
|
|
867
|
+
if (await waitForHubProcessExit(pid, { killFn, graceMs, pollMs })) {
|
|
868
|
+
return { ok: true, reason: "sigterm" };
|
|
869
|
+
}
|
|
870
|
+
try {
|
|
871
|
+
killFn(pid, "SIGKILL");
|
|
872
|
+
} catch (error) {
|
|
873
|
+
return { ok: false, reason: "sigkill_failed", error };
|
|
874
|
+
}
|
|
875
|
+
const exited = await waitForHubProcessExit(pid, {
|
|
876
|
+
killFn,
|
|
877
|
+
graceMs: 1000,
|
|
878
|
+
pollMs,
|
|
879
|
+
});
|
|
880
|
+
return { ok: exited, reason: exited ? "sigkill" : "still_alive" };
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
export async function cleanupDetachedHubProcesses({
|
|
884
|
+
hubs,
|
|
885
|
+
activeHealthy,
|
|
886
|
+
dryRun = true,
|
|
887
|
+
apply = false,
|
|
888
|
+
killFn = process.kill,
|
|
889
|
+
graceMs = 5000,
|
|
890
|
+
pollMs = 100,
|
|
891
|
+
} = {}) {
|
|
892
|
+
const results = [];
|
|
893
|
+
for (const hub of hubs || []) {
|
|
894
|
+
if (activeHealthy?.pid === hub.pid || hub.activeHealthy) {
|
|
895
|
+
results.push({ pid: hub.pid, action: "excluded-active", ok: true, hub });
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
if (!apply || dryRun) {
|
|
899
|
+
results.push({ pid: hub.pid, action: "dry-run-skip", ok: true, hub });
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
const retired = await retireDetachedHubPid(hub.pid, {
|
|
903
|
+
killFn,
|
|
904
|
+
graceMs,
|
|
905
|
+
pollMs,
|
|
906
|
+
});
|
|
907
|
+
results.push({
|
|
908
|
+
pid: hub.pid,
|
|
909
|
+
action: retired.ok ? "retired" : "failed",
|
|
910
|
+
ok: retired.ok,
|
|
911
|
+
reason: retired.reason,
|
|
912
|
+
hub,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
return {
|
|
916
|
+
dryRun: !apply || dryRun,
|
|
917
|
+
results,
|
|
918
|
+
failed: results.filter((result) => result.ok === false).length,
|
|
919
|
+
retired: results.filter((result) => result.action === "retired").length,
|
|
920
|
+
skipped: results.filter((result) => result.action === "dry-run-skip")
|
|
921
|
+
.length,
|
|
922
|
+
excluded: results.filter((result) => result.action === "excluded-active")
|
|
923
|
+
.length,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
|
|
508
927
|
function inferExitCode(error) {
|
|
509
928
|
if (Number.isInteger(error?.exitCode)) return error.exitCode;
|
|
510
929
|
if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
|
|
@@ -1864,6 +2283,9 @@ async function cmdDoctor(options = {}) {
|
|
|
1864
2283
|
fix = false,
|
|
1865
2284
|
reset = false,
|
|
1866
2285
|
purgeLogs = false,
|
|
2286
|
+
cleanupStaleHubs = false,
|
|
2287
|
+
cleanupStaleHubsDryRun = true,
|
|
2288
|
+
cleanupStaleHubsApply = false,
|
|
1867
2289
|
json = false,
|
|
1868
2290
|
} = options;
|
|
1869
2291
|
const report = {
|
|
@@ -1873,6 +2295,7 @@ async function cmdDoctor(options = {}) {
|
|
|
1873
2295
|
actions: [],
|
|
1874
2296
|
hook_coverage: { total: 0, registered: 0, missing: [] },
|
|
1875
2297
|
fsmonitorDaemons: { stale: 0, killed: 0 },
|
|
2298
|
+
hubServers: { detached: 0, stale: 0, activeHealthy: null },
|
|
1876
2299
|
issue_count: 0,
|
|
1877
2300
|
};
|
|
1878
2301
|
|
|
@@ -3232,6 +3655,96 @@ async function cmdDoctor(options = {}) {
|
|
|
3232
3655
|
}
|
|
3233
3656
|
}
|
|
3234
3657
|
|
|
3658
|
+
// 12.4. detached tfx-hub/server.mjs 누적 감지 및 opt-in 정리
|
|
3659
|
+
section("Hub Servers");
|
|
3660
|
+
try {
|
|
3661
|
+
const hubReport = await inspectDetachedHubProcesses();
|
|
3662
|
+
report.hubServers = {
|
|
3663
|
+
detached: hubReport.hubs.length,
|
|
3664
|
+
stale: hubReport.staleCandidates.length,
|
|
3665
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3666
|
+
expectedVersion: hubReport.expectedVersion,
|
|
3667
|
+
hubs: hubReport.hubs.map((hub) => ({
|
|
3668
|
+
pid: hub.pid,
|
|
3669
|
+
ppid: hub.ppid,
|
|
3670
|
+
version: hub.version,
|
|
3671
|
+
uptime: hub.uptime,
|
|
3672
|
+
ports: hub.ports,
|
|
3673
|
+
established: hub.established,
|
|
3674
|
+
rssKb: hub.rssKb,
|
|
3675
|
+
activeHealthy: hub.activeHealthy,
|
|
3676
|
+
staleCandidate: hub.staleCandidate,
|
|
3677
|
+
})),
|
|
3678
|
+
};
|
|
3679
|
+
addDoctorCheck(report, {
|
|
3680
|
+
name: "hub-server-processes",
|
|
3681
|
+
status: hubReport.staleCandidates.length > 0 ? "warning" : "ok",
|
|
3682
|
+
detached: hubReport.hubs.length,
|
|
3683
|
+
stale: hubReport.staleCandidates.length,
|
|
3684
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3685
|
+
});
|
|
3686
|
+
|
|
3687
|
+
if (hubReport.hubs.length === 0) {
|
|
3688
|
+
ok("PPID=1 hub/server.mjs 없음");
|
|
3689
|
+
} else {
|
|
3690
|
+
for (const hub of hubReport.hubs) {
|
|
3691
|
+
const portLabel = hub.ports.length > 0 ? hub.ports.join(",") : "?";
|
|
3692
|
+
const versionLabel = hub.version || "unknown";
|
|
3693
|
+
const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
|
|
3694
|
+
const activeLabel = hub.activeHealthy
|
|
3695
|
+
? " active healthy excluded"
|
|
3696
|
+
: " stale candidate";
|
|
3697
|
+
const line = `PID=${hub.pid} PPID=${hub.ppid} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
|
|
3698
|
+
if (hub.activeHealthy) ok(line);
|
|
3699
|
+
else warn(line);
|
|
3700
|
+
}
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
if (cleanupStaleHubs) {
|
|
3704
|
+
const cleanupResult = await cleanupDetachedHubProcesses({
|
|
3705
|
+
hubs: hubReport.hubs,
|
|
3706
|
+
activeHealthy: hubReport.activeHealthy,
|
|
3707
|
+
dryRun: cleanupStaleHubsDryRun,
|
|
3708
|
+
apply: cleanupStaleHubsApply,
|
|
3709
|
+
});
|
|
3710
|
+
report.actions.push({
|
|
3711
|
+
type: "cleanup-stale-hubs",
|
|
3712
|
+
status: cleanupResult.failed > 0 ? "failed" : "ok",
|
|
3713
|
+
dryRun: cleanupResult.dryRun,
|
|
3714
|
+
retired: cleanupResult.retired,
|
|
3715
|
+
skipped: cleanupResult.skipped,
|
|
3716
|
+
excluded: cleanupResult.excluded,
|
|
3717
|
+
failed: cleanupResult.failed,
|
|
3718
|
+
});
|
|
3719
|
+
for (const result of cleanupResult.results) {
|
|
3720
|
+
if (result.action === "excluded-active") {
|
|
3721
|
+
ok(`active healthy hub excluded: PID=${result.pid}`);
|
|
3722
|
+
} else if (result.action === "dry-run-skip") {
|
|
3723
|
+
info(`dry-run skip stale hub: PID=${result.pid}`);
|
|
3724
|
+
} else if (result.action === "retired") {
|
|
3725
|
+
ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
|
|
3726
|
+
} else {
|
|
3727
|
+
fail(`stale hub cleanup failed: PID=${result.pid}`);
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
issues += cleanupResult.failed;
|
|
3731
|
+
if (cleanupResult.dryRun && hubReport.staleCandidates.length > 0) {
|
|
3732
|
+
issues += hubReport.staleCandidates.length;
|
|
3733
|
+
}
|
|
3734
|
+
} else if (hubReport.staleCandidates.length > 0) {
|
|
3735
|
+
info("정리: tfx doctor --cleanup-stale-hubs --dry-run|--apply");
|
|
3736
|
+
issues += hubReport.staleCandidates.length;
|
|
3737
|
+
}
|
|
3738
|
+
} catch (error) {
|
|
3739
|
+
addDoctorCheck(report, {
|
|
3740
|
+
name: "hub-server-processes",
|
|
3741
|
+
status: "warning",
|
|
3742
|
+
error: error.message,
|
|
3743
|
+
});
|
|
3744
|
+
warn(`hub/server.mjs 검사 실패: ${error.message}`);
|
|
3745
|
+
issues++;
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3235
3748
|
// 12.5. 고아 node.exe 프로세스 정리 (Windows)
|
|
3236
3749
|
section("Orphan Processes");
|
|
3237
3750
|
if (process.platform === "win32") {
|
|
@@ -6012,7 +6525,28 @@ async function main() {
|
|
|
6012
6525
|
const fix = cmdArgs.includes("--fix");
|
|
6013
6526
|
const reset = cmdArgs.includes("--reset");
|
|
6014
6527
|
const purgeLogs = cmdArgs.includes("--purge-logs");
|
|
6015
|
-
|
|
6528
|
+
const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
|
|
6529
|
+
const cleanupApply = cmdArgs.includes("--apply");
|
|
6530
|
+
const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
|
|
6531
|
+
if (cleanupStaleHubs && cleanupApply && cmdArgs.includes("--dry-run")) {
|
|
6532
|
+
throw createCliError(
|
|
6533
|
+
"--cleanup-stale-hubs 에서는 --dry-run 과 --apply 중 하나만 지정하세요",
|
|
6534
|
+
{
|
|
6535
|
+
exitCode: EXIT_ARG_ERROR,
|
|
6536
|
+
reason: "argError",
|
|
6537
|
+
fix: "tfx doctor --cleanup-stale-hubs --dry-run",
|
|
6538
|
+
},
|
|
6539
|
+
);
|
|
6540
|
+
}
|
|
6541
|
+
await cmdDoctor({
|
|
6542
|
+
fix,
|
|
6543
|
+
reset,
|
|
6544
|
+
purgeLogs,
|
|
6545
|
+
cleanupStaleHubs,
|
|
6546
|
+
cleanupStaleHubsDryRun: cleanupDryRun,
|
|
6547
|
+
cleanupStaleHubsApply: cleanupApply,
|
|
6548
|
+
json: JSON_OUTPUT,
|
|
6549
|
+
});
|
|
6016
6550
|
return;
|
|
6017
6551
|
}
|
|
6018
6552
|
case "mcp":
|
|
@@ -6286,8 +6820,20 @@ ${s.options.map((o) => ` ${DIM}${o.name.padEnd(16)}${RESET} ${GRAY}${o.descri
|
|
|
6286
6820
|
}
|
|
6287
6821
|
}
|
|
6288
6822
|
|
|
6289
|
-
|
|
6290
|
-
|
|
6291
|
-
|
|
6292
|
-
|
|
6823
|
+
function isMainModule() {
|
|
6824
|
+
if (!process.argv[1]) return false;
|
|
6825
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
6826
|
+
try {
|
|
6827
|
+
return realpathSync(process.argv[1]) === modulePath;
|
|
6828
|
+
} catch {
|
|
6829
|
+
return resolve(process.argv[1]) === modulePath;
|
|
6830
|
+
}
|
|
6831
|
+
}
|
|
6832
|
+
|
|
6833
|
+
if (isMainModule()) {
|
|
6834
|
+
try {
|
|
6835
|
+
await main();
|
|
6836
|
+
} catch (error) {
|
|
6837
|
+
handleFatalError(error, { json: JSON_OUTPUT });
|
|
6838
|
+
}
|
|
6293
6839
|
}
|
package/hub/team/session.mjs
CHANGED
|
@@ -8,8 +8,9 @@ import {
|
|
|
8
8
|
capturePsmuxPane,
|
|
9
9
|
configurePsmuxKeybindings,
|
|
10
10
|
createPsmuxSession,
|
|
11
|
+
getMultiplexerType,
|
|
11
12
|
getPsmuxSessionAttachedCount,
|
|
12
|
-
|
|
13
|
+
hasMultiplexer,
|
|
13
14
|
killPsmuxSession,
|
|
14
15
|
listPsmuxSessions,
|
|
15
16
|
psmuxExec,
|
|
@@ -68,6 +69,38 @@ function hasGitBashTmux() {
|
|
|
68
69
|
}
|
|
69
70
|
}
|
|
70
71
|
|
|
72
|
+
function getCommandVersion(command) {
|
|
73
|
+
const r = spawnSync(command, ["-V"], {
|
|
74
|
+
encoding: "utf8",
|
|
75
|
+
timeout: 3000,
|
|
76
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
77
|
+
windowsHide: true,
|
|
78
|
+
});
|
|
79
|
+
if ((r.status ?? 1) !== 0) return null;
|
|
80
|
+
return `${r.stdout || ""}${r.stderr || ""}`.trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isPsmuxVersion(command) {
|
|
84
|
+
try {
|
|
85
|
+
return /\bpsmux\b/i.test(getCommandVersion(command) || "");
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPsmuxCommandName(command) {
|
|
92
|
+
const name = String(command || "")
|
|
93
|
+
.replace(/\\/g, "/")
|
|
94
|
+
.split("/")
|
|
95
|
+
.pop()
|
|
96
|
+
.toLowerCase();
|
|
97
|
+
return name === "psmux" || name === "psmux.exe" || name === "psmux.cmd";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hasLiteralPsmuxBinary(command = process.env.PSMUX_BIN || "psmux") {
|
|
101
|
+
return isPsmuxVersion(command);
|
|
102
|
+
}
|
|
103
|
+
|
|
71
104
|
/**
|
|
72
105
|
* 터미널 멀티플렉서 감지 (결과 캐싱 — 프로세스 수명 동안 불변)
|
|
73
106
|
* @returns {'tmux'|'git-bash-tmux'|'wsl-tmux'|'psmux'|null}
|
|
@@ -75,7 +108,26 @@ function hasGitBashTmux() {
|
|
|
75
108
|
let _cachedMux;
|
|
76
109
|
export function detectMultiplexer() {
|
|
77
110
|
if (_cachedMux !== undefined) return _cachedMux;
|
|
78
|
-
|
|
111
|
+
|
|
112
|
+
if (process.platform !== "win32") {
|
|
113
|
+
const primaryMux = getMultiplexerType();
|
|
114
|
+
if (primaryMux === "tmux" && hasMultiplexer()) {
|
|
115
|
+
_cachedMux = "tmux";
|
|
116
|
+
return _cachedMux;
|
|
117
|
+
}
|
|
118
|
+
if (hasTmux()) {
|
|
119
|
+
_cachedMux = "tmux";
|
|
120
|
+
return _cachedMux;
|
|
121
|
+
}
|
|
122
|
+
if (hasLiteralPsmuxBinary()) {
|
|
123
|
+
_cachedMux = "psmux";
|
|
124
|
+
return _cachedMux;
|
|
125
|
+
}
|
|
126
|
+
_cachedMux = null;
|
|
127
|
+
return _cachedMux;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (hasMultiplexer() && isPsmuxCommandName(getMultiplexerType())) {
|
|
79
131
|
_cachedMux = "psmux";
|
|
80
132
|
return _cachedMux;
|
|
81
133
|
}
|