mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 08:19:59 +00:00
feat(auth): add opt-in shared context groups across accounts
This commit is contained in:
@@ -221,6 +221,18 @@ ccs work "implement feature" # Terminal 1
|
|||||||
ccs "review code" # Terminal 2 (personal account)
|
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>
|
<br>
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -79,6 +79,12 @@ class AuthCommands {
|
|||||||
console.log(` ${dim('# Create & login to work profile')}`);
|
console.log(` ${dim('# Create & login to work profile')}`);
|
||||||
console.log(` ${color('ccs auth create work', 'command')}`);
|
console.log(` ${color('ccs auth create work', 'command')}`);
|
||||||
console.log('');
|
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(` ${dim('# Set work as default')}`);
|
||||||
console.log(` ${color('ccs auth default work', 'command')}`);
|
console.log(` ${color('ccs auth default work', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -95,6 +101,12 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${color('--force', 'command')} Allow overwriting existing profile (create)`
|
` ${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(
|
console.log(
|
||||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||||
);
|
);
|
||||||
@@ -112,6 +124,9 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` Use ${color('ccs auth default <profile>', 'command')} to change the default profile.`
|
` 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('');
|
console.log('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../..
|
|||||||
import { getClaudeCliInfo } from '../../utils/claude-detector';
|
import { getClaudeCliInfo } from '../../utils/claude-detector';
|
||||||
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
|
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||||
import { isUnifiedMode } from '../../config/unified-config-loader';
|
import { isUnifiedMode } from '../../config/unified-config-loader';
|
||||||
|
import {
|
||||||
|
resolveCreateAccountContext,
|
||||||
|
policyToAccountContextMetadata,
|
||||||
|
formatAccountContextPolicy,
|
||||||
|
} from '../account-context';
|
||||||
import { exitWithError } from '../../errors';
|
import { exitWithError } from '../../errors';
|
||||||
import { ExitCode } from '../../errors/exit-codes';
|
import { ExitCode } from '../../errors/exit-codes';
|
||||||
import { CommandContext, parseArgs } from './types';
|
import { CommandContext, parseArgs } from './types';
|
||||||
@@ -18,12 +23,14 @@ import { CommandContext, parseArgs } from './types';
|
|||||||
*/
|
*/
|
||||||
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const { profileName, force } = parseArgs(args);
|
const { profileName, force, shareContext, contextGroup } = parseArgs(args);
|
||||||
|
|
||||||
if (!profileName) {
|
if (!profileName) {
|
||||||
console.log(fail('Profile name is required'));
|
console.log(fail('Profile name is required'));
|
||||||
console.log('');
|
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('');
|
||||||
console.log('Example:');
|
console.log('Example:');
|
||||||
console.log(` ${color('ccs auth create work', 'command')}`);
|
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);
|
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 {
|
try {
|
||||||
// Create instance directory
|
// Create instance directory
|
||||||
console.log(info(`Creating profile: ${profileName}`));
|
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
|
// Create/update profile entry based on config mode
|
||||||
if (isUnifiedMode()) {
|
if (isUnifiedMode()) {
|
||||||
// Use unified config (config.yaml)
|
// Use unified config (config.yaml)
|
||||||
if (existsUnified) {
|
if (existsUnified) {
|
||||||
|
ctx.registry.updateAccountUnified(profileName, {
|
||||||
|
context_mode: contextMetadata.context_mode,
|
||||||
|
context_group: contextMetadata.context_group,
|
||||||
|
});
|
||||||
ctx.registry.touchAccountUnified(profileName);
|
ctx.registry.touchAccountUnified(profileName);
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createAccountUnified(profileName);
|
ctx.registry.createAccountUnified(profileName, contextMetadata);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use legacy profiles.json
|
// Use legacy profiles.json
|
||||||
if (existsLegacy) {
|
if (existsLegacy) {
|
||||||
ctx.registry.updateProfile(profileName, {
|
ctx.registry.updateProfile(profileName, {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
|
context_mode: contextMetadata.context_mode,
|
||||||
|
context_group: contextMetadata.context_group,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createProfile(profileName, {
|
ctx.registry.createProfile(profileName, {
|
||||||
type: 'account',
|
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('');
|
||||||
console.log(
|
console.log(
|
||||||
infoBox(
|
infoBox(
|
||||||
`Profile: ${profileName}\n` + `Instance: ${instancePath}\n` + `Type: account`,
|
`Profile: ${profileName}\n` +
|
||||||
|
`Instance: ${instancePath}\n` +
|
||||||
|
`Type: account\n` +
|
||||||
|
`Context: ${formatAccountContextPolicy(contextPolicy)}`,
|
||||||
'Profile Created'
|
'Profile Created'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { ProfileMetadata } from '../../types';
|
import { ProfileMetadata } from '../../types';
|
||||||
import { initUI, header, color, dim, warn, table } from '../../utils/ui';
|
import { initUI, header, color, dim, warn, table } from '../../utils/ui';
|
||||||
|
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||||
import { exitWithError } from '../../errors';
|
import { exitWithError } from '../../errors';
|
||||||
import { ExitCode } from '../../errors/exit-codes';
|
import { ExitCode } from '../../errors/exit-codes';
|
||||||
import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
|
import { CommandContext, ListOutput, parseArgs, formatRelativeTime } from './types';
|
||||||
@@ -41,6 +42,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
version: ctx.version,
|
version: ctx.version,
|
||||||
profiles: profileNames.map((name) => {
|
profiles: profileNames.map((name) => {
|
||||||
const profile = profiles[name];
|
const profile = profiles[name];
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
const isDefault = name === defaultProfile;
|
const isDefault = name === defaultProfile;
|
||||||
const instancePath = ctx.instanceMgr.getInstancePath(name);
|
const instancePath = ctx.instanceMgr.getInstancePath(name);
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
is_default: isDefault,
|
is_default: isDefault,
|
||||||
created: profile.created,
|
created: profile.created,
|
||||||
last_used: profile.last_used || null,
|
last_used: profile.last_used || null,
|
||||||
|
context_mode: contextPolicy.mode,
|
||||||
|
context_group: contextPolicy.group || null,
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -98,6 +102,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
const rows: string[][] = sorted.map((name) => {
|
const rows: string[][] = sorted.map((name) => {
|
||||||
const profile = profiles[name];
|
const profile = profiles[name];
|
||||||
const isDefault = name === defaultProfile;
|
const isDefault = name === defaultProfile;
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
|
|
||||||
// Status column
|
// Status column
|
||||||
const status = isDefault ? color('[OK] default', 'success') : color('[OK]', 'success');
|
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) {
|
if (verbose) {
|
||||||
row.push(lastUsed);
|
row.push(lastUsed);
|
||||||
|
row.push(formatAccountContextPolicy(contextPolicy));
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -119,14 +125,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
const headers = verbose
|
const headers = verbose
|
||||||
? ['Profile', 'Type', 'Status', 'Last Used']
|
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
|
||||||
: ['Profile', 'Type', 'Status'];
|
: ['Profile', 'Type', 'Status'];
|
||||||
|
|
||||||
// Print table
|
// Print table
|
||||||
console.log(
|
console.log(
|
||||||
table(rows, {
|
table(rows, {
|
||||||
head: headers,
|
head: headers,
|
||||||
colWidths: verbose ? [15, 12, 15, 12] : [15, 12, 15],
|
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { initUI, header, color, fail, table } from '../../utils/ui';
|
import { initUI, header, color, fail, table } from '../../utils/ui';
|
||||||
|
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||||
import { exitWithError } from '../../errors';
|
import { exitWithError } from '../../errors';
|
||||||
import { ExitCode } from '../../errors/exit-codes';
|
import { ExitCode } from '../../errors/exit-codes';
|
||||||
import { CommandContext, ProfileOutput, parseArgs } from './types';
|
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 defaultProfile = ctx.registry.getDefaultResolved();
|
||||||
const isDefault = profileName === defaultProfile;
|
const isDefault = profileName === defaultProfile;
|
||||||
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
|
|
||||||
// Count sessions
|
// Count sessions
|
||||||
let sessionCount = 0;
|
let sessionCount = 0;
|
||||||
@@ -56,6 +58,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
is_default: isDefault,
|
is_default: isDefault,
|
||||||
created: profile.created,
|
created: profile.created,
|
||||||
last_used: profile.last_used || null,
|
last_used: profile.last_used || null,
|
||||||
|
context_mode: contextPolicy.mode,
|
||||||
|
context_group: contextPolicy.group || null,
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
session_count: sessionCount,
|
session_count: sessionCount,
|
||||||
};
|
};
|
||||||
@@ -74,6 +78,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
['Instance', instancePath],
|
['Instance', instancePath],
|
||||||
['Created', new Date(profile.created).toLocaleString()],
|
['Created', new Date(profile.created).toLocaleString()],
|
||||||
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
|
['Last Used', profile.last_used ? new Date(profile.last_used).toLocaleString() : 'Never'],
|
||||||
|
['Context', formatAccountContextPolicy(contextPolicy)],
|
||||||
['Sessions', `${sessionCount}`],
|
['Sessions', `${sessionCount}`],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface AuthCommandArgs {
|
|||||||
verbose?: boolean;
|
verbose?: boolean;
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
|
shareContext?: boolean;
|
||||||
|
contextGroup?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +32,8 @@ export interface ProfileOutput {
|
|||||||
is_default: boolean;
|
is_default: boolean;
|
||||||
created: string;
|
created: string;
|
||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
|
context_mode?: 'isolated' | 'shared';
|
||||||
|
context_group?: string | null;
|
||||||
instance_path?: string;
|
instance_path?: string;
|
||||||
session_count?: number;
|
session_count?: number;
|
||||||
}
|
}
|
||||||
@@ -55,12 +59,45 @@ export interface CommandContext {
|
|||||||
* Parse command arguments from raw args array
|
* Parse command arguments from raw args array
|
||||||
*/
|
*/
|
||||||
export function parseArgs(args: string[]): AuthCommandArgs {
|
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 {
|
return {
|
||||||
profileName,
|
profileName,
|
||||||
force: args.includes('--force'),
|
force: args.includes('--force'),
|
||||||
verbose: args.includes('--verbose'),
|
verbose: args.includes('--verbose'),
|
||||||
json: args.includes('--json'),
|
json: args.includes('--json'),
|
||||||
yes: args.includes('--yes') || args.includes('-y'),
|
yes: args.includes('--yes') || args.includes('-y'),
|
||||||
|
shareContext: args.includes('--share-context'),
|
||||||
|
contextGroup,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,6 +200,8 @@ class ProfileDetector {
|
|||||||
type: 'account',
|
type: 'account',
|
||||||
created: account.created,
|
created: account.created,
|
||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
|
context_mode: account.context_mode,
|
||||||
|
context_group: account.context_group,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
saveUnifiedConfig,
|
saveUnifiedConfig,
|
||||||
isUnifiedMode,
|
isUnifiedMode,
|
||||||
} from '../config/unified-config-loader';
|
} from '../config/unified-config-loader';
|
||||||
|
import type { AccountConfig } from '../config/unified-config-types';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +20,8 @@ import { getCcsDir } from '../utils/config-manager';
|
|||||||
* type: 'account', // Profile type
|
* type: 'account', // Profile type
|
||||||
* created: <ISO timestamp>, // Creation time
|
* created: <ISO timestamp>, // Creation time
|
||||||
* last_used: <ISO timestamp or null> // Last usage 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:
|
* Removed fields from v2.x:
|
||||||
@@ -37,6 +40,8 @@ interface CreateMetadata {
|
|||||||
type?: string;
|
type?: string;
|
||||||
created?: string;
|
created?: string;
|
||||||
last_used?: string | null;
|
last_used?: string | null;
|
||||||
|
context_mode?: 'isolated' | 'shared';
|
||||||
|
context_group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProfileRegistry {
|
export class ProfileRegistry {
|
||||||
@@ -46,6 +51,30 @@ export class ProfileRegistry {
|
|||||||
this.profilesPath = path.join(getCcsDir(), 'profiles.json');
|
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
|
* Read profiles from disk
|
||||||
*/
|
*/
|
||||||
@@ -105,11 +134,13 @@ export class ProfileRegistry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// v3.0 minimal schema: only essential fields
|
// v3.0 minimal schema: only essential fields
|
||||||
data.profiles[name] = {
|
data.profiles[name] = this.normalizeLegacyProfileMetadata({
|
||||||
type: metadata.type || 'account',
|
type: metadata.type || 'account',
|
||||||
created: metadata.created || new Date().toISOString(),
|
created: metadata.created || new Date().toISOString(),
|
||||||
last_used: metadata.last_used || null,
|
last_used: metadata.last_used || null,
|
||||||
};
|
context_mode: metadata.context_mode,
|
||||||
|
context_group: metadata.context_group,
|
||||||
|
});
|
||||||
|
|
||||||
// Note: No longer auto-set as default
|
// Note: No longer auto-set as default
|
||||||
// Users must explicitly run: ccs auth default <profile>
|
// Users must explicitly run: ccs auth default <profile>
|
||||||
@@ -141,10 +172,10 @@ export class ProfileRegistry {
|
|||||||
throw new Error(`Profile not found: ${name}`);
|
throw new Error(`Profile not found: ${name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
data.profiles[name] = {
|
data.profiles[name] = this.normalizeLegacyProfileMetadata({
|
||||||
...data.profiles[name],
|
...data.profiles[name],
|
||||||
...updates,
|
...updates,
|
||||||
};
|
});
|
||||||
|
|
||||||
this._write(data);
|
this._write(data);
|
||||||
}
|
}
|
||||||
@@ -242,15 +273,32 @@ export class ProfileRegistry {
|
|||||||
/**
|
/**
|
||||||
* Create account in unified config (config.yaml)
|
* Create account in unified config (config.yaml)
|
||||||
*/
|
*/
|
||||||
createAccountUnified(name: string): void {
|
createAccountUnified(name: string, metadata: CreateMetadata = {}): void {
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
if (config.accounts[name]) {
|
if (config.accounts[name]) {
|
||||||
throw new Error(`Account already exists: ${name}`);
|
throw new Error(`Account already exists: ${name}`);
|
||||||
}
|
}
|
||||||
config.accounts[name] = {
|
config.accounts[name] = this.normalizeUnifiedAccountConfig({
|
||||||
created: new Date().toISOString(),
|
created: new Date().toISOString(),
|
||||||
last_used: null,
|
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);
|
saveUnifiedConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +354,7 @@ export class ProfileRegistry {
|
|||||||
/**
|
/**
|
||||||
* Get all accounts from unified config
|
* Get all accounts from unified config
|
||||||
*/
|
*/
|
||||||
getAllAccountsUnified(): Record<string, { created: string; last_used: string | null }> {
|
getAllAccountsUnified(): Record<string, AccountConfig> {
|
||||||
if (!isUnifiedMode()) return {};
|
if (!isUnifiedMode()) return {};
|
||||||
const config = loadOrCreateUnifiedConfig();
|
const config = loadOrCreateUnifiedConfig();
|
||||||
return config.accounts;
|
return config.accounts;
|
||||||
@@ -330,6 +378,7 @@ export class ProfileRegistry {
|
|||||||
throw new Error(`Account not found: ${name}`);
|
throw new Error(`Account not found: ${name}`);
|
||||||
}
|
}
|
||||||
config.accounts[name].last_used = new Date().toISOString();
|
config.accounts[name].last_used = new Date().toISOString();
|
||||||
|
config.accounts[name] = this.normalizeUnifiedAccountConfig(config.accounts[name]);
|
||||||
saveUnifiedConfig(config);
|
saveUnifiedConfig(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,6 +404,8 @@ export class ProfileRegistry {
|
|||||||
type: 'account',
|
type: 'account',
|
||||||
created: account.created,
|
created: account.created,
|
||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
|
context_mode: account.context_mode,
|
||||||
|
context_group: account.context_group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-1
@@ -589,6 +589,8 @@ async function main(): Promise<void> {
|
|||||||
const InstanceManager = InstanceManagerModule.default;
|
const InstanceManager = InstanceManagerModule.default;
|
||||||
const ProfileRegistryModule = await import('./auth/profile-registry');
|
const ProfileRegistryModule = await import('./auth/profile-registry');
|
||||||
const ProfileRegistry = ProfileRegistryModule.default;
|
const ProfileRegistry = ProfileRegistryModule.default;
|
||||||
|
const AccountContextModule = await import('./auth/account-context');
|
||||||
|
const { resolveAccountContextPolicy, isAccountContextMetadata } = AccountContextModule;
|
||||||
|
|
||||||
const detector = new ProfileDetector();
|
const detector = new ProfileDetector();
|
||||||
|
|
||||||
@@ -882,9 +884,13 @@ async function main(): Promise<void> {
|
|||||||
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
// All platforms: Use instance isolation with CLAUDE_CONFIG_DIR
|
||||||
const registry = new ProfileRegistry();
|
const registry = new ProfileRegistry();
|
||||||
const instanceMgr = new InstanceManager();
|
const instanceMgr = new InstanceManager();
|
||||||
|
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
||||||
|
? profileInfo.profile
|
||||||
|
: undefined;
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
||||||
|
|
||||||
// Ensure instance exists (lazy init if needed)
|
// 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)
|
// Update last_used timestamp (check unified config first, fallback to legacy)
|
||||||
if (registry.hasAccountUnified(profileInfo.name)) {
|
if (registry.hasAccountUnified(profileInfo.name)) {
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
|||||||
['Run multiple Claude accounts concurrently'],
|
['Run multiple Claude accounts concurrently'],
|
||||||
[
|
[
|
||||||
['ccs auth --help', 'Show account management commands'],
|
['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 list', 'List all account profiles'],
|
||||||
['ccs auth default <name>', 'Set default profile'],
|
['ccs auth default <name>', 'Set default profile'],
|
||||||
['ccs auth reset-default', 'Restore original CCS default'],
|
['ccs auth reset-default', 'Restore original CCS default'],
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export interface AccountConfig {
|
|||||||
created: string;
|
created: string;
|
||||||
/** ISO timestamp of last usage, null if never used */
|
/** ISO timestamp of last usage, null if never used */
|
||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
|
/** Context mode for project workspace data */
|
||||||
|
context_mode?: 'isolated' | 'shared';
|
||||||
|
/** Context-sharing group when context_mode='shared' */
|
||||||
|
context_group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import SharedManager from './shared-manager';
|
import SharedManager from './shared-manager';
|
||||||
|
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_MODE } from '../auth/account-context';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -26,7 +27,10 @@ class InstanceManager {
|
|||||||
/**
|
/**
|
||||||
* Ensure instance exists for profile (lazy init only)
|
* 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);
|
const instancePath = this.getInstancePath(profileName);
|
||||||
|
|
||||||
// Lazy initialization
|
// Lazy initialization
|
||||||
@@ -37,8 +41,8 @@ class InstanceManager {
|
|||||||
// Validate structure (auto-fix missing dirs)
|
// Validate structure (auto-fix missing dirs)
|
||||||
this.validateInstance(instancePath);
|
this.validateInstance(instancePath);
|
||||||
|
|
||||||
// Keep project memory shared across instances.
|
// Apply context policy (isolated by default, optional shared group).
|
||||||
await this.sharedManager.syncProjectMemories(instancePath);
|
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||||
|
|
||||||
return instancePath;
|
return instancePath;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import * as fs from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import { ok, info, warn } from '../utils/ui';
|
import { ok, info, warn } from '../utils/ui';
|
||||||
|
import { AccountContextPolicy, DEFAULT_ACCOUNT_CONTEXT_GROUP } from '../auth/account-context';
|
||||||
import { getCcsDir } from '../utils/config-manager';
|
import { getCcsDir } from '../utils/config-manager';
|
||||||
|
|
||||||
interface SharedItem {
|
interface SharedItem {
|
||||||
@@ -201,6 +202,102 @@ class SharedManager {
|
|||||||
this.normalizePluginRegistryPaths();
|
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.
|
* 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.
|
* Move directory, with cross-device fallback.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ export interface ProfileMetadata {
|
|||||||
type?: string; // Profile type (e.g., 'account')
|
type?: string; // Profile type (e.g., 'account')
|
||||||
created: string; // Creation time
|
created: string; // Creation time
|
||||||
last_used?: string | null; // Last usage 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 {
|
export interface ProfilesRegistry {
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
type: string;
|
type: string;
|
||||||
created: string;
|
created: string;
|
||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
|
context_mode?: 'isolated' | 'shared';
|
||||||
|
context_group?: string;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,8 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
type: meta.type || 'account',
|
type: meta.type || 'account',
|
||||||
created: meta.created,
|
created: meta.created,
|
||||||
last_used: meta.last_used || null,
|
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',
|
type: 'account',
|
||||||
created: account.created,
|
created: account.created,
|
||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
|
context_mode: account.context_mode,
|
||||||
|
context_group: account.context_group,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>;
|
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',
|
id: 'actions',
|
||||||
header: 'Actions',
|
header: 'Actions',
|
||||||
@@ -154,6 +172,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
type: 'w-[100px]',
|
type: 'w-[100px]',
|
||||||
created: 'w-[150px]',
|
created: 'w-[150px]',
|
||||||
last_used: 'w-[150px]',
|
last_used: 'w-[150px]',
|
||||||
|
context: 'w-[170px]',
|
||||||
actions: 'w-[180px]',
|
actions: 'w-[180px]',
|
||||||
}[header.id] || 'w-auto';
|
}[header.id] || 'w-auto';
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Copy, Check, Terminal } from 'lucide-react';
|
import { Copy, Check, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
interface CreateAuthProfileDialogProps {
|
interface CreateAuthProfileDialogProps {
|
||||||
@@ -23,14 +24,32 @@ interface CreateAuthProfileDialogProps {
|
|||||||
|
|
||||||
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
|
export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDialogProps) {
|
||||||
const [profileName, setProfileName] = useState('');
|
const [profileName, setProfileName] = useState('');
|
||||||
|
const [shareContext, setShareContext] = useState(false);
|
||||||
|
const [contextGroup, setContextGroup] = useState('');
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Validate profile name: alphanumeric, dash, underscore only
|
// Validate profile name: alphanumeric, dash, underscore only
|
||||||
const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName);
|
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 () => {
|
const handleCopy = async () => {
|
||||||
if (!isValidName) return;
|
if (!isValidName || (shareContext && !isValidContextGroup)) return;
|
||||||
await navigator.clipboard.writeText(command);
|
await navigator.clipboard.writeText(command);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
@@ -38,6 +57,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setProfileName('');
|
setProfileName('');
|
||||||
|
setShareContext(false);
|
||||||
|
setContextGroup('');
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -70,6 +91,41 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label>Command</Label>
|
<Label>Command</Label>
|
||||||
<div className="flex items-center gap-2 p-3 bg-muted rounded-md font-mono text-sm">
|
<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"
|
size="sm"
|
||||||
className="shrink-0 h-8 px-2"
|
className="shrink-0 h-8 px-2"
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
disabled={!isValidName}
|
disabled={!isValidName || (shareContext && !isValidContextGroup)}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="w-4 h-4 text-green-500" />
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
@@ -103,7 +159,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
<Button variant="ghost" onClick={handleClose}>
|
<Button variant="ghost" onClick={handleClose}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCopy} disabled={!isValidName}>
|
<Button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!isValidName || (shareContext && !isValidContextGroup)}
|
||||||
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<>
|
<>
|
||||||
<Check className="w-4 h-4 mr-2" />
|
<Check className="w-4 h-4 mr-2" />
|
||||||
|
|||||||
@@ -445,6 +445,8 @@ export interface Account {
|
|||||||
type?: string;
|
type?: string;
|
||||||
created: string;
|
created: string;
|
||||||
last_used?: string | null;
|
last_used?: string | null;
|
||||||
|
context_mode?: 'isolated' | 'shared';
|
||||||
|
context_group?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified config types
|
// Unified config types
|
||||||
|
|||||||
Reference in New Issue
Block a user