coding-tool-x 3.7.5 → 3.7.7

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 (47) hide show
  1. package/dist/web/assets/{Analytics-DnnufrQi.js → Analytics-B6FzPvdJ.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-BTVhMSfe.js → ConfigTemplates-DXVnqR5c.js} +1 -1
  3. package/dist/web/assets/{Home-D03PEREx.js → Home-FHJB7hYI.js} +1 -1
  4. package/dist/web/assets/{PluginManager-BiMqEiyQ.js → PluginManager-D8RToURX.js} +1 -1
  5. package/dist/web/assets/ProjectList-Cdz4jnOg.js +1 -0
  6. package/dist/web/assets/ProjectList-VeMNXfgO.css +1 -0
  7. package/dist/web/assets/SessionList-C4chCPxK.css +1 -0
  8. package/dist/web/assets/SessionList-XYydFlQg.js +1 -0
  9. package/dist/web/assets/{SkillManager-DAy8k5CL.js → SkillManager-Bu9p7mMb.js} +1 -1
  10. package/dist/web/assets/{WorkspaceManager-Bs2D6R9j.js → WorkspaceManager-Bx0Gj3Mk.js} +1 -1
  11. package/dist/web/assets/index-DxkAvn4g.js +2 -0
  12. package/dist/web/assets/index-S8wrU2Nf.css +1 -0
  13. package/dist/web/index.html +2 -2
  14. package/package.json +1 -1
  15. package/src/commands/daemon.js +3 -1
  16. package/src/commands/port-config.js +0 -2
  17. package/src/commands/switch.js +1 -5
  18. package/src/commands/ui.js +4 -3
  19. package/src/commands/workspace.js +35 -3
  20. package/src/config/loader.js +76 -5
  21. package/src/index.js +11 -0
  22. package/src/server/api/config.js +0 -3
  23. package/src/server/api/omp-channels.js +50 -7
  24. package/src/server/api/settings.js +0 -2
  25. package/src/server/api/workspaces.js +39 -1
  26. package/src/server/services/dashboard-snapshot-worker.js +2 -1
  27. package/src/server/services/https-cert.js +3 -2
  28. package/src/server/services/native-oauth-adapters.js +2 -1
  29. package/src/server/services/omp-auth-provider-worker.js +29 -0
  30. package/src/server/services/omp-auth-providers.js +138 -11
  31. package/src/server/services/omp-config.js +7 -4
  32. package/src/server/services/omp-sessions.js +18 -14
  33. package/src/server/services/plugins-service.js +2 -1
  34. package/src/server/services/project-snapshots.js +8 -1
  35. package/src/server/services/session-snapshots.js +10 -1
  36. package/src/server/services/sessions.js +35 -19
  37. package/src/server/services/snapshot-cache.js +47 -3
  38. package/src/server/services/web-build.js +2 -1
  39. package/src/server/services/workspace-service.js +204 -98
  40. package/src/utils/cli-flags.js +7 -0
  41. package/src/utils/session.js +10 -4
  42. package/dist/web/assets/ProjectList-C6BA3V49.js +0 -1
  43. package/dist/web/assets/ProjectList-DQ5ZCJO2.css +0 -1
  44. package/dist/web/assets/SessionList--9Bl6EhV.css +0 -1
  45. package/dist/web/assets/SessionList-DChG04jz.js +0 -1
  46. package/dist/web/assets/index-DnDBHGCC.js +0 -2
  47. package/dist/web/assets/index-U3TOywtB.css +0 -1
@@ -3,7 +3,7 @@ const fs = require('fs');
3
3
  const path = require('path');
4
4
  const os = require('os');
5
5
  const DEFAULT_CONFIG = require('./default');
6
- const { PATHS, ensureStorageDirMigrated } = require('./paths');
6
+ const { PATHS, NATIVE_PATHS, ensureStorageDirMigrated } = require('./paths');
7
7
  const { resolvePreferredHomeDir } = require('../utils/home-dir');
8
8
  const eventBus = require('../plugins/event-bus');
9
9
 
