From 667b1d1e1f0db7f56f88bb88feaee9e2ce994f5c Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Tue, 12 May 2026 22:17:16 -0400 Subject: [PATCH] fix: ignore internal instance directories in health scans Closes #1236 --- src/channels/official-channels-store.ts | 7 +- src/management/checks/profile-check.ts | 5 +- src/management/checks/symlink-check.ts | 5 +- src/management/instance-directory.ts | 28 ++++ src/management/instance-manager.ts | 14 +- src/management/shared-manager.ts | 26 +--- src/web-server/health/profile-checks.ts | 5 +- src/web-server/health/symlink-checks.ts | 5 +- src/web-server/usage/aggregator.ts | 15 +-- .../management/instance-directory.test.ts | 126 ++++++++++++++++++ 10 files changed, 179 insertions(+), 57 deletions(-) create mode 100644 src/management/instance-directory.ts create mode 100644 tests/unit/management/instance-directory.test.ts diff --git a/src/channels/official-channels-store.ts b/src/channels/official-channels-store.ts index 5eb73dac..60e94079 100644 --- a/src/channels/official-channels-store.ts +++ b/src/channels/official-channels-store.ts @@ -11,6 +11,7 @@ import { isOfficialChannelTokenRequired, } from './official-channels-runtime'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstancePaths } from '../management/instance-directory'; export type OfficialChannelTokenSource = 'saved_env' | 'process_env' | 'missing'; @@ -151,10 +152,8 @@ function listManagedClaudeConfigDirs(): string[] { return [...dirs]; } - for (const entry of fs.readdirSync(instancesDir, { withFileTypes: true })) { - if (entry.isDirectory()) { - dirs.add(path.join(instancesDir, entry.name)); - } + for (const instancePath of listAccountInstancePaths(instancesDir)) { + dirs.add(instancePath); } return [...dirs]; diff --git a/src/management/checks/profile-check.ts b/src/management/checks/profile-check.ts index 5c969a83..dd55ecee 100644 --- a/src/management/checks/profile-check.ts +++ b/src/management/checks/profile-check.ts @@ -7,6 +7,7 @@ import * as path from 'path'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../instance-directory'; const ora = createSpinner(); @@ -144,9 +145,7 @@ export class InstancesChecker implements IHealthChecker { return; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); if (instances.length === 0) { spinner.info(); diff --git a/src/management/checks/symlink-check.ts b/src/management/checks/symlink-check.ts index 56f7d0e4..c10851fb 100644 --- a/src/management/checks/symlink-check.ts +++ b/src/management/checks/symlink-check.ts @@ -8,6 +8,7 @@ import * as os from 'os'; import { ok, fail, warn } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../instance-directory'; const ora = createSpinner(); @@ -180,9 +181,7 @@ export class SettingsSymlinksChecker implements IHealthChecker { return; } - const instances = fs - .readdirSync(instancesDir) - .filter((n) => fs.statSync(path.join(instancesDir, n)).isDirectory()); + const instances = listAccountInstanceNames(instancesDir); const broken = instances.filter((inst) => { const instSettings = path.join(instancesDir, inst, 'settings.json'); diff --git a/src/management/instance-directory.ts b/src/management/instance-directory.ts new file mode 100644 index 00000000..56b0b384 --- /dev/null +++ b/src/management/instance-directory.ts @@ -0,0 +1,28 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export function isAccountInstanceName(name: string): boolean { + return !name.startsWith('.'); +} + +export function listAccountInstanceNames(instancesDir: string): string[] { + if (!fs.existsSync(instancesDir)) { + return []; + } + + return fs.readdirSync(instancesDir).filter((name) => { + if (!isAccountInstanceName(name)) { + return false; + } + + try { + return fs.statSync(path.join(instancesDir, name)).isDirectory(); + } catch { + return false; + } + }); +} + +export function listAccountInstancePaths(instancesDir: string): string[] { + return listAccountInstanceNames(instancesDir).map((name) => path.join(instancesDir, name)); +} diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index e6d73717..ba23138e 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -15,6 +15,7 @@ import type { AccountContextPolicy } from '../auth/account-context'; import { getCcsHome } from '../utils/config-manager'; import { createLogger } from '../services/logging'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstanceNames } from './instance-directory'; const logger = createLogger('management:instance-manager'); @@ -189,18 +190,7 @@ class InstanceManager { * List all instance names */ listInstances(): string[] { - if (!fs.existsSync(this.instancesDir)) { - return []; - } - - return fs.readdirSync(this.instancesDir).filter((name) => { - if (name.startsWith('.')) { - return false; - } - - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); + return listAccountInstanceNames(this.instancesDir); } /** diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index d9963679..6ce5b2ce 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -19,6 +19,7 @@ import { normalizePluginMetadataValue, } from './plugin-path-normalizer'; import { getCcsDir } from '../config/config-loader-facade'; +import { listAccountInstanceNames, listAccountInstancePaths } from './instance-directory'; export { normalizePluginMetadataContent, normalizePluginMetadataPathString, @@ -832,16 +833,8 @@ class SharedManager { path.join(this.claudeDir, 'plugins', 'known_marketplaces.json'), ]); - if (fs.existsSync(this.instancesDir)) { - for (const entry of fs.readdirSync(this.instancesDir, { withFileTypes: true })) { - if (!entry.isDirectory() || entry.name.startsWith('.')) { - continue; - } - - sourcePaths.add( - path.join(this.instancesDir, entry.name, 'plugins', 'known_marketplaces.json') - ); - } + for (const instancePath of listAccountInstancePaths(this.instancesDir)) { + sourcePaths.add(path.join(instancePath, 'plugins', 'known_marketplaces.json')); } if (configDir && path.resolve(configDir) !== path.resolve(this.claudeDir)) { @@ -1037,14 +1030,10 @@ class SharedManager { // Update all instances to use new symlinks if (fs.existsSync(this.instancesDir)) { try { - const instances = fs.readdirSync(this.instancesDir); - - for (const instance of instances) { + for (const instance of listAccountInstanceNames(this.instancesDir)) { const instancePath = path.join(this.instancesDir, instance); try { - if (fs.statSync(instancePath).isDirectory()) { - this.linkSharedDirectories(instancePath); - } + this.linkSharedDirectories(instancePath); } catch (_err) { console.log(warn(`Failed to update instance ${instance}: ${(_err as Error).message}`)); } @@ -1081,10 +1070,7 @@ class SharedManager { return; } - const instances = fs.readdirSync(this.instancesDir).filter((name) => { - const instancePath = path.join(this.instancesDir, name); - return fs.statSync(instancePath).isDirectory(); - }); + const instances = listAccountInstanceNames(this.instancesDir); let migrated = 0; let skipped = 0; diff --git a/src/web-server/health/profile-checks.ts b/src/web-server/health/profile-checks.ts index 5e5e6561..1d6088e7 100644 --- a/src/web-server/health/profile-checks.ts +++ b/src/web-server/health/profile-checks.ts @@ -10,6 +10,7 @@ import * as path from 'path'; import type { HealthCheck } from './types'; import { isUnifiedMode, loadUnifiedConfig } from '../../config/config-loader-facade'; +import { listAccountInstanceNames } from '../../management/instance-directory'; /** * Check profiles configuration (API profiles from config.json or config.yaml) @@ -108,9 +109,7 @@ export function checkInstances(ccsDir: string): HealthCheck { }; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(path.join(instancesDir, name)).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); if (instances.length === 0) { return { diff --git a/src/web-server/health/symlink-checks.ts b/src/web-server/health/symlink-checks.ts index 40b90b5f..de3c8a45 100644 --- a/src/web-server/health/symlink-checks.ts +++ b/src/web-server/health/symlink-checks.ts @@ -7,6 +7,7 @@ import * as fs from 'fs'; import * as path from 'path'; import type { HealthCheck } from './types'; +import { listAccountInstanceNames } from '../../management/instance-directory'; /** * Check CCS symlinks health @@ -100,9 +101,7 @@ export function checkSettingsSymlinks(ccsDir: string, claudeDir: string): Health }; } - const instances = fs.readdirSync(instancesDir).filter((name) => { - return fs.statSync(`${instancesDir}/${name}`).isDirectory(); - }); + const instances = listAccountInstanceNames(instancesDir); let broken = 0; for (const instance of instances) { diff --git a/src/web-server/usage/aggregator.ts b/src/web-server/usage/aggregator.ts index 537da727..c3e29d1c 100644 --- a/src/web-server/usage/aggregator.ts +++ b/src/web-server/usage/aggregator.ts @@ -41,6 +41,7 @@ import { getProviderModelKey, } from './model-identity'; import { getCcsDir } from '../../config/config-loader-facade'; +import { listAccountInstancePaths } from '../../management/instance-directory'; // ============================================================================ // Multi-Instance Support - Aggregate usage from CCS profiles @@ -77,15 +78,11 @@ function getInstancePaths(): string[] { } try { - const entries = fs.readdirSync(instancesDir, { withFileTypes: true }); - return entries - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(instancesDir, entry.name)) - .filter((instancePath) => { - // Only include instances that have a projects directory - const projectsPath = path.join(instancePath, 'projects'); - return fs.existsSync(projectsPath); - }); + return listAccountInstancePaths(instancesDir).filter((instancePath) => { + // Only include instances that have a projects directory + const projectsPath = path.join(instancePath, 'projects'); + return fs.existsSync(projectsPath); + }); } catch { console.error(fail('Failed to read CCS instances directory')); return []; diff --git a/tests/unit/management/instance-directory.test.ts b/tests/unit/management/instance-directory.test.ts new file mode 100644 index 00000000..da64d7f1 --- /dev/null +++ b/tests/unit/management/instance-directory.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { SettingsSymlinksChecker } from '../../../src/management/checks/symlink-check'; +import { HealthCheck } from '../../../src/management/checks/types'; +import { + listAccountInstanceNames, + listAccountInstancePaths, +} from '../../../src/management/instance-directory'; +import { InstancesChecker } from '../../../src/management/checks/profile-check'; +import { checkInstances } from '../../../src/web-server/health/profile-checks'; +import { checkSettingsSymlinks } from '../../../src/web-server/health/symlink-checks'; + +describe('account instance directory enumeration', () => { + let tempRoot = ''; + let originalCcsHome: string | undefined; + let originalCcsDir: string | undefined; + + const ccsDir = () => path.join(tempRoot, '.ccs'); + const claudeDir = () => path.join(tempRoot, '.claude'); + const instancesDir = () => path.join(ccsDir(), 'instances'); + + function createValidSettingsLayout(): void { + const claudeSettings = path.join(claudeDir(), 'settings.json'); + const sharedSettings = path.join(ccsDir(), 'shared', 'settings.json'); + const workSettings = path.join(instancesDir(), 'work', 'settings.json'); + + fs.mkdirSync(path.dirname(claudeSettings), { recursive: true }); + fs.mkdirSync(path.dirname(sharedSettings), { recursive: true }); + fs.mkdirSync(path.dirname(workSettings), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + + fs.writeFileSync(claudeSettings, '{}\n', 'utf8'); + fs.symlinkSync(claudeSettings, sharedSettings, 'file'); + fs.symlinkSync(sharedSettings, workSettings, 'file'); + } + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-instance-directory-test-')); + originalCcsHome = process.env.CCS_HOME; + originalCcsDir = process.env.CCS_DIR; + + process.env.CCS_HOME = tempRoot; + delete process.env.CCS_DIR; + spyOn(os, 'homedir').mockReturnValue(tempRoot); + spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + mock.restore(); + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalCcsDir !== undefined) process.env.CCS_DIR = originalCcsDir; + else delete process.env.CCS_DIR; + + if (tempRoot && fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('lists only user account instance directories', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), 'personal'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.cache'), { recursive: true }); + fs.writeFileSync(path.join(instancesDir(), 'README.txt'), 'not an instance', 'utf8'); + + expect(listAccountInstanceNames(instancesDir()).sort()).toEqual(['personal', 'work']); + expect(listAccountInstancePaths(instancesDir()).sort()).toEqual( + [path.join(instancesDir(), 'personal'), path.join(instancesDir(), 'work')].sort() + ); + }); + + it('skips transient entries that cannot be statted', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.symlinkSync( + path.join(instancesDir(), 'missing-instance'), + path.join(instancesDir(), 'transient'), + 'dir' + ); + + expect(listAccountInstanceNames(instancesDir())).toEqual(['work']); + }); + + it('keeps ccs doctor settings symlinks healthy when .locks exists', () => { + createValidSettingsLayout(); + + const results = new HealthCheck(); + new SettingsSymlinksChecker().run(results); + + expect(results.warnings).toEqual([]); + expect(results.checks.find((check) => check.name === 'Settings Symlinks')?.status).toBe( + 'success' + ); + expect(results.details['Settings Symlinks']?.info).toBe('1 instance(s) valid'); + }); + + it('keeps ccs doctor instance counts tied to real profiles', () => { + fs.mkdirSync(path.join(instancesDir(), 'work'), { recursive: true }); + fs.mkdirSync(path.join(instancesDir(), '.locks'), { recursive: true }); + + const results = new HealthCheck(); + new InstancesChecker().run(results); + + expect(results.checks.find((check) => check.name === 'Instances')?.message).toBe( + '1 account profiles' + ); + }); + + it('keeps dashboard health checks tied to real profiles', () => { + createValidSettingsLayout(); + + expect(checkSettingsSymlinks(ccsDir(), claudeDir())).toMatchObject({ + status: 'ok', + message: '1 instance(s) valid', + }); + expect(checkInstances(ccsDir())).toMatchObject({ + status: 'ok', + message: '1 account profile', + }); + }); +});