@triflux/core 10.54.0 → 10.55.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@triflux/core",
3
- "version": "10.54.0",
3
+ "version": "10.55.0",
4
4
  "description": "triflux core — CLI routing, adapters. Zero native dependencies.",
5
5
  "type": "module",
6
6
  "main": "hub/index.mjs",
@@ -15,9 +15,10 @@ import {
15
15
  writeFileSync,
16
16
  } from "node:fs";
17
17
  import { createRequire } from "node:module";
18
- import { homedir, platform as osPlatform } from "node:os";
18
+ import { homedir, platform as osPlatform, userInfo } from "node:os";
19
19
  import { basename, dirname, join, resolve } from "node:path";
20
20
  import { isDeepStrictEqual } from "node:util";
21
+ import { loadRegistryOrDefault, pinnedArgs } from "./mcp-guard-engine.mjs";
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const toml = require("@iarna/toml");
@@ -1236,3 +1237,131 @@ export function cleanupTfxHub({
1236
1237
  }
1237
1238
  return result;
1238
1239
  }
1240
+
1241
+ // doctor 가 레지스트리와 비교하는 사용자 설정 파일. [HOME 기준 경로, 형식, 클라이언트]
1242
+ // 프로젝트의 .mcp.json 은 저장소에 커밋될 수 있어 setup 이 바꾸지 않는다(doctor --fix, mcp sync 몫).
1243
+ const REGISTRY_PIN_FILES = [
1244
+ [".claude.json", "json", "claude"],
1245
+ [".mcp.json", "json", "claude"],
1246
+ [".claude/mcp.json", "json", "claude"],
1247
+ [".codex/config.toml", "toml", "codex"],
1248
+ [".gemini/config/mcp_config.json", "json", "antigravity"],
1249
+ ];
1250
+
1251
+ function pinJson(original, servers) {
1252
+ const data = JSON.parse(original);
1253
+ let count = 0;
1254
+ for (const [name, server] of servers) {
1255
+ const entry = data?.mcpServers?.[name];
1256
+ const args = pinnedArgs(entry, server);
1257
+ if (!args) continue;
1258
+ entry.args = args;
1259
+ count++;
1260
+ }
1261
+ return {
1262
+ output: count ? `${JSON.stringify(data, null, 2)}\n` : original,
1263
+ count,
1264
+ };
1265
+ }
1266
+
1267
+ // [mcp_servers.<name>] 머리글 다음 줄부터 다음 표 머리글 전까지의 범위.
1268
+ function tomlSectionBody(text, name) {
1269
+ let offset = 0;
1270
+ let start = -1;
1271
+ for (const line of text.split(/(?<=\n)/)) {
1272
+ if (start >= 0 && /^\s*\[/.test(line)) return { start, end: offset };
1273
+ const match = line.match(
1274
+ /^\s*\[mcp_servers\.(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_-]+))\]\s*(?:#.*)?\r?\n?$/,
1275
+ );
1276
+ offset += line.length;
1277
+ if ((match?.[1] ?? match?.[2] ?? match?.[3]) === name) start = offset;
1278
+ }
1279
+ return start >= 0 ? { start, end: offset } : null;
1280
+ }
1281
+
1282
+ function pinToml(original, servers) {
1283
+ const data = toml.parse(original);
1284
+ let output = original;
1285
+ let count = 0;
1286
+ for (const [name, server] of servers) {
1287
+ const args = pinnedArgs(data.mcp_servers?.[name], server);
1288
+ const section = args && tomlSectionBody(output, name);
1289
+ if (!section) continue;
1290
+ const body = output.slice(section.start, section.end);
1291
+ const next = body.replace(
1292
+ /^([ \t]*(?:args|"args"|'args')[ \t]*=[ \t]*)\[[^\]]*\]/m,
1293
+ `$1${JSON.stringify(args)}`,
1294
+ );
1295
+ if (next === body) continue;
1296
+ output = output.slice(0, section.start) + next + output.slice(section.end);
1297
+ data.mcp_servers[name].args = args;
1298
+ count++;
1299
+ }
1300
+ if (count && !isDeepStrictEqual(toml.parse(output), data))
1301
+ throw new Error("TOML 구조 불일치");
1302
+ return { output, count };
1303
+ }
1304
+
1305
+ /** 레지스트리 stdio 항목 중 패키지 버전만 다른 것을 레지스트리 고정 버전으로 바꾼다. */
1306
+ export function pinRegistryMcpPackages({
1307
+ home = homedir(),
1308
+ registry = loadRegistryOrDefault(),
1309
+ backups,
1310
+ } = {}) {
1311
+ const result = {
1312
+ ok: true,
1313
+ changed: false,
1314
+ pinned: 0,
1315
+ backups: [],
1316
+ warnings: [],
1317
+ };
1318
+ // 테스트가 실제 홈의 설정을 바꾸지 않게 한다.
1319
+ if (
1320
+ process.env.NODE_TEST_CONTEXT &&
1321
+ resolve(home) === resolve(userInfo().homedir)
1322
+ ) {
1323
+ result.skipped = true;
1324
+ return result;
1325
+ }
1326
+ const stdio = Object.entries(registry?.servers ?? {}).filter(
1327
+ ([, server]) =>
1328
+ typeof server?.command === "string" && Array.isArray(server.args),
1329
+ );
1330
+ const seen = new Set();
1331
+ for (const [relative, kind, client] of REGISTRY_PIN_FILES) {
1332
+ const servers = stdio.filter(([, server]) =>
1333
+ // mcp-guard-engine 의 serverTargets 처럼 빈 targets 는 기본 세 클라이언트다.
1334
+ (server.targets?.length
1335
+ ? server.targets
1336
+ : ["claude", "codex", "antigravity"]
1337
+ ).includes(client),
1338
+ );
1339
+ if (!servers.length) continue;
1340
+ const file = join(home, relative);
1341
+ try {
1342
+ const target = fileTarget(file);
1343
+ if (!target || !markFirst(seen, target)) continue;
1344
+ const original = readFileSync(target, "utf8");
1345
+ if (!original.trim()) continue;
1346
+ const plan =
1347
+ kind === "toml"
1348
+ ? pinToml(original, servers)
1349
+ : pinJson(original, servers);
1350
+ if (!plan.count) continue;
1351
+ if (
1352
+ fileTarget(file) !== target ||
1353
+ readFileSync(target, "utf8") !== original
1354
+ ) {
1355
+ result.warnings.push(`${file}: 검사 후 변경되어 건너뜀`);
1356
+ continue;
1357
+ }
1358
+ result.backups.push(writeAtomic(file, target, plan.output, backups));
1359
+ result.pinned += plan.count;
1360
+ result.changed = true;
1361
+ } catch {
1362
+ result.warnings.push(`${file}: MCP 버전 고정 반영 실패, 파일 보존`);
1363
+ result.ok = false;
1364
+ }
1365
+ }
1366
+ return result;
1367
+ }
@@ -6,6 +6,7 @@ import {
6
6
  readFileSync,
7
7
  writeFileSync,
8
8
  } from "node:fs";
