mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user