@triflux/core 10.50.1 → 10.51.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.
Files changed (90) hide show
  1. package/hooks/agy-session-hook.mjs +11 -56
  2. package/hooks/codex-session-hook.mjs +11 -101
  3. package/hub/bridge.mjs +1 -79
  4. package/hub/cli-adapter-base.mjs +12 -90
  5. package/hub/codex-adapter.mjs +0 -1
  6. package/hub/codex-preflight.mjs +5 -28
  7. package/hub/index.mjs +3 -9
  8. package/hub/lib/cto-env.mjs +0 -23
  9. package/hub/lib/known-errors.json +2 -2
  10. package/hub/lib/process-utils.mjs +0 -123
  11. package/hub/lib/spawn-trace.mjs +3 -2
  12. package/hub/lib/timeout-defaults.mjs +0 -1
  13. package/hub/paths.mjs +0 -6
  14. package/hub/pipeline/index.mjs +0 -190
  15. package/hub/pipeline/transitions.mjs +0 -10
  16. package/hub/reflexion.mjs +1 -84
  17. package/hub/role-contract.mjs +0 -2
  18. package/hud/cli-policy.mjs +0 -26
  19. package/hud/colors.mjs +0 -13
  20. package/hud/constants.mjs +7 -153
  21. package/hud/context-monitor.mjs +3 -64
  22. package/hud/hud-qos-status.mjs +54 -264
  23. package/hud/providers/claude.mjs +4 -16
  24. package/hud/providers/gemini.mjs +83 -689
  25. package/hud/renderers.mjs +59 -367
  26. package/hud/terminal.mjs +1 -63
  27. package/hud/utils.mjs +2 -26
  28. package/package.json +1 -4
  29. package/scripts/lib/agent-route-policy.mjs +162 -14
  30. package/scripts/lib/claudemd-scanner.mjs +2 -199
  31. package/scripts/lib/cli-claude.mjs +0 -2
  32. package/scripts/lib/context.mjs +3 -19
  33. package/scripts/lib/env-probe.mjs +5 -64
  34. package/scripts/lib/gemini-profiles.mjs +2 -69
  35. package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
  36. package/scripts/lib/machine-profile.mjs +3 -17
  37. package/scripts/lib/mcp-filter.mjs +109 -53
  38. package/scripts/lib/mcp-health.mjs +2 -2
  39. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  40. package/scripts/lib/psmux-info.mjs +25 -104
  41. package/scripts/lib/session-presence.mjs +1 -1
  42. package/scripts/lib/skill-template.mjs +0 -271
  43. package/hub/codex-compat.mjs +0 -12
  44. package/hub/dynamic-routing-engine.mjs +0 -511
  45. package/hub/fullcycle.mjs +0 -113
  46. package/hub/gemini-adapter.mjs +0 -174
  47. package/hub/intent.mjs +0 -321
  48. package/hub/lib/mcp-response-cache.mjs +0 -239
  49. package/hub/lib/path-utils.mjs +0 -165
  50. package/hub/lib/risk-tier.mjs +0 -53
  51. package/hub/lib/ssh-command.mjs +0 -231
  52. package/hub/lib/staged-file-detect.mjs +0 -43
  53. package/hub/lib/tfx-route-args.mjs +0 -353
  54. package/hub/lib/timeline-adapter.mjs +0 -190
  55. package/hub/pipeline/gates/confidence.mjs +0 -75
  56. package/hub/pipeline/gates/consensus.mjs +0 -106
  57. package/hub/pipeline/gates/index.mjs +0 -9
  58. package/hub/pipeline/gates/selfcheck.mjs +0 -120
  59. package/hub/quality/deslop.mjs +0 -274
  60. package/hub/research.mjs +0 -155
  61. package/hub/role-control-events.mjs +0 -113
  62. package/hub/routing/complexity.mjs +0 -269
  63. package/hub/routing/index.mjs +0 -122
  64. package/hub/routing/q-learning.mjs +0 -387
  65. package/hub/routing-snapshot.mjs +0 -261
  66. package/hub/token-mode.mjs +0 -228
  67. package/hud/mission-board.mjs +0 -56
  68. package/hud/providers/cto.mjs +0 -76
  69. package/mesh/index.mjs +0 -69
  70. package/mesh/mesh-budget.mjs +0 -137
  71. package/mesh/mesh-heartbeat.mjs +0 -100
  72. package/mesh/mesh-protocol.mjs +0 -96
  73. package/mesh/mesh-queue.mjs +0 -163
  74. package/mesh/mesh-registry.mjs +0 -78
  75. package/mesh/mesh-router.mjs +0 -77
  76. package/scripts/lib/agent-json.mjs +0 -27
  77. package/scripts/lib/async.mjs +0 -174
  78. package/scripts/lib/cli-agy.mjs +0 -85
  79. package/scripts/lib/cli-codex.mjs +0 -62
  80. package/scripts/lib/cli-gemini.mjs +0 -37
  81. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  82. package/scripts/lib/env.mjs +0 -14
  83. package/scripts/lib/hub.mjs +0 -48
  84. package/scripts/lib/pid.mjs +0 -63
  85. package/scripts/lib/quota.mjs +0 -47
  86. package/scripts/lib/skill-state.mjs +0 -220
  87. package/scripts/lib/team.mjs +0 -75
  88. package/scripts/lib/timeout.mjs +0 -66
  89. package/scripts/lib/tmp.mjs +0 -26
  90. package/scripts/lib/toml.mjs +0 -132
