fix: ignore internal instance directories in health scans

Closes #1236
This commit is contained in:
Kai (Tam Nhu) Tran
2026-05-12 22:17:16 -04:00
committed by GitHub
parent ac00ba4f00
commit 667b1d1e1f
10 changed files with 179 additions and 57 deletions
+3 -4
View File
@@ -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];
+2 -3
View File
@@ -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();
+2 -3
View File
@@ -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');
+28
View File
@@ -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));
}
+2 -12
View File
@@ -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);
}
/**
+6 -20
View File
@@ -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;
+2 -3
View File
@@ -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 {
+2 -3
View File
@@ -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) {
+6 -9
View File
@@ -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 [];
@@ -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',
});
});
});