9
+ import { createRequire } from "node:module";
9
10
  import { homedir } from "node:os";
10
11
  import {
11
12
  basename,
@@ -16,6 +17,7 @@ import {
16
17
  resolve,
17
18
  } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
20
+ import { isDeepStrictEqual } from "node:util";
19
21
 
20
22
  const PROJECT_ROOT = fileURLToPath(new URL("../../", import.meta.url));
21
23
  const DEFAULT_REGISTRY_PATH = join(PROJECT_ROOT, "config", "mcp-registry.json");
@@ -632,20 +634,31 @@ function formatTomlArray(values = []) {
632
634
  return `[${values.map((value) => formatTomlString(value)).join(", ")}]`;
633
635
  }
634
636
 
637
+ // 한 줄에서 열린 [ 와 닫힌 ] 의 차. 문자열과 주석 안의 괄호는 세지 않는다.
638
+ function arrayDepth(text) {
639
+ const code = text
640
+ .replace(/"""[\s\S]*?"""|'''[\s\S]*?'''/g, "")
641
+ .replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, "")
642
+ .replace(/#.*$/, "");
643
+ return (code.match(/\[/g) || []).length - (code.match(/\]/g) || []).length;
644
+ }
645
+
646
+ const TOML_MANAGED_KEYS = new Set([
647
+ "url",
648
+ "transport",
649
+ "command",
650
+ "args",
651
+ "bearer_token_env_var",
652
+ "http_headers",
653
+ "env_http_headers",
654
+ "env",
655
+ "env_vars",
656
+ ]);
657
+
635
658
  function upsertTomlServer(raw, name, config, codex = {}) {
636
659
  const lines = String(raw || "").split(/\r?\n/);
637
660
  const header = `[mcp_servers.${name}]`;
638
- const managedKeys = new Set([
639
- "url",
640
- "transport",
641
- "command",
642
- "args",
643
- "bearer_token_env_var",
644
- "http_headers",
645
- "env_http_headers",
646
- "env",
647
- "env_vars",
648
- ]);
661
+ const managedKeys = TOML_MANAGED_KEYS;
649
662
  const nextManagedLines = [];
650
663
  if (typeof config.url === "string") {
651
664
  nextManagedLines.push(`url = ${formatTomlString(config.url)}`);
@@ -696,6 +709,21 @@ function upsertTomlServer(raw, name, config, codex = {}) {
696
709
  const keyMatch = lines[index].match(/^\s*([A-Za-z0-9_]+)\s*=/);
697
710
  if (!keyMatch || !managedKeys.has(keyMatch[1])) {
698
711
  preserved.push(lines[index]);
712
+ } else {
713
+ // 여러 줄 배열 값은 닫는 ] 까지 함께 걷어낸다. 남기면 다음 줄들이 고아가 되어 파일이 깨진다.
714
+ // 배열 값일 때만, 다음 키나 표 머리글 앞에서는 멈춘다.
715
+ const value = lines[index].slice(keyMatch[0].length);
716
+ let depth = value.trimStart().startsWith("[") ? arrayDepth(value) : 0;
717
+ while (
718
+ depth > 0 &&
719
+ index + 1 < lines.length &&
720
+ !/^\s*(?:[A-Za-z0-9_-]+\s*=|\[\[?\s*[A-Za-z0-9_"'-])/.test(
721
+ lines[index + 1],
722
+ )
723
+ ) {
724
+ index += 1;
725
+ depth += arrayDepth(lines[index]);
726
+ }
699
727
  }
700
728
  index += 1;
701
729
  }
@@ -751,6 +779,29 @@ function syncDenylistKey(client, serverName) {
751
779
  .toLowerCase()}`;
752
780
  }
753
781
 
782
+ // 범위 이름(@scope/name)의 첫 @ 는 이름에 속한다.
783
+ function splitPackageSpec(spec) {
784
+ const match = /^((?:@[^/@\s]+\/)?[^@\s]+)(?:@(\S+))?$/.exec(spec);
785
+ return match ? { name: match[1], version: match[2] ?? null } : null;
786
+ }
787
+
788
+ // 명령과 인자 수가 같고, 다른 자리는 레지스트리의 고정 패키지와 이름만 같을 때 고정 인자를 돌려준다.
789
+ export function pinnedArgs(entry, server) {
790
+ if (entry?.command !== server.command || !Array.isArray(entry.args))
791
+ return null;
792
+ if (entry.args.length !== server.args.length) return null;
793
+ let changed = false;
794
+ for (const [index, want] of server.args.entries()) {
795
+ const have = entry.args[index];
796
+ if (have === want) continue;
797
+ const pin = splitPackageSpec(want);
798
+ if (!pin?.version || typeof have !== "string") return null;
799
+ if (splitPackageSpec(have)?.name !== pin.name) return null;
800
+ changed = true;
801
+ }
802
+ return changed ? [...server.args] : null;
803
+ }
804
+
754
805
  export function buildDesiredServerRecord(name, serverConfig, filePath) {
755
806
  const policy = serverPolicy(serverConfig);
756
807
  if (policy === "stdio") {
@@ -1107,6 +1158,27 @@ function updateCodexConfig(filePath, updates = [], removals = []) {
1107
1158
  if (finalRaw === previousRaw) {
1108
1159
  return { modified: false, filePath: resolvedPath };
1109
1160
  }
1161
+ // 줄 단위 치환이 파일을 깨뜨리거나 관리 키 밖의 값을 지우면 쓰지 않는다.
1162
+ // 파서가 원본부터 못 읽는 표기(파서가 모르는 TOML 1.0 문법)는 판정할 수 없어 예전처럼 쓴다.
1163
+ const toml = tomlParser();
1164
+ try {
1165
+ if (
1166
+ toml &&
1167
+ parsesAsToml(toml, previousRaw) &&
1168
+ !isDeepStrictEqual(
1169
+ withoutManagedServers(toml.parse(previousRaw), updates, removals),
1170
+ withoutManagedServers(toml.parse(finalRaw), updates, removals),
1171
+ )
1172
+ )
1173
+ throw new Error("unmanaged TOML changed");
1174
+ } catch {
1175
+ return {
1176
+ modified: false,
1177
+ filePath: resolvedPath,
1178
+ skipped: true,
1179
+ reason: "toml-rewrite-unsafe",
1180
+ };
1181
+ }
1110
1182
 
1111
1183
  mkdirSync(dirname(resolvedPath), { recursive: true });
1112
1184
  // 헤더 비밀이 들어갈 수 있다. 새 파일은 0600, 기존 파일은 권한이 그대로 유지된다.
@@ -1830,6 +1902,52 @@ export function removeServerFromTargets(name, options = {}) {
1830
1902
  return { actions };
1831
1903
  }
1832
1904
 
1905
+ // packages/remote 는 @iarna/toml 을 의존으로 두지 않아 늦게 불러오고, 없으면 null.
1906
+ function tomlParser() {
1907
+ try {
1908
+ return createRequire(import.meta.url)("@iarna/toml");
1909
+ } catch {
1910
+ return null;
1911
+ }
1912
+ }
1913
+
1914
+ // 갱신한 서버의 관리 키만 뺀다. 남은 값이 원본과 같아야 치환이 사용자 값을 건드리지 않은 것이다.
1915
+ function withoutManagedServers(data, updates, removals) {
1916
+ for (const name of removals) delete data.mcp_servers?.[name];
1917
+ for (const { name } of updates) {
1918
+ const server = data.mcp_servers?.[name];
1919
+ if (!server) continue;
1920
+ for (const key of TOML_MANAGED_KEYS) delete server[key];
1921
+ if (!Object.keys(server).length) delete data.mcp_servers[name];
1922
+ }
1923
+ if (data.mcp_servers && !Object.keys(data.mcp_servers).length)
1924
+ delete data.mcp_servers;
1925
+ return data;
1926
+ }
1927
+
1928
+ function parsesAsToml(toml, raw) {
1929
+ try {
1930
+ toml.parse(raw);
1931
+ return true;
1932
+ } catch {
1933
+ return false;
1934
+ }
1935
+ }
1936
+
1937
+ // 간이 TOML 스캐너는 작은따옴표와 줄 끝 주석을 잘못 읽으므로 보호 판정에 쓸 Codex 항목은 실제 파서로 읽는다.
1938
+ function existingServerEntry(filePath, snapshot, name) {
1939
+ const scanned = snapshot.servers.find((server) => server.name === name);
1940
+ const toml = isCodexConfig(filePath) && tomlParser();
1941
+ if (!toml) return scanned;
1942
+ try {
1943
+ const entry = toml.parse(readFileSync(resolveFilePath(filePath), "utf8"))
1944
+ .mcp_servers?.[name];
1945
+ return entry && { ...entry, args: entry.args ?? [] };
1946
+ } catch {
1947
+ return scanned;
1948
+ }
1949
+ }
1950
+
1833
1951
  export function syncRegistryTargets(options = {}) {
1834
1952
  const registry = options.registry || loadRegistryOrDefault();
1835
1953
  const syncTargets = Array.isArray(options.targets)
@@ -1934,6 +2052,29 @@ export function syncRegistryTargets(options = {}) {
1934
2052
  continue;
1935
2053
  }
1936
2054
 
2055
+ // 사용자가 바꾼 stdio 항목은 덮어쓰지 않는다. 같은 패키지의 버전 차이만 고정 버전으로 맞춘다.
2056
+ const existing = existingServerEntry(target.filePath, snapshot, name);
2057
+ if (
2058
+ serverPolicy(serverConfig) === "stdio" &&
2059
+ typeof existing?.command === "string" &&
2060
+ existing.command &&
2061
+ !(
2062
+ existing.command === serverConfig.command &&
2063
+ JSON.stringify(existing.args) === JSON.stringify(serverConfig.args)
2064
+ ) &&
2065
+ !pinnedArgs(existing, serverConfig)
2066
+ ) {
2067
+ actions.push({
2068
+ type: "sync",
2069
+ filePath: target.filePath,
2070
+ label: target.label,
2071
+ status: "warning",
2072
+ server: name,
2073
+ message: `${name}: 레지스트리와 다른 사용자 항목이라 덮어쓰지 않음 (${[existing.command, ...(existing.args ?? [])].join(" ")})`,
2074
+ });
2075
+ continue;
2076
+ }
2077
+
1937
2078
  updates.push(
1938
2079
  buildDesiredServerRecord(name, serverConfig, target.filePath),
1939
2080
  );
@@ -1950,6 +2091,18 @@ export function syncRegistryTargets(options = {}) {
1950
2091
  continue;
1951
2092
  }
1952
2093
 
2094
+ if (result.reason === "toml-rewrite-unsafe") {
2095
+ actions.push({
2096
+ type: "sync",
2097
+ filePath: target.filePath,
2098
+ label: target.label,
2099
+ status: "warning",
2100
+ message:
2101
+ "줄 단위로 고칠 수 없는 TOML 표기(여러 줄 값 등)라 파일을 바꾸지 않음",
2102
+ });
2103
+ continue;
2104
+ }
2105
+
1953
2106
  actions.push({
1954
2107
  type: "sync",
1955
2108
  filePath: target.filePath,