@@ -1,239 +0,0 @@
1
- // hub/lib/mcp-response-cache.mjs — MCP 응답 캐시 (LRU + TTL)
2
- // Deep 스킬에서 3개 모델이 같은 context7 문서를 요청하면 1번만 호출, 나머지는 캐시 hit.
3
- // 파일 기반 영속 + 메모리 기반 핫캐시 이중 구조.
4
-
5
- import { createHash } from "node:crypto";
6
- import {
7
- existsSync,
8
- mkdirSync,
9
- readdirSync,
10
- readFileSync,
11
- unlinkSync,
12
- writeFileSync,
13
- } from "node:fs";
14
- import { homedir } from "node:os";
15
- import { join } from "node:path";
16
-
17
- const DEFAULT_CACHE_DIR = join(homedir(), ".triflux", "mcp-cache");
18
- const DEFAULT_MAX_ENTRIES = 500;
19
-
20
- // 서버별 TTL (ms)
21
- const SERVER_TTL = Object.freeze({
22
- context7: 60 * 60 * 1000, // 1시간 — 라이브러리 문서는 자주 안 바뀜
23
- "brave-search": 10 * 60 * 1000, // 10분 — 웹 검색
24
- exa: 10 * 60 * 1000, // 10분
25
- tavily: 10 * 60 * 1000, // 10분
26
- serena: 5 * 60 * 1000, // 5분 — 코드 분석 (코드 변경 가능)
27
- jira: 2 * 60 * 1000, // 2분 — 이슈 상태 자주 변경
28
- notion: 5 * 60 * 1000, // 5분
29
- _default: 5 * 60 * 1000, // 기본 5분
30
- });
31
-
32
- function hashKey(server, method, params) {
33
- const payload = JSON.stringify({ server, method, params });
34
- return createHash("sha256").update(payload).digest("hex").slice(0, 16);
35
- }
36
-
37
- /**
38
- * MCP 응답 캐시 생성.
39
- * @param {object} [opts]
40
- * @param {string} [opts.cacheDir] — 캐시 디렉토리
41
- * @param {number} [opts.maxEntries] — 최대 엔트리 수 (LRU)
42
- * @returns {McpResponseCache}
43
- */
44
- export function createMcpResponseCache(opts = {}) {
45
- const cacheDir = opts.cacheDir || DEFAULT_CACHE_DIR;
46
- const maxEntries = opts.maxEntries || DEFAULT_MAX_ENTRIES;
47
-
48
- mkdirSync(cacheDir, { recursive: true });
49
-
50
- // 메모리 핫캐시 (Map — insertion order = LRU)
51
- const hot = new Map();
52
-
53
- let hits = 0;
54
- let misses = 0;
55
-
56
- function getTtl(server) {
57
- return SERVER_TTL[server] || SERVER_TTL._default;
58
- }
59
-
60
- function getCachePath(key) {
61
- return join(cacheDir, `${key}.json`);
62
- }
63
-
64
- /**
65
- * 캐시 조회.
66
- * @param {string} server — MCP 서버 이름
67
- * @param {string} method — MCP 메서드
68
- * @param {*} params — 요청 파라미터
69
- * @returns {{ hit: boolean, data?: * }}
70
- */
71
- function get(server, method, params) {
72
- const key = hashKey(server, method, params);
73
- const ttl = getTtl(server);
74
-
75
- // 1) 메모리 핫캐시
76
- if (hot.has(key)) {
77
- const entry = hot.get(key);
78
- if (Date.now() - entry.ts < ttl) {
79
- hits++;
80
- // LRU: 삭제 후 재삽입 (Map은 insertion order)
81
- hot.delete(key);
82
- hot.set(key, entry);
83
- return { hit: true, data: entry.data };
84
- }
85
- hot.delete(key);
86
- }
87
-
88
- // 2) 파일 캐시
89
- const path = getCachePath(key);
90
- if (existsSync(path)) {
91
- try {
92
- const entry = JSON.parse(readFileSync(path, "utf8"));
93
- if (Date.now() - entry.ts < ttl) {
94
- hits++;
95
- hot.set(key, entry);
96
- evictIfNeeded();
97
- return { hit: true, data: entry.data };
98
- }
99
- // expired — 삭제
100
- try {
101
- unlinkSync(path);
102
- } catch {
103
- /* ignore */
104
- }
105
- } catch {
106
- /* corrupt file */
107
- }
108
- }
109
-
110
- misses++;
111
- return { hit: false };
112
- }
113
-
114
- /**
115
- * 캐시 저장.
116
- * @param {string} server
117
- * @param {string} method
118
- * @param {*} params
119
- * @param {*} data — 응답 데이터
120
- */
121
- function set(server, method, params, data) {
122
- const key = hashKey(server, method, params);
123
- const entry = { ts: Date.now(), server, method, data };
124
-
125
- // 메모리
126
- hot.set(key, entry);
127
- evictIfNeeded();
128
-
129
- // 파일
130
- try {
131
- writeFileSync(getCachePath(key), JSON.stringify(entry), "utf8");
132
- } catch {
133
- /* best-effort */
134
- }
135
- }
136
-
137
- function evictIfNeeded() {
138
- while (hot.size > maxEntries) {
139
- const oldest = hot.keys().next().value;
140
- hot.delete(oldest);
141
- }
142
- }
143
-
144
- /**
145
- * 만료된 파일 캐시 정리.
146
- * @returns {number} 삭제된 파일 수
147
- */
148
- function prune() {
149
- let pruned = 0;
150
- try {
151
- for (const name of readdirSync(cacheDir)) {
152
- if (!name.endsWith(".json")) continue;
153
- const path = join(cacheDir, name);
154
- try {
155
- const entry = JSON.parse(readFileSync(path, "utf8"));
156
- const ttl = getTtl(entry.server || "_default");
157
- if (Date.now() - entry.ts >= ttl) {
158
- unlinkSync(path);
159
- pruned++;
160
- }
161
- } catch {
162
- // corrupt — 삭제
163
- try {
164
- unlinkSync(path);
165
- pruned++;
166
- } catch {
167
- /* ignore */
168
- }
169
- }
170
- }
171
- } catch {
172
- /* ignore */
173
- }
174
- return pruned;
175
- }
176
-
177
- /**
178
- * 캐시 통계.
179
- * @returns {{ hits, misses, hotSize, diskFiles, hitRate }}
180
- */
181
- function stats() {
182
- let diskFiles = 0;
183
- try {
184
- diskFiles = readdirSync(cacheDir).filter((n) =>
185
- n.endsWith(".json"),
186
- ).length;
187
- } catch {
188
- /* ignore */
189
- }
190
-
191
- const total = hits + misses;
192
- return {
193
- hits,
194
- misses,
195
- hotSize: hot.size,
196
- diskFiles,
197
- hitRate: total > 0 ? `${((hits / total) * 100).toFixed(1)}%` : "0%",
198
- };
199
- }
200
-
201
- /** 전체 캐시 초기화. */
202
- function clear() {
203
- hot.clear();
204
- try {
205
- for (const name of readdirSync(cacheDir)) {
206
- if (name.endsWith(".json")) {
207
- try {
208
- unlinkSync(join(cacheDir, name));
209
- } catch {
210
- /* ignore */
211
- }
212
- }
213
- }
214
- } catch {
215
- /* ignore */
216
- }
217
- hits = 0;
218
- misses = 0;
219
- }
220
-
221
- return Object.freeze({
222
- get,
223
- set,
224
- prune,
225
- stats,
226
- clear,
227
- get cacheDir() {
228
- return cacheDir;
229
- },
230
- });
231
- }
232
-
233
- // 싱글톤 인스턴스 (Hub 프로세스 내에서 공유)
234
- let _instance = null;
235
-
236
- export function getMcpResponseCache(opts) {
237
- if (!_instance) _instance = createMcpResponseCache(opts);
238
- return _instance;
239
- }
@@ -1,165 +0,0 @@
1
- // hub/lib/path-utils.mjs — Windows/POSIX 경로 변환 유틸리티
2
-
3
- /**
4
- * Windows 경로를 Git Bash 스타일 POSIX 경로로 변환한다.
5
- * C:\foo\bar → /c/foo/bar
6
- * 이미 POSIX 경로면 그대로 반환.
7
- * null/undefined → 빈 문자열
8
- * @param {string|null|undefined} windowsPath
9
- * @returns {string}
10
- */
11
- export function toPosixPath(windowsPath) {
12
- if (windowsPath == null) return "";
13
- const p = String(windowsPath);
14
- if (!p) return "";
15
-
16
- // 이미 POSIX 경로 (/ 로 시작하거나 드라이브 레터 없음)
17
- const winDriveMatch = p.match(/^([A-Za-z]):[/\\](.*)/);
18
- if (!winDriveMatch) {
19
- // 백슬래시만 있는 상대 경로도 forward slash로 변환
20
- return p.replace(/\\/g, "/");
21
- }
22
-
23
- const drive = winDriveMatch[1].toLowerCase();
24
- const rest = winDriveMatch[2].replace(/\\/g, "/");
25
- return `/${drive}/${rest}`;
26
- }
27
-
28
- /**
29
- * POSIX 경로(Git Bash 스타일)를 Windows 경로로 변환한다.
30
- * /c/foo/bar → C:\foo\bar
31
- * 이미 Windows 경로면 그대로 반환.
32
- * null/undefined → 빈 문자열
33
- * @param {string|null|undefined} posixPath
34
- * @returns {string}
35
- */
36
- export function toWindowsPath(posixPath) {
37
- if (posixPath == null) return "";
38
- const p = String(posixPath);
39
- if (!p) return "";
40
-
41
- // 이미 Windows 경로
42
- if (/^[A-Za-z]:/.test(p)) {
43
- return p.replace(/\//g, "\\");
44
- }
45
-
46
- // Git Bash 스타일: /c/foo/bar
47
- const gitBashMatch = p.match(/^\/([a-zA-Z])(\/.*)?$/);
48
- if (gitBashMatch) {
49
- const drive = gitBashMatch[1].toUpperCase();
50
- const rest = gitBashMatch[2] ? gitBashMatch[2].replace(/\//g, "\\") : "\\";
51
- return `${drive}:${rest}`;
52
- }
53
-
54
- return p;
55
- }
56
-
57
- /**
58
- * 현재 OS에 맞게 경로를 정규화한다.
59
- * win32: backslash, 그 외: forward slash
60
- * @param {string|null|undefined} p
61
- * @returns {string}
62
- */
63
- export function normalizePath(p) {
64
- if (p == null) return "";
65
- const str = String(p);
66
- if (process.platform === "win32") {
67
- return str.replace(/\//g, "\\");
68
- }
69
- return str.replace(/\\/g, "/");
70
- }
71
-
72
- /**
73
- * 쉘 타입에 맞는 경로로 변환한다.
74
- * @param {string|null|undefined} path
75
- * @param {'git-bash'|'wsl'|'cmd'|'powershell'} shellType
76
- * @returns {string}
77
- */
78
- export function resolveShellPath(path, shellType) {
79
- if (path == null) return "";
80
- const p = String(path);
81
-
82
- switch (shellType) {
83
- case "git-bash":
84
- return toPosixPath(p);
85
-
86
- case "wsl": {
87
- // Git Bash 경로를 먼저 Windows로 변환 후 WSL 형식으로
88
- const winPath = /^[A-Za-z]:/.test(p) ? p : toWindowsPath(p);
89
- const wslDriveMatch = winPath.match(/^([A-Za-z]):[/\\](.*)/);
90
- if (wslDriveMatch) {
91
- const drive = wslDriveMatch[1].toLowerCase();
92
- const rest = wslDriveMatch[2].replace(/\\/g, "/");
93
- return rest ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
94
- }
95
- // 이미 /mnt/ 형식이면 그대로
96
- if (p.startsWith("/mnt/")) return p;
97
- return p.replace(/\\/g, "/");
98
- }
99
-
100
- case "cmd":
101
- case "powershell":
102
- return toWindowsPath(p);
103
-
104
- default:
105
- return p;
106
- }
107
- }
108
-
109
- /**
110
- * 환경 변수와 플랫폼 정보로 현재 쉘 타입을 추론한다.
111
- * @returns {'git-bash'|'wsl'|'cmd'|'powershell'|'unix'}
112
- */
113
- export function detectShellType() {
114
- // WSL 감지: WSL_DISTRO_NAME 또는 WSLENV
115
- if (process.env.WSL_DISTRO_NAME || process.env.WSLENV) {
116
- return "wsl";
117
- }
118
-
119
- // Windows 플랫폼
120
- if (process.platform === "win32") {
121
- const shell = process.env.SHELL || "";
122
- const term = process.env.TERM || "";
123
- const msystem = process.env.MSYSTEM || "";
124
-
125
- // Git Bash 감지: SHELL=/usr/bin/bash + MSYSTEM=MINGW64 등
126
- if (
127
- shell.includes("bash") ||
128
- msystem.startsWith("MINGW") ||
129
- msystem.startsWith("MSYS") ||
130
- term === "xterm"
131
- ) {
132
- return "git-bash";
133
- }
134
-
135
- // PowerShell 감지
136
- if (process.env.PSModulePath || process.env.PSHOME) {
137
- return "powershell";
138
- }
139
-
140
- return "cmd";
141
- }
142
-
143
- // 비-Windows
144
- return "unix";
145
- }
146
-
147
- /**
148
- * WSL 경로 여부를 확인한다 (/mnt/ 시작).
149
- * @param {string|null|undefined} p
150
- * @returns {boolean}
151
- */
152
- export function isWslPath(p) {
153
- if (p == null) return false;
154
- return String(p).startsWith("/mnt/");
155
- }
156
-
157
- /**
158
- * Git Bash 경로 여부를 확인한다 (/c/ 또는 /d/ 등 단일 소문자 드라이브 레터).
159
- * @param {string|null|undefined} p
160
- * @returns {boolean}
161
- */
162
- export function isGitBashPath(p) {
163
- if (p == null) return false;
164
- return /^\/[a-zA-Z](\/|$)/.test(String(p));
165
- }
@@ -1,53 +0,0 @@
1
- export const HIGH_PATTERNS = Object.freeze([
2
- /^hub\//,
3
- /^scripts\//,
4
- /^\.claude\/rules\//,
5
- /^bin\//,
6
- /^\.github\//,
7
- ]);
8
-
9
- export const MEDIUM_PATTERNS = Object.freeze([
10
- /package\.json$/,
11
- /\.ya?ml$/,
12
- /\.toml$/,
13
- /^config\//,
14
- /^hooks\//,
15
- ]);
16
-
17
- export const LOW_PATTERNS = Object.freeze([/\.md$/, /\.txt$/, /\.test\./]);
18
-
19
- export function classifyRiskTier({ changedFiles }) {
20
- const files = Array.isArray(changedFiles)
21
- ? changedFiles.filter((file) => typeof file === "string" && file.length > 0)
22
- : [];
23
-
24
- if (files.length === 0) {
25
- return "low";
26
- }
27
-
28
- if (matchesAny(files, HIGH_PATTERNS)) {
29
- return "high";
30
- }
31
-
32
- if (files.length > 1) {
33
- return "medium";
34
- }
35
-
36
- if (matchesAny(files, MEDIUM_PATTERNS)) {
37
- return "medium";
38
- }
39
-
40
- if (matchesPattern(files[0], LOW_PATTERNS)) {
41
- return "low";
42
- }
43
-
44
- return "medium";
45
- }
46
-
47
- function matchesAny(files, patterns) {
48
- return files.some((file) => matchesPattern(file, patterns));
49
- }
50
-
51
- function matchesPattern(file, patterns) {
52
- return patterns.some((pattern) => pattern.test(file));
53
- }
@@ -1,231 +0,0 @@
1
- // hub/lib/ssh-command.mjs — OS-aware SSH command builder
2
- // PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
3
- // 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
4
-
5
- import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
6
-
7
- /** hosts.json 캐시 (프로세스 수명 동안 유지) */
8
- let hostsCache = null;
9
- let hostsCacheRoot = null;
10
-
11
- /**
12
- * hosts.json을 로드하여 캐시한다.
13
- * @param {string} [repoRoot] — 프로젝트 루트 경로
14
- */
15
- function loadHostsCache(repoRoot) {
16
- const cacheKey = repoRoot || process.cwd();
17
- if (hostsCache && hostsCacheRoot === cacheKey) return;
18
- hostsCache = readHosts(repoRoot);
19
- hostsCacheRoot = cacheKey;
20
- }
21
-
22
- /**
23
- * hosts.json에서 호스트 OS를 조회한다.
24
- * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
25
- * @param {string} [repoRoot] — 프로젝트 루트 경로
26
- * @returns {"windows"|"posix"} 호스트 OS 유형
27
- */
28
- export function detectHostOs(hostAlias, repoRoot) {
29
- loadHostsCache(repoRoot);
30
-
31
- const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
32
- if (hostCfg?.os === "windows") return "windows";
33
- if (hostCfg?.os) return "posix";
34
-
35
- // IP 주소나 unknown alias → posix 기본값
36
- return "posix";
37
- }
38
-
39
- /**
40
- * OS에 맞는 셸 쿼팅을 적용한다.
41
- * - posix: 싱글쿼트 래핑 + 내부 싱글쿼트 이스케이프
42
- * - windows: PowerShell 싱글쿼트 래핑 + 내부 싱글쿼트 더블링
43
- * @param {string} value
44
- * @param {"windows"|"posix"} os
45
- * @returns {string}
46
- */
47
- export function shellQuoteForHost(value, os) {
48
- const s = String(value);
49
- if (os === "windows") {
50
- // PowerShell: 싱글쿼트 내부에서 '' 로 이스케이프
51
- return `'${s.replace(/'/g, "''")}'`;
52
- }
53
- // POSIX: 싱글쿼트 내부에서 '\'' 로 이스케이프
54
- return `'${s.replace(/'/g, "'\\''")}'`;
55
- }
56
-
57
- /**
58
- * OS에 맞는 stderr 억제 구문을 반환한다.
59
- * @param {"windows"|"posix"} os
60
- * @returns {string} "2>$null" 또는 "2>/dev/null"
61
- */
62
- export function suppressStderr(os) {
63
- return os === "windows" ? "2>$null" : "2>/dev/null";
64
- }
65
-
66
- /**
67
- * OS에 맞는 명령 연결자를 반환한다.
68
- * @param {"windows"|"posix"} os
69
- * @returns {string} "; " (PowerShell) 또는 " && " (bash)
70
- */
71
- export function commandJoin(os) {
72
- return os === "windows" ? "; " : " && ";
73
- }
74
-
75
- /**
76
- * OS에 맞는 null 디바이스 경로를 반환한다.
77
- * @param {"windows"|"posix"} os
78
- * @returns {string}
79
- */
80
- export function nullDevice(os) {
81
- return os === "windows" ? "$null" : "/dev/null";
82
- }
83
-
84
- /**
85
- * SSH execFileSync 인자 배열을 안전하게 구성한다.
86
- * psmux 명령처럼 원격 셸에서 실행할 명령을 배열로 받아
87
- * 적절한 쿼팅을 적용한 SSH 인자 배열을 반환한다.
88
- *
89
- * @param {string} host — SSH 호스트 (user@ip 또는 alias)
90
- * @param {string[]} remoteCmd — 원격에서 실행할 명령 + 인자
91
- * @param {object} [opts]
92
- * @param {number} [opts.connectTimeout=5] — SSH 연결 타임아웃(초)
93
- * @param {boolean} [opts.keepalive=true] — ServerAliveInterval/CountMax 활성화
94
- * @param {"windows"|"posix"} [opts.os] — 호스트 OS (미지정 시 자동 감지)
95
- * @param {string} [opts.repoRoot] — hosts.json 탐색 경로
96
- * @returns {string[]} execFileSync('ssh', returnValue) 형태로 사용
97
- */
98
- export function buildSshArgs(host, remoteCmd, opts = {}) {
99
- const connectTimeout = opts.connectTimeout ?? 5;
100
- const keepalive = opts.keepalive !== false;
101
- const hostAlias = host.includes("@") ? host.split("@").pop() : host;
102
- const os = opts.os ?? detectHostOs(hostAlias, opts.repoRoot);
103
-
104
- // 명령어(첫 요소)는 쿼팅하지 않고, 인자만 쿼팅
105
- const [cmd, ...args] = remoteCmd;
106
- const quotedArgs = args.map((a) => shellQuoteForHost(a, os));
107
- const remoteCmdStr = [cmd, ...quotedArgs].join(" ");
108
-
109
- const sshArgs = [
110
- "-o",
111
- `ConnectTimeout=${connectTimeout}`,
112
- "-o",
113
- "BatchMode=yes",
114
- ];
115
-
116
- if (keepalive) {
117
- sshArgs.push("-o", "ServerAliveInterval=30", "-o", "ServerAliveCountMax=3");
118
- }
119
-
120
- sshArgs.push(host, remoteCmdStr);
121
- return sshArgs;
122
- }
123
-
124
- /**
125
- * 검증: 문자열에 호스트 OS와 맞지 않는 셸 문법이 포함되었는지 검사한다.
126
- * SSH 명령 생성 시 안전 게이트로 사용.
127
- *
128
- * @param {string} command — 원격 실행할 명령 문자열
129
- * @param {"windows"|"posix"} os — 대상 호스트 OS
130
- * @returns {{ safe: boolean, violations: string[] }}
131
- */
132
- export function validateCommandForOs(command, os) {
133
- const violations = [];
134
-
135
- if (os === "windows") {
136
- // PowerShell에서 해석 불가능한 bash 문법 검출
137
- if (/2>\/dev\/null/.test(command)) {
138
- violations.push("2>/dev/null → PowerShell에서는 2>$null 사용");
139
- }
140
- if (/\$\(/.test(command) && !/\$\(/.test(command) === false) {
141
- // $() bash substitution vs PowerShell $() — 문맥 의존
142
- // PowerShell도 $()를 사용하므로 여기선 경고만
143
- }
144
- if (/\s&&\s/.test(command)) {
145
- violations.push("&& → PowerShell에서는 ; 또는 -and 사용");
146
- }
147
- if (/\s\|\|\s/.test(command)) {
148
- violations.push("|| → PowerShell에서는 ; 또는 -or 사용");
149
- }
150
- if (/>\s*\/dev\/null/.test(command)) {
151
- violations.push(">/dev/null → PowerShell에서는 >$null 사용");
152
- }
153
- }
154
-
155
- return { safe: violations.length === 0, violations };
156
- }
157
-
158
- /**
159
- * hosts.json에서 호스트 전체 설정을 조회한다.
160
- * @param {string} hostAlias — hosts.json의 키 (e.g. "ultra4")
161
- * @param {string} [repoRoot]
162
- * @returns {object|null}
163
- */
164
- export function getHostConfig(hostAlias, repoRoot) {
165
- loadHostsCache(repoRoot);
166
- return readHost(hostAlias, repoRoot);
167
- }
168
-
169
- /**
170
- * 별칭(alias)으로 호스트 키를 찾는다.
171
- * @param {string} alias — 호스트 키 또는 aliases 배열 내 값
172
- * @param {string} [repoRoot]
173
- * @returns {string|null} 호스트 키 (e.g. "ultra4") 또는 null
174
- */
175
- export function resolveHostAlias(alias, repoRoot) {
176
- loadHostsCache(repoRoot);
177
-
178
- return resolveHost(alias, repoRoot)?.name ?? null;
179
- }
180
-
181
- /**
182
- * 특정 capability를 보유한 호스트 목록을 반환한다.
183
- * 명시적 선택 UI에서 바로 리소스 정보를 보여줄 수 있도록 specs를 평탄화한다.
184
- * @param {string} capability — e.g. "codex", "claude", "gpu"
185
- * @param {string} [repoRoot]
186
- * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
187
- */
188
- export function selectHostForCapability(capability, repoRoot) {
189
- loadHostsCache(repoRoot);
190
-
191
- const matches = [];
192
- for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
193
- if (cfg.capabilities?.includes(capability)) {
194
- matches.push({
195
- name,
196
- config: cfg,
197
- specs: {
198
- cores: cfg.specs?.cores,
199
- ram_gb: cfg.specs?.ram_gb,
200
- },
201
- });
202
- }
203
- }
204
- return matches;
205
- }
206
-
207
- /**
208
- * 특정 capability를 가진 호스트를 리소스 기준으로 정렬해 반환한다.
209
- * 자동 선택은 하지 않고, 명시적 선택 목록 정렬에만 사용한다.
210
- * @param {string} capability — e.g. "codex", "claude", "gpu"
211
- * @param {"cores"|"ram_gb"} sortBy
212
- * @param {string} [repoRoot]
213
- * @returns {Array<{ name: string, config: object, specs: { cores?: number, ram_gb?: number } }>}
214
- */
215
- export function selectHostByResources(capability, sortBy, repoRoot) {
216
- if (sortBy !== "cores" && sortBy !== "ram_gb") {
217
- throw new TypeError('sortBy must be "cores" or "ram_gb"');
218
- }
219
-
220
- return selectHostForCapability(capability, repoRoot).sort((a, b) => {
221
- const aValue = a.specs?.[sortBy] ?? -Infinity;
222
- const bValue = b.specs?.[sortBy] ?? -Infinity;
223
- return bValue - aValue;
224
- });
225
- }
226
-
227
- /** hosts.json 캐시 초기화 (테스트용) */
228
- export function resetHostsCache() {
229
- hostsCache = null;
230
- hostsCacheRoot = null;
231
- }