triflux 10.16.0 → 10.17.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 CHANGED
@@ -5073,29 +5073,28 @@ function startHubAfterUpdate(info) {
5073
5073
  function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
5074
5074
  section("MCP 자동 등록");
5075
5075
 
5076
- // Codex — config.json에 기본 disabled 엔트리로 등록
5077
- if (which("codex")) {
5078
- try {
5079
- const result = ensureCodexHubServerConfig({
5080
- mcpUrl,
5081
- createIfMissing: true,
5082
- enabled: codexEnabled,
5083
- });
5084
- if (!result.ok) throw new Error(result.reason || "unknown");
5085
- if (result.changed) {
5086
- ok(
5087
- `Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})`,
5088
- );
5089
- } else {
5090
- ok(
5091
- `Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})`,
5092
- );
5093
- }
5094
- } catch (e) {
5095
- warn(`Codex 등록 실패: ${e.message}`);
5076
+ // Codex — config.json에 기본 disabled 엔트리로 등록.
5077
+ // Hub startup must keep the MCP config fresh even on CI/dev machines where
5078
+ // the Codex CLI binary itself is not installed.
5079
+ try {
5080
+ const result = ensureCodexHubServerConfig({
5081
+ mcpUrl,
5082
+ createIfMissing: true,
5083
+ enabled: codexEnabled,
5084
+ });
5085
+ if (!result.ok) throw new Error(result.reason || "unknown");
5086
+ const suffix = which("codex") ? "" : " (CLI 미설치)";
5087
+ if (result.changed) {
5088
+ ok(
5089
+ `Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})${suffix}`,
5090
+ );
5091
+ } else {
5092
+ ok(
5093
+ `Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})${suffix}`,
5094
+ );
5096
5095
  }
5097
- } else {
5098
- info("Codex: 미설치 (건너뜀)");
5096
+ } catch (e) {
5097
+ warn(`Codex 등록 실패: ${e.message}`);
5099
5098
  }
5100
5099
 
5101
5100
  // Gemini — settings.json 직접 수정
@@ -97,6 +97,18 @@
97
97
  "source": "(?:정리해|슬롭|클린업)",
98
98
  "flags": "i"
99
99
  },
100
+ {
101
+ "source": "(?:병렬|동시에|parallel|concurrent)",
102
+ "flags": "i"
103
+ },
104
+ {
105
+ "source": "(?:점검|진단|확인해)",
106
+ "flags": "i"
107
+ },
108
+ {
109
+ "source": "(?:계속해|이어서|진행해)",
110
+ "flags": "i"
111
+ },
100
112
  {
101
113
  "source": "\\b(?:implement|build|fix|review|test|plan|analyze)\\b",
102
114
  "flags": "i"
@@ -123,9 +123,12 @@ function getWindowsHostIds() {
123
123
  ids.add(name);
124
124
  if (cfg.tailscale?.ip) ids.add(cfg.tailscale.ip);
125
125
  if (cfg.tailscale?.dns) ids.add(cfg.tailscale.dns);
126
+ if (cfg.ssh?.host) ids.add(cfg.ssh.host);
126
127
  if (cfg.ssh?.user) {
127
128
  ids.add(`${cfg.ssh.user}@${name}`);
128
129
  if (cfg.tailscale?.ip) ids.add(`${cfg.ssh.user}@${cfg.tailscale.ip}`);
130
+ if (cfg.tailscale?.dns) ids.add(`${cfg.ssh.user}@${cfg.tailscale.dns}`);
131
+ if (cfg.ssh?.host) ids.add(`${cfg.ssh.user}@${cfg.ssh.host}`);
129
132
  }
130
133
  }
131
134
  } catch {
@@ -72,6 +72,11 @@ let _cachedVersion = null;
72
72
  */
73
73
  export function getCodexVersion() {
74
74
  if (_cachedVersion !== null) return _cachedVersion;
75
+ const override = Number(process.env.TFX_CODEX_VERSION_MINOR);
76
+ if (Number.isFinite(override) && override > 0) {
77
+ _cachedVersion = override;
78
+ return _cachedVersion;
79
+ }
75
80
  try {
76
81
  const out = execSync("codex --version", {
77
82
  encoding: "utf8",
@@ -80,7 +85,10 @@ export function getCodexVersion() {
80
85
  const match = out.match(/(\d+)\.(\d+)\.(\d+)/);
81
86
  _cachedVersion = match ? Number.parseInt(match[2], 10) : 0;
82
87
  } catch {
83
- _cachedVersion = 0;
88
+ // Command builders should remain stable in CI even when the real Codex
89
+ // CLI is absent. Runtime preflight still reports/install-gates Codex
90
+ // separately; this fallback only selects the modern argv shape.
91
+ _cachedVersion = 117;
84
92
  }
85
93
  return _cachedVersion;
86
94
  }
@@ -182,10 +190,7 @@ export function buildExecCommand(prompt, resultFile = null, opts = {}) {
182
190
  // ── Sleep ───────────────────────────────────────────────────────
183
191
 
184
192
  export function sleep(ms) {
185
- return new Promise((resolve) => {
186
- const timer = setTimeout(resolve, ms);
187
- timer.unref?.();
188
- });
193
+ return new Promise((resolve) => setTimeout(resolve, ms));
189
194
  }
190
195
 
191
196
  // ── Result factory ──────────────────────────────────────────────
@@ -133,8 +133,20 @@ function normalizeLastProbe(rawProbe) {
133
133
  return Object.keys(probe).length > 0 ? probe : null;
134
134
  }
135
135
 
136
+ function normalizeResources(rawHost) {
137
+ const rawResources =
138
+ rawHost.resources && typeof rawHost.resources === "object"
139
+ ? rawHost.resources
140
+ : {};
141
+ const rawSpecs =
142
+ rawHost.specs && typeof rawHost.specs === "object" ? rawHost.specs : {};
143
+ return { ...rawResources, ...rawSpecs };
144
+ }
145
+
136
146
  export function normalizeHost(rawHost = {}, name = "") {
137
147
  const sshUser = rawHost.ssh_user || rawHost.ssh?.user || rawHost.user || null;
148
+ const sshHost = rawHost.ssh?.host || rawHost.host || null;
149
+ const resources = normalizeResources(rawHost);
138
150
  const tailscale = {
139
151
  ip: rawHost.tailscale?.ip || null,
140
152
  dns: rawHost.tailscale?.dns || null,
@@ -167,15 +179,14 @@ export function normalizeHost(rawHost = {}, name = "") {
167
179
  ssh: {
168
180
  ...(rawHost.ssh && typeof rawHost.ssh === "object" ? rawHost.ssh : {}),
169
181
  user: sshUser,
182
+ host: sshHost,
170
183
  },
171
184
  tailscale,
172
185
  capabilities,
173
186
  capabilities_v2,
174
187
  last_probe: normalizeLastProbe(rawHost.last_probe),
175
- specs:
176
- rawHost.specs && typeof rawHost.specs === "object"
177
- ? { ...rawHost.specs }
178
- : {},
188
+ resources,
189
+ specs: { ...resources },
179
190
  raw: { ...rawHost },
180
191
  };
181
192
  }
@@ -234,6 +245,7 @@ export function resolveHost(nameOrAlias, repoRoot) {
234
245
  ...host.aliases,
235
246
  host.tailscale.ip,
236
247
  host.tailscale.dns,
248
+ host.ssh.host,
237
249
  host.ssh_user ? `${host.ssh_user}@${name}` : null,
238
250
  host.ssh_user && host.tailscale.ip
239
251
  ? `${host.ssh_user}@${host.tailscale.ip}`
@@ -241,6 +253,9 @@ export function resolveHost(nameOrAlias, repoRoot) {
241
253
  host.ssh_user && host.tailscale.dns
242
254
  ? `${host.ssh_user}@${host.tailscale.dns}`
243
255
  : null,
256
+ host.ssh_user && host.ssh.host
257
+ ? `${host.ssh_user}@${host.ssh.host}`
258
+ : null,
244
259
  ]);
245
260
  for (const alias of aliases) {
246
261
  if (alias && String(alias).toLowerCase() === lowered) {
@@ -2,7 +2,7 @@
2
2
  // PowerShell 호스트에 bash 문법(2>/dev/null, &&, $())을 보내는 사고를 방지한다.
3
3
  // 모든 SSH 명령 생성 코드에서 이 유틸리티를 사용할 것.
4
4
 
5
- import { readHosts } from "./hosts-compat.mjs";
5
+ import { readHost, readHosts, resolveHost } from "./hosts-compat.mjs";
6
6
 
7
7
  /** hosts.json 캐시 (프로세스 수명 동안 유지) */
8
8
  let hostsCache = null;
@@ -28,7 +28,7 @@ function loadHostsCache(repoRoot) {
28
28
  export function detectHostOs(hostAlias, repoRoot) {
29
29
  loadHostsCache(repoRoot);
30
30
 
31
- const hostCfg = hostsCache.hosts?.[hostAlias];
31
+ const hostCfg = resolveHost(hostAlias, repoRoot)?.host;
32
32
  if (hostCfg?.os === "windows") return "windows";
33
33
  if (hostCfg?.os) return "posix";
34
34
 
@@ -163,7 +163,7 @@ export function validateCommandForOs(command, os) {
163
163
  */
164
164
  export function getHostConfig(hostAlias, repoRoot) {
165
165
  loadHostsCache(repoRoot);
166
- return hostsCache.hosts?.[hostAlias] ?? null;
166
+ return readHost(hostAlias, repoRoot);
167
167
  }
168
168
 
169
169
  /**
@@ -175,13 +175,7 @@ export function getHostConfig(hostAlias, repoRoot) {
175
175
  export function resolveHostAlias(alias, repoRoot) {
176
176
  loadHostsCache(repoRoot);
177
177
 
178
- if (hostsCache.hosts?.[alias]) return alias;
179
-
180
- for (const [name, cfg] of Object.entries(hostsCache.hosts || {})) {
181
- if (cfg.aliases.includes(alias)) return name;
182
- }
183
-
184
- return null;
178
+ return resolveHost(alias, repoRoot)?.name ?? null;
185
179
  }
186
180
 
187
181
  /**
package/hub/router.mjs CHANGED
@@ -60,6 +60,21 @@ function normalizeAgentTopics(store, agentId, runtimeTopics) {
60
60
  return Array.from(topics);
61
61
  }
62
62
 
63
+ function waitForEmitterOnce(emitter, eventName, timeoutMs) {
64
+ let timer = null;
65
+ const timeout = Math.max(0, Math.min(Number(timeoutMs) || 0, 30000));
66
+ return Promise.race([
67
+ once(emitter, eventName),
68
+ new Promise((_, reject) => {
69
+ timer = setTimeout(() => {
70
+ reject(new Error(`timed out waiting for ${eventName}`));
71
+ }, timeout);
72
+ }),
73
+ ]).finally(() => {
74
+ if (timer) clearTimeout(timer);
75
+ });
76
+ }
77
+
63
78
  /**
64
79
  * 라우터 생성
65
80
  * @param {object} store
@@ -525,9 +540,11 @@ export function createRouter(store) {
525
540
  }
526
541
 
527
542
  try {
528
- const [response] = await once(responseEmitter, cid, {
529
- signal: AbortSignal.timeout(Math.min(await_response_ms, 30000)),
530
- });
543
+ const [response] = await waitForEmitterOnce(
544
+ responseEmitter,
545
+ cid,
546
+ await_response_ms,
547
+ );
531
548
  return {
532
549
  ok: true,
533
550
  data: {
package/hub/server.mjs CHANGED
@@ -326,7 +326,14 @@ async function syncHubMcpSettingsIfAvailable({ hubUrl }) {
326
326
  if (typeof mod?.syncCodexHubUrl === "function") {
327
327
  await mod.syncCodexHubUrl({ hubUrl });
328
328
  }
329
- if (typeof mod?.syncProjectMcpJson === "function") {
329
+ const allowProjectSync =
330
+ process.env.TFX_PROJECT_MCP_SYNC === "1" ||
331
+ !(
332
+ process.env.NODE_ENV === "test" ||
333
+ process.env.CI === "true" ||
334
+ process.env.TFX_TEST === "1"
335
+ );
336
+ if (allowProjectSync && typeof mod?.syncProjectMcpJson === "function") {
330
337
  await mod.syncProjectMcpJson({ hubUrl, projectRoot: PROJECT_ROOT });
331
338
  }
332
339
  } catch (error) {
@@ -1,6 +1,8 @@
1
1
  {
2
2
  "executor": "codex",
3
3
  "build-fixer": "codex",
4
+ "cleanup": "codex",
5
+ "deslop": "codex",
4
6
  "debugger": "codex",
5
7
  "deep-executor": "codex",
6
8
  "architect": "codex",
@@ -55,21 +55,22 @@ export function expandRange({ ref = "HEAD", base } = {}) {
55
55
  * - `HEAD` with no prior commit returns an empty string
56
56
  * - optional `file` scopes the diff to a single path via `-- <file>`
57
57
  *
58
- * @param {{ ref?: string, base?: string, file?: string }} opts
58
+ * @param {{ ref?: string, base?: string, file?: string, runner?: (cmd: string, args: string[]) => string }} opts
59
59
  * @returns {{ diff: string, range: string, file?: string }}
60
60
  */
61
- export function resolveReviewDiff({ ref = "HEAD", base, file } = {}) {
61
+ export function resolveReviewDiff({
62
+ ref = "HEAD",
63
+ base,
64
+ file,
65
+ runner = _defaultGitRunner,
66
+ } = {}) {
62
67
  const range = expandRange({ ref, base });
63
68
 
64
69
  const args = ["log", "-p", "--stat", "--no-color", range];
65
70
  if (file) {
66
71
  args.push("--", file);
67
72
  }
68
- const diff = execFileSync("git", args, {
69
- encoding: "utf8",
70
- windowsHide: true,
71
- maxBuffer: 50 * 1024 * 1024,
72
- });
73
+ const diff = runner("git", args);
73
74
  return { diff, range, file };
74
75
  }
75
76
 
@@ -306,11 +307,15 @@ export async function runCodexReview({
306
307
  timeoutMs = 180_000,
307
308
  sandbox = "read-only",
308
309
  env = process.env,
310
+ _deps = {},
309
311
  } = {}) {
310
312
  let range;
311
313
  let diff;
312
314
  try {
313
- ({ diff, range } = resolveReviewDiff({ ref, base }));
315
+ ({ diff, range } = (_deps.resolveReviewDiff || resolveReviewDiff)({
316
+ ref,
317
+ base,
318
+ }));
314
319
  } catch (err) {
315
320
  return {
316
321
  ok: false,
@@ -353,7 +358,7 @@ export async function runCodexReview({
353
358
  };
354
359
  }
355
360
 
356
- return _runCodexOnPrompt({
361
+ return (_deps.runCodexOnPrompt || _runCodexOnPrompt)({
357
362
  prompt,
358
363
  range,
359
364
  diffBytes,
@@ -57,7 +57,7 @@ export const STATES = Object.freeze({
57
57
  /** 유효한 상태 전이 테이블 */
58
58
  const TRANSITIONS = Object.freeze({
59
59
  [STATES.INIT]: [STATES.STARTING],
60
- [STATES.STARTING]: [STATES.HEALTHY, STATES.FAILED],
60
+ [STATES.STARTING]: [STATES.HEALTHY, STATES.FAILED, STATES.COMPLETED],
61
61
  [STATES.HEALTHY]: [
62
62
  STATES.STALLED,
63
63
  STATES.INPUT_WAIT,
@@ -337,7 +337,6 @@ export function createConductor(opts = {}) {
337
337
  forceKill(pid);
338
338
  resolve();
339
339
  }, graceMs);
340
- timer.unref?.();
341
340
  child.once("exit", () => {
342
341
  clearTimeout(timer);
343
342
  resolve();
@@ -629,6 +628,7 @@ export function createConductor(opts = {}) {
629
628
  });
630
629
 
631
630
  if (TERMINAL_STATES.has(session.state)) return;
631
+ if (shuttingDown) return;
632
632
 
633
633
  if (code === 0 && !signal) {
634
634
  transition(session, STATES.COMPLETED, "exit_0");
@@ -675,7 +675,7 @@ export function createConductor(opts = {}) {
675
675
  session: session.id,
676
676
  error: err.message,
677
677
  });
678
- if (!TERMINAL_STATES.has(session.state)) {
678
+ if (!shuttingDown && !TERMINAL_STATES.has(session.state)) {
679
679
  handleFailure(session, `child_error:${err.message}`);
680
680
  }
681
681
  });
@@ -866,6 +866,8 @@ export function createConductor(opts = {}) {
866
866
  host,
867
867
  });
868
868
 
869
+ if (shuttingDown || TERMINAL_STATES.has(session.state)) return;
870
+
869
871
  // 원격 세션은 broker lease를 사용하지 않으므로 release 불필요
870
872
  // (spawnSession에서 config.remote === true일 때 lease 건너뜀)
871
873
  if (code === 0) {
@@ -900,7 +902,9 @@ export function createConductor(opts = {}) {
900
902
  session: session.id,
901
903
  error: err.message,
902
904
  });
903
- handleFailure(session, `spawn_error:${err.message}`);
905
+ if (!shuttingDown && !TERMINAL_STATES.has(session.state)) {
906
+ handleFailure(session, `spawn_error:${err.message}`);
907
+ }
904
908
  });
905
909
  }
906
910
 
@@ -940,7 +940,7 @@ function killOrphanMcpProcesses(sessionName) {
940
940
  // (e.g. `<session>2-worker-1.txt`).
941
941
  const escSession = escapeRegex(safeSessionUnix);
942
942
  const pids = childProcess
943
- .execFileSync("pgrep", ["-f", `tfx-headless/${escSession}[-/.]`], {
943
+ .execFileSync("pgrep", ["-f", `tfx-headless/${escSession}[-./]`], {
944
944
  encoding: "utf8",
945
945
  timeout: 5000,
946
946
  stdio: ["ignore", "pipe", "ignore"],
@@ -58,7 +58,20 @@ function git(args, cwd) {
58
58
  execFile(
59
59
  "git",
60
60
  args,
61
- { cwd, windowsHide: true, timeout: 30_000 },
61
+ {
62
+ cwd,
63
+ windowsHide: true,
64
+ timeout: 30_000,
65
+ env: {
66
+ ...process.env,
67
+ GIT_AUTHOR_NAME: process.env.GIT_AUTHOR_NAME || "Triflux",
68
+ GIT_AUTHOR_EMAIL:
69
+ process.env.GIT_AUTHOR_EMAIL || "triflux@example.invalid",
70
+ GIT_COMMITTER_NAME: process.env.GIT_COMMITTER_NAME || "Triflux",
71
+ GIT_COMMITTER_EMAIL:
72
+ process.env.GIT_COMMITTER_EMAIL || "triflux@example.invalid",
73
+ },
74
+ },
62
75
  (err, stdout, stderr) => {
63
76
  if (err) {
64
77
  const msg = `git ${args[0]} failed: ${stderr?.trim() || err.message}`;
@@ -876,7 +876,6 @@ export class CodexAppServerWorker {
876
876
  raw: null,
877
877
  });
878
878
  }, timeoutMs);
879
- timer.unref?.();
880
879
  });
881
880
 
882
881
  const result = await Promise.race([resultPromise, timeoutPromise]);
@@ -952,8 +951,7 @@ export class CodexAppServerWorker {
952
951
  });
953
952
  client.close("closing");
954
953
  const deadline = new Promise((resolve) => {
955
- const t = setTimeout(resolve, UNSUBSCRIBE_DEADLINE_MS);
956
- t.unref?.();
954
+ setTimeout(resolve, UNSUBSCRIBE_DEADLINE_MS);
957
955
  });
958
956
  await Promise.race([unsubPromise, deadline]);
959
957
  }
@@ -971,12 +969,12 @@ export class CodexAppServerWorker {
971
969
  try {
972
970
  if (child.exitCode === null && !child.killed) child.kill("SIGTERM");
973
971
  } catch {}
974
- const killTimer = setTimeout(() => {
972
+ if (child.exitCode === null && !child.killed) {
973
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
975
974
  try {
976
975
  if (child.exitCode === null && !child.killed) child.kill("SIGKILL");
977
976
  } catch {}
978
- }, 1_000);
979
- killTimer.unref?.();
977
+ }
980
978
  }
981
979
  }
982
980
 
@@ -178,7 +178,6 @@ export class JsonRpcStdioClient {
178
178
  ),
179
179
  );
180
180
  }, timeoutMs);
181
- if (typeof timer.unref === "function") timer.unref();
182
181
  }
183
182
 
184
183
  this._pendingRequests.set(id, { resolve, reject, timer, method });
@@ -25,8 +25,7 @@ export function createWorkerError(message, details = {}) {
25
25
 
26
26
  function sleep(delayMs) {
27
27
  return new Promise((resolve) => {
28
- const timer = setTimeout(resolve, Math.max(0, delayMs));
29
- timer.unref?.();
28
+ setTimeout(resolve, Math.max(0, delayMs));
30
29
  });
31
30
  }
32
31
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "10.16.0",
3
+ "version": "10.17.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@ import { SERVERS } from "./mcp-gateway-start.mjs";
21
21
 
22
22
  const CODEX_CONFIG = join(homedir(), ".codex", "config.toml");
23
23
  const BACKUP_SUFFIX = ".pre-gateway.bak";
24
+ const CODEX_CONFIG_SYNC_OPT_IN = "TFX_CODEX_CONFIG_SYNC";
24
25
 
25
26
  // gateway 서버 → SSE URL 매핑
26
27
  const GATEWAY_MAP = new Map(
@@ -75,9 +76,25 @@ function _buildStdioEntry(name, block) {
75
76
  return `[mcp_servers.${name}]\n${block}\n`;
76
77
  }
77
78
 
79
+ function isProtectedCodexConfigMutationEnv(env = process.env) {
80
+ return env.NODE_ENV === "test" || env.CI === "true" || env.TFX_TEST === "1";
81
+ }
82
+
83
+ function shouldSkipCodexConfigMutation() {
84
+ return (
85
+ process.env[CODEX_CONFIG_SYNC_OPT_IN] !== "1" &&
86
+ isProtectedCodexConfigMutationEnv()
87
+ );
88
+ }
89
+
78
90
  // ── enable: stdio → SSE ──
79
91
 
80
92
  export function enableGateway() {
93
+ if (shouldSkipCodexConfigMutation()) {
94
+ console.log("[SKIP] protected environment; config.toml not modified");
95
+ return { changed: 0, skipped: 0, reason: "protected-env" };
96
+ }
97
+
81
98
  if (!existsSync(CODEX_CONFIG)) {
82
99
  console.log("[SKIP] ~/.codex/config.toml not found");
83
100
  return { changed: 0, skipped: 0 };
@@ -151,6 +168,11 @@ export function enableGateway() {
151
168
  // ── disable: SSE → stdio 복원 ──
152
169
 
153
170
  export function disableGateway() {
171
+ if (shouldSkipCodexConfigMutation()) {
172
+ console.log("[SKIP] protected environment; config.toml not restored");
173
+ return false;
174
+ }
175
+
154
176
  const backupPath = CODEX_CONFIG + BACKUP_SUFFIX;
155
177
  if (!existsSync(backupPath)) {
156
178
  console.log("[SKIP] No backup found — nothing to restore");
@@ -258,6 +258,28 @@ function generateSummary(stats, sysInfo, hookTimings, traceCount) {
258
258
  return lines.join("\n");
259
259
  }
260
260
 
261
+ function createZipArchive(bundleDir, zipPath) {
262
+ if (platform() === "win32") {
263
+ execFileSync(
264
+ "powershell.exe",
265
+ [
266
+ "-NoProfile",
267
+ "-NonInteractive",
268
+ "-Command",
269
+ `Compress-Archive -Path '${bundleDir}\\*' -DestinationPath '${zipPath}' -Force`,
270
+ ],
271
+ { timeout: 30000, windowsHide: true },
272
+ );
273
+ return;
274
+ }
275
+
276
+ execFileSync("zip", ["-qr", zipPath, "."], {
277
+ cwd: bundleDir,
278
+ timeout: 30000,
279
+ windowsHide: true,
280
+ });
281
+ }
282
+
261
283
  export async function diagnose({ json = false } = {}) {
262
284
  mkdirSync(DIAG_DIR, { recursive: true });
263
285
 
@@ -319,19 +341,10 @@ export async function diagnose({ json = false } = {}) {
319
341
  const summary = generateSummary(stats, sysInfo, hookTimings, traces.length);
320
342
  writeFileSync(join(bundleDir, "summary.txt"), summary);
321
343
 
322
- // 7. zip via PowerShell Compress-Archive
344
+ // 7. zip via platform archive tool
323
345
  const zipPath = `${bundleDir}.zip`;
324
346
  try {
325
- execFileSync(
326
- "powershell.exe",
327
- [
328
- "-NoProfile",
329
- "-NonInteractive",
330
- "-Command",
331
- `Compress-Archive -Path '${bundleDir}\\*' -DestinationPath '${zipPath}' -Force`,
332
- ],
333
- { timeout: 30000, windowsHide: true },
334
- );
347
+ createZipArchive(bundleDir, zipPath);
335
348
  } catch (err) {
336
349
  // fallback: leave the directory unzipped
337
350
  if (json) {
@@ -98,6 +98,17 @@ function isCodexConfig(filePath) {
98
98
  return normalized.endsWith("/.codex/config.toml");
99
99
  }
100
100
 
101
+ function isProtectedCodexConfigMutationEnv(env = process.env) {
102
+ return env.NODE_ENV === "test" || env.CI === "true" || env.TFX_TEST === "1";
103
+ }
104
+
105
+ function shouldSkipCodexConfigMutation() {
106
+ return (
107
+ process.env.TFX_CODEX_CONFIG_SYNC !== "1" &&
108
+ isProtectedCodexConfigMutationEnv()
109
+ );
110
+ }
111
+
101
112
  function detectClient(filePath) {
102
113
  const normalized = normalizeForMatch(filePath);
103
114
  if (normalized.endsWith("/.gemini/settings.json")) return "gemini";
@@ -448,6 +459,15 @@ function updateJsonConfig(filePath, updates = [], removals = []) {
448
459
 
449
460
  function updateCodexConfig(filePath, updates = [], removals = []) {
450
461
  const resolvedPath = resolveFilePath(filePath);
462
+ if (shouldSkipCodexConfigMutation()) {
463
+ return {
464
+ modified: false,
465
+ filePath: resolvedPath,
466
+ skipped: true,
467
+ reason: "protected-env",
468
+ };
469
+ }
470
+
451
471
  let raw = existsSync(resolvedPath) ? readFileSync(resolvedPath, "utf8") : "";
452
472
 
453
473
  for (const name of removals) {
package/scripts/setup.mjs CHANGED
@@ -581,6 +581,15 @@ function ensureHooksInSettings({ settingsPath, registryPath }) {
581
581
  }
582
582
  }
583
583
 
584
+ function isProtectedCodexConfigMutationEnv(env = process.env) {
585
+ return (
586
+ env.NODE_ENV === "test" ||
587
+ env.CI === "true" ||
588
+ env.TFX_TEST === "1" ||
589
+ Boolean(env.TRIFLUX_TEST_HOME)
590
+ );
591
+ }
592
+
584
593
  /**
585
594
  * Codex config.json에 tfx-hub MCP 서버 엔트리를 보장한다.
586
595
  * @param {{ mcpUrl: string, createIfMissing?: boolean, enabled?: boolean }} opts
@@ -594,7 +603,19 @@ function ensureCodexHubServerConfig({
594
603
  }) {
595
604
  try {
596
605
  const codexConfigDir = join(homedir(), ".codex");
597
- const configPath = configFile || join(codexConfigDir, "config.json");
606
+ const hasExplicitConfigFile =
607
+ typeof configFile === "string" && configFile.length > 0;
608
+ const configPath = hasExplicitConfigFile
609
+ ? configFile
610
+ : join(codexConfigDir, "config.json");
611
+
612
+ if (
613
+ !hasExplicitConfigFile &&
614
+ process.env.TFX_CODEX_CONFIG_SYNC !== "1" &&
615
+ isProtectedCodexConfigMutationEnv()
616
+ ) {
617
+ return { ok: true, changed: false, reason: "protected-env" };
618
+ }
598
619
 
599
620
  if (!existsSync(configPath)) {
600
621
  if (!createIfMissing)
@@ -642,6 +663,13 @@ const REQUIRED_TOP_LEVEL_SETTINGS = [
642
663
 
643
664
  function ensureCodexProfiles() {
644
665
  try {
666
+ if (
667
+ process.env.TFX_CODEX_CONFIG_SYNC !== "1" &&
668
+ isProtectedCodexConfigMutationEnv()
669
+ ) {
670
+ return { ok: true, changed: 0, reason: "protected-env" };
671
+ }
672
+
645
673
  if (!existsSync(CODEX_DIR)) mkdirSync(CODEX_DIR, { recursive: true });
646
674
 
647
675
  const original = existsSync(CODEX_CONFIG_PATH)
@@ -12,6 +12,7 @@ const CODEX_CONFIG_FILE = [".codex", "config.toml"];
12
12
  const TFX_HUB_SECTION = "tfx-hub";
13
13
  const CODEX_DEFAULT_HUB_URL = "http://127.0.0.1:27888/mcp";
14
14
  const FILE_LOCKS = new Map();
15
+ const CODEX_CONFIG_SYNC_OPT_IN = "TFX_CODEX_CONFIG_SYNC";
15
16
 
16
17
  // Windows 에서 process.env.HOME 만 set 하고 USERPROFILE 은 그대로 둔 fixture 환경
17
18
  // (e.g. integration test) 에서 production path 로 새는 것을 방지하려면 platform
@@ -40,6 +41,29 @@ function getCodexConfigPath(codexConfigPath) {
40
41
  return join(resolveHome(), ...CODEX_CONFIG_FILE);
41
42
  }
42
43
 
44
+ function isProtectedCodexConfigEnv(env = process.env) {
45
+ return (
46
+ env.NODE_ENV === "test" ||
47
+ env.CI === "true" ||
48
+ env.TFX_TEST === "1" ||
49
+ Boolean(env.TRIFLUX_TEST_HOME)
50
+ );
51
+ }
52
+
53
+ function shouldSkipImplicitCodexConfigWrite({
54
+ codexConfigPath,
55
+ allowProtectedEnvWrite,
56
+ env = process.env,
57
+ }) {
58
+ if (allowProtectedEnvWrite || env[CODEX_CONFIG_SYNC_OPT_IN] === "1") {
59
+ return false;
60
+ }
61
+ if (typeof codexConfigPath === "string" && codexConfigPath.length > 0) {
62
+ return false;
63
+ }
64
+ return isProtectedCodexConfigEnv(env);
65
+ }
66
+
43
67
  export function getProjectMcpJsonPaths(projectRoot) {
44
68
  const root =
45
69
  typeof projectRoot === "string" && projectRoot.length > 0
@@ -533,6 +557,7 @@ export async function syncCodexHubUrl({
533
557
  codexConfigPath,
534
558
  dryRun = false,
535
559
  logger = console,
560
+ allowProtectedEnvWrite = false,
536
561
  }) {
537
562
  const result = {
538
563
  updated: [],
@@ -540,8 +565,20 @@ export async function syncCodexHubUrl({
540
565
  errors: [],
541
566
  };
542
567
 
568
+ const filePath = getCodexConfigPath(codexConfigPath);
569
+ if (
570
+ shouldSkipImplicitCodexConfigWrite({
571
+ codexConfigPath,
572
+ allowProtectedEnvWrite,
573
+ })
574
+ ) {
575
+ log(logger, "info", `[codex-mcp-sync] skipped: ${filePath}`);
576
+ result.skipped.push(filePath);
577
+ return result;
578
+ }
579
+
543
580
  const outcome = await syncCodexConfigFile({
544
- filePath: getCodexConfigPath(codexConfigPath),
581
+ filePath,
545
582
  hubUrl,
546
583
  dryRun,
547
584
  logger,
@@ -8,13 +8,107 @@
8
8
  // 사용: node scripts/test-lock.mjs [-- ...node --test args]
9
9
 
10
10
  import { spawn } from "node:child_process";
11
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
12
- import { join } from "node:path";
11
+ import {
12
+ mkdirSync,
13
+ readdirSync,
14
+ readFileSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
19
+ import { fileURLToPath } from "node:url";
13
20
 
14
- const LOCK_DIR = join(import.meta.dirname, "..", ".test-lock");
21
+ const SCRIPT_PATH = fileURLToPath(import.meta.url);
22
+ const LOCK_DIR = join(dirname(SCRIPT_PATH), "..", ".test-lock");
15
23
  const LOCK_FILE = join(LOCK_DIR, "pid.lock");
16
24
  const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10분 넘으면 stale
17
25
 
26
+ function toPosixPath(path) {
27
+ return path.replace(/\\/g, "/");
28
+ }
29
+
30
+ function stripLeadingDotSlash(pattern) {
31
+ return pattern.startsWith("./") ? pattern.slice(2) : pattern;
32
+ }
33
+
34
+ function globToRegExp(pattern) {
35
+ let source = "^";
36
+ for (let i = 0; i < pattern.length; i++) {
37
+ const char = pattern[i];
38
+ if (char === "*") {
39
+ if (pattern[i + 1] === "*") {
40
+ if (pattern[i + 2] === "/") {
41
+ source += "(?:.*/)?";
42
+ i += 2;
43
+ } else {
44
+ source += ".*";
45
+ i += 1;
46
+ }
47
+ } else {
48
+ source += "[^/]*";
49
+ }
50
+ continue;
51
+ }
52
+ source += /[\\^$+?.()|[\]{}]/.test(char) ? `\\${char}` : char;
53
+ }
54
+ source += "$";
55
+ return new RegExp(source);
56
+ }
57
+
58
+ function staticGlobPrefix(pattern) {
59
+ const firstGlob = pattern.indexOf("*");
60
+ if (firstGlob === -1) return pattern;
61
+ const slash = pattern.lastIndexOf("/", firstGlob);
62
+ if (slash === -1) return ".";
63
+ return pattern.slice(0, slash) || ".";
64
+ }
65
+
66
+ function walkFiles(dir) {
67
+ let entries;
68
+ try {
69
+ entries = readdirSync(dir, { withFileTypes: true });
70
+ } catch {
71
+ return [];
72
+ }
73
+
74
+ const files = [];
75
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
76
+ const fullPath = join(dir, entry.name);
77
+ if (entry.isDirectory()) {
78
+ files.push(...walkFiles(fullPath));
79
+ } else if (entry.isFile()) {
80
+ files.push(fullPath);
81
+ }
82
+ }
83
+ return files;
84
+ }
85
+
86
+ function expandGlobArg(arg, cwd) {
87
+ const normalizedPattern = stripLeadingDotSlash(toPosixPath(arg));
88
+ const matchPattern = isAbsolute(arg)
89
+ ? toPosixPath(resolve(arg))
90
+ : normalizedPattern;
91
+ const matcher = globToRegExp(matchPattern);
92
+ const prefix = staticGlobPrefix(normalizedPattern);
93
+ const root = isAbsolute(arg) ? resolve(prefix) : resolve(cwd, prefix);
94
+
95
+ return walkFiles(root)
96
+ .map((file) => {
97
+ if (isAbsolute(arg)) return toPosixPath(resolve(file));
98
+ return toPosixPath(relative(cwd, file));
99
+ })
100
+ .filter((file) => matcher.test(file))
101
+ .sort((a, b) => a.localeCompare(b));
102
+ }
103
+
104
+ export function expandTestArgs(args, { cwd = process.cwd() } = {}) {
105
+ return args.flatMap((arg) => {
106
+ if (arg.startsWith("-") || !arg.includes("*")) return [arg];
107
+ const expanded = expandGlobArg(arg, cwd);
108
+ return expanded.length > 0 ? expanded : [arg];
109
+ });
110
+ }
111
+
18
112
  function readLock() {
19
113
  try {
20
114
  const content = readFileSync(LOCK_FILE, "utf8").trim();
@@ -56,36 +150,40 @@ function releaseLock() {
56
150
  }
57
151
  }
58
152
 
59
- // ── main ──
60
-
61
- acquireLock();
62
-
63
- // cleanup on exit
64
- process.on("exit", releaseLock);
65
- process.on("SIGINT", () => {
66
- releaseLock();
67
- process.exit(130);
68
- });
69
- process.on("SIGTERM", () => {
70
- releaseLock();
71
- process.exit(143);
72
- });
73
-
74
- // forward args after -- to node --test
75
- const args = process.argv.slice(2);
76
- // stdio split (issue #192 F1): when prepare.mjs spawns this lock with
77
- // ["ignore","pipe","pipe"], full inherit cascades the parent stdin=ignore
78
- // to grand-child node --test, breaking ConPTY assumptions on Windows and
79
- // surfacing as EXIT=1 (false-failed). Pipe stdin only stdout/stderr stay
80
- // inherited so the grand-child still streams to whoever attached to us.
81
- const child = spawn(process.execPath, args, {
82
- stdio: ["pipe", "inherit", "inherit"],
83
- env: { ...process.env, TEST_LOCK_PID: String(process.pid) },
84
- });
85
- // Close stdin immediately so node --test never blocks waiting for input.
86
- child.stdin?.end();
87
-
88
- child.on("exit", (code) => {
89
- releaseLock();
90
- process.exit(code ?? 1);
91
- });
153
+ export function main(argv = process.argv.slice(2)) {
154
+ acquireLock();
155
+
156
+ // cleanup on exit
157
+ process.on("exit", releaseLock);
158
+ process.on("SIGINT", () => {
159
+ releaseLock();
160
+ process.exit(130);
161
+ });
162
+ process.on("SIGTERM", () => {
163
+ releaseLock();
164
+ process.exit(143);
165
+ });
166
+
167
+ // forward args after -- to node --test
168
+ const args = expandTestArgs(argv);
169
+ // stdio split (issue #192 F1): when prepare.mjs spawns this lock with
170
+ // ["ignore","pipe","pipe"], full inherit cascades the parent stdin=ignore
171
+ // to grand-child node --test, breaking ConPTY assumptions on Windows and
172
+ // surfacing as EXIT=1 (false-failed). Pipe stdin only stdout/stderr stay
173
+ // inherited so the grand-child still streams to whoever attached to us.
174
+ const child = spawn(process.execPath, args, {
175
+ stdio: ["pipe", "inherit", "inherit"],
176
+ env: { ...process.env, TEST_LOCK_PID: String(process.pid) },
177
+ });
178
+ // Close stdin immediately so node --test never blocks waiting for input.
179
+ child.stdin?.end();
180
+
181
+ child.on("exit", (code) => {
182
+ releaseLock();
183
+ process.exit(code ?? 1);
184
+ });
185
+ }
186
+
187
+ if (process.argv[1] && resolve(process.argv[1]) === SCRIPT_PATH) {
188
+ main();
189
+ }