mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
feat(auth): add shared resource controls
This commit is contained in:
@@ -26,6 +26,7 @@ Dashboard auth and account context metadata are separate:
|
|||||||
|
|
||||||
- `dashboard_auth`: protects dashboard access with username/password
|
- `dashboard_auth`: protects dashboard access with username/password
|
||||||
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
|
- `accounts.<name>.context_mode/context_group`: controls isolated vs shared account context
|
||||||
|
- `accounts.<name>.shared_resource_mode`: controls plugins/commands/skills/agents/settings.json sharing
|
||||||
|
|
||||||
Account context is isolation-first. The recommended two-account route is:
|
Account context is isolation-first. The recommended two-account route is:
|
||||||
|
|
||||||
@@ -48,15 +49,22 @@ Shared continuity depth:
|
|||||||
- `standard` (default): shares project workspace context only
|
- `standard` (default): shares project workspace context only
|
||||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||||
|
|
||||||
`ccs auth show <profile>` reports credential isolation, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
`ccs auth show <profile>` reports credential isolation, shared resource mode, settings sync state, history lane, and whether plain `ccs` currently uses the same resume lane.
|
||||||
|
|
||||||
Non-bare account profiles share the basic Claude `settings.json` with native Claude:
|
Non-bare account profiles share Claude-local resources with native Claude:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
|
~/.ccs/instances/<profile>/settings.json -> ~/.ccs/shared/settings.json -> ~/.claude/settings.json
|
||||||
```
|
```
|
||||||
|
|
||||||
This keeps ordinary Claude settings in sync without copying account tokens. Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
This keeps ordinary Claude settings, plugins, commands, skills, and agents in sync without copying account tokens. Existing accounts can opt out or back in:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccs auth resources work --mode profile-local
|
||||||
|
ccs auth resources work --mode shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Local history is separate: if users want future plain `ccs` and `ccs ck` sessions to resume from the same account lane, run `ccs auth default ck` after backing up the current native lane with `ccs auth backup default`.
|
||||||
|
|
||||||
`context_group` normalization and validation:
|
`context_group` normalization and validation:
|
||||||
|
|
||||||
@@ -83,6 +91,13 @@ Dashboard accounts context editing:
|
|||||||
- rejects CLIProxy OAuth account keys for this route
|
- rejects CLIProxy OAuth account keys for this route
|
||||||
- applies normalization/validation rules above
|
- applies normalization/validation rules above
|
||||||
|
|
||||||
|
Shared resource editing:
|
||||||
|
|
||||||
|
- `PUT /api/accounts/:name/shared-resources` updates `shared_resource_mode` for existing auth accounts
|
||||||
|
- accepts only `shared` or `profile-local`
|
||||||
|
- rejects CLIProxy OAuth account keys for this route
|
||||||
|
- reconciles the account instance after metadata is updated
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
### `ccs config auth setup`
|
### `ccs config auth setup`
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ ccs personal
|
|||||||
|
|
||||||
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
|
This keeps usage and credentials isolated. Each account owns its own Claude config directory, login state, and `.anthropic` credentials.
|
||||||
|
|
||||||
Basic Claude settings are shared for non-bare account profiles:
|
Shared Resources are separate from History Sync. By default, non-bare account profiles inherit Claude-local resources from native Claude:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.ccs/instances/<account>/settings.json
|
~/.ccs/instances/<account>/settings.json
|
||||||
@@ -34,7 +34,17 @@ Basic Claude settings are shared for non-bare account profiles:
|
|||||||
-> ~/.claude/settings.json
|
-> ~/.claude/settings.json
|
||||||
```
|
```
|
||||||
|
|
||||||
This is for ordinary Claude Code settings, hooks, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports the current `Settings`, `History`, and `Plain ccs` lanes so users can see whether settings and resume history are aligned.
|
This covers ordinary Claude Code `settings.json`, commands, skills, agents, and plugins. It is not token sharing. `ccs auth show <account>` reports `Resources`, `Settings`, `History`, and `Plain ccs` lanes so users can see whether shared resources and resume history are aligned.
|
||||||
|
|
||||||
|
For existing accounts, change Shared Resources from the CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccs auth resources work --mode profile-local
|
||||||
|
ccs auth resources work --mode shared
|
||||||
|
```
|
||||||
|
|
||||||
|
- `shared`: link plugins, commands, skills, agents, and `settings.json` from the shared Claude resource layout.
|
||||||
|
- `profile-local`: detach those shared resources for the account. This is the existing `--bare` behavior exposed as an existing-account setting.
|
||||||
|
|
||||||
Only opt in to shared history when both accounts should see the same local continuity:
|
Only opt in to shared history when both accounts should see the same local continuity:
|
||||||
|
|
||||||
@@ -42,7 +52,7 @@ Only opt in to shared history when both accounts should see the same local conti
|
|||||||
ccs auth create work2 --share-context --context-group daily --deeper-continuity
|
ccs auth create work2 --share-context --context-group daily --deeper-continuity
|
||||||
```
|
```
|
||||||
|
|
||||||
For existing accounts, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context.
|
For existing History Sync, use Dashboard -> Accounts -> Sync on both accounts, set both to `shared`, and use the same `History Sync Group`. Use `deeper` only when users expect stronger local handoff beyond project context. History Sync does not control plugins or `settings.json`; use `ccs auth resources` for that.
|
||||||
|
|
||||||
## Why This Is Safe Enough
|
## Why This Is Safe Enough
|
||||||
|
|
||||||
@@ -147,11 +157,20 @@ ccs auth create backup2 --share-context --context-group sprint-a --deeper-contin
|
|||||||
|
|
||||||
### Existing account
|
### Existing account
|
||||||
|
|
||||||
|
History Sync:
|
||||||
|
|
||||||
- Open `ccs config`
|
- Open `ccs config`
|
||||||
- Go to `Accounts`
|
- Go to `Accounts`
|
||||||
- Click the pencil icon (`Edit History Sync`)
|
- Click the pencil icon (`Edit History Sync`)
|
||||||
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||||
|
|
||||||
|
Shared Resources:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ccs auth resources work --mode profile-local
|
||||||
|
ccs auth resources work --mode shared
|
||||||
|
```
|
||||||
|
|
||||||
No account recreation required for this workflow.
|
No account recreation required for this workflow.
|
||||||
|
|
||||||
### Backup Before Changing Sync
|
### Backup Before Changing Sync
|
||||||
@@ -172,6 +191,7 @@ ccs auth backup default
|
|||||||
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
- Shared context is local filesystem sharing. It does not bypass remote provider permission models.
|
||||||
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
||||||
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||||
|
- Dashboard controls for existing-account Shared Resources are tracked separately; CLI/API support is the source of truth until that UI lands.
|
||||||
|
|
||||||
## Alternative: CLIProxy Claude Pool
|
## Alternative: CLIProxy Claude Pool
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
handleBackup,
|
handleBackup,
|
||||||
handleList,
|
handleList,
|
||||||
handleShow,
|
handleShow,
|
||||||
|
handleResources,
|
||||||
handleRemove,
|
handleRemove,
|
||||||
handleDefault,
|
handleDefault,
|
||||||
handleResetDefault,
|
handleResetDefault,
|
||||||
@@ -74,6 +75,9 @@ class AuthCommands {
|
|||||||
);
|
);
|
||||||
console.log(` ${color('list', 'command')} List all saved profiles`);
|
console.log(` ${color('list', 'command')} List all saved profiles`);
|
||||||
console.log(` ${color('show <profile>', 'command')} Show profile details`);
|
console.log(` ${color('show <profile>', 'command')} Show profile details`);
|
||||||
|
console.log(
|
||||||
|
` ${color('resources <profile>', 'command')} Show or change shared resource mode`
|
||||||
|
);
|
||||||
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
|
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
|
||||||
console.log(` ${color('default <profile>', 'command')} Set default profile`);
|
console.log(` ${color('default <profile>', 'command')} Set default profile`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -105,6 +109,10 @@ class AuthCommands {
|
|||||||
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
|
console.log(` ${dim('# Create clean profile without shared commands/skills/agents')}`);
|
||||||
console.log(` ${color('ccs auth create sandbox --bare', 'command')}`);
|
console.log(` ${color('ccs auth create sandbox --bare', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
console.log(` ${dim('# Change shared resources for an existing account')}`);
|
||||||
|
console.log(` ${color('ccs auth resources work --mode profile-local', 'command')}`);
|
||||||
|
console.log(` ${color('ccs auth resources work --mode shared', '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('');
|
||||||
@@ -136,6 +144,9 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
` ${color('--bare', 'command')} Create clean profile without shared symlinks (no CK/commands/skills)`
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
` ${color('--mode <mode>', 'command')} Shared resource mode for resources: shared|profile-local`
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||||
);
|
);
|
||||||
@@ -160,6 +171,9 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
` Non-bare account profiles share basic ${color('settings.json', 'path')} with ${color('~/.claude/settings.json', 'path')}; ${color('ccs auth show <profile>', 'command')} shows the link state.`
|
||||||
);
|
);
|
||||||
|
console.log(
|
||||||
|
` Shared Resources control plugins/commands/skills/agents/settings.json; History Sync controls project/session continuity only.`
|
||||||
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
` History sync is opt-in: both accounts need shared mode and the same ${color('context_group', 'path')}.`
|
||||||
);
|
);
|
||||||
@@ -170,7 +184,10 @@ class AuthCommands {
|
|||||||
` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default <profile>', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.`
|
` To make future plain ${color('ccs', 'command')} resume with an account, set ${color('ccs auth default <profile>', 'command')}; back up the current native lane first with ${color('ccs auth backup default', 'command')}.`
|
||||||
);
|
);
|
||||||
console.log(
|
console.log(
|
||||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
` Existing history sync: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
` Existing shared resources: use ${color('ccs auth resources <profile> --mode shared|profile-local', 'command')}.`
|
||||||
);
|
);
|
||||||
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`);
|
||||||
console.log(
|
console.log(
|
||||||
@@ -204,6 +221,10 @@ class AuthCommands {
|
|||||||
return handleShow(this.getContext(), args);
|
return handleShow(this.getContext(), args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleResources(args: string[]): Promise<void> {
|
||||||
|
return handleResources(this.getContext(), args);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove profile - delegates to remove-command.ts
|
* Remove profile - delegates to remove-command.ts
|
||||||
*/
|
*/
|
||||||
@@ -263,6 +284,10 @@ class AuthCommands {
|
|||||||
await this.handleShow(commandArgs);
|
await this.handleShow(commandArgs);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'resources':
|
||||||
|
await this.handleResources(commandArgs);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'remove':
|
case 'remove':
|
||||||
await this.handleRemove(commandArgs);
|
await this.handleRemove(commandArgs);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
resolveRuntimePlainCcsResumeLane,
|
resolveRuntimePlainCcsResumeLane,
|
||||||
} from '../resume-lane-diagnostics';
|
} from '../resume-lane-diagnostics';
|
||||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
|
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
|
||||||
|
import { isProfileLocalSharedResourceMode } from '../shared-resource-policy';
|
||||||
import { CommandContext, parseArgs } from './types';
|
import { CommandContext, parseArgs } from './types';
|
||||||
|
|
||||||
interface BackupManifest {
|
interface BackupManifest {
|
||||||
@@ -69,7 +70,7 @@ export async function handleBackup(ctx: CommandContext, args: string[]): Promise
|
|||||||
isAccountContextMetadata(profile) ? profile : undefined
|
isAccountContextMetadata(profile) ? profile : undefined
|
||||||
);
|
);
|
||||||
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||||
bare: profile.bare === true,
|
bare: isProfileLocalSharedResourceMode(profile),
|
||||||
});
|
});
|
||||||
artifactNames = getContinuityArtifactNames('account');
|
artifactNames = getContinuityArtifactNames('account');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import {
|
|||||||
isValidAccountProfileName,
|
isValidAccountProfileName,
|
||||||
resolveAccountContextPolicy,
|
resolveAccountContextPolicy,
|
||||||
} from '../account-context';
|
} from '../account-context';
|
||||||
|
import {
|
||||||
|
isProfileLocalSharedResourceMode,
|
||||||
|
sharedResourceModeToMetadata,
|
||||||
|
} from '../shared-resource-policy';
|
||||||
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';
|
||||||
@@ -36,11 +40,20 @@ function sanitizeProfileNameForInstance(name: string): string {
|
|||||||
*/
|
*/
|
||||||
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, shareContext, contextGroup, deeperContinuity, bare, unknownFlags } =
|
const {
|
||||||
parseArgs(args);
|
profileName,
|
||||||
|
force,
|
||||||
|
shareContext,
|
||||||
|
contextGroup,
|
||||||
|
deeperContinuity,
|
||||||
|
bare,
|
||||||
|
mode,
|
||||||
|
unknownFlags,
|
||||||
|
} = parseArgs(args);
|
||||||
|
|
||||||
if (unknownFlags && unknownFlags.length > 0) {
|
const unsupportedOptions = [...(unknownFlags ?? []), ...(mode !== undefined ? ['--mode'] : [])];
|
||||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
if (unsupportedOptions.length > 0) {
|
||||||
|
const unknownList = unsupportedOptions.map((flag) => `"${flag}"`).join(', ');
|
||||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(
|
console.log(
|
||||||
@@ -117,8 +130,10 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
? ctx.registry.getAllAccountsUnified()[profileName]
|
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||||
: undefined;
|
: undefined;
|
||||||
const previousBare =
|
const previousBare =
|
||||||
previousLegacyProfile?.bare === true || previousUnifiedProfile?.bare === true;
|
isProfileLocalSharedResourceMode(previousLegacyProfile) ||
|
||||||
|
isProfileLocalSharedResourceMode(previousUnifiedProfile);
|
||||||
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
const effectiveBare = bare === true || (profileExistedBeforeCreate && previousBare);
|
||||||
|
const resourceMetadata = effectiveBare ? sharedResourceModeToMetadata('profile-local') : {};
|
||||||
const previousContextPolicy =
|
const previousContextPolicy =
|
||||||
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
profileExistedBeforeCreate && (previousUnifiedProfile || previousLegacyProfile)
|
||||||
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
? resolveAccountContextPolicy(previousUnifiedProfile || previousLegacyProfile)
|
||||||
@@ -200,13 +215,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
ctx.registry.updateAccountUnified(profileName, {
|
ctx.registry.updateAccountUnified(profileName, {
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
context_group: contextMetadata.context_group,
|
||||||
...(effectiveBare ? { bare: true } : {}),
|
...resourceMetadata,
|
||||||
});
|
});
|
||||||
ctx.registry.touchAccountUnified(profileName);
|
ctx.registry.touchAccountUnified(profileName);
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createAccountUnified(profileName, {
|
ctx.registry.createAccountUnified(profileName, {
|
||||||
...contextMetadata,
|
...contextMetadata,
|
||||||
...(effectiveBare ? { bare: true } : {}),
|
...resourceMetadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -216,14 +231,14 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
type: 'account',
|
type: 'account',
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
context_group: contextMetadata.context_group,
|
||||||
...(effectiveBare ? { bare: true } : {}),
|
...resourceMetadata,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ctx.registry.createProfile(profileName, {
|
ctx.registry.createProfile(profileName, {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
context_mode: contextMetadata.context_mode,
|
context_mode: contextMetadata.context_mode,
|
||||||
context_group: contextMetadata.context_group,
|
context_group: contextMetadata.context_group,
|
||||||
...(effectiveBare ? { bare: true } : {}),
|
...resourceMetadata,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,7 +297,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
`Type: account\n` +
|
`Type: account\n` +
|
||||||
`Context: ${formatAccountContextPolicy(contextPolicy)}\n` +
|
`Context: ${formatAccountContextPolicy(contextPolicy)}\n` +
|
||||||
`Tokens: isolated per account\n` +
|
`Tokens: isolated per account\n` +
|
||||||
`Settings: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude/settings.json'}` +
|
`Resources: ${effectiveBare ? 'profile-local (bare)' : 'shared with ~/.claude'}` +
|
||||||
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
(effectiveBare ? '\nMode: bare (no shared symlinks)' : ''),
|
||||||
'Profile Created'
|
'Profile Created'
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export { handleCreate } from './create-command';
|
|||||||
export { handleBackup } from './backup-command';
|
export { handleBackup } from './backup-command';
|
||||||
export { handleList } from './list-command';
|
export { handleList } from './list-command';
|
||||||
export { handleShow } from './show-command';
|
export { handleShow } from './show-command';
|
||||||
|
export { handleResources } from './resources-command';
|
||||||
export { handleRemove } from './remove-command';
|
export { handleRemove } from './remove-command';
|
||||||
export { handleDefault, handleResetDefault } from './default-command';
|
export { handleDefault, handleResetDefault } from './default-command';
|
||||||
|
|||||||
@@ -7,6 +7,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 { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||||
|
import { resolveSharedResourcePolicy } from '../shared-resource-policy';
|
||||||
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';
|
||||||
@@ -33,6 +34,8 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
context_group: account.context_group,
|
||||||
continuity_mode: account.continuity_mode,
|
continuity_mode: account.continuity_mode,
|
||||||
|
shared_resource_mode: account.shared_resource_mode,
|
||||||
|
bare: account.bare,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +49,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
profiles: profileNames.map((name) => {
|
profiles: profileNames.map((name) => {
|
||||||
const profile = profiles[name];
|
const profile = profiles[name];
|
||||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
|
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||||
const isDefault = name === defaultProfile;
|
const isDefault = name === defaultProfile;
|
||||||
const instancePath = ctx.instanceMgr.getInstancePath(name);
|
const instancePath = ctx.instanceMgr.getInstancePath(name);
|
||||||
|
|
||||||
@@ -58,6 +62,9 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group || null,
|
context_group: contextPolicy.group || null,
|
||||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||||
|
shared_resource_mode: resourcePolicy.mode,
|
||||||
|
shared_resource_inferred: resourcePolicy.inferred,
|
||||||
|
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -107,6 +114,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
const profile = profiles[name];
|
const profile = profiles[name];
|
||||||
const isDefault = name === defaultProfile;
|
const isDefault = name === defaultProfile;
|
||||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
|
const resourcePolicy = resolveSharedResourcePolicy(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');
|
||||||
@@ -122,6 +130,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));
|
row.push(formatAccountContextPolicy(contextPolicy));
|
||||||
|
row.push(resourcePolicy.mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return row;
|
return row;
|
||||||
@@ -129,14 +138,14 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
|
|
||||||
// Headers
|
// Headers
|
||||||
const headers = verbose
|
const headers = verbose
|
||||||
? ['Profile', 'Type', 'Status', 'Last Used', 'Context']
|
? ['Profile', 'Type', 'Status', 'Last Used', 'Context', 'Resources']
|
||||||
: ['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, 34] : [15, 12, 15],
|
colWidths: verbose ? [15, 12, 15, 12, 34, 16] : [15, 12, 15],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { initUI, header, color, fail, ok, table } from '../../utils/ui';
|
||||||
|
import { resolveAccountContextPolicy } from '../account-context';
|
||||||
|
import {
|
||||||
|
isSharedResourceMode,
|
||||||
|
resolveSharedResourcePolicy,
|
||||||
|
sharedResourceModeToMetadata,
|
||||||
|
type SharedResourceMode,
|
||||||
|
} from '../shared-resource-policy';
|
||||||
|
import { exitWithError } from '../../errors';
|
||||||
|
import { ExitCode } from '../../errors/exit-codes';
|
||||||
|
import { CommandContext, parseArgs } from './types';
|
||||||
|
|
||||||
|
function formatMode(mode: SharedResourceMode): string {
|
||||||
|
return mode === 'profile-local' ? 'profile-local' : 'shared';
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeDescription(mode: SharedResourceMode): string {
|
||||||
|
return mode === 'profile-local'
|
||||||
|
? 'profile-local resources; plugins/settings/commands/skills/agents are not linked from ~/.claude'
|
||||||
|
: 'shared resources from ~/.claude; plugins/settings/commands/skills/agents are linked into the account';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleResources(ctx: CommandContext, args: string[]): Promise<void> {
|
||||||
|
await initUI();
|
||||||
|
const { profileName, mode, json } = parseArgs(args);
|
||||||
|
|
||||||
|
if (!profileName) {
|
||||||
|
console.log(fail('Profile name is required'));
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
`Usage: ${color('ccs auth resources <profile> [--mode shared|profile-local] [--json]', 'command')}`
|
||||||
|
);
|
||||||
|
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profiles = ctx.registry.getAllProfilesMerged();
|
||||||
|
const currentProfile = profiles[profileName];
|
||||||
|
if (!currentProfile || currentProfile.type !== 'account') {
|
||||||
|
exitWithError(`Profile not found: ${profileName}`, ExitCode.PROFILE_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPolicy = resolveSharedResourcePolicy(currentProfile);
|
||||||
|
|
||||||
|
if (mode !== undefined && !isSharedResourceMode(mode)) {
|
||||||
|
exitWithError(
|
||||||
|
'Invalid shared resource mode: expected shared|profile-local',
|
||||||
|
ExitCode.PROFILE_ERROR
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mode) {
|
||||||
|
if (json) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: profileName,
|
||||||
|
shared_resource_mode: currentPolicy.mode,
|
||||||
|
shared_resource_inferred: currentPolicy.inferred,
|
||||||
|
bare: currentPolicy.profileLocal ? true : undefined,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(header(`Shared Resources: ${profileName}`));
|
||||||
|
console.log('');
|
||||||
|
console.log(
|
||||||
|
table(
|
||||||
|
[
|
||||||
|
['Mode', formatMode(currentPolicy.mode)],
|
||||||
|
['Effective', modeDescription(currentPolicy.mode)],
|
||||||
|
],
|
||||||
|
{ colWidths: [14, 70] }
|
||||||
|
)
|
||||||
|
);
|
||||||
|
console.log('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsUnified = ctx.registry.hasAccountUnified(profileName);
|
||||||
|
const existsLegacy = ctx.registry.hasProfile(profileName);
|
||||||
|
const previousUnified = existsUnified
|
||||||
|
? ctx.registry.getAllAccountsUnified()[profileName]
|
||||||
|
: undefined;
|
||||||
|
const previousLegacy = existsLegacy ? ctx.registry.getProfile(profileName) : undefined;
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(currentProfile);
|
||||||
|
const metadata = sharedResourceModeToMetadata(mode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsUnified) {
|
||||||
|
ctx.registry.updateAccountUnified(profileName, metadata);
|
||||||
|
}
|
||||||
|
if (existsLegacy) {
|
||||||
|
ctx.registry.updateProfile(profileName, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
|
||||||
|
bare: mode === 'profile-local',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (existsUnified && previousUnified) {
|
||||||
|
ctx.registry.updateAccountUnified(profileName, {
|
||||||
|
...previousUnified,
|
||||||
|
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||||
|
bare: previousUnified.bare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existsLegacy && previousLegacy) {
|
||||||
|
ctx.registry.updateProfile(profileName, {
|
||||||
|
...previousLegacy,
|
||||||
|
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||||
|
bare: previousLegacy.bare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json) {
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: profileName,
|
||||||
|
shared_resource_mode: mode,
|
||||||
|
shared_resource_inferred: false,
|
||||||
|
bare: mode === 'profile-local' ? true : undefined,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(ok(`Shared resources for "${profileName}" set to ${formatMode(mode)}`));
|
||||||
|
console.log('');
|
||||||
|
console.log(modeDescription(mode));
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import { initUI, header, color, fail, table } from '../../utils/ui';
|
|||||||
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
import { resolveAccountContextPolicy, formatAccountContextPolicy } from '../account-context';
|
||||||
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
|
import { describeSettingsSync, summarizeAccountHistory } from '../account-profile-diagnostics';
|
||||||
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
|
import { resolveConfiguredPlainCcsResumeLane } from '../resume-lane-diagnostics';
|
||||||
|
import { resolveSharedResourcePolicy } from '../shared-resource-policy';
|
||||||
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';
|
||||||
@@ -45,7 +46,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
const isDefault = profileName === defaultProfile;
|
const isDefault = profileName === defaultProfile;
|
||||||
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
const instancePath = ctx.instanceMgr.getInstancePath(profileName);
|
||||||
const contextPolicy = resolveAccountContextPolicy(profile);
|
const contextPolicy = resolveAccountContextPolicy(profile);
|
||||||
const settingsSync = describeSettingsSync(instancePath, { bare: profile.bare === true });
|
const resourcePolicy = resolveSharedResourcePolicy(profile);
|
||||||
|
const settingsSync = describeSettingsSync(instancePath, { bare: resourcePolicy.profileLocal });
|
||||||
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
|
const historySummary = summarizeAccountHistory(instancePath, contextPolicy);
|
||||||
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
|
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane().catch(() => null);
|
||||||
const plainCcsUsesThisAccount =
|
const plainCcsUsesThisAccount =
|
||||||
@@ -74,6 +76,8 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group || null,
|
context_group: contextPolicy.group || null,
|
||||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||||
|
shared_resource_mode: resourcePolicy.mode,
|
||||||
|
shared_resource_inferred: resourcePolicy.inferred,
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
session_count: sessionCount,
|
session_count: sessionCount,
|
||||||
settings_sync: {
|
settings_sync: {
|
||||||
@@ -94,7 +98,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
...(profile.bare ? { bare: true } : {}),
|
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||||
};
|
};
|
||||||
console.log(JSON.stringify(output, null, 2));
|
console.log(JSON.stringify(output, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -112,6 +116,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
['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)],
|
['Context', formatAccountContextPolicy(contextPolicy)],
|
||||||
|
['Resources', resourcePolicy.mode],
|
||||||
['Credentials', 'isolated per account'],
|
['Credentials', 'isolated per account'],
|
||||||
['Settings', settingsSync.description],
|
['Settings', settingsSync.description],
|
||||||
['History', formatHistorySummary(historySummary)],
|
['History', formatHistorySummary(historySummary)],
|
||||||
@@ -125,7 +130,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
],
|
],
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(profile.bare ? [['Bare', 'yes (no shared symlinks)']] : []),
|
...(resourcePolicy.profileLocal ? [['Bare', 'yes (no shared symlinks)']] : []),
|
||||||
['Sessions', `${sessionCount}`],
|
['Sessions', `${sessionCount}`],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface AuthCommandArgs {
|
|||||||
contextGroup?: string;
|
contextGroup?: string;
|
||||||
deeperContinuity?: boolean;
|
deeperContinuity?: boolean;
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
|
mode?: string;
|
||||||
unknownFlags?: string[];
|
unknownFlags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +39,8 @@ export interface ProfileOutput {
|
|||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string | null;
|
context_group?: string | null;
|
||||||
continuity_mode?: 'standard' | 'deeper' | null;
|
continuity_mode?: 'standard' | 'deeper' | null;
|
||||||
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
|
shared_resource_inferred?: boolean;
|
||||||
instance_path?: string;
|
instance_path?: string;
|
||||||
session_count?: number;
|
session_count?: number;
|
||||||
settings_sync?: {
|
settings_sync?: {
|
||||||
@@ -86,6 +89,7 @@ export interface CommandContext {
|
|||||||
export function parseArgs(args: string[]): AuthCommandArgs {
|
export function parseArgs(args: string[]): AuthCommandArgs {
|
||||||
let profileName: string | undefined;
|
let profileName: string | undefined;
|
||||||
let contextGroup: string | undefined;
|
let contextGroup: string | undefined;
|
||||||
|
let mode: string | undefined;
|
||||||
const unknownFlags = new Set<string>();
|
const unknownFlags = new Set<string>();
|
||||||
const knownBooleanFlags = new Set([
|
const knownBooleanFlags = new Set([
|
||||||
'--force',
|
'--force',
|
||||||
@@ -97,7 +101,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
|||||||
'--deeper-continuity',
|
'--deeper-continuity',
|
||||||
'--bare',
|
'--bare',
|
||||||
]);
|
]);
|
||||||
const knownValueFlags = new Set(['--context-group']);
|
const knownValueFlags = new Set(['--context-group', '--mode']);
|
||||||
|
|
||||||
for (let i = 0; i < args.length; i++) {
|
for (let i = 0; i < args.length; i++) {
|
||||||
const arg = args[i];
|
const arg = args[i];
|
||||||
@@ -114,11 +118,28 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg === '--mode') {
|
||||||
|
const next = args[i + 1];
|
||||||
|
if (!next || next.startsWith('-')) {
|
||||||
|
mode = '';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mode = next;
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg.startsWith('--context-group=')) {
|
if (arg.startsWith('--context-group=')) {
|
||||||
contextGroup = arg.slice('--context-group='.length);
|
contextGroup = arg.slice('--context-group='.length);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (arg.startsWith('--mode=')) {
|
||||||
|
mode = arg.slice('--mode='.length);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg.startsWith('-')) {
|
if (arg.startsWith('-')) {
|
||||||
const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
const normalizedFlag = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
||||||
const isKnownFlag =
|
const isKnownFlag =
|
||||||
@@ -149,6 +170,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
|||||||
shareContext: args.includes('--share-context'),
|
shareContext: args.includes('--share-context'),
|
||||||
deeperContinuity: args.includes('--deeper-continuity'),
|
deeperContinuity: args.includes('--deeper-continuity'),
|
||||||
bare: args.includes('--bare'),
|
bare: args.includes('--bare'),
|
||||||
|
mode,
|
||||||
contextGroup,
|
contextGroup,
|
||||||
unknownFlags: [...unknownFlags],
|
unknownFlags: [...unknownFlags],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { warn } from '../utils/ui';
|
|||||||
import InstanceManager from '../management/instance-manager';
|
import InstanceManager from '../management/instance-manager';
|
||||||
import ProfileRegistry from './profile-registry';
|
import ProfileRegistry from './profile-registry';
|
||||||
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
import { isAccountContextMetadata, resolveAccountContextPolicy } from './account-context';
|
||||||
|
import { isProfileLocalSharedResourceMode } from './shared-resource-policy';
|
||||||
import type { ProfileType } from '../types/profile';
|
import type { ProfileType } from '../types/profile';
|
||||||
import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat';
|
import { getProfileLookupCandidates, resolveAliasToCanonical } from '../utils/profile-compat';
|
||||||
import {
|
import {
|
||||||
@@ -155,7 +156,7 @@ export async function resolveProfileContinuityInheritance(
|
|||||||
);
|
);
|
||||||
const instanceMgr = new InstanceManager();
|
const instanceMgr = new InstanceManager();
|
||||||
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
|
const instancePath = await instanceMgr.ensureInstance(sourceAccount, contextPolicy, {
|
||||||
bare: mappedProfile.bare === true,
|
bare: isProfileLocalSharedResourceMode(mappedProfile),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ class ProfileDetector {
|
|||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
context_group: account.context_group,
|
||||||
continuity_mode: account.continuity_mode,
|
continuity_mode: account.continuity_mode,
|
||||||
|
shared_resource_mode: account.shared_resource_mode,
|
||||||
bare: account.bare,
|
bare: account.bare,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
loadOrCreateUnifiedConfig,
|
loadOrCreateUnifiedConfig,
|
||||||
mutateConfig,
|
mutateConfig,
|
||||||
} from '../config/config-loader-facade';
|
} from '../config/config-loader-facade';
|
||||||
|
import { normalizeSharedResourceMetadata, type SharedResourceMode } from './shared-resource-policy';
|
||||||
|
|
||||||
const logger = createLogger('auth:profile-registry');
|
const logger = createLogger('auth:profile-registry');
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ interface CreateMetadata {
|
|||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
|
shared_resource_mode?: SharedResourceMode;
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +92,7 @@ export class ProfileRegistry {
|
|||||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalizeSharedResourceMetadata(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
|
private normalizeUnifiedAccountConfig(account: AccountConfig): AccountConfig {
|
||||||
@@ -110,7 +112,7 @@ export class ProfileRegistry {
|
|||||||
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalizeSharedResourceMetadata(normalized);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,6 +181,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
continuity_mode: metadata.continuity_mode,
|
continuity_mode: metadata.continuity_mode,
|
||||||
|
shared_resource_mode: metadata.shared_resource_mode,
|
||||||
bare: metadata.bare,
|
bare: metadata.bare,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -335,6 +338,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
continuity_mode: metadata.continuity_mode,
|
continuity_mode: metadata.continuity_mode,
|
||||||
|
shared_resource_mode: metadata.shared_resource_mode,
|
||||||
bare: metadata.bare,
|
bare: metadata.bare,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -462,6 +466,7 @@ export class ProfileRegistry {
|
|||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
context_group: account.context_group,
|
||||||
continuity_mode: account.continuity_mode,
|
continuity_mode: account.continuity_mode,
|
||||||
|
shared_resource_mode: account.shared_resource_mode,
|
||||||
bare: account.bare,
|
bare: account.bare,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
export type SharedResourceMode = 'shared' | 'profile-local';
|
||||||
|
|
||||||
|
export interface SharedResourceMetadata {
|
||||||
|
shared_resource_mode?: unknown;
|
||||||
|
bare?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedResourcePolicy {
|
||||||
|
mode: SharedResourceMode;
|
||||||
|
inferred: boolean;
|
||||||
|
profileLocal: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedResourceMetadataUpdate {
|
||||||
|
shared_resource_mode: SharedResourceMode;
|
||||||
|
bare?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSharedResourceMode(value: unknown): value is SharedResourceMode {
|
||||||
|
return value === 'shared' || value === 'profile-local';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveSharedResourcePolicy(
|
||||||
|
metadata?: SharedResourceMetadata | null
|
||||||
|
): SharedResourcePolicy {
|
||||||
|
const explicitMode = metadata?.shared_resource_mode;
|
||||||
|
if (isSharedResourceMode(explicitMode)) {
|
||||||
|
return {
|
||||||
|
mode: explicitMode,
|
||||||
|
inferred: false,
|
||||||
|
profileLocal: explicitMode === 'profile-local',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata?.bare === true) {
|
||||||
|
return {
|
||||||
|
mode: 'profile-local',
|
||||||
|
inferred: true,
|
||||||
|
profileLocal: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: 'shared',
|
||||||
|
inferred: true,
|
||||||
|
profileLocal: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProfileLocalSharedResourceMode(
|
||||||
|
metadata?: SharedResourceMetadata | null
|
||||||
|
): boolean {
|
||||||
|
return resolveSharedResourcePolicy(metadata).profileLocal;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sharedResourceModeToMetadata(
|
||||||
|
mode: SharedResourceMode
|
||||||
|
): SharedResourceMetadataUpdate {
|
||||||
|
if (mode === 'profile-local') {
|
||||||
|
return {
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
bare: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
shared_resource_mode: 'shared',
|
||||||
|
bare: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSharedResourceMetadata<T extends SharedResourceMetadata>(metadata: T): T {
|
||||||
|
const normalized = { ...metadata } as T & {
|
||||||
|
shared_resource_mode?: SharedResourceMode;
|
||||||
|
bare?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (normalized.shared_resource_mode === 'profile-local') {
|
||||||
|
normalized.bare = true;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.shared_resource_mode === 'shared') {
|
||||||
|
delete normalized.bare;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete normalized.shared_resource_mode;
|
||||||
|
if (normalized.bare === true) {
|
||||||
|
normalized.shared_resource_mode = 'profile-local';
|
||||||
|
normalized.bare = true;
|
||||||
|
} else {
|
||||||
|
delete normalized.bare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
@@ -248,6 +248,7 @@ export const AUTH_SUBCOMMANDS = [
|
|||||||
'backup',
|
'backup',
|
||||||
'list',
|
'list',
|
||||||
'show',
|
'show',
|
||||||
|
'resources',
|
||||||
'remove',
|
'remove',
|
||||||
'default',
|
'default',
|
||||||
'reset-default',
|
'reset-default',
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
|
|||||||
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
|
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
|
||||||
if (subcommand === 'show')
|
if (subcommand === 'show')
|
||||||
return completeSubcommands(getProfileNames('accounts'), ['--json']);
|
return completeSubcommands(getProfileNames('accounts'), ['--json']);
|
||||||
|
if (subcommand === 'resources')
|
||||||
|
return completeSubcommands(getProfileNames('accounts'), [
|
||||||
|
'--mode',
|
||||||
|
'--mode=shared',
|
||||||
|
'--mode=profile-local',
|
||||||
|
'--json',
|
||||||
|
]);
|
||||||
if (subcommand === 'remove')
|
if (subcommand === 'remove')
|
||||||
return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']);
|
return completeSubcommands(getProfileNames('accounts'), ['--yes', '-y']);
|
||||||
if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts'));
|
if (subcommand === 'default') return completeSubcommands(getProfileNames('accounts'));
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async function showProfilesHelp(writeLine: HelpWriter): Promise<void> {
|
|||||||
'Profile Types',
|
'Profile Types',
|
||||||
[
|
[
|
||||||
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
|
{ name: 'ccs auth create <name>', summary: 'Concurrent Claude account profile' },
|
||||||
|
{ name: 'ccs auth resources <name>', summary: 'Shared resources for an account profile' },
|
||||||
{ name: 'ccs api create', summary: 'API-backed settings profile' },
|
{ name: 'ccs api create', summary: 'API-backed settings profile' },
|
||||||
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
|
{ name: 'ccs cliproxy create <name>', summary: 'Named CLIProxy variant profile' },
|
||||||
{ name: 'ccs env <profile>', summary: 'Export an existing profile for other tools' },
|
{ name: 'ccs env <profile>', summary: 'Export an existing profile for other tools' },
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ export async function handleSyncCommand(): Promise<void> {
|
|||||||
const { InstanceManager } = await import('../management/instance-manager');
|
const { InstanceManager } = await import('../management/instance-manager');
|
||||||
const instanceMgr = new InstanceManager();
|
const instanceMgr = new InstanceManager();
|
||||||
const ProfileRegistry = (await import('../auth/profile-registry')).default;
|
const ProfileRegistry = (await import('../auth/profile-registry')).default;
|
||||||
|
const { isProfileLocalSharedResourceMode } = await import('../auth/shared-resource-policy');
|
||||||
const registry = new ProfileRegistry();
|
const registry = new ProfileRegistry();
|
||||||
const allProfiles = registry.getAllProfilesMerged();
|
const allProfiles = registry.getAllProfilesMerged();
|
||||||
let mcpSynced = 0;
|
let mcpSynced = 0;
|
||||||
|
|
||||||
for (const [name, profile] of Object.entries(allProfiles)) {
|
for (const [name, profile] of Object.entries(allProfiles)) {
|
||||||
if (profile.bare) {
|
if (isProfileLocalSharedResourceMode(profile)) {
|
||||||
continue; // Skip bare profiles
|
continue; // Skip profile-local shared-resource profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!instanceMgr.hasInstance(name)) {
|
if (!instanceMgr.hasInstance(name)) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { createEmptyUnifiedConfig } from './unified-config-types';
|
|||||||
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
|
||||||
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
|
import { saveUnifiedConfig, hasUnifiedConfig, loadUnifiedConfig } from './unified-config-loader';
|
||||||
import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context';
|
import { isValidContextGroupName, normalizeContextGroupName } from '../auth/account-context';
|
||||||
|
import { isSharedResourceMode, resolveSharedResourcePolicy } from '../auth/shared-resource-policy';
|
||||||
import { infoBox, warn } from '../utils/ui';
|
import { infoBox, warn } from '../utils/ui';
|
||||||
|
|
||||||
const BACKUP_DIR_PREFIX = 'backup-v1-';
|
const BACKUP_DIR_PREFIX = 'backup-v1-';
|
||||||
@@ -222,6 +223,16 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
|||||||
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
||||||
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
|
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
|
||||||
};
|
};
|
||||||
|
const resourcePolicy = resolveSharedResourcePolicy({
|
||||||
|
shared_resource_mode: metadata.shared_resource_mode,
|
||||||
|
bare: metadata.bare,
|
||||||
|
});
|
||||||
|
if (resourcePolicy.mode === 'profile-local') {
|
||||||
|
account.shared_resource_mode = 'profile-local';
|
||||||
|
account.bare = true;
|
||||||
|
} else if (isSharedResourceMode(metadata.shared_resource_mode)) {
|
||||||
|
account.shared_resource_mode = 'shared';
|
||||||
|
}
|
||||||
unifiedConfig.accounts[name] = account;
|
unifiedConfig.accounts[name] = account;
|
||||||
}
|
}
|
||||||
migratedFiles.push('profiles.json → config.yaml.accounts');
|
migratedFiles.push('profiles.json → config.yaml.accounts');
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export interface AccountConfig {
|
|||||||
context_group?: string;
|
context_group?: string;
|
||||||
/** Shared continuity depth when context_mode='shared' */
|
/** Shared continuity depth when context_mode='shared' */
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
|
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||||
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { execClaude } from '../../utils/shell-executor';
|
import { execClaude } from '../../utils/shell-executor';
|
||||||
import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning';
|
import { maybeWarnAboutResumeLaneMismatch } from '../../auth/resume-lane-warning';
|
||||||
|
import { isProfileLocalSharedResourceMode } from '../../auth/shared-resource-policy';
|
||||||
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
|
import { resolveNativeClaudeLaunchArgs } from '../environment-builder';
|
||||||
import type { ProfileDispatchContext } from '../dispatcher-context';
|
import type { ProfileDispatchContext } from '../dispatcher-context';
|
||||||
|
|
||||||
@@ -26,10 +27,11 @@ export async function runAccountFlow(ctx: ProfileDispatchContext): Promise<void>
|
|||||||
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
const accountMetadata = isAccountContextMetadata(profileInfo.profile)
|
||||||
? profileInfo.profile
|
? profileInfo.profile
|
||||||
: undefined;
|
: undefined;
|
||||||
const isBareProfile =
|
const isBareProfile = isProfileLocalSharedResourceMode(
|
||||||
typeof profileInfo.profile === 'object' &&
|
typeof profileInfo.profile === 'object' && profileInfo.profile !== null
|
||||||
profileInfo.profile !== null &&
|
? (profileInfo.profile as { shared_resource_mode?: unknown; bare?: unknown })
|
||||||
(profileInfo.profile as { bare?: unknown }).bare === true;
|
: undefined
|
||||||
|
);
|
||||||
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
const contextPolicy = resolveAccountContextPolicy(accountMetadata);
|
||||||
|
|
||||||
// Ensure instance exists (lazy init if needed)
|
// Ensure instance exists (lazy init if needed)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { loadSettingsFromFile, type ProfileType } from '../auth/profile-detector
|
|||||||
import ProfileDetector from '../auth/profile-detector';
|
import ProfileDetector from '../auth/profile-detector';
|
||||||
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
import { resolveProfileContinuityInheritance } from '../auth/profile-continuity-inheritance';
|
||||||
import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context';
|
import { resolveAccountContextPolicy, isAccountContextMetadata } from '../auth/account-context';
|
||||||
|
import { isProfileLocalSharedResourceMode } from '../auth/shared-resource-policy';
|
||||||
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
import type { ProfileDetectionResult } from '../auth/profile-detector';
|
||||||
import {
|
import {
|
||||||
getEffectiveEnvVars,
|
getEffectiveEnvVars,
|
||||||
@@ -158,8 +159,12 @@ async function resolveExtensionEnv(
|
|||||||
const policy = resolveAccountContextPolicy(
|
const policy = resolveAccountContextPolicy(
|
||||||
isAccountContextMetadata(result.profile) ? result.profile : undefined
|
isAccountContextMetadata(result.profile) ? result.profile : undefined
|
||||||
);
|
);
|
||||||
|
const sharedResourceMetadata =
|
||||||
|
typeof result.profile === 'object' && result.profile !== null
|
||||||
|
? (result.profile as { shared_resource_mode?: unknown; bare?: unknown })
|
||||||
|
: undefined;
|
||||||
const instancePath = await instanceManager.ensureInstance(result.name, policy, {
|
const instancePath = await instanceManager.ensureInstance(result.name, policy, {
|
||||||
bare: result.profile?.bare === true,
|
bare: isProfileLocalSharedResourceMode(sharedResourceMetadata),
|
||||||
});
|
});
|
||||||
notes.push('Account profiles authenticate through the isolated Claude config directory.');
|
notes.push('Account profiles authenticate through the isolated Claude config directory.');
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export interface ProfileMetadata {
|
|||||||
context_group?: string;
|
context_group?: string;
|
||||||
/** Shared continuity depth when context_mode='shared' */
|
/** Shared continuity depth when context_mode='shared' */
|
||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
|
/** Account-level shared resource behavior for plugins, commands, skills, agents, and settings.json */
|
||||||
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
/** Bare profile: no shared symlinks (commands, skills, agents, settings.json) */
|
||||||
bare?: boolean;
|
bare?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ export interface MergedAccountEntry {
|
|||||||
continuity_mode?: 'standard' | 'deeper';
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
continuity_inferred?: boolean;
|
continuity_inferred?: boolean;
|
||||||
|
shared_resource_mode?: 'shared' | 'profile-local';
|
||||||
|
shared_resource_inferred?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ import {
|
|||||||
normalizeContextGroupName,
|
normalizeContextGroupName,
|
||||||
resolveAccountContextPolicy,
|
resolveAccountContextPolicy,
|
||||||
} from '../../auth/account-context';
|
} from '../../auth/account-context';
|
||||||
|
import {
|
||||||
|
isSharedResourceMode,
|
||||||
|
resolveSharedResourcePolicy,
|
||||||
|
sharedResourceModeToMetadata,
|
||||||
|
} from '../../auth/shared-resource-policy';
|
||||||
import {
|
import {
|
||||||
buildCliproxyAccountKey,
|
buildCliproxyAccountKey,
|
||||||
parseCliproxyKey,
|
parseCliproxyKey,
|
||||||
@@ -79,6 +84,7 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
// Add legacy profiles first
|
// Add legacy profiles first
|
||||||
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
for (const [name, meta] of Object.entries(legacyProfiles)) {
|
||||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||||
|
const resourcePolicy = resolveSharedResourcePolicy(meta);
|
||||||
const hasExplicitContextMode =
|
const hasExplicitContextMode =
|
||||||
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||||
const hasExplicitContinuityMode =
|
const hasExplicitContinuityMode =
|
||||||
@@ -93,6 +99,9 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
context_inferred: !hasExplicitContextMode,
|
context_inferred: !hasExplicitContextMode,
|
||||||
continuity_inferred:
|
continuity_inferred:
|
||||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||||
|
shared_resource_mode: resourcePolicy.mode,
|
||||||
|
shared_resource_inferred: resourcePolicy.inferred,
|
||||||
|
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +109,7 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
for (const [name, account] of Object.entries(unifiedAccounts)) {
|
||||||
const rawAccount = rawUnifiedAccounts[name];
|
const rawAccount = rawUnifiedAccounts[name];
|
||||||
const contextPolicy = resolveAccountContextPolicy(account);
|
const contextPolicy = resolveAccountContextPolicy(account);
|
||||||
|
const resourcePolicy = resolveSharedResourcePolicy(account);
|
||||||
const hasExplicitContextMode =
|
const hasExplicitContextMode =
|
||||||
rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared';
|
rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared';
|
||||||
const hasExplicitContinuityMode =
|
const hasExplicitContinuityMode =
|
||||||
@@ -114,6 +124,9 @@ router.get('/', async (_req: Request, res: Response): Promise<void> => {
|
|||||||
context_inferred: !hasExplicitContextMode,
|
context_inferred: !hasExplicitContextMode,
|
||||||
continuity_inferred:
|
continuity_inferred:
|
||||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||||
|
shared_resource_mode: resourcePolicy.mode,
|
||||||
|
shared_resource_inferred: resourcePolicy.inferred,
|
||||||
|
...(resourcePolicy.profileLocal ? { bare: true } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,7 +318,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
|
|
||||||
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||||
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||||
const isBare = previousUnified?.bare === true || previousLegacy?.bare === true;
|
const resourcePolicy = resolveSharedResourcePolicy(previousUnified ?? previousLegacy);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (existsUnified) {
|
if (existsUnified) {
|
||||||
@@ -315,13 +328,21 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
registry.updateProfile(name, metadata);
|
registry.updateProfile(name, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
await instanceMgr.ensureInstance(name, policy, { bare: isBare });
|
await instanceMgr.ensureInstance(name, policy, { bare: resourcePolicy.profileLocal });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (existsUnified && previousUnified) {
|
if (existsUnified && previousUnified) {
|
||||||
registry.updateAccountUnified(name, previousUnified);
|
registry.updateAccountUnified(name, {
|
||||||
|
...previousUnified,
|
||||||
|
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||||
|
bare: previousUnified.bare,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (existsLegacy && previousLegacy) {
|
if (existsLegacy && previousLegacy) {
|
||||||
registry.updateProfile(name, previousLegacy);
|
registry.updateProfile(name, {
|
||||||
|
...previousLegacy,
|
||||||
|
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||||
|
bare: previousLegacy.bare,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -342,6 +363,89 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/accounts/:name/shared-resources - Update account shared resource mode
|
||||||
|
*/
|
||||||
|
router.put('/:name/shared-resources', async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const registry = createProfileRegistry();
|
||||||
|
const instanceMgr = createInstanceManager();
|
||||||
|
const { name } = req.params;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
res.status(400).json({ error: 'Missing account name' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null;
|
||||||
|
if (cliproxyKey) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: `Shared resource mode is not supported for CLIProxy account: ${name}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name);
|
||||||
|
const existsLegacy = registry.hasProfile(name);
|
||||||
|
if (!existsUnified && !existsLegacy) {
|
||||||
|
res.status(404).json({ error: `Account not found: ${name}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = req.body?.shared_resource_mode;
|
||||||
|
if (!isSharedResourceMode(mode)) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Missing or invalid shared_resource_mode: expected shared|profile-local',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined;
|
||||||
|
const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined;
|
||||||
|
const previousMetadata = previousUnified ?? previousLegacy;
|
||||||
|
const contextPolicy = resolveAccountContextPolicy(previousMetadata);
|
||||||
|
const metadata = sharedResourceModeToMetadata(mode);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (existsUnified) {
|
||||||
|
registry.updateAccountUnified(name, metadata);
|
||||||
|
}
|
||||||
|
if (existsLegacy) {
|
||||||
|
registry.updateProfile(name, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
await instanceMgr.ensureInstance(name, contextPolicy, {
|
||||||
|
bare: mode === 'profile-local',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (existsUnified && previousUnified) {
|
||||||
|
registry.updateAccountUnified(name, {
|
||||||
|
...previousUnified,
|
||||||
|
shared_resource_mode: previousUnified.shared_resource_mode,
|
||||||
|
bare: previousUnified.bare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (existsLegacy && previousLegacy) {
|
||||||
|
registry.updateProfile(name, {
|
||||||
|
...previousLegacy,
|
||||||
|
shared_resource_mode: previousLegacy.shared_resource_mode,
|
||||||
|
bare: previousLegacy.bare,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
name,
|
||||||
|
shared_resource_mode: mode,
|
||||||
|
shared_resource_inferred: false,
|
||||||
|
bare: mode === 'profile-local' ? true : undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/accounts/reset-default - Reset to CCS default
|
* DELETE /api/accounts/reset-default - Reset to CCS default
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ describe('auth command args parsing', () => {
|
|||||||
expect(parsed.contextGroup).toBe('sprint-a');
|
expect(parsed.contextGroup).toBe('sprint-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('parses shared resource mode value for resources command', () => {
|
||||||
|
const parsed = parseArgs(['work', '--mode', 'profile-local']);
|
||||||
|
|
||||||
|
expect(parsed.profileName).toBe('work');
|
||||||
|
expect(parsed.mode).toBe('profile-local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses inline shared resource mode value', () => {
|
||||||
|
const parsed = parseArgs(['work', '--mode=shared']);
|
||||||
|
|
||||||
|
expect(parsed.profileName).toBe('work');
|
||||||
|
expect(parsed.mode).toBe('shared');
|
||||||
|
});
|
||||||
|
|
||||||
it('tracks unknown flags and keeps positional profile intact', () => {
|
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import ProfileRegistry from '../../src/auth/profile-registry';
|
||||||
|
import InstanceManager from '../../src/management/instance-manager';
|
||||||
|
import { handleResources } from '../../src/auth/commands/resources-command';
|
||||||
|
import { handleCreate } from '../../src/auth/commands/create-command';
|
||||||
|
|
||||||
|
describe('auth resources command', () => {
|
||||||
|
let tempRoot = '';
|
||||||
|
let originalCcsHome: string | undefined;
|
||||||
|
let originalUnifiedMode: string | undefined;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-resources-'));
|
||||||
|
originalCcsHome = process.env.CCS_HOME;
|
||||||
|
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
|
||||||
|
process.env.CCS_HOME = tempRoot;
|
||||||
|
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
|
||||||
|
else delete process.env.CCS_HOME;
|
||||||
|
|
||||||
|
if (originalUnifiedMode !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
|
||||||
|
else delete process.env.CCS_UNIFIED_CONFIG;
|
||||||
|
|
||||||
|
if (tempRoot && fs.existsSync(tempRoot)) {
|
||||||
|
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an existing account shared resource mode without changing context metadata', async () => {
|
||||||
|
const ccsDir = path.join(tempRoot, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
' context_mode: shared',
|
||||||
|
' context_group: sprint-a',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||||
|
path.join(ccsDir, 'instances', 'work')
|
||||||
|
);
|
||||||
|
const lines: string[] = [];
|
||||||
|
const originalLog = console.log;
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
lines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleResources(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
['work', '--mode', 'profile-local', '--json']
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
console.log = originalLog;
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = JSON.parse(lines.join('\n')) as {
|
||||||
|
shared_resource_mode: string;
|
||||||
|
bare?: boolean;
|
||||||
|
};
|
||||||
|
expect(output.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(output.bare).toBe(true);
|
||||||
|
|
||||||
|
const account = registry.getAllAccountsUnified().work;
|
||||||
|
expect(account.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(account.bare).toBe(true);
|
||||||
|
expect(account.context_mode).toBe('shared');
|
||||||
|
expect(account.context_group).toBe('sprint-a');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back inferred shared metadata when instance reconciliation fails', async () => {
|
||||||
|
const ccsDir = path.join(tempRoot, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||||
|
new Error('reconcile failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
handleResources(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
['work', '--mode', 'profile-local']
|
||||||
|
)
|
||||||
|
).rejects.toThrow('reconcile failed');
|
||||||
|
} finally {
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = registry.getAllAccountsUnified().work;
|
||||||
|
expect(account.shared_resource_mode).toBeUndefined();
|
||||||
|
expect(account.bare).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects --mode on auth create instead of silently ignoring it', async () => {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const instanceMgr = new InstanceManager();
|
||||||
|
const originalExit = process.exit;
|
||||||
|
const originalLog = console.log;
|
||||||
|
const originalError = console.error;
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
process.exit = ((code?: number) => {
|
||||||
|
throw new Error(`process.exit(${code ?? 0})`);
|
||||||
|
}) as typeof process.exit;
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
lines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
lines.push(args.map(String).join(' '));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(
|
||||||
|
handleCreate(
|
||||||
|
{
|
||||||
|
registry,
|
||||||
|
instanceMgr,
|
||||||
|
version: 'test',
|
||||||
|
},
|
||||||
|
['work', '--mode', 'profile-local']
|
||||||
|
)
|
||||||
|
).rejects.toThrow('process.exit(7)');
|
||||||
|
} finally {
|
||||||
|
process.exit = originalExit;
|
||||||
|
console.log = originalLog;
|
||||||
|
console.error = originalError;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(lines.join('\n')).toContain('Unknown option(s): "--mode"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -98,6 +98,7 @@ describe('profile-registry context normalization', () => {
|
|||||||
|
|
||||||
const profile = registry.getProfile('work');
|
const profile = registry.getProfile('work');
|
||||||
expect(profile.bare).toBe(true);
|
expect(profile.bare).toBe(true);
|
||||||
|
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists bare flag for unified accounts and merged projection', () => {
|
it('persists bare flag for unified accounts and merged projection', () => {
|
||||||
@@ -126,8 +127,52 @@ describe('profile-registry context normalization', () => {
|
|||||||
|
|
||||||
const accounts = registry.getAllAccountsUnified();
|
const accounts = registry.getAllAccountsUnified();
|
||||||
expect(accounts.work.bare).toBe(true);
|
expect(accounts.work.bare).toBe(true);
|
||||||
|
expect(accounts.work.shared_resource_mode).toBe('profile-local');
|
||||||
|
|
||||||
const merged = registry.getAllProfilesMerged();
|
const merged = registry.getAllProfilesMerged();
|
||||||
expect(merged.work.bare).toBe(true);
|
expect(merged.work.bare).toBe(true);
|
||||||
|
expect(merged.work.shared_resource_mode).toBe('profile-local');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets explicit shared resource mode override stale unified bare metadata', () => {
|
||||||
|
process.env.CCS_UNIFIED_CONFIG = '1';
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-03-05T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
' shared_resource_mode: shared',
|
||||||
|
' bare: true',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const accounts = registry.getAllAccountsUnified();
|
||||||
|
|
||||||
|
expect(accounts.work.shared_resource_mode).toBe('shared');
|
||||||
|
expect(accounts.work.bare).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists explicit profile-local mode for legacy profiles', () => {
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
registry.createProfile('work', {
|
||||||
|
type: 'account',
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
});
|
||||||
|
|
||||||
|
const profile = registry.getProfile('work');
|
||||||
|
expect(profile.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(profile.bare).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test';
|
||||||
|
import {
|
||||||
|
normalizeSharedResourceMetadata,
|
||||||
|
resolveSharedResourcePolicy,
|
||||||
|
sharedResourceModeToMetadata,
|
||||||
|
} from '../../../src/auth/shared-resource-policy';
|
||||||
|
|
||||||
|
describe('shared resource policy', () => {
|
||||||
|
it('defaults missing metadata to shared resources', () => {
|
||||||
|
const policy = resolveSharedResourcePolicy(undefined);
|
||||||
|
|
||||||
|
expect(policy.mode).toBe('shared');
|
||||||
|
expect(policy.inferred).toBe(true);
|
||||||
|
expect(policy.profileLocal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves legacy bare metadata as profile-local', () => {
|
||||||
|
const policy = resolveSharedResourcePolicy({ bare: true });
|
||||||
|
|
||||||
|
expect(policy.mode).toBe('profile-local');
|
||||||
|
expect(policy.inferred).toBe(true);
|
||||||
|
expect(policy.profileLocal).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets explicit shared mode override stale bare metadata', () => {
|
||||||
|
const normalized = normalizeSharedResourceMetadata({
|
||||||
|
shared_resource_mode: 'shared',
|
||||||
|
bare: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.shared_resource_mode).toBe('shared');
|
||||||
|
expect(normalized.bare).toBeUndefined();
|
||||||
|
expect(resolveSharedResourcePolicy(normalized).profileLocal).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes explicit profile-local mode to legacy-compatible bare metadata', () => {
|
||||||
|
const normalized = normalizeSharedResourceMetadata({
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalized.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(normalized.bare).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('builds write metadata for both supported modes', () => {
|
||||||
|
expect(sharedResourceModeToMetadata('profile-local')).toEqual({
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
bare: true,
|
||||||
|
});
|
||||||
|
expect(sharedResourceModeToMetadata('shared')).toEqual({
|
||||||
|
shared_resource_mode: 'shared',
|
||||||
|
bare: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('sync command MCP sync behavior', () => {
|
|||||||
mock.restore();
|
mock.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('syncs MCP servers only to non-bare profiles', async () => {
|
it('syncs MCP servers only to shared-resource profiles', async () => {
|
||||||
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
spyOn(ClaudeDirInstaller.prototype, 'install').mockReturnValue(true);
|
||||||
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
spyOn(ClaudeDirInstaller.prototype, 'cleanupDeprecated').mockReturnValue({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -42,6 +42,8 @@ describe('sync command MCP sync behavior', () => {
|
|||||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||||
work: profile(),
|
work: profile(),
|
||||||
sandbox: profile({ bare: true }),
|
sandbox: profile({ bare: true }),
|
||||||
|
local: profile({ shared_resource_mode: 'profile-local' }),
|
||||||
|
restored: profile({ bare: true, shared_resource_mode: 'shared' }),
|
||||||
personal: profile(),
|
personal: profile(),
|
||||||
});
|
});
|
||||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||||
@@ -55,8 +57,16 @@ describe('sync command MCP sync behavior', () => {
|
|||||||
|
|
||||||
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
await expect(handleSyncCommand()).rejects.toThrow('process.exit(0)');
|
||||||
|
|
||||||
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual(['work', 'personal']);
|
expect(getInstancePathSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||||
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual(['/tmp/work', '/tmp/personal']);
|
'work',
|
||||||
|
'restored',
|
||||||
|
'personal',
|
||||||
|
]);
|
||||||
|
expect(syncMcpSpy.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
'/tmp/work',
|
||||||
|
'/tmp/restored',
|
||||||
|
'/tmp/personal',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips MCP sync when all profiles are bare', async () => {
|
it('skips MCP sync when all profiles are bare', async () => {
|
||||||
@@ -69,7 +79,7 @@ describe('sync command MCP sync behavior', () => {
|
|||||||
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
spyOn(SharedManager.prototype, 'ensureSharedDirectories').mockImplementation(() => {});
|
||||||
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
spyOn(ProfileRegistry.prototype, 'getAllProfilesMerged').mockReturnValue({
|
||||||
sandbox: profile({ bare: true }),
|
sandbox: profile({ bare: true }),
|
||||||
experiment: profile({ bare: true }),
|
experiment: profile({ shared_resource_mode: 'profile-local' }),
|
||||||
});
|
});
|
||||||
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
spyOn(InstanceManager.prototype, 'hasInstance').mockReturnValue(true);
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,43 @@ describe('migration-manager legacy kimi compatibility', () => {
|
|||||||
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('migrates legacy bare accounts to profile-local shared resource mode', async () => {
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'profiles.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
default: 'sandbox',
|
||||||
|
profiles: {
|
||||||
|
sandbox: {
|
||||||
|
type: 'account',
|
||||||
|
created: '2026-02-01T00:00:00.000Z',
|
||||||
|
last_used: null,
|
||||||
|
bare: true,
|
||||||
|
},
|
||||||
|
restored: {
|
||||||
|
type: 'account',
|
||||||
|
created: '2026-02-02T00:00:00.000Z',
|
||||||
|
last_used: null,
|
||||||
|
shared_resource_mode: 'shared',
|
||||||
|
bare: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await migrate(false);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
|
||||||
|
const unified = loadUnifiedConfig();
|
||||||
|
expect(unified?.accounts.sandbox.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(unified?.accounts.sandbox.bare).toBe(true);
|
||||||
|
expect(unified?.accounts.restored.shared_resource_mode).toBe('shared');
|
||||||
|
expect(unified?.accounts.restored.bare).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('applies safe browser defaults when migrating legacy config files', async () => {
|
it('applies safe browser defaults when migrating legacy config files', async () => {
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(ccsDir, 'config.json'),
|
path.join(ccsDir, 'config.json'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
@@ -262,6 +262,51 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
expect(fs.existsSync(instancePath)).toBe(false);
|
expect(fs.existsSync(instancePath)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reports effective shared resource mode without initializing an account instance', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 12',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' shared_resource_mode: profile-local',
|
||||||
|
' restored:',
|
||||||
|
' created: "2026-02-02T00:00:00.000Z"',
|
||||||
|
' shared_resource_mode: shared',
|
||||||
|
' bare: true',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = await getJson<{
|
||||||
|
accounts: Array<{
|
||||||
|
name: string;
|
||||||
|
shared_resource_mode?: string;
|
||||||
|
shared_resource_inferred?: boolean;
|
||||||
|
bare?: boolean;
|
||||||
|
}>;
|
||||||
|
}>(baseUrl, '/api/accounts');
|
||||||
|
|
||||||
|
const work = payload.accounts.find((account) => account.name === 'work');
|
||||||
|
const restored = payload.accounts.find((account) => account.name === 'restored');
|
||||||
|
expect(work?.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(work?.shared_resource_inferred).toBe(false);
|
||||||
|
expect(work?.bare).toBe(true);
|
||||||
|
expect(restored?.shared_resource_mode).toBe('shared');
|
||||||
|
expect(restored?.shared_resource_inferred).toBe(false);
|
||||||
|
expect(restored?.bare).toBeUndefined();
|
||||||
|
expect(fs.existsSync(path.join(ccsDir, 'instances', 'work'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not delete metadata when instance deletion fails', async () => {
|
it('does not delete metadata when instance deletion fails', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
@@ -354,6 +399,148 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
expect(work?.continuity_mode).toBe('deeper');
|
expect(work?.continuity_mode).toBe('deeper');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('updates existing account shared resource mode without changing history sync metadata', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
' context_mode: shared',
|
||||||
|
' context_group: sprint-a',
|
||||||
|
' continuity_mode: deeper',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
|
||||||
|
path.join(ccsDir, 'instances', 'work')
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
shared_resource_mode: string;
|
||||||
|
shared_resource_inferred?: boolean;
|
||||||
|
};
|
||||||
|
expect(payload.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(payload.shared_resource_inferred).toBe(false);
|
||||||
|
expect(ensureSpy).toHaveBeenCalledWith(
|
||||||
|
'work',
|
||||||
|
{
|
||||||
|
mode: 'shared',
|
||||||
|
group: 'sprint-a',
|
||||||
|
continuityMode: 'deeper',
|
||||||
|
},
|
||||||
|
{ bare: true }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const account = registry.getAllAccountsUnified().work;
|
||||||
|
expect(account.shared_resource_mode).toBe('profile-local');
|
||||||
|
expect(account.bare).toBe(true);
|
||||||
|
expect(account.context_mode).toBe('shared');
|
||||||
|
expect(account.context_group).toBe('sprint-a');
|
||||||
|
expect(account.continuity_mode).toBe('deeper');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back inferred shared resource metadata when instance reconciliation fails', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockRejectedValue(
|
||||||
|
new Error('reconcile failed')
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
const payload = (await response.json()) as { error: string };
|
||||||
|
expect(payload.error).toContain('reconcile failed');
|
||||||
|
} finally {
|
||||||
|
ensureSpy.mockRestore();
|
||||||
|
}
|
||||||
|
|
||||||
|
const registry = new ProfileRegistry();
|
||||||
|
const account = registry.getAllAccountsUnified().work;
|
||||||
|
expect(account.shared_resource_mode).toBeUndefined();
|
||||||
|
expect(account.bare).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid shared resource mode updates', async () => {
|
||||||
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(ccsDir, 'config.yaml'),
|
||||||
|
[
|
||||||
|
'version: 8',
|
||||||
|
'accounts:',
|
||||||
|
' work:',
|
||||||
|
' created: "2026-02-01T00:00:00.000Z"',
|
||||||
|
' last_used: null',
|
||||||
|
'profiles: {}',
|
||||||
|
'cliproxy:',
|
||||||
|
' oauth_accounts: {}',
|
||||||
|
' providers: {}',
|
||||||
|
' variants: {}',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await putJson(baseUrl, '/api/accounts/work/shared-resources', {
|
||||||
|
shared_resource_mode: 'plugins-only',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const payload = (await response.json()) as { error: string };
|
||||||
|
expect(payload.error).toContain('shared_resource_mode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects shared resource updates for CLIProxy account identifiers', async () => {
|
||||||
|
const response = await putJson(baseUrl, '/api/accounts/gemini:test/shared-resources', {
|
||||||
|
shared_resource_mode: 'profile-local',
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { error: string };
|
||||||
|
expect(payload.error).toContain('CLIProxy');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects shared mode updates without context_group', async () => {
|
it('rejects shared mode updates without context_group', async () => {
|
||||||
const ccsDir = path.join(tempHome, '.ccs');
|
const ccsDir = path.join(tempHome, '.ccs');
|
||||||
fs.mkdirSync(ccsDir, { recursive: true });
|
fs.mkdirSync(ccsDir, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user