@triflux/remote 10.48.1 → 10.50.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/hub/server.mjs +0 -122
- package/hub/team/claude-daemon-control.mjs +54 -6
- package/hub/team/claude-transcript.mjs +240 -0
- package/hub/team/cli/commands/start/index.mjs +17 -3
- package/hub/team/cli/commands/start/parse-args.mjs +1 -0
- package/hub/team/session-context.mjs +203 -0
- package/hub/team/uds-orchestrator.mjs +63 -1
- package/hub/team/worktree-lifecycle.mjs +0 -1
- package/package.json +2 -3
- package/scripts/lib/doctor-env-checks.mjs +0 -85
- package/scripts/lib/legacy-hook-cleanup.mjs +230 -0
- package/scripts/lib/legacy-mcp-cleanup.mjs +772 -0
- package/scripts/lib/mcp-guard-engine.mjs +44 -271
- package/scripts/lib/mcp-manifest.mjs +2 -2
- package/hub/mac-focus.mjs +0 -94
- package/hub/mac-tray.swift +0 -144
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/public/tray.html +0 -756
- package/hub/tray-lifecycle.mjs +0 -138
- package/hub/tray-runtime.mjs +0 -265
- package/hub/tray-state.mjs +0 -524
- package/hub/tray.mjs +0 -527
- package/scripts/lib/cross-review-utils.mjs +0 -59
- package/scripts/lib/hook-utils.mjs +0 -16
- package/scripts/lib/keyword-rules.mjs +0 -219
- package/scripts/lib/mcp-gateway-health-check.mjs +0 -224
- package/scripts/lib/mcp-gateway-servers.mjs +0 -70
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +0 -35
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
constants,
|
|
5
|
+
copyFileSync,
|
|
6
|
+
existsSync,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
realpathSync,
|
|
11
|
+
renameSync,
|
|
12
|
+
statSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
17
|
+
import { homedir, platform as osPlatform } from "node:os";
|
|
18
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
19
|
+
import { isDeepStrictEqual } from "node:util";
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const toml = require("@iarna/toml");
|
|
23
|
+
const SERVERS = [
|
|
24
|
+
["context7", 8100, "npx", ["-y", "@upstash/context7-mcp@latest"], []],
|
|
25
|
+
[
|
|
26
|
+
"brave-search",
|
|
27
|
+
8101,
|
|
28
|
+
"npx",
|
|
29
|
+
["-y", "@brave/brave-search-mcp-server"],
|
|
30
|
+
["BRAVE_API_KEY"],
|
|
31
|
+
],
|
|
32
|
+
["exa", 8102, "npx", ["-y", "exa-mcp-server"], ["EXA_API_KEY"]],
|
|
33
|
+
["tavily", 8103, "npx", ["-y", "tavily-mcp@latest"], ["TAVILY_API_KEY"]],
|
|
34
|
+
[
|
|
35
|
+
"jira",
|
|
36
|
+
8104,
|
|
37
|
+
"npx",
|
|
38
|
+
["-y", "mcp-jira-cloud@latest"],
|
|
39
|
+
["JIRA_API_TOKEN", "JIRA_EMAIL", "JIRA_INSTANCE_URL"],
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"serena",
|
|
43
|
+
8105,
|
|
44
|
+
"uvx",
|
|
45
|
+
[
|
|
46
|
+
"--from",
|
|
47
|
+
"git+https://github.com/oraios/serena",
|
|
48
|
+
"serena",
|
|
49
|
+
"start-mcp-server",
|
|
50
|
+
],
|
|
51
|
+
[],
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
"notion",
|
|
55
|
+
8106,
|
|
56
|
+
"npx",
|
|
57
|
+
["-y", "@notionhq/notion-mcp-server"],
|
|
58
|
+
["NOTION_TOKEN"],
|
|
59
|
+
],
|
|
60
|
+
[
|
|
61
|
+
"notion-guest",
|
|
62
|
+
8107,
|
|
63
|
+
"npx",
|
|
64
|
+
["-y", "@notionhq/notion-mcp-server"],
|
|
65
|
+
["NOTION_TOKEN"],
|
|
66
|
+
],
|
|
67
|
+
].map(([name, port, command, args, envVars]) => ({
|
|
68
|
+
name,
|
|
69
|
+
port,
|
|
70
|
+
command,
|
|
71
|
+
args,
|
|
72
|
+
envVars,
|
|
73
|
+
}));
|
|
74
|
+
const BY_PORT = new Map(SERVERS.map((server) => [server.port, server]));
|
|
75
|
+
const LABEL = "com.tellang.mcp-gateway";
|
|
76
|
+
const TASK = "MCP Gateway";
|
|
77
|
+
const UNIT = "mcp-gateway.service";
|
|
78
|
+
|
|
79
|
+
function defaultRun(command, args) {
|
|
80
|
+
return execFileSync(command, args, {
|
|
81
|
+
encoding: "utf8",
|
|
82
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
83
|
+
timeout: 10_000,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function fileTarget(file) {
|
|
88
|
+
const entry = lstatSync(file, { throwIfNoEntry: false });
|
|
89
|
+
if (!entry) return null;
|
|
90
|
+
const target = entry.isSymbolicLink() ? realpathSync(file) : file;
|
|
91
|
+
if (!statSync(target).isFile())
|
|
92
|
+
throw new Error(`${file}: 일반 파일이 아닙니다`);
|
|
93
|
+
return target;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function ownedUrl(value) {
|
|
97
|
+
if (typeof value !== "string") return null;
|
|
98
|
+
let url;
|
|
99
|
+
try {
|
|
100
|
+
url = new URL(value);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (
|
|
105
|
+
url.protocol !== "http:" ||
|
|
106
|
+
!["127.0.0.1", "localhost", "[::1]"].includes(url.hostname)
|
|
107
|
+
)
|
|
108
|
+
return null;
|
|
109
|
+
const server = BY_PORT.get(Number(url.port));
|
|
110
|
+
if (!server) return null;
|
|
111
|
+
return {
|
|
112
|
+
server,
|
|
113
|
+
valid:
|
|
114
|
+
!url.username &&
|
|
115
|
+
!url.password &&
|
|
116
|
+
["/mcp", "/sse"].includes(url.pathname) &&
|
|
117
|
+
!url.search &&
|
|
118
|
+
!url.hash,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function envWarning(server, home, env, warnings) {
|
|
123
|
+
const secretFiles = [
|
|
124
|
+
join(home, ".config/triflux/secrets.env"),
|
|
125
|
+
join(home, ".config/triflux/mcp-gateway.env"),
|
|
126
|
+
join(home, ".config/tfx/mcp-gateway.env"),
|
|
127
|
+
join(home, ".mcp-gateway.env"),
|
|
128
|
+
];
|
|
129
|
+
for (const name of server.envVars) {
|
|
130
|
+
if (env[name]) continue;
|
|
131
|
+
const inFile = secretFiles.some((file) => {
|
|
132
|
+
const target = fileTarget(file);
|
|
133
|
+
return (
|
|
134
|
+
target &&
|
|
135
|
+
new RegExp(`^\\s*(?:export\\s+)?${name}\\s*=`, "m").test(
|
|
136
|
+
readFileSync(target, "utf8"),
|
|
137
|
+
)
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
warnings.push(
|
|
141
|
+
`${server.name}: ${name} ${inFile ? "secrets.env에만 있음; MCP 클라이언트 환경으로 export 필요" : "값 없음; 환경변수 설정 필요"}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function patchJson(original, file, home, env, warnings) {
|
|
147
|
+
const data = JSON.parse(original);
|
|
148
|
+
if (!data || typeof data !== "object" || Array.isArray(data))
|
|
149
|
+
throw new Error(`${file}: JSON 객체가 아닙니다`);
|
|
150
|
+
const groups = [
|
|
151
|
+
data.mcpServers,
|
|
152
|
+
...Object.values(data.projects ?? {}).map((project) => project?.mcpServers),
|
|
153
|
+
];
|
|
154
|
+
let count = 0;
|
|
155
|
+
let blocked = false;
|
|
156
|
+
for (const entries of groups) {
|
|
157
|
+
if (entries === undefined) continue;
|
|
158
|
+
if (!entries || typeof entries !== "object" || Array.isArray(entries))
|
|
159
|
+
throw new Error(`${file}: mcpServers 객체가 아닙니다`);
|
|
160
|
+
for (const [name, config] of Object.entries(entries)) {
|
|
161
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
162
|
+
continue;
|
|
163
|
+
const matches = [config.url, config.httpUrl]
|
|
164
|
+
.map(ownedUrl)
|
|
165
|
+
.filter(Boolean);
|
|
166
|
+
if (!matches.length) continue;
|
|
167
|
+
const server = matches[0].server;
|
|
168
|
+
if (
|
|
169
|
+
matches.length !== 1 ||
|
|
170
|
+
name !== server.name ||
|
|
171
|
+
!matches[0].valid ||
|
|
172
|
+
config.command ||
|
|
173
|
+
config.args ||
|
|
174
|
+
config.env ||
|
|
175
|
+
config.headers ||
|
|
176
|
+
config.httpHeaders ||
|
|
177
|
+
config.auth ||
|
|
178
|
+
config.oauth ||
|
|
179
|
+
config.authProviderType ||
|
|
180
|
+
(config.type && !["http", "sse"].includes(config.type))
|
|
181
|
+
) {
|
|
182
|
+
warnings.push(
|
|
183
|
+
`${file}: ${name} gateway 항목의 소유 또는 직접 연결 형식을 확인할 수 없음`,
|
|
184
|
+
);
|
|
185
|
+
blocked = true;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
const { url, httpUrl, type, ...kept } = config;
|
|
189
|
+
entries[name] = {
|
|
190
|
+
...kept,
|
|
191
|
+
command: server.command,
|
|
192
|
+
args: server.args,
|
|
193
|
+
...(server.envVars.length
|
|
194
|
+
? {
|
|
195
|
+
env: {
|
|
196
|
+
...kept.env,
|
|
197
|
+
...Object.fromEntries(
|
|
198
|
+
server.envVars.map((key) => [key, `\${${key}}`]),
|
|
199
|
+
),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
: {}),
|
|
203
|
+
};
|
|
204
|
+
envWarning(server, home, env, warnings);
|
|
205
|
+
count++;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return {
|
|
209
|
+
output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
|
|
210
|
+
count,
|
|
211
|
+
blocked,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function patchToml(original, file, home, env, warnings) {
|
|
216
|
+
const data = toml.parse(original);
|
|
217
|
+
const lines = original.split(/(?<=\n)/);
|
|
218
|
+
const headers = [];
|
|
219
|
+
let offset = 0;
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
const match = line.match(
|
|
222
|
+
/^\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
|
|
223
|
+
);
|
|
224
|
+
if (match)
|
|
225
|
+
headers.push({
|
|
226
|
+
name: match[1] ?? match[2] ?? match[3],
|
|
227
|
+
start: offset,
|
|
228
|
+
body: offset + line.length,
|
|
229
|
+
});
|
|
230
|
+
offset += line.length;
|
|
231
|
+
}
|
|
232
|
+
const allSections = [
|
|
233
|
+
...original.matchAll(/^\[[^\r\n]+\].*(?:\r?\n|$)/gm),
|
|
234
|
+
].map((match) => match.index);
|
|
235
|
+
const edits = [];
|
|
236
|
+
let blocked = false;
|
|
237
|
+
for (const header of headers) {
|
|
238
|
+
const end =
|
|
239
|
+
allSections.find((start) => start > header.start) ?? original.length;
|
|
240
|
+
const config = data.mcp_servers?.[header.name];
|
|
241
|
+
const match = ownedUrl(config?.url);
|
|
242
|
+
if (!match) continue;
|
|
243
|
+
const { server } = match;
|
|
244
|
+
const body = original.slice(header.body, end);
|
|
245
|
+
if (
|
|
246
|
+
header.name !== server.name ||
|
|
247
|
+
!match.valid ||
|
|
248
|
+
config.headers ||
|
|
249
|
+
config.http_headers ||
|
|
250
|
+
config.env_http_headers ||
|
|
251
|
+
config.bearer_token_env_var ||
|
|
252
|
+
config.oauth ||
|
|
253
|
+
config.auth ||
|
|
254
|
+
config.command ||
|
|
255
|
+
config.args ||
|
|
256
|
+
config.env_vars ||
|
|
257
|
+
config.env ||
|
|
258
|
+
(config.transport && !["http", "sse"].includes(config.transport)) ||
|
|
259
|
+
(config.type && !["http", "sse"].includes(config.type)) ||
|
|
260
|
+
!/^\s*url\s*=\s*["'][^"']+["']\s*(?:#.*)?$/m.test(body)
|
|
261
|
+
) {
|
|
262
|
+
warnings.push(
|
|
263
|
+
`${file}: ${header.name} gateway 항목의 소유 또는 직접 연결 형식을 확인할 수 없음`,
|
|
264
|
+
);
|
|
265
|
+
blocked = true;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const kept = body.replace(
|
|
269
|
+
/^[ \t]*(?:url|transport|type)\s*=.*(?:\r?\n|$)/gm,
|
|
270
|
+
"",
|
|
271
|
+
);
|
|
272
|
+
const replacement = `${kept}${kept.endsWith("\n") || !kept ? "" : "\n"}command = ${JSON.stringify(server.command)}\nargs = ${JSON.stringify(server.args)}\nenv_vars = ${JSON.stringify(server.envVars)}\n`;
|
|
273
|
+
edits.push({ start: header.body, end, replacement });
|
|
274
|
+
delete config.url;
|
|
275
|
+
delete config.type;
|
|
276
|
+
delete config.transport;
|
|
277
|
+
Object.assign(config, {
|
|
278
|
+
command: server.command,
|
|
279
|
+
args: server.args,
|
|
280
|
+
env_vars: server.envVars,
|
|
281
|
+
});
|
|
282
|
+
envWarning(server, home, env, warnings);
|
|
283
|
+
}
|
|
284
|
+
const preflight = headers.find(
|
|
285
|
+
(header) => header.name === "tfx-gateway-preflight",
|
|
286
|
+
);
|
|
287
|
+
if (preflight) {
|
|
288
|
+
const end =
|
|
289
|
+
allSections.find((start) => start > preflight.start) ?? original.length;
|
|
290
|
+
const config = data.mcp_servers[preflight.name];
|
|
291
|
+
if (
|
|
292
|
+
config.command === "node" &&
|
|
293
|
+
config.args?.length === 1 &&
|
|
294
|
+
isLegacyScript(config.args[0], "codex-gateway-preflight.mjs") &&
|
|
295
|
+
Object.keys(config).every((key) =>
|
|
296
|
+
["command", "args", "enabled", "startup_timeout_sec"].includes(key),
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
edits.push({ start: preflight.start, end, replacement: "" });
|
|
300
|
+
delete data.mcp_servers[preflight.name];
|
|
301
|
+
} else {
|
|
302
|
+
warnings.push(`${file}: preflight 항목 소유 확인 실패`);
|
|
303
|
+
blocked = true;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
let output = original;
|
|
307
|
+
for (const edit of edits.sort((a, b) => b.start - a.start))
|
|
308
|
+
output =
|
|
309
|
+
output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
|
|
310
|
+
for (const [name, config] of Object.entries(data.mcp_servers ?? {})) {
|
|
311
|
+
if (ownedUrl(config?.url)) {
|
|
312
|
+
warnings.push(`${file}: ${name} TOML 표현의 소유 확인 실패`);
|
|
313
|
+
blocked = true;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
if (!isDeepStrictEqual(toml.parse(output), data))
|
|
318
|
+
throw new Error("TOML 구조 불일치");
|
|
319
|
+
} catch {
|
|
320
|
+
warnings.push(`${file}: 이주 결과 TOML 검증 실패`);
|
|
321
|
+
blocked = true;
|
|
322
|
+
}
|
|
323
|
+
return { output, count: edits.length, blocked };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function backupFile(file, content) {
|
|
327
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
328
|
+
let backup = `${file}.tfx-bak-${stamp}`;
|
|
329
|
+
for (let i = 1; existsSync(backup); i++)
|
|
330
|
+
backup = `${file}.tfx-bak-${stamp}-${i}`;
|
|
331
|
+
if (content === undefined)
|
|
332
|
+
copyFileSync(file, backup, constants.COPYFILE_EXCL);
|
|
333
|
+
else writeFileSync(backup, content, { flag: "wx", mode: 0o600 });
|
|
334
|
+
chmodSync(backup, 0o600);
|
|
335
|
+
return backup;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function backupStartup(home, file) {
|
|
339
|
+
const directory = join(home, ".config/triflux");
|
|
340
|
+
mkdirSync(directory, { recursive: true });
|
|
341
|
+
return backupFile(join(directory, basename(file)), readFileSync(file));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function writeAtomic(file, target, output) {
|
|
345
|
+
const backup = backupFile(file);
|
|
346
|
+
const temp = join(
|
|
347
|
+
dirname(target),
|
|
348
|
+
`.tfx-mcp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`,
|
|
349
|
+
);
|
|
350
|
+
try {
|
|
351
|
+
writeFileSync(temp, output, {
|
|
352
|
+
encoding: "utf8",
|
|
353
|
+
flag: "wx",
|
|
354
|
+
mode: statSync(target).mode,
|
|
355
|
+
});
|
|
356
|
+
renameSync(temp, target);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
try {
|
|
359
|
+
unlinkSync(temp);
|
|
360
|
+
} catch {
|
|
361
|
+
/* 임시 파일 없음 */
|
|
362
|
+
}
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
return backup;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function tryRun(run, command, args) {
|
|
369
|
+
try {
|
|
370
|
+
return { ok: true, output: String(run(command, args) ?? "") };
|
|
371
|
+
} catch (error) {
|
|
372
|
+
return {
|
|
373
|
+
ok: false,
|
|
374
|
+
error: error.message,
|
|
375
|
+
stderr: String(error.stderr ?? ""),
|
|
376
|
+
status: error.status,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isLegacyScript(value, filename = "mcp-gateway-start.mjs") {
|
|
382
|
+
if (typeof value !== "string") return false;
|
|
383
|
+
const normalized = value.replaceAll("\\", "/");
|
|
384
|
+
return (
|
|
385
|
+
/\/triflux(?:\/\.worktrees\/[^/]+|\/v?\d+\.\d+\.\d+[^/]*)?\/scripts\//.test(
|
|
386
|
+
normalized,
|
|
387
|
+
) && normalized.endsWith(`/scripts/${filename}`)
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function absent(result) {
|
|
392
|
+
if (result.ok) return false;
|
|
393
|
+
const message = `${result.error ?? ""}\n${result.stderr ?? ""}`;
|
|
394
|
+
return /Could not find service|service not found|No such process/i.test(
|
|
395
|
+
message,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function removeDarwin(home, run, warnings, uid) {
|
|
400
|
+
const plist = join(home, "Library/LaunchAgents", `${LABEL}.plist`);
|
|
401
|
+
const target = fileTarget(plist);
|
|
402
|
+
if (!target) return { attempted: false, verified: true };
|
|
403
|
+
const parsed = tryRun(run, "plutil", ["-convert", "json", "-o", "-", plist]);
|
|
404
|
+
let data;
|
|
405
|
+
try {
|
|
406
|
+
data = parsed.ok ? JSON.parse(parsed.output) : null;
|
|
407
|
+
} catch {
|
|
408
|
+
data = null;
|
|
409
|
+
}
|
|
410
|
+
if (
|
|
411
|
+
data?.Label !== LABEL ||
|
|
412
|
+
JSON.stringify(data.ProgramArguments) !==
|
|
413
|
+
JSON.stringify([
|
|
414
|
+
"/bin/bash",
|
|
415
|
+
join(home, ".local/bin/mcp-gateway-wrapper.sh"),
|
|
416
|
+
])
|
|
417
|
+
) {
|
|
418
|
+
warnings.push(`${plist}: 소유 확인 실패`);
|
|
419
|
+
return { attempted: true, verified: false };
|
|
420
|
+
}
|
|
421
|
+
const backupPath = backupStartup(home, plist);
|
|
422
|
+
const service = `gui/${uid}/${LABEL}`;
|
|
423
|
+
const before = tryRun(run, "launchctl", ["print", service]);
|
|
424
|
+
if (!before.ok && !absent(before)) {
|
|
425
|
+
warnings.push(`${LABEL}: launchctl 상태 확인 실패`);
|
|
426
|
+
return { attempted: true, verified: false, backupPath };
|
|
427
|
+
}
|
|
428
|
+
if (before.ok) {
|
|
429
|
+
const stop = tryRun(run, "launchctl", ["bootout", `gui/${uid}`, plist]);
|
|
430
|
+
const after = tryRun(run, "launchctl", ["print", service]);
|
|
431
|
+
if (!stop.ok || !absent(after)) {
|
|
432
|
+
warnings.push(`${LABEL}: launchctl bootout 또는 해제 검증 실패`);
|
|
433
|
+
return { attempted: true, verified: false, backupPath };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
const wrapper = join(home, ".local/bin/mcp-gateway-wrapper.sh");
|
|
437
|
+
const wrapperTarget = fileTarget(wrapper);
|
|
438
|
+
if (wrapperTarget) {
|
|
439
|
+
const command = readFileSync(wrapperTarget, "utf8").match(
|
|
440
|
+
/^exec\s+'[^']*\/node'\s+'([^']+)'\s*$/m,
|
|
441
|
+
);
|
|
442
|
+
if (isLegacyScript(command?.[1])) {
|
|
443
|
+
backupStartup(home, wrapper);
|
|
444
|
+
unlinkSync(wrapper);
|
|
445
|
+
} else warnings.push(`${wrapper}: 소유 확인 실패, 파일 보존`);
|
|
446
|
+
}
|
|
447
|
+
unlinkSync(plist);
|
|
448
|
+
return { attempted: true, verified: !existsSync(plist), backupPath };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function removeLinux(home, run, warnings) {
|
|
452
|
+
const unit = join(home, ".config/systemd/user", UNIT);
|
|
453
|
+
const target = fileTarget(unit);
|
|
454
|
+
if (!target) return { attempted: false, verified: true };
|
|
455
|
+
const body = readFileSync(target, "utf8");
|
|
456
|
+
if (
|
|
457
|
+
!body.includes("Description=Triflux MCP Gateway") ||
|
|
458
|
+
!isLegacyScript(
|
|
459
|
+
body.match(/^ExecStart="[^"\n]*\/node"\s+"([^"\n]+)"\s*$/m)?.[1],
|
|
460
|
+
)
|
|
461
|
+
) {
|
|
462
|
+
warnings.push(`${unit}: 소유 확인 실패`);
|
|
463
|
+
return { attempted: true, verified: false };
|
|
464
|
+
}
|
|
465
|
+
const backupPath = backupStartup(home, unit);
|
|
466
|
+
const removed = tryRun(run, "systemctl", [
|
|
467
|
+
"--user",
|
|
468
|
+
"disable",
|
|
469
|
+
"--now",
|
|
470
|
+
UNIT,
|
|
471
|
+
]);
|
|
472
|
+
const after = tryRun(run, "systemctl", [
|
|
473
|
+
"--user",
|
|
474
|
+
"show",
|
|
475
|
+
UNIT,
|
|
476
|
+
"--property=ActiveState",
|
|
477
|
+
"--property=UnitFileState",
|
|
478
|
+
]);
|
|
479
|
+
if (
|
|
480
|
+
!removed.ok ||
|
|
481
|
+
!after.ok ||
|
|
482
|
+
!/^ActiveState=(?:inactive|failed)$/m.test(after.output) ||
|
|
483
|
+
!/^UnitFileState=disabled$/m.test(after.output)
|
|
484
|
+
) {
|
|
485
|
+
warnings.push(`${UNIT}: 해제 검증 실패`);
|
|
486
|
+
return { attempted: true, verified: false, backupPath };
|
|
487
|
+
}
|
|
488
|
+
const pending = `${backupPath}.pending`;
|
|
489
|
+
renameSync(unit, pending);
|
|
490
|
+
const reload = tryRun(run, "systemctl", ["--user", "daemon-reload"]);
|
|
491
|
+
if (reload.ok) unlinkSync(pending);
|
|
492
|
+
else {
|
|
493
|
+
renameSync(pending, unit);
|
|
494
|
+
warnings.push(`${UNIT}: daemon-reload 실패, unit 복원`);
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
attempted: true,
|
|
498
|
+
verified: reload.ok && !existsSync(unit),
|
|
499
|
+
backupPath,
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function runPowerShell(run, script) {
|
|
504
|
+
return tryRun(run, "powershell", [
|
|
505
|
+
"-NoProfile",
|
|
506
|
+
"-Command",
|
|
507
|
+
"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.Encoding]::UTF8; " +
|
|
508
|
+
script,
|
|
509
|
+
]);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function removeWindows(home, run, warnings) {
|
|
513
|
+
const connect =
|
|
514
|
+
"$s=New-Object -ComObject Schedule.Service; $s.Connect(); $f=$s.GetFolder('\\'); ";
|
|
515
|
+
const queryScript =
|
|
516
|
+
connect +
|
|
517
|
+
`$tasks=@($f.GetTasks(1) | Where-Object { $_.Name -eq '${TASK}' }); if ($tasks.Count -eq 0) { 'null' } else { $t=$tasks[0]; $x=[xml]$t.Xml; @{ xml=$t.Xml; command=[string]$x.Task.Actions.Exec.Command; args=[string]$x.Task.Actions.Exec.Arguments; actions=$x.Task.Actions.ChildNodes.Count } | ConvertTo-Json -Compress }`;
|
|
518
|
+
const query = () => {
|
|
519
|
+
const result = runPowerShell(run, queryScript);
|
|
520
|
+
if (!result.ok) throw new Error(`${TASK}: 작업 상태 확인 실패`);
|
|
521
|
+
return JSON.parse(result.output);
|
|
522
|
+
};
|
|
523
|
+
const task = query();
|
|
524
|
+
if (
|
|
525
|
+
task &&
|
|
526
|
+
(!/[/\\]node\.exe$/i.test(task.command?.replace(/^"|"$/g, "") ?? "") ||
|
|
527
|
+
!isLegacyScript(task.args?.replace(/^"|"$/g, "")) ||
|
|
528
|
+
task.actions !== 1)
|
|
529
|
+
) {
|
|
530
|
+
warnings.push(`${TASK}: 작업 소유 확인 실패`);
|
|
531
|
+
return { attempted: true, verified: false };
|
|
532
|
+
}
|
|
533
|
+
let backupPath;
|
|
534
|
+
if (task) {
|
|
535
|
+
const directory = join(home, ".config/triflux");
|
|
536
|
+
mkdirSync(directory, { recursive: true });
|
|
537
|
+
backupPath = backupFile(join(directory, "legacy-mcp-task.xml"), task.xml);
|
|
538
|
+
const expected = task.xml.replaceAll("'", "''");
|
|
539
|
+
const removed = runPowerShell(
|
|
540
|
+
run,
|
|
541
|
+
connect +
|
|
542
|
+
`$t=$f.GetTask('${TASK}'); if ($t.Xml -ne '${expected}') { throw 'task changed' }; $t.Enabled=$false; if ($t.State -eq 4) { $t.Stop(0) }; $f.DeleteTask('${TASK}',0)`,
|
|
543
|
+
);
|
|
544
|
+
if (!removed.ok || query() !== null) {
|
|
545
|
+
warnings.push(`${TASK}: 작업 해제 검증 실패`);
|
|
546
|
+
return { attempted: true, verified: false, backupPath };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const link = join(
|
|
550
|
+
home,
|
|
551
|
+
"AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup/MCP Gateway.lnk",
|
|
552
|
+
);
|
|
553
|
+
const hadLink = existsSync(link);
|
|
554
|
+
if (hadLink) {
|
|
555
|
+
const quoted = link.replaceAll("'", "''");
|
|
556
|
+
const inspected = runPowerShell(
|
|
557
|
+
run,
|
|
558
|
+
`$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${quoted}'); @{command=$s.TargetPath; args=$s.Arguments} | ConvertTo-Json -Compress`,
|
|
559
|
+
);
|
|
560
|
+
const shortcut = inspected.ok ? JSON.parse(inspected.output) : null;
|
|
561
|
+
if (
|
|
562
|
+
shortcut &&
|
|
563
|
+
/[/\\]node\.exe$/i.test(shortcut.command) &&
|
|
564
|
+
isLegacyScript(shortcut.args.replace(/^"|"$/g, ""))
|
|
565
|
+
) {
|
|
566
|
+
backupStartup(home, link);
|
|
567
|
+
unlinkSync(link);
|
|
568
|
+
} else warnings.push(`${link}: startup link 소유 확인 실패`);
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
attempted: Boolean(task) || hadLink,
|
|
572
|
+
verified: !existsSync(link),
|
|
573
|
+
backupPath,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function ownedCommand(command) {
|
|
578
|
+
const tokens = command.match(/^(?:"([^"]+)"|(\S+))\s+(.+)$/);
|
|
579
|
+
if (
|
|
580
|
+
!tokens ||
|
|
581
|
+
!/(?:^|[/\\])(?:node|npm|npx)(?:\.exe)?$/i.test(tokens[1] ?? tokens[2])
|
|
582
|
+
)
|
|
583
|
+
return false;
|
|
584
|
+
if (isLegacyScript(tokens[3].replace(/^["']|["']$/g, ""))) return true;
|
|
585
|
+
return (
|
|
586
|
+
/\bsupergateway\b/.test(command) &&
|
|
587
|
+
/\s--stdio\s/.test(command) &&
|
|
588
|
+
[
|
|
589
|
+
"--outputTransport streamableHttp",
|
|
590
|
+
"--stateful",
|
|
591
|
+
"--streamableHttpPath /mcp",
|
|
592
|
+
"--healthEndpoint /healthz",
|
|
593
|
+
].every((flag) => command.includes(flag)) &&
|
|
594
|
+
SERVERS.some(
|
|
595
|
+
({ port, command: executable, args }) =>
|
|
596
|
+
new RegExp(`\\s--port\\s+${port}(?:\\s|$)`).test(command) &&
|
|
597
|
+
command.includes([executable, ...args].join(" ")),
|
|
598
|
+
)
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function ownedProcesses(run, uid) {
|
|
603
|
+
const listed = tryRun(run, "ps", ["-axo", "pid=,uid=,command="]);
|
|
604
|
+
if (!listed.ok) throw new Error("gateway 프로세스 조회 실패");
|
|
605
|
+
return listed.output.split(/\r?\n/).flatMap((line) => {
|
|
606
|
+
const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.+)$/);
|
|
607
|
+
if (!match || match[2] !== String(uid) || !ownedCommand(match[3]))
|
|
608
|
+
return [];
|
|
609
|
+
const started = tryRun(run, "ps", ["-p", match[1], "-o", "lstart="]);
|
|
610
|
+
if (!started.ok && started.status !== 1)
|
|
611
|
+
throw new Error(`gateway PID ${match[1]}: 시작 시각 확인 실패`);
|
|
612
|
+
if (!started.ok || !started.output.trim()) return [];
|
|
613
|
+
return [
|
|
614
|
+
{ pid: match[1], identity: `${started.output.trim()}|${match[3]}` },
|
|
615
|
+
];
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function ownedWindowsProcesses(run) {
|
|
620
|
+
const result = runPowerShell(
|
|
621
|
+
run,
|
|
622
|
+
"$me=[Security.Principal.WindowsIdentity]::GetCurrent().Name; $items=@(Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { $p=$_; if ($p.CommandLine -match 'supergateway|mcp-gateway-start\\.mjs') { $o=Invoke-CimMethod -InputObject $p -MethodName GetOwner; if ($o.ReturnValue -eq 0 -and \"$($o.Domain)\\$($o.User)\" -eq $me) { @{ pid=[string]$p.ProcessId; command=$p.CommandLine; started=[string]$p.CreationDate } } } }); ConvertTo-Json -InputObject $items -Compress",
|
|
623
|
+
);
|
|
624
|
+
if (!result.ok) throw new Error("gateway 프로세스 조회 실패");
|
|
625
|
+
return JSON.parse(result.output)
|
|
626
|
+
.filter(({ command }) => ownedCommand(command))
|
|
627
|
+
.map(({ pid, command, started }) => ({
|
|
628
|
+
pid,
|
|
629
|
+
identity: `${started}|${command}`,
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function stopProcesses(run, warnings, platform, uid) {
|
|
634
|
+
const list =
|
|
635
|
+
platform === "win32"
|
|
636
|
+
? () => ownedWindowsProcesses(run)
|
|
637
|
+
: () => ownedProcesses(run, uid);
|
|
638
|
+
for (const original of list()) {
|
|
639
|
+
const current = list().find(({ pid }) => pid === original.pid);
|
|
640
|
+
if (!current) continue;
|
|
641
|
+
if (current.identity !== original.identity) {
|
|
642
|
+
warnings.push(`gateway PID ${original.pid}: 소유 재확인 실패`);
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
const stopped =
|
|
646
|
+
platform === "win32"
|
|
647
|
+
? tryRun(run, "taskkill", ["/F", "/T", "/PID", original.pid])
|
|
648
|
+
: tryRun(run, "kill", ["-TERM", original.pid]);
|
|
649
|
+
if (!stopped.ok) warnings.push(`gateway PID ${original.pid}: 종료 실패`);
|
|
650
|
+
}
|
|
651
|
+
let remaining;
|
|
652
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
653
|
+
remaining = list();
|
|
654
|
+
if (!remaining.length) return true;
|
|
655
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
656
|
+
}
|
|
657
|
+
for (const { pid } of remaining)
|
|
658
|
+
warnings.push(`gateway PID ${pid}: 종료 확인 실패`);
|
|
659
|
+
return false;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** 이전 로컬 MCP 연결을 직접 stdio 연결로 이주한다. */
|
|
663
|
+
export function cleanupLegacyMcp({
|
|
664
|
+
home = homedir(),
|
|
665
|
+
repoRoot = process.cwd(),
|
|
666
|
+
platform = osPlatform(),
|
|
667
|
+
run = defaultRun,
|
|
668
|
+
uid = process.getuid?.(),
|
|
669
|
+
env = process.env,
|
|
670
|
+
dryRun = false,
|
|
671
|
+
} = {}) {
|
|
672
|
+
const result = {
|
|
673
|
+
ok: true,
|
|
674
|
+
changed: false,
|
|
675
|
+
migrated: 0,
|
|
676
|
+
backups: [],
|
|
677
|
+
warnings: [],
|
|
678
|
+
teardown: { attempted: false, verified: false },
|
|
679
|
+
};
|
|
680
|
+
// 별도 HOME과 테스트에서는 실제 시스템 명령을 실행하지 않는다.
|
|
681
|
+
if (
|
|
682
|
+
run === defaultRun &&
|
|
683
|
+
(process.env.NODE_TEST_CONTEXT || process.env.TRIFLUX_TEST_HOME)
|
|
684
|
+
) {
|
|
685
|
+
result.skipped = true;
|
|
686
|
+
result.warnings.push("테스트 환경: 주입한 run이 없어 MCP 이주를 건너뜀");
|
|
687
|
+
return result;
|
|
688
|
+
}
|
|
689
|
+
if (
|
|
690
|
+
!dryRun &&
|
|
691
|
+
run === defaultRun &&
|
|
692
|
+
(resolve(home) !== resolve(homedir()) || platform !== osPlatform())
|
|
693
|
+
) {
|
|
694
|
+
result.ok = false;
|
|
695
|
+
result.warnings.push(
|
|
696
|
+
"격리 실행에는 임시 home, repoRoot와 주입한 run이 필요함",
|
|
697
|
+
);
|
|
698
|
+
return result;
|
|
699
|
+
}
|
|
700
|
+
if (
|
|
701
|
+
resolve(home) !== resolve(homedir()) &&
|
|
702
|
+
resolve(repoRoot) === process.cwd()
|
|
703
|
+
) {
|
|
704
|
+
result.ok = false;
|
|
705
|
+
result.warnings.push("별도 HOME과 실제 작업 디렉터리가 섞여 이주를 중단함");
|
|
706
|
+
return result;
|
|
707
|
+
}
|
|
708
|
+
const files = [
|
|
709
|
+
[join(home, ".claude.json"), "json"],
|
|
710
|
+
[join(repoRoot, ".mcp.json"), "json"],
|
|
711
|
+
[join(home, ".codex/config.toml"), "toml"],
|
|
712
|
+
[join(home, ".gemini/settings.json"), "json"],
|
|
713
|
+
];
|
|
714
|
+
const plans = [];
|
|
715
|
+
let blocked = false;
|
|
716
|
+
for (const [file, kind] of files) {
|
|
717
|
+
try {
|
|
718
|
+
const target = fileTarget(file);
|
|
719
|
+
if (!target) continue;
|
|
720
|
+
const original = readFileSync(target, "utf8");
|
|
721
|
+
const plan =
|
|
722
|
+
kind === "toml"
|
|
723
|
+
? patchToml(original, file, home, env, result.warnings)
|
|
724
|
+
: patchJson(original, file, home, env, result.warnings);
|
|
725
|
+
if (plan.blocked) blocked = true;
|
|
726
|
+
if (plan.count) plans.push({ file, target, original, ...plan });
|
|
727
|
+
} catch {
|
|
728
|
+
result.warnings.push(`${file}: 설정 형식 또는 읽기 실패`);
|
|
729
|
+
blocked = true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (blocked) {
|
|
733
|
+
result.ok = false;
|
|
734
|
+
return result;
|
|
735
|
+
}
|
|
736
|
+
result.migrated = plans.reduce((sum, plan) => sum + plan.count, 0);
|
|
737
|
+
if (dryRun) return result;
|
|
738
|
+
try {
|
|
739
|
+
for (const plan of plans) {
|
|
740
|
+
if (
|
|
741
|
+
fileTarget(plan.file) !== plan.target ||
|
|
742
|
+
readFileSync(plan.target, "utf8") !== plan.original
|
|
743
|
+
)
|
|
744
|
+
throw new Error(`${plan.file}: 검사 후 변경됨`);
|
|
745
|
+
result.backups.push(writeAtomic(plan.file, plan.target, plan.output));
|
|
746
|
+
result.changed = true;
|
|
747
|
+
}
|
|
748
|
+
} catch (error) {
|
|
749
|
+
result.ok = false;
|
|
750
|
+
result.warnings.push(`설정 쓰기 실패: ${error.message}`);
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
result.teardown =
|
|
755
|
+
platform === "darwin"
|
|
756
|
+
? removeDarwin(home, run, result.warnings, uid)
|
|
757
|
+
: platform === "linux"
|
|
758
|
+
? removeLinux(home, run, result.warnings)
|
|
759
|
+
: platform === "win32"
|
|
760
|
+
? removeWindows(home, run, result.warnings)
|
|
761
|
+
: { attempted: false, verified: true };
|
|
762
|
+
if (!result.teardown.verified) result.ok = false;
|
|
763
|
+
if (result.teardown.verified) {
|
|
764
|
+
const stopped = stopProcesses(run, result.warnings, platform, uid);
|
|
765
|
+
if (!stopped) result.ok = result.teardown.verified = false;
|
|
766
|
+
}
|
|
767
|
+
} catch (error) {
|
|
768
|
+
result.ok = false;
|
|
769
|
+
result.warnings.push(`시작 등록 해제 실패: ${error.message}`);
|
|
770
|
+
}
|
|
771
|
+
return result;
|
|
772
|
+
}
|