diff --git a/src/browser/profile.test.ts b/src/browser/profile.test.ts index 2a68d729..9bf2e9a0 100644 --- a/src/browser/profile.test.ts +++ b/src/browser/profile.test.ts @@ -3,7 +3,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { ENV_PREFIX } from '../brand.js'; -import { profileListRows, profileRouteParams, resolveProfileSelection } from './profile.js'; +import { ArgumentError } from '../errors.js'; +import { loadProfileConfig, profileListRows, profileRouteParams, resolveProfileSelection, setDefaultProfile } from './profile.js'; describe('profile selection', () => { let configDir: string; @@ -97,3 +98,60 @@ describe('profileListRows', () => { ]); }); }); + +describe('setDefaultProfile membership', () => { + let configDir: string; + + beforeEach(() => { + configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-profile-use-')); + vi.stubEnv(`${ENV_PREFIX}_CONFIG_DIR`, configDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + fs.rmSync(configDir, { recursive: true, force: true }); + }); + + const rows = profileListRows( + { version: 1, aliases: { work: 'ctx-work' }, defaultContextId: 'ctx-default' }, + [{ contextId: 'ctx-live', runtimeVersion: '1.0.3' }], + ); + + it('sets the default from a connected contextId', () => { + const config = setDefaultProfile('ctx-live', rows); + expect(config.defaultContextId).toBe('ctx-live'); + expect(loadProfileConfig().defaultContextId).toBe('ctx-live'); + }); + + it('sets the default from a saved alias and stores the contextId', () => { + expect(setDefaultProfile('work', rows).defaultContextId).toBe('ctx-work'); + }); + + it('rejects an unknown name and enumerates valid profiles', () => { + try { + setDefaultProfile('__audit_nope__', rows); + expect.unreachable(); + } catch (err) { + expect(err).toBeInstanceOf(ArgumentError); + expect((err as ArgumentError).exitCode).toBe(2); + expect((err as ArgumentError).message).toBe( + 'No profile matches "__audit_nope__". Valid profiles: work, ctx-live, ctx-work, ctx-default', + ); + expect((err as ArgumentError).hint).toBe( + 'usage: webcmd profile use \nexample: webcmd profile use work', + ); + } + }); + + it('rejects an unknown name when no profiles exist', () => { + expect(() => setDefaultProfile('__audit_nope__', [])).toThrow(ArgumentError); + try { + setDefaultProfile('__audit_nope__', []); + } catch (err) { + expect((err as ArgumentError).message).toBe( + 'No profile matches "__audit_nope__". No Cloak profiles are available.', + ); + expect((err as ArgumentError).hint).toContain('webcmd profile list'); + } + }); +}); diff --git a/src/browser/profile.ts b/src/browser/profile.ts index dd18c446..661f9fc6 100644 --- a/src/browser/profile.ts +++ b/src/browser/profile.ts @@ -1,7 +1,8 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; +import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; +import { ArgumentError } from '../errors.js'; export const DEFAULT_CONTEXT_ID = 'default'; @@ -105,11 +106,48 @@ export function renameProfile(contextId: string, alias: string): ProfileConfig { return config; } -export function setDefaultProfile(profile: string): ProfileConfig { - const contextId = resolveProfileContextId(profile) ?? normalizeContextId(profile); - if (!contextId) throw new Error('profile is required'); +export function knownProfileLabels(rows: ProfileListRow[]): string[] { + const labels: string[] = []; + const seen = new Set(); + for (const row of rows) { + if (!row.alias || seen.has(row.alias)) continue; + seen.add(row.alias); + labels.push(row.alias); + } + for (const row of rows) { + if (seen.has(row.contextId)) continue; + seen.add(row.contextId); + labels.push(row.contextId); + } + return labels; +} + +export function resolveKnownProfile(profile: string, rows: ProfileListRow[]): ProfileListRow | undefined { + const name = normalizeContextId(profile); + if (!name) return undefined; + return rows.find(row => row.contextId === name || row.alias === name); +} + +export function setDefaultProfile(profile: string, rows: ProfileListRow[]): ProfileConfig { + const name = normalizeContextId(profile); + if (!name) throw new ArgumentError('profile is required'); + const match = resolveKnownProfile(name, rows); + if (!match) { + const labels = knownProfileLabels(rows); + const usage = `usage: ${CLI_COMMAND} profile use `; + if (labels.length === 0) { + throw new ArgumentError( + `No profile matches "${name}". No Cloak profiles are available.`, + `${usage}\nRun ${CLI_COMMAND} profile list, or create one with a browser-backed command.`, + ); + } + throw new ArgumentError( + `No profile matches "${name}". Valid profiles: ${labels.join(', ')}`, + `${usage}\nexample: ${CLI_COMMAND} profile use ${labels[0]}`, + ); + } const config = loadProfileConfig(); - config.defaultContextId = contextId; + config.defaultContextId = match.contextId; saveProfileConfig(config); return config; } diff --git a/src/cli.test.ts b/src/cli.test.ts index 9340d9c9..59a839b5 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -2026,6 +2026,38 @@ describe('structured output for data-returning built-ins', () => { ]); }); + it('rejects profile use of an unknown name and enumerates valid profiles', async () => { + vi.mocked(fetch).mockResolvedValue(daemonStatusResponse({ + profiles: [{ contextId: 'ctx_live', runtimeConnected: true, runtimeVersion: '1.0.3', pending: 0 }], + })); + + await expect(createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'use', '__audit_nope__'])) + .rejects.toThrow(/No profile matches "__audit_nope__". Valid profiles: ctx_live/); + }); + + it('sets the default from a connected profile', async () => { + vi.mocked(fetch).mockResolvedValue(daemonStatusResponse({ + profiles: [{ contextId: 'ctx_live', runtimeConnected: true, runtimeVersion: '1.0.3', pending: 0 }], + })); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'use', 'ctx_live']); + + expect(stdout()).toContain('Default Cloak profile: ctx_live'); + }); + + it('sets the default from a saved alias when the daemon is down', async () => { + fs.mkdirSync(process.env.WEBCMD_CONFIG_DIR!, { recursive: true }); + fs.writeFileSync(path.join(process.env.WEBCMD_CONFIG_DIR!, 'browser-profiles.json'), JSON.stringify({ + version: 1, + aliases: { work: 'ctx_work' }, + })); + vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'use', 'work']); + + expect(stdout()).toContain('Default Cloak profile: ctx_work'); + }); + it('fails structured profile list with DAEMON_UNAVAILABLE instead of an empty array', async () => { vi.mocked(fetch).mockRejectedValue(new Error('ECONNREFUSED')); diff --git a/src/cli.ts b/src/cli.ts index 6f9e3bde..7b8c0927 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1982,15 +1982,15 @@ cli({ profileCmd .command('use') .description('Set the default Cloak profile for future commands') - .argument('', 'Profile alias or contextId') - .action((profile: string) => { - try { - const config = setDefaultProfile(profile); - console.log(`Default Cloak profile: ${config.defaultContextId ?? profile}`); - } catch (err) { - console.error(`Error: ${getErrorMessage(err)}`); - process.exitCode = EXIT_CODES.USAGE_ERROR; - } + .argument('', 'Profile alias or contextId from webcmd profile list') + .action(async (profile: string) => { + const status = await fetchDaemonStatus(); + const config = loadProfileConfig(); + const connected = status && !isDaemonStale(status, PKG_VERSION) && Array.isArray(status.profiles) + ? status.profiles + : []; + const next = setDefaultProfile(profile, profileListRows(config, connected)); + console.log(`Default Cloak profile: ${next.defaultContextId ?? profile}`); }); // ── Built-in: daemon ──────────────────────────────────────────────────────