@@ -23,12 +23,79 @@ function getConfigFilePath() {
23
23
  * 展开 ~ 为用户主目录
24
24
  */
25
25
  function expandHome(filepath) {
26
+ if (typeof filepath !== 'string') {
27
+ return filepath;
28
+ }
26
29
  if (filepath.startsWith('~')) {
27
30
  return path.join(HOME_DIR, filepath.slice(1));
28
31
  }
29
32
  return filepath;
30
33
  }
31
34
 
35
+ function collapseHome(filepath) {
36
+ if (typeof filepath !== 'string') {
37
+ return filepath;
38
+ }
39
+ if (filepath === HOME_DIR) {
40
+ return '~';
41
+ }
42
+ const prefix = `${HOME_DIR}${path.sep}`;
43
+ if (filepath.startsWith(prefix)) {
44
+ return path.join('~', filepath.slice(prefix.length));
45
+ }
46
+ return filepath;
47
+ }
48
+
49
+ function getNativeClaudeProjectsDir() {
50
+ return NATIVE_PATHS?.claude?.projects || path.join(HOME_DIR, '.claude', 'projects');
51
+ }
52
+
53
+ function hasExplicitProjectsDir(config = {}) {
54
+ return Object.prototype.hasOwnProperty.call(config, 'projectsDir') &&
55
+ typeof config.projectsDir === 'string' &&
56
+ config.projectsDir.trim() !== '';
57
+ }
58
+
59
+ function resolveClaudeProjectsDir(config = {}) {
60
+ if (!hasExplicitProjectsDir(config) || isDefaultProjectsDir(config.projectsDir)) {
61
+ return getNativeClaudeProjectsDir();
62
+ }
63
+ return expandHome(config.projectsDir);
64
+ }
65
+
66
+ function normalizeComparablePath(filepath) {
67
+ if (typeof filepath !== 'string' || !filepath.trim()) {
68
+ return '';
69
+ }
70
+ return path.normalize(expandHome(filepath.trim()));
71
+ }
72
+
73
+ function isDefaultProjectsDir(projectsDir) {
74
+ const normalized = normalizeComparablePath(projectsDir);
75
+ if (!normalized) {
76
+ return true;
77
+ }
78
+ const nativeDefault = normalizeComparablePath(getNativeClaudeProjectsDir());
79
+ const legacyHomeDefault = normalizeComparablePath(path.join(HOME_DIR, '.claude', 'projects'));
80
+ if (normalized === nativeDefault || normalized === legacyHomeDefault) {
81
+ return true;
82
+ }
83
+ const normalizedParts = normalized.replace(/\\/g, '/').split('/').filter(Boolean);
84
+ const last = normalizedParts[normalizedParts.length - 1];
85
+ const previous = normalizedParts[normalizedParts.length - 2];
86
+ return previous === '.claude' && last === 'projects' && !fs.existsSync(normalized);
87
+ }
88
+
89
+ function normalizeConfigForSave(config = {}) {
90
+ const normalized = { ...config };
91
+ if (isDefaultProjectsDir(normalized.projectsDir)) {
92
+ delete normalized.projectsDir;
93
+ } else if (typeof normalized.projectsDir === 'string') {
94
+ normalized.projectsDir = collapseHome(normalized.projectsDir);
95
+ }
96
+ return normalized;
97
+ }
98
+
32
99
  function mergePricing(defaultPricing, overrides = {}) {
33
100
  const merged = {};
34
101
  Object.keys(defaultPricing).forEach((key) => {
@@ -96,7 +163,7 @@ function loadConfig() {
96
163
  if (fs.existsSync(configFilePath)) {
97
164
  const userConfig = readJsonFile(configFilePath);
98
165
  const config = { ...DEFAULT_CONFIG, ...userConfig };
99
- config.projectsDir = expandHome(config.projectsDir || DEFAULT_CONFIG.projectsDir);
166
+ config.projectsDir = resolveClaudeProjectsDir(userConfig);
100
167
 
101
168
  // 合并 ports 配置
102
169
  config.ports = { ...DEFAULT_CONFIG.ports, ...userConfig.ports };
@@ -120,7 +187,7 @@ function loadConfig() {
120
187
  }
121
188
  const defaultConfig = {
122
189
  ...DEFAULT_CONFIG,
123
- projectsDir: expandHome(DEFAULT_CONFIG.projectsDir),
190
+ projectsDir: resolveClaudeProjectsDir(),
124
191
  currentProject: DEFAULT_CONFIG.defaultProject
125
192
  };
126
193
  eventBus.emitSync('config:loaded', { config: defaultConfig });
@@ -137,8 +204,9 @@ function saveConfig(config) {
137
204
  if (!fs.existsSync(dir)) {
138
205
  fs.mkdirSync(dir, { recursive: true });
139
206
  }
140
- fs.writeFileSync(configFilePath, JSON.stringify(config, null, 2), 'utf8');
141
- eventBus.emitSync('config:saved', { config });
207
+ const normalizedConfig = normalizeConfigForSave(config);
208
+ fs.writeFileSync(configFilePath, JSON.stringify(normalizedConfig, null, 2), 'utf8');
209
+ eventBus.emitSync('config:saved', { config: normalizedConfig });
142
210
  } catch (error) {
143
211
  console.error(`保存配置失败 (${configFilePath}):`, error.message);
144
212
  }
@@ -148,5 +216,8 @@ module.exports = {
148
216
  loadConfig,
149
217
  saveConfig,
150
218
  expandHome,
219
+ resolveClaudeProjectsDir,
220
+ normalizeConfigForSave,
221
+ isDefaultProjectsDir,
151
222
  getConfigFilePath
152
223
  };
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ const { ensureStorageDirMigrated } = require('./config/paths');
17
17
  const { normalizePlatformKey } = require('./shared/platforms');
18
18
  const PluginManager = require('./plugins/plugin-manager');
19
19
  const eventBus = require('./plugins/event-bus');
20
+ const { hasHostFlag } = require('./utils/cli-flags');
20
21
  const chalk = require('chalk');
21
22
  const path = require('path');
22
23
  const fs = require('fs');
@@ -54,9 +55,12 @@ function showHelp() {
54
55
  console.log(' ctx ui 前台启动 Web UI(仅本地访问)');
55
56
  console.log(' ctx ui --https 前台启动 Web UI(本地 HTTPS)');
56
57
  console.log(' ctx ui --host 前台启动 Web UI(允许 LAN 访问,默认允许远程写操作)');
58
+ console.log(' ctx ui --hosts 前台启动 Web UI(同 --host)');
59
+ console.log(' ctx --hosts 前台启动 Web UI(同 ctx ui --host)');
57
60
  console.log(' ctx ui start 后台启动 Web UI');
58
61
  console.log(' ctx ui start --https 后台启动 Web UI(本地 HTTPS)');
59
62
  console.log(' ctx ui start --host 后台启动 Web UI(允许 LAN 访问,默认允许远程写操作)');
63
+ console.log(' ctx ui start --hosts 后台启动 Web UI(同 --host)');
60
64
  console.log(' ctx ui stop 停止 Web UI');
61
65
  console.log(' ctx ui restart 重启 Web UI\n');
62
66
  console.log(chalk.gray(' 提示: 如需禁止 LAN 远程写操作,可设置 CC_TOOL_ALLOW_REMOTE_WRITE=false\n'));
@@ -149,6 +153,13 @@ async function main() {
149
153
  // 处理命令行参数
150
154
  const args = process.argv.slice(2);
151
155
 
156
+ // LAN 快捷入口,等同于 ctx ui --host/--hosts
157
+ if (hasHostFlag(process.argv) && !args.some((arg) => arg && !arg.startsWith('-'))) {
158
+ const { handleUI } = require('./commands/ui');
159
+ await handleUI();
160
+ return;
161
+ }
162
+
152
163
  // --version 或 -v - 显示版本号
153
164
  if (args[0] === '--version' || args[0] === '-v') {
154
165
  console.log(getVersion());
@@ -330,7 +330,6 @@ router.post('/default-models', (req, res) => {
330
330
  // Save config
331
331
  const newConfig = {
332
332
  ...config,
333
- projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
334
333
  defaultModels: newDefaultModels
335
334
  };
336
335
 
@@ -378,7 +377,6 @@ router.post('/default-models/reset', (req, res) => {
378
377
  // Save config
379
378
  const newConfig = {
380
379
  ...config,
381
- projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
382
380
  defaultModels: newDefaultModels
383
381
  };
384
382
 
@@ -490,7 +488,6 @@ router.post('/advanced', (req, res) => {
490
488
  // 更新配置
491
489
  const newConfig = {
492
490
  ...config,
493
- projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
494
491
  ports: normalizedPorts,
495
492
  maxLogs: maxLogs !== undefined ? parseInt(maxLogs) : config.maxLogs,
496
493
  statsInterval: statsInterval !== undefined ? parseInt(statsInterval) : config.statsInterval,
@@ -23,9 +23,13 @@ const {
23
23
  } = require('../services/model-detector');
24
24
  const {
25
25
  findAuthProviderForKey,
26
- getOmpAuthProviderSnapshot
26
+ getCachedOmpAuthProviderSnapshot,
27
+ getOmpAuthProviderCacheMeta,
28
+ warmOmpAuthProviderSnapshot
27
29
  } = require('../services/omp-auth-providers');
28
30
 
31
+ const CHANNEL_LIST_AUTH_OPTIONS = { accountCheck: false, includeStatus: false };
32
+
29
33
  function uniqueModels(models = []) {
30
34
  const seen = new Set();
31
35
  const result = [];
@@ -136,19 +140,53 @@ function attachAuthProvider(channel, snapshot) {
136
140
  };
137
141
  }
138
142
 
143
+ function getAuthSnapshotForChannelList() {
144
+ const snapshot = getCachedOmpAuthProviderSnapshot(CHANNEL_LIST_AUTH_OPTIONS);
145
+ const shouldWarm = !snapshot || snapshot.stale;
146
+ if (shouldWarm) {
147
+ warmOmpAuthProviderSnapshot(CHANNEL_LIST_AUTH_OPTIONS);
148
+ }
149
+ const meta = getOmpAuthProviderCacheMeta(CHANNEL_LIST_AUTH_OPTIONS);
150
+ return {
151
+ snapshot,
152
+ meta: {
153
+ ...meta,
154
+ stale: !snapshot || Boolean(snapshot.stale),
155
+ refreshing: shouldWarm || meta.refreshing,
156
+ fallback: !snapshot
157
+ }
158
+ };
159
+ }
160
+
161
+ function buildUnavailableAuthProviderMeta(error) {
162
+ return {
163
+ cached: false,
164
+ stale: false,
165
+ refreshing: false,
166
+ fallback: true,
167
+ checkedAt: new Date().toISOString(),
168
+ error: error || 'omp-not-available'
169
+ };
170
+ }
171
+
139
172
  module.exports = () => {
140
173
  router.get('/', (req, res) => {
141
174
  try {
142
175
  if (!isOmpInstalled()) {
143
- return res.json({ channels: [], installed: false, error: 'OMP CLI not installed' });
176
+ return res.json({
177
+ channels: [],
178
+ installed: false,
179
+ error: 'OMP CLI not installed',
180
+ authProviderMeta: buildUnavailableAuthProviderMeta('OMP CLI not installed')
181
+ });
144
182
  }
145
183
  const data = getChannels();
146
- const authSnapshot = getOmpAuthProviderSnapshot({ accountCheck: false, includeStatus: false });
184
+ const { snapshot: authSnapshot, meta: authProviderMeta } = getAuthSnapshotForChannelList();
147
185
  const channels = (data.channels || []).map(ch => ({
148
186
  ...attachAuthProvider(ch, authSnapshot),
149
187
  health: getChannelHealthStatus(ch.id, 'omp')
150
188
  }));
151
- res.json({ channels, installed: true });
189
+ res.json({ channels, installed: true, authProviderMeta });
152
190
  } catch (err) {
153
191
  console.error('[OMP Channels API] Failed to get channels:', err);
154
192
  res.status(500).json({ error: err.message });
@@ -158,14 +196,19 @@ module.exports = () => {
158
196
  router.get('/enabled', (req, res) => {
159
197
  try {
160
198
  if (!isOmpInstalled()) {
161
- return res.json({ channels: [], installed: false, error: 'OMP CLI not installed' });
199
+ return res.json({
200
+ channels: [],
201
+ installed: false,
202
+ error: 'OMP CLI not installed',
203
+ authProviderMeta: buildUnavailableAuthProviderMeta('OMP CLI not installed')
204
+ });
162
205
  }
163
206
  const data = getChannels();
164
- const authSnapshot = getOmpAuthProviderSnapshot({ accountCheck: false, includeStatus: false });
207
+ const { snapshot: authSnapshot, meta: authProviderMeta } = getAuthSnapshotForChannelList();
165
208
  const channels = (data.channels || [])
166
209
  .filter(ch => ch.enabled !== false)
167
210
  .map(ch => ({ ...attachAuthProvider(ch, authSnapshot), health: getChannelHealthStatus(ch.id, 'omp') }));
168
- res.json({ channels, installed: true });
211
+ res.json({ channels, installed: true, authProviderMeta });
169
212
  } catch (err) {
170
213
  console.error('[OMP Channels API] Failed to get enabled channels:', err);
171
214
  res.status(500).json({ error: err.message });
@@ -90,7 +90,6 @@ function handleSaveModelSettings(req, res) {
90
90
  const config = loadConfig();
91
91
  const newConfig = {
92
92
  ...config,
93
- projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
94
93
  modelMetadataOverrides: overrides && typeof overrides === 'object'
95
94
  ? overrides
96
95
  : (config.modelMetadataOverrides || {})
@@ -124,7 +123,6 @@ function handleDeleteModelOverride(req, res) {
124
123
 
125
124
  const newConfig = {
126
125
  ...config,
127
- projectsDir: config.projectsDir.replace(require('os').homedir(), '~'),
128
126
  modelMetadataOverrides: overrides
129
127
  };
130
128
  saveConfig(newConfig);
@@ -33,6 +33,24 @@ function validateBranchName(branchName) {
33
33
  }
34
34
  }
35
35
 
36
+ function normalizeBranchMode(branchMode) {
37
+ if (typeof branchMode !== 'string' || !branchMode.trim()) {
38
+ return '';
39
+ }
40
+ return branchMode.trim();
41
+ }
42
+
43
+ function validateBranchMode(branchMode) {
44
+ const normalized = normalizeBranchMode(branchMode);
45
+ if (!normalized) {
46
+ return { valid: true, normalized: '' };
47
+ }
48
+ if (normalized !== 'existing' && normalized !== 'new') {
49
+ return { valid: false, normalized, message: `分支模式不合法: ${normalized}` };
50
+ }
51
+ return { valid: true, normalized };
52
+ }
53
+
36
54
  /**
37
55
  * GET /api/workspaces
38
56
  * 获取所有工作区列表
@@ -226,6 +244,17 @@ router.post('/', (req, res) => {
226
244
  });
227
245
  }
228
246
 
247
+ const branchModeValidation = validateBranchMode(proj.branchMode);
248
+ if (!branchModeValidation.valid) {
249
+ return res.status(400).json({
250
+ success: false,
251
+ message: branchModeValidation.message
252
+ });
253
+ }
254
+ if (branchModeValidation.normalized) {
255
+ proj.branchMode = branchModeValidation.normalized;
256
+ }
257
+
229
258
  const normalizedBranch = normalizeBranchName(proj.branch);
230
259
  if (normalizedBranch) {
231
260
  const branchValidation = validateBranchName(normalizedBranch);
@@ -329,9 +358,10 @@ router.put('/:id/last-used', (req, res) => {
329
358
  router.post('/:id/projects', (req, res) => {
330
359
  try {
331
360
  const { id } = req.params;
332
- const { sourcePath, name, createWorktree, branch, baseBranch } = req.body;
361
+ const { sourcePath, name, createWorktree, branch, baseBranch, branchMode } = req.body;
333
362
  const normalizedBranch = normalizeBranchName(branch);
334
363
  const normalizedBaseBranch = normalizeBranchName(baseBranch);
364
+ const branchModeValidation = validateBranchMode(branchMode);
335
365
 
336
366
  if (!sourcePath || !sourcePath.trim()) {
337
367
  return res.status(400).json({
@@ -340,6 +370,13 @@ router.post('/:id/projects', (req, res) => {
340
370
  });
341
371
  }
342
372
 
373
+ if (!branchModeValidation.valid) {
374
+ return res.status(400).json({
375
+ success: false,
376
+ message: branchModeValidation.message
377
+ });
378
+ }
379
+
343
380
  if (normalizedBranch) {
344
381
  const branchValidation = validateBranchName(normalizedBranch);
345
382
  if (!branchValidation.valid) {
@@ -364,6 +401,7 @@ router.post('/:id/projects', (req, res) => {
364
401
  sourcePath,
365
402
  name,
366
403
  createWorktree,
404
+ branchMode: branchModeValidation.normalized || branchMode,
367
405
  branch: normalizedBranch || branch,
368
406
  baseBranch: normalizedBaseBranch || baseBranch
369
407
  });
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { fork } = require('child_process');
4
4
 
5
- const DASHBOARD_SNAPSHOT_WORKER_TIMEOUT_MS = 120 * 1000;
5
+ const DASHBOARD_SNAPSHOT_WORKER_TIMEOUT_MS = 180 * 1000;
6
6
 
7
7
  function sortClaudeProjects(projects, order = []) {
8
8
  if (!Array.isArray(order) || order.length === 0) {
@@ -130,6 +130,7 @@ function runDashboardSnapshotWorker(kind, source, config = {}, options = {}) {
130
130
  return new Promise((resolve, reject) => {
131
131
  const child = fork(__filename, [], {
132
132
  stdio: ['ignore', 'ignore', 'pipe', 'ipc'],
133
+ windowsHide: true,
133
134
  env: {
134
135
  ...process.env,
135
136
  CC_TOOL_DASHBOARD_SNAPSHOT_WORKER: '1'
@@ -42,7 +42,7 @@ function resolveOpenSslCommand() {
42
42
 
43
43
  for (const command of commands) {
44
44
  try {
45
- execFileSync(command, ['version'], { stdio: 'ignore' });
45
+ execFileSync(command, ['version'], { stdio: 'ignore', windowsHide: true });
46
46
  return command;
47
47
  } catch {
48
48
  // Try next candidate.
@@ -96,7 +96,8 @@ function generatePemCertificateWithOpenSsl(command) {
96
96
  '-config',
97
97
  configPath
98
98
  ], {
99
- stdio: 'ignore'
99
+ stdio: 'ignore',
100
+ windowsHide: true
100
101
  });
101
102
 
102
103
  writeMeta({
@@ -1029,7 +1029,8 @@ function runOmpCommand(args = [], options = {}) {
1029
1029
  ...process.env,
1030
1030
  ...(options.env || {})
1031
1031
  },
1032
- timeout: options.timeout || 10000
1032
+ timeout: options.timeout || 10000,
1033
+ windowsHide: true
1033
1034
  });
1034
1035
  const status = result?.status === undefined || result?.status === null ? 0 : result.status;
1035
1036
  return {
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ const { getOmpAuthProviderSnapshot } = require('./omp-auth-providers');
4
+
5
+ function attachWorkerHandler() {
6
+ process.on('message', (options = {}) => {
7
+ try {
8
+ const value = getOmpAuthProviderSnapshot(options);
9
+ if (process.send) {
10
+ process.send({ ok: true, value }, () => process.exit(0));
11
+ return;
12
+ }
13
+ } catch (error) {
14
+ if (process.send) {
15
+ process.send({ ok: false, error: error?.message || String(error) }, () => process.exit(1));
16
+ return;
17
+ }
18
+ }
19
+ process.exit(1);
20
+ });
21
+ }
22
+
23
+ if (process.env.CC_TOOL_OMP_AUTH_PROVIDER_WORKER === '1' || require.main === module) {
24
+ attachWorkerHandler();
25
+ }
26
+
27
+ module.exports = {
28
+ attachWorkerHandler
29
+ };
@@ -1,9 +1,11 @@
1
- const { spawnSync } = require('child_process');
1
+ const path = require('path');
2
+ const { fork, spawnSync } = require('child_process');
2
3
  const ompConfig = require('./omp-config');
3
4
 
4
5
  const COMMAND_TIMEOUT_MS = 5000;
5
6
  const TOKEN_LIST_TIMEOUT_MS = 3000;
6
7
  const CACHE_TTL_MS = 15000;
8
+ const WARMUP_WORKER_TIMEOUT_MS = COMMAND_TIMEOUT_MS + 1000;
7
9
 
8
10
  const LOGIN_PROVIDER_IDS = new Set([
9
11
  'openai-codex',
@@ -50,6 +52,20 @@ const PROVIDER_ALIASES = {
50
52
  };
51
53
 
52
54
  let snapshotCache = {};
55
+ const warmupTimers = new Map();
56
+ const warmupErrors = new Map();
57
+
58
+ function getSnapshotCacheKey(options = {}) {
59
+ return options.accountCheck === false
60
+ ? (options.includeStatus === false ? 'metadata' : 'metadata-status')
61
+ : 'full';
62
+ }
63
+
64
+ function writeSnapshotCache(cacheKey, loadedAt, value) {
65
+ snapshotCache[cacheKey] = { loadedAt, value };
66
+ warmupErrors.delete(cacheKey);
67
+ return value;
68
+ }
53
69
 
54
70
  function normalizeProviderId(value = '') {
55
71
  return String(value || '')
@@ -95,7 +111,8 @@ function runCommand(command, args = [], options = {}) {
95
111
  ...process.env,
96
112
  ...(options.env || {})
97
113
  },
98
- timeout: options.timeout || COMMAND_TIMEOUT_MS
114
+ timeout: options.timeout || COMMAND_TIMEOUT_MS,
115
+ windowsHide: true
99
116
  });
100
117
  if (typeof result === 'string' || Buffer.isBuffer(result)) {
101
118
  return { status: 0, stdout: normalizeCommandOutput(result), stderr: '' };
@@ -205,9 +222,7 @@ function buildEmptySnapshot(runtime, reason) {
205
222
 
206
223
  function getOmpAuthProviderSnapshot(options = {}) {
207
224
  const now = Date.now();
208
- const cacheKey = options.accountCheck === false
209
- ? (options.includeStatus === false ? 'metadata' : 'metadata-status')
210
- : 'full';
225
+ const cacheKey = getSnapshotCacheKey(options);
211
226
  const cached = snapshotCache[cacheKey];
212
227
  if (!options.forceRefresh && cached && now - cached.loadedAt < CACHE_TTL_MS) {
213
228
  return cached.value;
@@ -217,16 +232,14 @@ function getOmpAuthProviderSnapshot(options = {}) {
217
232
  const runtime = options.runtime || ompConfig.resolveOmpRuntime(env, options.runtimeOptions || {});
218
233
  if (!runtime || runtime.runtime !== 'omp' || !runtime.installed) {
219
234
  const value = buildEmptySnapshot(runtime || null, 'omp-not-available');
220
- snapshotCache[cacheKey] = { loadedAt: now, value };
221
- return value;
235
+ return writeSnapshotCache(cacheKey, now, value);
222
236
  }
223
237
 
224
238
  const command = runtime.command;
225
239
  const listResult = parseJsonCommand(command, ['auth-broker', 'list', '--json'], options);
226
240
  if (!listResult.ok || !Array.isArray(listResult.value)) {
227
241
  const value = buildEmptySnapshot(runtime, listResult.error || listResult.stderr || 'auth-provider-list-failed');
228
- snapshotCache[cacheKey] = { loadedAt: now, value };
229
- return value;
242
+ return writeSnapshotCache(cacheKey, now, value);
230
243
  }
231
244
 
232
245
  const supportedProviders = listResult.value
@@ -268,8 +281,113 @@ function getOmpAuthProviderSnapshot(options = {}) {
268
281
  aliases: { ...PROVIDER_ALIASES },
269
282
  checkedAt: new Date().toISOString()
270
283
  };
271
- snapshotCache[cacheKey] = { loadedAt: now, value };
272
- return value;
284
+ return writeSnapshotCache(cacheKey, now, value);
285
+ }
286
+
287
+ function getCachedOmpAuthProviderSnapshot(options = {}) {
288
+ const cacheKey = getSnapshotCacheKey(options);
289
+ const cached = snapshotCache[cacheKey];
290
+ if (!cached) return null;
291
+ return {
292
+ ...cached.value,
293
+ stale: Date.now() - cached.loadedAt >= CACHE_TTL_MS
294
+ };
295
+ }
296
+
297
+ function getOmpAuthProviderCacheMeta(options = {}) {
298
+ const cacheKey = getSnapshotCacheKey(options);
299
+ const cached = snapshotCache[cacheKey];
300
+ const stale = !cached || Date.now() - cached.loadedAt >= CACHE_TTL_MS;
301
+ const cachedReason = cached?.value?.available === false ? cached.value.reason || null : null;
302
+ return {
303
+ cached: Boolean(cached),
304
+ stale,
305
+ refreshing: warmupTimers.has(cacheKey),
306
+ fallback: !cached,
307
+ checkedAt: cached?.value?.checkedAt || null,
308
+ error: warmupErrors.get(cacheKey) || cachedReason || null
309
+ };
310
+ }
311
+
312
+ function warmOmpAuthProviderSnapshot(options = {}) {
313
+ const cacheKey = getSnapshotCacheKey(options);
314
+ if (warmupTimers.has(cacheKey)) return;
315
+
316
+ const finishWarmup = (task, error = null, value = null) => {
317
+ if (!warmupTimers.has(cacheKey)) return;
318
+ if (task.timeout) clearTimeout(task.timeout);
319
+ if (task.child) {
320
+ task.child.removeAllListeners();
321
+ if (!task.child.killed) task.child.kill();
322
+ }
323
+ warmupTimers.delete(cacheKey);
324
+ if (error) {
325
+ warmupErrors.set(cacheKey, sanitizeIdentity(error?.message || String(error || 'auth-provider-warmup-failed')));
326
+ return;
327
+ }
328
+ if (value) {
329
+ writeSnapshotCache(cacheKey, Date.now(), value);
330
+ }
331
+ };
332
+
333
+ // Tests and callers that supply a function-based command runner cannot cross
334
+ // the process boundary. Production channel-list warmups use the worker path.
335
+ if (process.env.NODE_ENV === 'test' || options.commandRunner) {
336
+ const task = { timeout: null, child: null };
337
+ const timer = setTimeout(() => {
338
+ try {
339
+ finishWarmup(task, null, getOmpAuthProviderSnapshot(options));
340
+ } catch (error) {
341
+ finishWarmup(task, error);
342
+ }
343
+ }, 0);
344
+ task.timeout = timer;
345
+ warmupTimers.set(cacheKey, task);
346
+ return;
347
+ }
348
+
349
+ const workerPath = path.join(__dirname, 'omp-auth-provider-worker.js');
350
+ const task = { timeout: null, child: null };
351
+ try {
352
+ const child = fork(workerPath, [], {
353
+ stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
354
+ windowsHide: true,
355
+ env: {
356
+ ...process.env,
357
+ CC_TOOL_OMP_AUTH_PROVIDER_WORKER: '1'
358
+ }
359
+ });
360
+ task.child = child;
361
+ task.timeout = setTimeout(() => {
362
+ finishWarmup(task, new Error('auth-provider-warmup-timed-out'));
363
+ }, WARMUP_WORKER_TIMEOUT_MS);
364
+ warmupTimers.set(cacheKey, task);
365
+
366
+ child.on('message', (message) => {
367
+ if (message?.ok) {
368
+ finishWarmup(task, null, message.value);
369
+ } else {
370
+ finishWarmup(task, new Error(message?.error || 'auth-provider-warmup-failed'));
371
+ }
372
+ });
373
+ child.on('error', (error) => finishWarmup(task, error));
374
+ child.on('exit', (code, signal) => {
375
+ if (warmupTimers.get(cacheKey) === task) {
376
+ finishWarmup(task, new Error(`auth-provider-warmup-worker-exited-${code || signal || 'unknown'}`));
377
+ }
378
+ });
379
+ child.send({
380
+ accountCheck: options.accountCheck,
381
+ includeStatus: options.includeStatus,
382
+ forceRefresh: options.forceRefresh === true,
383
+ runtime: options.runtime,
384
+ runtimeOptions: options.runtimeOptions
385
+ });
386
+ } catch (error) {
387
+ // `fork` itself can fail before the task is registered. Preserve the
388
+ // failure for the non-blocking status endpoint instead of dropping it.
389
+ warmupErrors.set(cacheKey, sanitizeIdentity(error?.message || String(error || 'auth-provider-warmup-failed')));
390
+ }
273
391
  }
274
392
 
275
393
  function findAuthProviderForKey(providerKey, snapshot) {
@@ -280,6 +398,12 @@ function findAuthProviderForKey(providerKey, snapshot) {
280
398
 
281
399
  function clearOmpAuthProviderCache() {
282
400
  snapshotCache = {};
401
+ for (const task of warmupTimers.values()) {
402
+ if (task.timeout) clearTimeout(task.timeout);
403
+ if (task.child && !task.child.killed) task.child.kill();
404
+ }
405
+ warmupTimers.clear();
406
+ warmupErrors.clear();
283
407
  }
284
408
 
285
409
  module.exports = {
@@ -287,7 +411,10 @@ module.exports = {
287
411
  ACCOUNT_CHECK_PROVIDER_IDS,
288
412
  clearOmpAuthProviderCache,
289
413
  findAuthProviderForKey,
414
+ getCachedOmpAuthProviderSnapshot,
415
+ getOmpAuthProviderCacheMeta,
290
416
  getOmpAuthProviderSnapshot,
417
+ warmOmpAuthProviderSnapshot,
291
418
  normalizeProviderId,
292
419
  resolveProviderAlias,
293
420
  sanitizeIdentity