feat(auth): add opt-in shared context groups across accounts

This commit is contained in:
Tam Nhu Tran
2026-02-24 23:54:03 +07:00
parent 1dae52014b
commit 4fed74c64b
21 changed files with 787 additions and 25 deletions
+12
View File
@@ -221,6 +221,18 @@ ccs work "implement feature" # Terminal 1
ccs "review code" # Terminal 2 (personal account)
```
Need continuity between two accounts for the same project? Opt in to shared context:
```bash
# Share context with default group
ccs auth create backup --share-context
# Or isolate by named group (only accounts in this group share context)
ccs auth create backup2 --context-group sprint-a
```
Isolation remains the default. Shared context only links project workspace data; credentials stay per-account.
<br>
## Maintenance
+164
View File
@@ -0,0 +1,164 @@
/**
* Account context policy helpers.
*
* Controls whether account instances keep project context isolated, or share
* project workspace context with other accounts in the same context group.
*/
export type AccountContextMode = 'isolated' | 'shared';
export interface AccountContextMetadata {
context_mode?: AccountContextMode;
context_group?: string;
}
export interface AccountContextPolicy {
mode: AccountContextMode;
group?: string;
}
export interface CreateAccountContextInput {
shareContext: boolean;
contextGroup?: string;
}
export interface ResolvedCreateAccountContext {
policy: AccountContextPolicy;
error?: string;
}
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
/**
* Normalize context group names so paths and config stay consistent.
*/
export function normalizeContextGroupName(value: string): string {
return value.trim().toLowerCase();
}
/**
* Validate context group naming constraints.
*/
export function isValidContextGroupName(value: string): boolean {
return CONTEXT_GROUP_PATTERN.test(value);
}
/**
* Runtime type guard for account context metadata payloads.
*/
export function isAccountContextMetadata(value: unknown): value is AccountContextMetadata {
if (typeof value !== 'object' || value === null) {
return false;
}
const candidate = value as Record<string, unknown>;
const mode = candidate['context_mode'];
const group = candidate['context_group'];
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
const groupValid = group === undefined || typeof group === 'string';
return modeValid && groupValid;
}
/**
* Resolve create-command flags into a valid context policy.
*/
export function resolveCreateAccountContext(
input: CreateAccountContextInput
): ResolvedCreateAccountContext {
const hasGroupFlag = input.contextGroup !== undefined;
if (hasGroupFlag) {
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
return {
policy: { mode: 'isolated' },
error: 'Context group name is required after --context-group',
};
}
const normalizedGroup = normalizeContextGroupName(input.contextGroup);
if (!isValidContextGroupName(normalizedGroup)) {
return {
policy: { mode: 'isolated' },
error:
'Invalid context group. Use letters/numbers/dash/underscore and start with a letter.',
};
}
return {
policy: {
mode: 'shared',
group: normalizedGroup,
},
};
}
if (input.shareContext) {
return {
policy: {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
},
};
}
return {
policy: { mode: DEFAULT_ACCOUNT_CONTEXT_MODE },
};
}
/**
* Resolve persisted metadata into runtime policy with safe defaults.
*/
export function resolveAccountContextPolicy(
metadata?: AccountContextMetadata | null
): AccountContextPolicy {
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const rawGroup = metadata?.context_group;
if (rawGroup && rawGroup.trim().length > 0) {
const normalized = normalizeContextGroupName(rawGroup);
if (isValidContextGroupName(normalized)) {
return { mode: 'shared', group: normalized };
}
}
return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP };
}
return { mode: 'isolated' };
}
/**
* Convert runtime policy back to persisted metadata.
*/
export function policyToAccountContextMetadata(
policy: AccountContextPolicy
): AccountContextMetadata {
if (policy.mode === 'shared') {
return {
context_mode: 'shared',
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
};
}
return {
context_mode: 'isolated',
};
}
/**
* User-facing summary for display/help output.
*/
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
if (policy.mode === 'shared') {
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`;
}
return 'isolated';
}
+15
View File
@@ -79,6 +79,12 @@ class AuthCommands {
console.log(` ${dim('# Create & login to work profile')}`);
console.log(` ${color('ccs auth create work', 'command')}`);
console.log('');
console.log(` ${dim('# Create account with shared project context (default group)')}`);
console.log(` ${color('ccs auth create work2 --share-context', 'command')}`);
console.log('');
console.log(` ${dim('# Share context only within a specific group')}`);
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
console.log('');
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
@@ -95,6 +101,12 @@ class AuthCommands {
console.log(
` ${color('--force', 'command')} Allow overwriting existing profile (create)`
);
console.log(
` ${color('--share-context', 'command')} Share project workspace context across accounts`
);
console.log(
` ${color('--context-group <name>', 'command')} Share context only within a named group`
);
console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
);
@@ -112,6 +124,9 @@ class AuthCommands {
console.log(
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
);
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log('');
}
+37 -5
View File
@@ -9,6 +9,11 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../..
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader';
import {
resolveCreateAccountContext,
policyToAccountContextMetadata,
formatAccountContextPolicy,
} from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, parseArgs } from './types';
@@ -18,12 +23,14 @@ import { CommandContext, parseArgs } from './types';
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force } = parseArgs(args);
const { profileName, force, shareContext, contextGroup } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth create <profile> [--force]', 'command')}`);
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
);
console.log('');
console.log('Example:');
console.log(` ${color('ccs auth create work', 'command')}`);
@@ -39,28 +46,50 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
exitWithError(`Profile already exists: ${profileName}`, ExitCode.PROFILE_ERROR);
}
const resolvedContext = resolveCreateAccountContext({
shareContext: !!shareContext,
contextGroup,
});
if (resolvedContext.error) {
console.log(fail(resolvedContext.error));
console.log('');
exitWithError(resolvedContext.error, ExitCode.PROFILE_ERROR);
}
const contextPolicy = resolvedContext.policy;
const contextMetadata = policyToAccountContextMetadata(contextPolicy);
try {
// Create instance directory
console.log(info(`Creating profile: ${profileName}`));
const instancePath = await ctx.instanceMgr.ensureInstance(profileName);
const instancePath = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy);
// Create/update profile entry based on config mode
if (isUnifiedMode()) {
// Use unified config (config.yaml)
if (existsUnified) {
ctx.registry.updateAccountUnified(profileName, {
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
});
ctx.registry.touchAccountUnified(profileName);
} else {
ctx.registry.createAccountUnified(profileName);
ctx.registry.createAccountUnified(profileName, contextMetadata);
}
} else {
// Use legacy profiles.json
if (existsLegacy) {
ctx.registry.updateProfile(profileName, {
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
});
} else {
ctx.registry.createProfile(profileName, {
type: 'account',
context_mode: contextMetadata.context_mode,
context_group: contextMetadata.context_group,
});
}
}
@@ -108,7 +137,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log('');
console.log(
infoBox(
`Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`,
`Profile: ${profileName}\n` +
`Instance: ${instancePath}\n` +
`Type: account\n` +
`Context: ${formatAccountContextPolicy(contextPolicy)}`,
'Profile Created'
)
);
+8 -2
View File
@@ -6,6 +6,7 @@
import { ProfileMetadata } from '../../types';
import { initUI, header, color, dim, warn, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
@@ -41,6 +42,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
version: ctx.version,
profiles: profileNames.map((name) => {
const profile = profiles[name];
const contextPolicy = resolveAccountContextPolicy(profile);
const isDefault = name === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(name);
@@ -50,6 +52,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath,
};
}),
@@ -98,6 +102,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
const rows: string[][] = sorted.map((name) => {
const profile = profiles[name];
const isDefault = name === defaultProfile;
const contextPolicy = resolveAccountContextPolicy(profile);
// Status column
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
@@ -112,6 +117,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
if (verbose) {
row.push(lastUsed);
row.push(formatAccountContextPolicy(contextPolicy));
}
return row;
@@ -119,14 +125,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
// Headers
const headers = verbose
? ['Profile', 'Type', 'Status', 'Last Used']
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
: ['Profile', 'Type', 'Status'];
// Print table
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
})
);
console.log('');
+5
View File
@@ -7,6 +7,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, header, color, fail, table } from '../../utils/ui';
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import { CommandContext, ProfileOutput, parseArgs } from './types';
@@ -35,6 +36,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
const defaultProfile = ctx.registry.getDefaultResolved();
const isDefault = profileName === defaultProfile;
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
const contextPolicy = resolveAccountContextPolicy(profile);
// Count sessions
let sessionCount = 0;
@@ -56,6 +58,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
is_default: isDefault,
created: profile.created,
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
instance_path: instancePath,
session_count: sessionCount,
};
@@ -74,6 +78,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
['Instance', instancePath],
['Created', new Date(profile.created).toLocaleString()],
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
['Context', formatAccountContextPolicy(contextPolicy)],
['Sessions', `${sessionCount}`],
];
+38 -1
View File
@@ -19,6 +19,8 @@ export interface AuthCommandArgs {
verbose?: boolean;
json?: boolean;
yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
}
/**
@@ -30,6 +32,8 @@ export interface ProfileOutput {
is_default: boolean;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
instance_path?: string;
session_count?: number;
}
@@ -55,12 +59,45 @@ export interface CommandContext {
* Parse command arguments from raw args array
*/
export function parseArgs(args: string[]): AuthCommandArgs {
const profileName = args.find((arg) => !arg.startsWith('--'));
let profileName: string | undefined;
let contextGroup: string | undefined;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--context-group') {
const next = args[i + 1];
if (!next || next.startsWith('-')) {
contextGroup = '';
continue;
}
contextGroup = next;
i++;
continue;
}
if (arg.startsWith('--context-group=')) {
contextGroup = arg.slice('--context-group='.length);
continue;
}
if (arg.startsWith('-')) {
continue;
}
if (!profileName) {
profileName = arg;
}
}
return {
profileName,
force: args.includes('--force'),
verbose: args.includes('--verbose'),
json: args.includes('--json'),
yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
contextGroup,
};
}
+2
View File
@@ -200,6 +200,8 @@ class ProfileDetector {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
},
};
}
+59 -8
View File
@@ -6,6 +6,7 @@ import {
saveUnifiedConfig,
isUnifiedMode,
} from '../config/unified-config-loader';
import type { AccountConfig } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
/**
@@ -19,6 +20,8 @@ import { getCcsDir } from '../utils/config-manager';
* type: 'account', // Profile type
* created: <ISO timestamp>, // Creation time
* last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* }
*
* Removed fields from v2.x:
@@ -37,6 +40,8 @@ interface CreateMetadata {
type?: string;
created?: string;
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
}
export class ProfileRegistry {
@@ -46,6 +51,30 @@ export class ProfileRegistry {
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
}
private normalizeLegacyProfileMetadata(metadata: ProfileMetadata): ProfileMetadata {
const normalized: ProfileMetadata = { ...metadata };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
}
return normalized;
}
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
const normalized: AccountConfig = { ...account };
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
} else if (!normalized.context_group || normalized.context_group.trim().length === 0) {
delete normalized.context_group;
}
return normalized;
}
/**
* Read profiles from disk
*/
@@ -105,11 +134,13 @@ export class ProfileRegistry {
}
// v3.0 minimal schema: only essential fields
data.profiles[name] = {
data.profiles[name] = this.normalizeLegacyProfileMetadata({
type: metadata.type || 'account',
created: metadata.created || new Date().toISOString(),
last_used: metadata.last_used || null,
};
context_mode: metadata.context_mode,
context_group: metadata.context_group,
});
// Note: No longer auto-set as default
// Users must explicitly run: ccs auth default <profile>
@@ -141,10 +172,10 @@ export class ProfileRegistry {
throw new Error(`Profile not found: ${name}`);
}
data.profiles[name] = {
data.profiles[name] = this.normalizeLegacyProfileMetadata({
...data.profiles[name],
...updates,
};
});
this._write(data);
}
@@ -242,15 +273,32 @@ export class ProfileRegistry {
/**
* Create account in unified config (config.yaml)
*/
createAccountUnified(name: string): void {
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
const config = loadOrCreateUnifiedConfig();
if (config.accounts[name]) {
throw new Error(`Account already exists: ${name}`);
}
config.accounts[name] = {
config.accounts[name] = this.normalizeUnifiedAccountConfig({
created: new Date().toISOString(),
last_used: null,
};
context_mode: metadata.context_mode,
context_group: metadata.context_group,
});
saveUnifiedConfig(config);
}
/**
* Update account metadata in unified config
*/
updateAccountUnified(name: string, updates: Partial<AccountConfig>): void {
const config = loadOrCreateUnifiedConfig();
if (!config.accounts[name]) {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name] = this.normalizeUnifiedAccountConfig({
...config.accounts[name],
...updates,
});
saveUnifiedConfig(config);
}
@@ -306,7 +354,7 @@ export class ProfileRegistry {
/**
* Get all accounts from unified config
*/
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> {
getAllAccountsUnified(): Record<string, AccountConfig> {
if (!isUnifiedMode()) return {};
const config = loadOrCreateUnifiedConfig();
return config.accounts;
@@ -330,6 +378,7 @@ export class ProfileRegistry {
throw new Error(`Account not found: ${name}`);
}
config.accounts[name].last_used = new Date().toISOString();
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
saveUnifiedConfig(config);
}
@@ -355,6 +404,8 @@ export class ProfileRegistry {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
};
}
+7 -1
View File
@@ -589,6 +589,8 @@ async function main(): Promise<void> {
const InstanceManager = InstanceManagerModule.default;
const ProfileRegistryModule = await import('./auth/profile-registry');
const ProfileRegistry = ProfileRegistryModule.default;
const AccountContextModule = await import('./auth/account-context');
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
const detector = new ProfileDetector();
@@ -882,9 +884,13 @@ async function main(): Promise<void> {
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
? profileInfo.profile
: undefined;
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
// Ensure instance exists (lazy init if needed)
const instancePath = await instanceMgr.ensureInstance(profileInfo.name);
const instancePath = await instanceMgr.ensureInstance(profileInfo.name, contextPolicy);
// Update last_used timestamp (check unified config first, fallback to legacy)
if (registry.hasAccountUnified(profileInfo.name)) {
+1 -1
View File
@@ -150,7 +150,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['Run multiple Claude accounts concurrently'],
[
['ccs auth --help', 'Show account management commands'],
['ccs auth create <name>', 'Create new account profile'],
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
+4
View File
@@ -40,6 +40,10 @@ export interface AccountConfig {
created: string;
/** ISO timestamp of last usage, null if never used */
last_used: string | null;
/** Context mode for project workspace data */
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
}
/**
+7 -3
View File
@@ -9,6 +9,7 @@
import * as fs from 'fs';
import * as path from 'path';
import SharedManager from './shared-manager';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
/**
@@ -26,7 +27,10 @@ class InstanceManager {
/**
* Ensure instance exists for profile (lazy init only)
*/
async ensureInstance(profileName: string): Promise<string> {
async ensureInstance(
profileName: string,
contextPolicy: AccountContextPolicy = { mode: DEFAULT_ACCOUNT_CONTEXT_MODE }
): Promise<string> {
const instancePath = this.getInstancePath(profileName);
// Lazy initialization
@@ -37,8 +41,8 @@ class InstanceManager {
// Validate structure (auto-fix missing dirs)
this.validateInstance(instancePath);
// Keep project memory shared across instances.
await this.sharedManager.syncProjectMemories(instancePath);
// Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
return instancePath;
}
+179
View File
@@ -10,6 +10,7 @@ import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { ok, info, warn } from '../utils/ui';
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context';
import { getCcsDir } from '../utils/config-manager';
interface SharedItem {
@@ -201,6 +202,102 @@ class SharedManager {
this.normalizePluginRegistryPaths();
}
/**
* Sync project workspace context based on account policy.
*
* - isolated (default): each profile keeps its own ./projects directory.
* - shared: profile ./projects becomes symlink to shared context group root.
*/
async syncProjectContext(instancePath: string, policy: AccountContextPolicy): Promise<void> {
const projectsPath = path.join(instancePath, 'projects');
const instanceName = path.basename(instancePath);
const mode = policy.mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
const sharedProjectsPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'projects'
);
await this.ensureDirectory(sharedProjectsPath);
await this.ensureDirectory(path.dirname(projectsPath));
const currentStats = await this.getLstat(projectsPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(projectsPath, sharedProjectsPath)) {
return;
}
const currentTarget = await this.resolveSymlinkTargetPath(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedProjectsPath) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedProjectsPath,
instanceName
);
}
await fs.promises.unlink(projectsPath);
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
if (currentStats.isDirectory()) {
await this.detachLegacySharedMemoryLinks(projectsPath, instanceName);
await this.mergeDirectoryWithConflictCopies(projectsPath, sharedProjectsPath, instanceName);
await fs.promises.rm(projectsPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
await fs.promises.rm(projectsPath, { force: true });
await this.linkDirectoryWithFallback(sharedProjectsPath, projectsPath);
return;
}
const currentStats = await this.getLstat(projectsPath);
if (!currentStats) {
await this.ensureDirectory(projectsPath);
return;
}
if (currentStats.isDirectory()) {
await this.detachLegacySharedMemoryLinks(projectsPath, instanceName);
return;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(projectsPath);
await fs.promises.unlink(projectsPath);
await this.ensureDirectory(projectsPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(projectsPath) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(currentTarget, projectsPath, instanceName);
}
return;
}
await fs.promises.rm(projectsPath, { force: true });
await this.ensureDirectory(projectsPath);
}
/**
* Ensure all project memory directories for an instance are shared.
*
@@ -569,6 +666,88 @@ class SharedManager {
}
}
/**
* Resolve symlink target to absolute path.
*/
private async resolveSymlinkTargetPath(linkPath: string): Promise<string | null> {
try {
const currentTarget = await fs.promises.readlink(linkPath);
return path.resolve(path.dirname(linkPath), currentTarget);
} catch (_err) {
return null;
}
}
/**
* Link directory with Windows fallback to recursive copy.
*/
private async linkDirectoryWithFallback(targetPath: string, linkPath: string): Promise<void> {
const symlinkType: 'dir' | 'junction' = process.platform === 'win32' ? 'junction' : 'dir';
const linkTarget = process.platform === 'win32' ? path.resolve(targetPath) : targetPath;
try {
await fs.promises.symlink(linkTarget, linkPath, symlinkType);
} catch (_err) {
if (process.platform === 'win32') {
this.copyDirectoryFallback(targetPath, linkPath);
console.log(
warn(`Symlink failed for context projects, copied instead (enable Developer Mode)`)
);
return;
}
throw _err;
}
}
/**
* Migrate legacy per-project memory symlinks that point to ~/.ccs/shared/memory.
* This preserves data while restoring true profile isolation.
*/
private async detachLegacySharedMemoryLinks(
projectsPath: string,
instanceName: string
): Promise<void> {
const sharedMemoryRoot = path.resolve(path.join(this.sharedDir, 'memory'));
let projectEntries: fs.Dirent[] = [];
try {
projectEntries = await fs.promises.readdir(projectsPath, { withFileTypes: true });
} catch (_err) {
return;
}
for (const entry of projectEntries) {
if (!entry.isDirectory()) {
continue;
}
const projectPath = path.join(projectsPath, entry.name);
const memoryPath = path.join(projectPath, 'memory');
const memoryStats = await this.getLstat(memoryPath);
if (!memoryStats?.isSymbolicLink()) {
continue;
}
const memoryTarget = await this.resolveSymlinkTargetPath(memoryPath);
if (!memoryTarget) {
continue;
}
if (!path.resolve(memoryTarget).startsWith(sharedMemoryRoot)) {
continue;
}
await fs.promises.unlink(memoryPath);
await this.ensureDirectory(memoryPath);
if (await this.pathExists(memoryTarget)) {
await this.mergeDirectoryWithConflictCopies(memoryTarget, memoryPath, instanceName);
}
}
}
/**
* Move directory, with cross-device fallback.
*/
+4
View File
@@ -87,6 +87,10 @@ export interface ProfileMetadata {
type?: string; // Profile type (e.g., 'account')
created: string; // Creation time
last_used?: string | null; // Last usage time
/** Context mode for project workspace data */
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
}
export interface ProfilesRegistry {
+6
View File
@@ -53,6 +53,8 @@ router.get('/', (_req: Request, res: Response): void => {
type: string;
created: string;
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
provider?: string;
displayName?: string;
}
@@ -64,6 +66,8 @@ router.get('/', (_req: Request, res: Response): void => {
type: meta.type || 'account',
created: meta.created,
last_used: meta.last_used || null,
context_mode: meta.context_mode,
context_group: meta.context_group,
};
}
@@ -73,6 +77,8 @@ router.get('/', (_req: Request, res: Response): void => {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
};
}
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'bun:test';
import { parseArgs } from '../../src/auth/commands/types';
describe('auth command args parsing', () => {
it('parses create with explicit shared context', () => {
const parsed = parseArgs(['work', '--share-context']);
expect(parsed.profileName).toBe('work');
expect(parsed.shareContext).toBe(true);
expect(parsed.contextGroup).toBeUndefined();
});
it('parses context group with separate value', () => {
const parsed = parseArgs(['work', '--context-group', 'sprint-a']);
expect(parsed.profileName).toBe('work');
expect(parsed.shareContext).toBe(false);
expect(parsed.contextGroup).toBe('sprint-a');
});
it('parses context group with equals form', () => {
const parsed = parseArgs(['--context-group=sprint-a', 'work']);
expect(parsed.profileName).toBe('work');
expect(parsed.contextGroup).toBe('sprint-a');
});
it('flags missing context group value as empty string', () => {
const parsed = parseArgs(['work', '--context-group']);
expect(parsed.profileName).toBe('work');
expect(parsed.contextGroup).toBe('');
});
});
+121
View File
@@ -0,0 +1,121 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import SharedManager from '../../src/management/shared-manager';
import type { AccountContextPolicy } from '../../src/auth/account-context';
function getTestCcsDir(): string {
if (!process.env.CCS_HOME) {
throw new Error('CCS_HOME must be set in tests');
}
return path.join(path.resolve(process.env.CCS_HOME), '.ccs');
}
describe('SharedManager context policy', () => {
let tempRoot = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalCcsDir: string | undefined;
beforeEach(() => {
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-context-policy-test-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalCcsDir = process.env.CCS_DIR;
const isolatedHome = path.join(tempRoot, 'home');
fs.mkdirSync(isolatedHome, { recursive: true });
process.env.HOME = isolatedHome;
process.env.CCS_HOME = tempRoot;
delete process.env.CCS_DIR;
});
afterEach(() => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
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 });
}
});
async function applyPolicy(policy: AccountContextPolicy): Promise<{ instancePath: string; ccsDir: string }> {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, policy);
return { instancePath, ccsDir };
}
it('keeps projects isolated by default', async () => {
const { instancePath } = await applyPolicy({ mode: 'isolated' });
const projectsPath = path.join(instancePath, 'projects');
expect(fs.existsSync(projectsPath)).toBe(true);
expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true);
});
it('migrates local projects into shared context group', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
const localProjectsPath = path.join(instancePath, 'projects');
const localFile = path.join(localProjectsPath, '-tmp-project', 'notes.md');
fs.mkdirSync(path.dirname(localFile), { recursive: true });
fs.writeFileSync(localFile, 'local context', 'utf8');
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'shared', group: 'sprint-a' });
const linkStats = fs.lstatSync(localProjectsPath);
expect(linkStats.isSymbolicLink()).toBe(true);
const sharedFile = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'projects',
'-tmp-project',
'notes.md'
);
expect(fs.existsSync(sharedFile)).toBe(true);
expect(fs.readFileSync(sharedFile, 'utf8')).toBe('local context');
});
it('switches from shared mode back to isolated without data loss', async () => {
const { instancePath, ccsDir } = await applyPolicy({ mode: 'shared', group: 'sprint-a' });
const sharedFile = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'projects',
'-tmp-project',
'history.md'
);
fs.mkdirSync(path.dirname(sharedFile), { recursive: true });
fs.writeFileSync(sharedFile, 'shared history', 'utf8');
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, { mode: 'isolated' });
const projectsPath = path.join(instancePath, 'projects');
const projectFile = path.join(projectsPath, '-tmp-project', 'history.md');
expect(fs.lstatSync(projectsPath).isDirectory()).toBe(true);
expect(fs.existsSync(projectFile)).toBe(true);
expect(fs.readFileSync(projectFile, 'utf8')).toBe('shared history');
});
});
@@ -87,6 +87,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
return <span className="text-muted-foreground">{date.toLocaleDateString()}</span>;
},
},
{
id: 'context',
header: 'Context',
size: 170,
cell: ({ row }) => {
if (row.original.type === 'cliproxy') {
return <span className="text-muted-foreground/50">-</span>;
}
const mode = row.original.context_mode || 'isolated';
if (mode === 'shared') {
const group = row.original.context_group || 'default';
return <span className="text-muted-foreground">shared ({group})</span>;
}
return <span className="text-muted-foreground">isolated</span>;
},
},
{
id: 'actions',
header: 'Actions',
@@ -154,6 +172,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
type: 'w-[100px]',
created: 'w-[150px]',
last_used: 'w-[150px]',
context: 'w-[170px]',
actions: 'w-[180px]',
}[header.id] || 'w-auto';
@@ -14,6 +14,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Copy, Check, Terminal } from 'lucide-react';
interface CreateAuthProfileDialogProps {
@@ -23,14 +24,32 @@ interface CreateAuthProfileDialogProps {
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false);
const [contextGroup, setContextGroup] = useState('');
const [copied, setCopied] = useState(false);
// Validate profile name: alphanumeric, dash, underscore only
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
const command = profileName ? `ccs auth create ${profileName}` : 'ccs auth create <name>';
const normalizedGroup = contextGroup.trim().toLowerCase();
const isValidContextGroup =
normalizedGroup.length === 0 || /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(normalizedGroup);
const command =
profileName && isValidName
? [
`ccs auth create ${profileName}`,
shareContext
? normalizedGroup.length > 0
? `--context-group ${normalizedGroup}`
: '--share-context'
: '',
]
.filter(Boolean)
.join(' ')
: 'ccs auth create <name>';
const handleCopy = async () => {
if (!isValidName) return;
if (!isValidName || (shareContext && !isValidContextGroup)) return;
await navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
@@ -38,6 +57,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
const handleClose = () => {
setProfileName('');
setShareContext(false);
setContextGroup('');
setCopied(false);
onClose();
};
@@ -70,6 +91,41 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
)}
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Checkbox
id="share-context"
checked={shareContext}
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Share project context with other accounts
</Label>
</div>
{shareContext && (
<div className="space-y-2 pl-6">
<Label htmlFor="context-group">Context Group (optional)</Label>
<Input
id="context-group"
value={contextGroup}
onChange={(e) => setContextGroup(e.target.value)}
placeholder="default, sprint-a, client-x"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group.
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
Group must start with a letter and use only letters, numbers, dashes, or
underscores.
</p>
)}
</div>
)}
</div>
<div className="space-y-2">
<Label>Command</Label>
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
@@ -80,7 +136,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
size="sm"
className="shrink-0 h-8 px-2"
onClick={handleCopy}
disabled={!isValidName}
disabled={!isValidName || (shareContext && !isValidContextGroup)}
>
{copied ? (
<Check className="w-4 h-4 text-green-500" />
@@ -103,7 +159,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<Button variant="ghost" onClick={handleClose}>
Close
</Button>
<Button onClick={handleCopy} disabled={!isValidName}>
<Button
onClick={handleCopy}
disabled={!isValidName || (shareContext && !isValidContextGroup)}
>
{copied ? (
<>
<Check className="w-4 h-4 mr-2" />
+2
View File
@@ -445,6 +445,8 @@ export interface Account {
type?: string;
created: string;
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
}
// Unified config types