fix(cli): close third review — token collision, migration, ownership
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / lint (pull_request) Successful in 2m13s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m9s
CI/CD / smoke (pull_request) Failing after 2m44s
CI/CD / publish (pull_request) Has been skipped
Some checks failed
CI/CD / typecheck (pull_request) Successful in 1m5s
CI/CD / lint (pull_request) Successful in 2m13s
CI/CD / test (pull_request) Successful in 1m20s
CI/CD / build (pull_request) Successful in 2m9s
CI/CD / smoke (pull_request) Failing after 2m44s
CI/CD / publish (pull_request) Has been skipped
Round 2 fixed the first review but introduced regressions of its own, all of which only bite against state written by the previously installed build. `config prime-agent`: - Mint each credential under a unique `prime-agent-<stamp>` name again. `McpToken` is unique on (name, projectId) and revoke is a soft delete, so round 2's fixed `prime-agent` name could only ever be minted once per project — and the revoke-first ordering destroyed the working credential before discovering the mint would fail. - Provision the credential BEFORE touching settings.json. Registering the new project unmounts the previously active one, so a failed mint must not be able to leave prime-agent with no working project at all. The command now aborts with settings.json untouched. - Retire only the token this auth.json actually held, once its replacement is stored. Sweeping every `prime-agent*` token for the project would revoke the credential another install (or a custom --output run) is using; anything else that looks orphaned is reported, not deleted. - Validate a pre-existing credential instead of trusting its presence: a revoked or expired token used to short-circuit provisioning and leave prime-agent broken while the command reported success. Matched by tokenPrefix against the project's active tokens, so the secret is never sent. Fails open when the API can't be consulted. - Actually write auth.json 0600. `writeFile`'s mode is ignored for an existing file and prime-agent creates auth.json itself at 0644, so chmod after writing. - Recognise the untagged mcpServers entries older CLIs wrote (canonical proxy URL + an `mcp:<name>` mcpctl PAT in auth.json) so a switch unmounts them instead of leaving two gateways live. Hand-configured servers have no such credential and are still preserved. Same rule in the `/mcpctl` switcher's active-project lookup. - Add `--skip-marker`, and pass it from the `/mcpctl` switcher: the extension runs from whatever directory prime-agent was started in, and was silently re-scoping that repo's `.mcpctl-project`. `skills sync --agent prime-agent`: - Record ownership from the skill's own scope, not the syncing project's. Globals were being pinned to whichever project happened to sync them, after which every other project refused to update them forever. - Never adopt legacy, ownership-less state into the current scope. Round 2 did, which deleted the other project's skills on the first sync after upgrading. Such entries are attributed to the project that last wrote the state file, and left alone when that isn't the project syncing now. - Close the overwrite-guard bypass: a sync with no project, or a global landing on a project-owned name, could still clobber and re-own a tracked skill. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017BMXdb2qZbPSh8Q7XpTyjB
This commit is contained in:
@@ -17,11 +17,21 @@ import {
|
||||
primeAgentSettingsPath,
|
||||
DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
writePrimeAgentAuth,
|
||||
hasPrimeAgentAuth,
|
||||
readPrimeAgentAuthKey,
|
||||
mcpTokenPrefixOf,
|
||||
isMcpctlToken,
|
||||
} from '../config/prime-agent.js';
|
||||
import { MCPCTL_SWITCH_EXTENSION, MCPCTL_SWITCH_EXTENSION_FILENAME } from '../config/prime-agent-extension.js';
|
||||
import { runPrimeAgentSkillsSync } from '../utils/prime-agent-skills.js';
|
||||
|
||||
/**
|
||||
* Name (and name prefix) of the mcptokens `config prime-agent` mints. Each mint
|
||||
* gets a unique `<prefix>-<stamp>` name because `McpToken` is unique on
|
||||
* (name, projectId) and revoke is a soft delete — a fixed name could only ever
|
||||
* be minted once per project.
|
||||
*/
|
||||
const PRIME_AGENT_TOKEN_PREFIX = 'prime-agent';
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
|
||||
}
|
||||
@@ -215,6 +225,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
.option('--token <pat>', 'mcpctl project bearer token to store in auth.json (skips auto-minting)')
|
||||
.option('--skip-skills', 'Skip the skills sync step')
|
||||
.option('--skip-extension', 'Do not install the /mcpctl project-switcher extension')
|
||||
.option('--skip-marker', 'Do not write a .mcpctl-project marker in the current directory')
|
||||
.option('--dry-run', 'Print what would change without writing or syncing')
|
||||
.action(async (opts: {
|
||||
project?: string;
|
||||
@@ -223,6 +234,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
token?: string;
|
||||
skipSkills?: boolean;
|
||||
skipExtension?: boolean;
|
||||
skipMarker?: boolean;
|
||||
dryRun?: boolean;
|
||||
}) => {
|
||||
if (opts.project === undefined || opts.project === '') {
|
||||
@@ -245,55 +257,49 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
authPath,
|
||||
mcpServers: { [opts.project]: { type: 'http', url } },
|
||||
extension: opts.skipExtension === true ? '<skipped>' : extPath,
|
||||
marker: opts.skipMarker === true ? '<skipped>' : join(process.cwd(), '.mcpctl-project'),
|
||||
},
|
||||
action: 'write settings.json + write auth.json credential + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
|
||||
action: 'provision auth.json credential + write settings.json + write .mcpctl-project marker + sync skills to ~/.prime/agent/skills/',
|
||||
}, null, 2);
|
||||
log(dry);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Register the proxy MCP gateway (merge; never destroy settings).
|
||||
try {
|
||||
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl);
|
||||
log(reg.created
|
||||
? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})`
|
||||
: `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`);
|
||||
} catch (err: unknown) {
|
||||
log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Provision the bearer credential prime-agent needs for this project.
|
||||
// 1. Provision the bearer credential prime-agent needs for this project.
|
||||
// mcpctl's stdio bridge supplied auth implicitly; over HTTP we must
|
||||
// store an mcp:<project> token in auth.json. Use --token if given,
|
||||
// keep an existing one, otherwise mint it via the API. A switch with
|
||||
// no usable credential is a FAILURE (exit != 0) so the /mcpctl
|
||||
// extension does not report success after leaving a project bare.
|
||||
// keep a still-valid existing one, otherwise mint it via the API.
|
||||
//
|
||||
// This runs BEFORE settings.json is touched: registering the new
|
||||
// project unmounts the previously active one, so a mint failure must
|
||||
// not be able to leave prime-agent with no working project at all.
|
||||
// A switch with no usable credential is a FAILURE (exit != 0) so the
|
||||
// /mcpctl extension does not report success over a bare project.
|
||||
let provisioned = false;
|
||||
try {
|
||||
// Whatever this auth.json held before we touched it — the only token
|
||||
// this run is entitled to retire once it has a replacement.
|
||||
const staleKey = await readPrimeAgentAuthKey(opts.project, authPath);
|
||||
if (opts.token !== undefined && opts.token !== '') {
|
||||
await writePrimeAgentAuth(opts.project, opts.token, authPath);
|
||||
log(`Stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
|
||||
provisioned = true;
|
||||
} else if (await hasPrimeAgentAuth(opts.project, authPath)) {
|
||||
// Only when we actually replaced something: `--token` with a fresh
|
||||
// auth.json must stay entirely offline, as documented.
|
||||
if (staleKey !== null) {
|
||||
await retireSupersededToken(opts.project, staleKey, opts.token);
|
||||
}
|
||||
} else if (await hasUsableCredential(opts.project, staleKey)) {
|
||||
log(`Bearer credential for '${opts.project}' already present in ${authPath}`);
|
||||
provisioned = true;
|
||||
} else if (skillsClient) {
|
||||
// Revoke any prior active `prime-agent` token for this project
|
||||
// first (tokens are immutable + shown once), so we never litter
|
||||
// never-expiring tokens on repeated reprovisioning.
|
||||
const list = await skillsClient
|
||||
.get<Array<{ id: string; name: string; status: string }> | unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(opts.project)}`)
|
||||
.catch(() => []);
|
||||
const existing = Array.isArray(list) ? list : [];
|
||||
for (const t of existing) {
|
||||
if (t.name === 'prime-agent' && t.status === 'active') {
|
||||
try { await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
// Mint under a fresh, unique name. `McpToken` is unique on
|
||||
// (name, projectId) and revoke is a soft delete, so reusing a fixed
|
||||
// name would collide with the revoked row forever. Retire the old
|
||||
// tokens only *after* the replacement is safely on disk.
|
||||
const stamp = `${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`;
|
||||
const minted = await skillsClient.post<{ token?: string }>('/api/v1/mcptokens', {
|
||||
name: 'prime-agent',
|
||||
name: `${PRIME_AGENT_TOKEN_PREFIX}-${stamp}`,
|
||||
projectName: opts.project,
|
||||
ttl: 'never',
|
||||
description: `mcpctl proxy MCP credential for prime-agent (${new Date().toISOString()})`,
|
||||
@@ -302,6 +308,7 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
await writePrimeAgentAuth(opts.project, minted.token, authPath);
|
||||
log(`Minted + stored bearer credential for '${opts.project}' (mcp:${opts.project}) in ${authPath}`);
|
||||
provisioned = true;
|
||||
await retireSupersededToken(opts.project, staleKey, minted.token);
|
||||
} else {
|
||||
log(`Error: no token returned minting for '${opts.project}'; pass --token to supply one`);
|
||||
}
|
||||
@@ -313,15 +320,37 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
}
|
||||
if (!provisioned) {
|
||||
process.exitCode = 1;
|
||||
log(`Aborted: leaving ${settingsPath} unchanged so the currently active project keeps working`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Register the proxy MCP gateway (merge; never destroy settings).
|
||||
try {
|
||||
const reg = await registerPrimeAgentMcp(opts.project, settingsPath, opts.gatewayUrl, { authPath });
|
||||
log(reg.created
|
||||
? `Created ${settingsPath} and registered '${reg.addedServer}' proxy MCP (${reg.url})`
|
||||
: `Registered '${reg.addedServer}' proxy MCP in ${settingsPath} (${reg.url}; ${String(reg.totalServers)} server(s) total)`);
|
||||
if (reg.removed.length > 0) {
|
||||
log(`Unmounted previously active mcpctl project(s): ${reg.removed.join(', ')}`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
log(`Error: failed to write ${settingsPath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Write the .mcpctl-project marker so later `skills sync` calls can
|
||||
// resolve the project. An explicit -p is authoritative: it updates a
|
||||
// differing up-tree marker (so the scope doesn't silently revert on
|
||||
// the next sync), is a no-op when it already matches, and never
|
||||
// scopes $HOME itself.
|
||||
// scopes $HOME itself. `--skip-marker` opts out entirely: the
|
||||
// /mcpctl switcher runs this command from whatever directory
|
||||
// prime-agent happens to be started in, and must not silently
|
||||
// re-scope an unrelated repo that Claude Code's own sync reads.
|
||||
try {
|
||||
if (process.cwd() !== homedir()) {
|
||||
if (opts.skipMarker === true) {
|
||||
log('Skipped .mcpctl-project marker (--skip-marker)');
|
||||
} else if (process.cwd() !== homedir()) {
|
||||
const existing = await findProjectMarker(process.cwd(), homedir());
|
||||
if (existing !== null && existing.project === opts.project) {
|
||||
log(`Already scoped by marker ${existing.markerPath} ('${existing.project}')`);
|
||||
@@ -375,6 +404,87 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?:
|
||||
if (hidden) {
|
||||
void cmd;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the credential already in auth.json still usable?
|
||||
*
|
||||
* A key being *present* proves nothing — a revoked or expired token would
|
||||
* short-circuit provisioning and leave prime-agent silently unable to reach
|
||||
* the gateway while the command reported success. mcptokens are only ever
|
||||
* shown once, so we compare the stored token's 16-char `tokenPrefix`
|
||||
* against the project's *active* tokens instead of sending the secret.
|
||||
*
|
||||
* Fails open: no client, a non-mcpctl token (a user-supplied PAT of some
|
||||
* other kind), or an unreachable API all mean "keep what's there" rather
|
||||
* than minting a duplicate on every run.
|
||||
*/
|
||||
async function hasUsableCredential(project: string, key: string | null): Promise<boolean> {
|
||||
if (key === null) return false;
|
||||
if (!skillsClient || !isMcpctlToken(key)) return true;
|
||||
const tokens = await listProjectTokens(project);
|
||||
if (tokens === null) return true; // can't check → don't churn credentials
|
||||
const prefix = mcpTokenPrefixOf(key);
|
||||
const live = tokens.some((t) => t.status === 'active' && t.tokenPrefix === prefix);
|
||||
if (!live) {
|
||||
log(`Stored credential for '${project}' is no longer active — minting a replacement`);
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retire the token this auth.json used to hold, now that `keepToken` has
|
||||
* replaced it on disk.
|
||||
*
|
||||
* Scoped to that one credential on purpose. Sweeping every `prime-agent`
|
||||
* token for the project would revoke the one a *different* auth.json is
|
||||
* using — another machine, or this machine when the run targeted a custom
|
||||
* `--output`. Anything else that looks orphaned is reported, not deleted:
|
||||
* an unnecessary token costs nothing, a revoked one costs a broken install.
|
||||
* Best-effort throughout, and only ever called once the replacement is
|
||||
* safely stored.
|
||||
*/
|
||||
async function retireSupersededToken(project: string, staleKey: string | null, keepToken: string): Promise<void> {
|
||||
if (!skillsClient) return;
|
||||
const keepPrefix = mcpTokenPrefixOf(keepToken);
|
||||
const stalePrefix = staleKey !== null && isMcpctlToken(staleKey) ? mcpTokenPrefixOf(staleKey) : null;
|
||||
if (stalePrefix === keepPrefix) return;
|
||||
const tokens = await listProjectTokens(project);
|
||||
if (tokens === null) return;
|
||||
|
||||
const orphans: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (typeof t.id !== 'string' || t.status !== 'active') continue;
|
||||
if (t.tokenPrefix === keepPrefix) continue;
|
||||
// Only ever consider tokens minted for this purpose.
|
||||
const name = t.name ?? '';
|
||||
if (name !== PRIME_AGENT_TOKEN_PREFIX && !name.startsWith(`${PRIME_AGENT_TOKEN_PREFIX}-`)) continue;
|
||||
if (t.tokenPrefix === stalePrefix) {
|
||||
try {
|
||||
await skillsClient.post(`/api/v1/mcptokens/${t.id}/revoke`);
|
||||
log(`Revoked the superseded '${name}' token for '${project}'`);
|
||||
} catch { /* best-effort */ }
|
||||
} else {
|
||||
orphans.push(name);
|
||||
}
|
||||
}
|
||||
if (orphans.length > 0) {
|
||||
log(`Note: '${project}' still has other prime-agent token(s): ${orphans.join(', ')}. `
|
||||
+ `They may belong to another install; remove any you don't need with \`mcpctl delete mcptoken <name> --project ${project}\`.`);
|
||||
}
|
||||
}
|
||||
|
||||
interface ProjectToken { id?: string; name?: string; status?: string; tokenPrefix?: string }
|
||||
|
||||
/** The project's tokens, or null when the API can't be consulted. */
|
||||
async function listProjectTokens(project: string): Promise<ProjectToken[] | null> {
|
||||
if (!skillsClient) return null;
|
||||
try {
|
||||
const list = await skillsClient.get<unknown>(`/api/v1/mcptokens?projectName=${encodeURIComponent(project)}`);
|
||||
return Array.isArray(list) ? list as ProjectToken[] : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registerClaudeCommand('claude', false);
|
||||
|
||||
@@ -192,14 +192,19 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
// ~/.claude/skills + the shared state file; prime-agent gets its own
|
||||
// tree + separate state file so the two never collide.
|
||||
const isPrimeAgent = target === 'prime-agent';
|
||||
// Canonical ownership scope: a project name, or null for globals. Recorded on
|
||||
// every tracked skill so cross-project clashes in the shared prime-agent tree
|
||||
// can be detected. (`?? null` keeps global-only sync consistent.)
|
||||
const scope = projectName ?? null;
|
||||
// Canonical ownership scope for a *skill*: null when the skill is global
|
||||
// (globals are visible from every project, so pinning one to whichever
|
||||
// project happened to sync it would lock every other project out of ever
|
||||
// updating it), otherwise the project that installed it.
|
||||
const ownerOf = (s: VisibleSkill): string | null => (s.scope === 'global' ? null : (projectName ?? null));
|
||||
const statePath = opts.statePath ?? (isPrimeAgent
|
||||
? join(homeDir, '.mcpctl', 'skills-state-prime-agent.json')
|
||||
: defaultStatePath());
|
||||
const state = await loadState(statePath);
|
||||
// Which project last wrote this state file, captured before step 7 overwrites
|
||||
// it. Skills tracked by a CLI that predates ownership recording carry no
|
||||
// `project` field; this is the only evidence of who installed them.
|
||||
const priorSyncProject = state.lastSyncProject;
|
||||
const installRoot = opts.installRoot ?? (isPrimeAgent
|
||||
? join(homeDir, '.prime', 'agent', 'skills')
|
||||
: join(homeDir, '.claude', 'skills'));
|
||||
@@ -243,13 +248,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
// their first project just by configuring a second one). Only remove
|
||||
// skills this project (or globals) previously installed and that have
|
||||
// since left the visible set.
|
||||
if (isPrimeAgent) {
|
||||
const priorScope = prior.project === undefined ? scope : prior.project;
|
||||
// Remove an orphan only if it belongs to the current scope (project or
|
||||
// global). Legacy state (project === undefined) is normalised to the
|
||||
// current scope so stale skills don't linger on disk forever.
|
||||
if (priorScope !== scope) continue;
|
||||
}
|
||||
if (isPrimeAgent && !ownsOrphan(prior)) continue;
|
||||
try {
|
||||
// Preserve user-modified skills — warn + skip.
|
||||
const modified = await detectModifiedFiles(prior.installDir, prior.files);
|
||||
@@ -319,6 +318,27 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
|
||||
return result;
|
||||
|
||||
/**
|
||||
* May this sync delete `prior` from the shared prime-agent tree now that it
|
||||
* has left the visible set? The tree is flat and shared across projects while
|
||||
* each project syncs only its own slice, so "not visible to me" is never on
|
||||
* its own a reason to delete.
|
||||
*
|
||||
* - global (`null`) — globals are visible from every project and on a
|
||||
* global-only sync, so gone here means gone. Removable.
|
||||
* - a project name — removable only while syncing that same project.
|
||||
* - `undefined` — written before ownership tracking existed. The only
|
||||
* evidence of the owner is which project last wrote
|
||||
* the state file; when that isn't the project syncing
|
||||
* now, leave it alone rather than delete another
|
||||
* project's skills on the first post-upgrade sync.
|
||||
*/
|
||||
function ownsOrphan(prior: SkillState): boolean {
|
||||
if (prior.project === null) return true;
|
||||
if (typeof prior.project === 'string') return prior.project === projectName;
|
||||
return priorSyncProject !== null && priorSyncProject === projectName;
|
||||
}
|
||||
|
||||
async function applyOne(v: VisibleSkill): Promise<void> {
|
||||
try {
|
||||
// If on-disk files were locally modified, preserve unless --force.
|
||||
@@ -346,15 +366,17 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
}
|
||||
}
|
||||
|
||||
// prime-agent: if this skill name is *tracked* to a different project in
|
||||
// the shared flat tree, don't silently overwrite it when syncing another
|
||||
// project — that's the exact cross-project data loss ownership tracking
|
||||
// was added to prevent. Locally-same-project and global skills still
|
||||
// update normally.
|
||||
if (isPrimeAgent && prior !== undefined && projectName !== undefined &&
|
||||
prior.project !== undefined && prior.project !== null && prior.project !== projectName) {
|
||||
// prime-agent: if this skill name is *tracked* to a specific project in
|
||||
// the shared flat tree, don't silently overwrite it with a different
|
||||
// project's (or a global) skill of the same name — that's the exact
|
||||
// cross-project data loss ownership tracking was added to prevent.
|
||||
// Same-owner updates, globals (owner null) and untracked legacy entries
|
||||
// (owner undefined, adopted on write) all proceed normally.
|
||||
const owner = ownerOf(v);
|
||||
if (isPrimeAgent && prior !== undefined &&
|
||||
typeof prior.project === 'string' && prior.project !== owner) {
|
||||
if (!opts.force) {
|
||||
warn(`mcpctl: '${v.name}' is owned by project '${prior.project}' — leaving it untouched while syncing '${projectName}'. Re-run with --force to overwrite.`);
|
||||
warn(`mcpctl: '${v.name}' is owned by project '${prior.project}' — leaving it untouched while syncing ${owner === null ? 'globals' : `'${owner}'`}. Re-run with --force to overwrite.`);
|
||||
result.preserved.push(v.name);
|
||||
return;
|
||||
}
|
||||
@@ -502,7 +524,7 @@ export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise<Syn
|
||||
files: fileStates,
|
||||
postInstallHash,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
...(isPrimeAgent ? { project: scope } : {}),
|
||||
...(isPrimeAgent ? { project: owner } : {}),
|
||||
};
|
||||
state.skills[v.name] = newState;
|
||||
if (prior) result.updated.push(v.name);
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,13 +17,16 @@
|
||||
* - A project's existing `mcpServers` entry is merged (user-added fields are
|
||||
* kept), never replaced wholesale.
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
||||
import { readFile, writeFile, mkdir, stat, chmod } from 'node:fs/promises';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
|
||||
/** Base URL of the deployed mcpctl HTTP MCP gateway. */
|
||||
export const DEFAULT_MCPCTL_GATEWAY_URL = 'https://mcp.ad.itaz.eu';
|
||||
|
||||
/** Every mcpctl bearer token starts with this (see `@mcpctl/shared` generateToken). */
|
||||
const MCPCTL_TOKEN_PREFIX = 'mcpctl_pat_';
|
||||
|
||||
/** Resolve the prime-agent settings.json path. */
|
||||
export function primeAgentSettingsPath(homeDir: string = homedir()): string {
|
||||
return join(homeDir, '.prime', 'agent', 'settings.json');
|
||||
@@ -72,6 +75,61 @@ export async function loadPrimeAgentSettings(path: string): Promise<PrimeAgentSe
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the `mcpServers` entry named `name` one that mcpctl installed?
|
||||
*
|
||||
* Two shapes count:
|
||||
* - `mcpctlManaged: true` — written by this CLI (current releases tag every
|
||||
* entry they write).
|
||||
* - *untagged*, but the URL is exactly the canonical `/projects/<name>/mcp`
|
||||
* proxy URL **and** auth.json holds an `mcp:<name>` mcpctl PAT. Older CLIs
|
||||
* wrote the entry + credential pair but no tag; without adopting them a
|
||||
* project switch would leave two gateways mounted at once and the `/mcpctl`
|
||||
* switcher would report no active project.
|
||||
*
|
||||
* A hand-configured server never has an mcpctl PAT stored under `mcp:<name>`,
|
||||
* so it is never adopted — that pairing is what makes the legacy match safe.
|
||||
*/
|
||||
export function isMcpctlManagedEntry(
|
||||
name: string,
|
||||
entry: unknown,
|
||||
authKeys: ReadonlySet<string>,
|
||||
): boolean {
|
||||
if (entry === null || typeof entry !== 'object') return false;
|
||||
const rec = entry as Record<string, unknown>;
|
||||
if (rec['mcpctlManaged'] === true) return true;
|
||||
const url = rec['url'];
|
||||
if (typeof url !== 'string') return false;
|
||||
// Host-agnostic: adopt regardless of which gateway the old entry pointed at.
|
||||
const canonical = new RegExp(`/projects/${escapeRegExp(encodeURIComponent(name))}/mcp/*$`);
|
||||
return canonical.test(url) && authKeys.has(name);
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the projects auth.json holds an mcpctl PAT for (`mcp:<project>`).
|
||||
* Used to recognise entries an older, tag-less CLI wrote. A missing or corrupt
|
||||
* auth.json yields an empty set — adoption then simply doesn't happen.
|
||||
*/
|
||||
export async function primeAgentAuthProjects(authPath: string): Promise<Set<string>> {
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = await loadPrimeAgentAuth(authPath);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const out = new Set<string>();
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (!k.startsWith('mcp:')) continue;
|
||||
const key = (v as { key?: unknown } | null)?.key;
|
||||
if (typeof key === 'string' && key.startsWith(MCPCTL_TOKEN_PREFIX)) out.add(k.slice(4));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface RegisterMcpResult {
|
||||
settingsPath: string;
|
||||
created: boolean; // true if the settings file did not previously exist
|
||||
@@ -93,6 +151,7 @@ export async function registerPrimeAgentMcp(
|
||||
project: string,
|
||||
settingsPath: string,
|
||||
gatewayUrl: string = DEFAULT_MCPCTL_GATEWAY_URL,
|
||||
opts: { authPath?: string } = {},
|
||||
): Promise<RegisterMcpResult> {
|
||||
const existed = await pathExists(settingsPath);
|
||||
const settings = await loadPrimeAgentSettings(settingsPath);
|
||||
@@ -110,13 +169,16 @@ export async function registerPrimeAgentMcp(
|
||||
|
||||
// prime-agent loads every mcpServers entry, so only ONE mcpctl project should
|
||||
// be active at a time. Remove any *other* mcpctl-managed project entries we
|
||||
// previously installed, but preserve untagged servers (e.g. a hand-configured
|
||||
// previously installed — including the untagged ones older CLIs wrote (see
|
||||
// isMcpctlManagedEntry) — but preserve hand-configured servers (a bespoke
|
||||
// `sre`, websearch, etc) so switching never nukes unrelated integrations.
|
||||
const authKeys = opts.authPath !== undefined
|
||||
? await primeAgentAuthProjects(opts.authPath)
|
||||
: new Set<string>();
|
||||
const removed: string[] = [];
|
||||
for (const k of Object.keys(settings.mcpServers)) {
|
||||
if (k === project) continue;
|
||||
const entry = settings.mcpServers[k];
|
||||
if (entry && typeof entry === 'object' && (entry as Record<string, unknown>)['mcpctlManaged'] === true) {
|
||||
if (isMcpctlManagedEntry(k, settings.mcpServers[k], authKeys)) {
|
||||
delete settings.mcpServers[k];
|
||||
removed.push(k);
|
||||
}
|
||||
@@ -134,22 +196,19 @@ export async function registerPrimeAgentMcp(
|
||||
* `~/.prime/agent/auth.json`, merging with any existing entries (the `itaz`
|
||||
* provider credential, other `mcp:*` servers, etc).
|
||||
*
|
||||
* auth.json holds bearer tokens, so it is written 0600 (preserving an existing
|
||||
* file's mode if present) — never the default umask.
|
||||
* auth.json holds never-expiring bearer tokens, so it always ends up 0600 —
|
||||
* never the default umask. `writeFile`'s `mode` only applies when the file is
|
||||
* created, and prime-agent itself creates auth.json 0644, so we chmod after
|
||||
* writing rather than trusting the open flags.
|
||||
*/
|
||||
export async function writePrimeAgentAuth(project: string, key: string, authPath: string): Promise<void> {
|
||||
const current = await loadPrimeAgentAuth(authPath);
|
||||
current[`mcp:${project}`] = { type: 'api_key', key };
|
||||
await mkdir(dirname(authPath), { recursive: true });
|
||||
// Preserve an existing 0600 mode; always 0600 on first creation.
|
||||
let mode: number | undefined;
|
||||
await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
const s = await stat(authPath);
|
||||
mode = s.mode;
|
||||
} catch {
|
||||
mode = 0o600;
|
||||
}
|
||||
await writeFile(authPath, JSON.stringify(current, null, 2) + '\n', { mode });
|
||||
await chmod(authPath, 0o600);
|
||||
} catch { /* best-effort: a credential written is better than one refused */ }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,11 +235,36 @@ async function loadPrimeAgentAuth(path: string): Promise<Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */
|
||||
export async function hasPrimeAgentAuth(project: string, authPath: string): Promise<boolean> {
|
||||
/**
|
||||
* The credential currently stored for `project`, or null if there is none.
|
||||
* Throws on corrupt JSON (the caller must refuse to overwrite the file).
|
||||
*/
|
||||
export async function readPrimeAgentAuthKey(project: string, authPath: string): Promise<string | null> {
|
||||
const parsed = await loadPrimeAgentAuth(authPath) as Record<string, { type?: string; key?: string } | undefined>;
|
||||
const entry = parsed[`mcp:${project}`];
|
||||
return Boolean(entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0);
|
||||
if (entry && typeof entry === 'object' && typeof entry.key === 'string' && entry.key.length > 0) {
|
||||
return entry.key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Does the project already have a credential in auth.json? Throws on corrupt JSON. */
|
||||
export async function hasPrimeAgentAuth(project: string, authPath: string): Promise<boolean> {
|
||||
return (await readPrimeAgentAuthKey(project, authPath)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The displayable prefix mcpd records for a raw token (`tokenPrefix` on
|
||||
* McpToken): the first 16 characters. Lets us match a stored credential against
|
||||
* the server's token list without ever sending the secret.
|
||||
*/
|
||||
export function mcpTokenPrefixOf(raw: string): string {
|
||||
return raw.slice(0, 16);
|
||||
}
|
||||
|
||||
/** Is this string shaped like an mcpctl PAT (and therefore checkable server-side)? */
|
||||
export function isMcpctlToken(raw: string): boolean {
|
||||
return raw.startsWith(MCPCTL_TOKEN_PREFIX);
|
||||
}
|
||||
|
||||
async function pathExists(p: string): Promise<boolean> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { writeFileSync, readFileSync, mkdtempSync, rmSync, existsSync, statSync, chmodSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { createConfigCommand } from '../../src/commands/config.js';
|
||||
@@ -297,6 +297,231 @@ describe('config prime-agent', () => {
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // untagged preserved
|
||||
});
|
||||
|
||||
it('adopts an untagged entry an older CLI wrote, keeping hand-configured ones', async () => {
|
||||
// Written by a CLI that predates `mcpctlManaged`: an untagged entry whose
|
||||
// URL is canonical AND a matching mcp:<project> PAT in auth.json.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
mcpServers: {
|
||||
legacy: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/legacy/mcp` },
|
||||
websearch: { type: 'http', url: 'https://search.example/mcp' }, // hand-configured
|
||||
sre: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/sre/mcp` }, // canonical URL, no credential
|
||||
},
|
||||
}));
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
itaz: { type: 'api_key', key: 'sk-provider' },
|
||||
'mcp:legacy': { type: 'api_key', key: 'mcpctl_pat_legacytoken1234' },
|
||||
}));
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const written = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
expect(written.mcpServers['legacy']).toBeUndefined(); // adopted + unmounted
|
||||
expect(written.mcpServers['websearch']).toBeDefined(); // unrelated, preserved
|
||||
expect(written.mcpServers['sre']).toBeDefined(); // no PAT → hand-set, preserved
|
||||
expect(written.mcpServers['labctl'].mcpctlManaged).toBe(true);
|
||||
});
|
||||
|
||||
it('mints each credential under a unique name (never a fixed one)', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const body = client.post.mock.calls.find((c) => c[0] === '/api/v1/mcptokens')?.[1] as { name: string };
|
||||
// A fixed name can only ever be minted once: McpToken is unique on
|
||||
// (name, projectId) and revoke is a soft delete.
|
||||
expect(body.name).not.toBe('prime-agent');
|
||||
expect(body.name).toMatch(/^prime-agent-[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it('revokes the token it replaced, only after the replacement is stored', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const authPath = join(tmpDir, 'auth.json');
|
||||
writeFileSync(authPath, JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_oldtoken00000' },
|
||||
}));
|
||||
const order: string[] = [];
|
||||
const api = {
|
||||
get: vi.fn(async (url: string) => {
|
||||
order.push(`get ${url}`);
|
||||
return [
|
||||
{ id: 'tok-old', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_oldto' },
|
||||
{ id: 'tok-other', name: 'ci-runner', status: 'active', tokenPrefix: 'mcpctl_pat_ci000' },
|
||||
];
|
||||
}),
|
||||
post: vi.fn(async (url: string) => {
|
||||
order.push(`post ${url}`);
|
||||
return {};
|
||||
}),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
// Explicitly replace the stored credential.
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension', '--token', 'mcpctl_pat_supplied00000'], { from: 'user' });
|
||||
|
||||
// The new credential landed on disk...
|
||||
expect(JSON.parse(readFileSync(authPath, 'utf-8'))['mcp:p'].key).toBe('mcpctl_pat_supplied00000');
|
||||
// ...before the token it replaced was revoked — never the other way round.
|
||||
const revokeAt = order.indexOf('post /api/v1/mcptokens/tok-old/revoke');
|
||||
expect(revokeAt).toBeGreaterThanOrEqual(0);
|
||||
expect(statSync(authPath).mtimeMs).toBeGreaterThan(0);
|
||||
// Tokens this auth.json never held are reported, never revoked.
|
||||
expect(order).not.toContain('post /api/v1/mcptokens/tok-other/revoke');
|
||||
});
|
||||
|
||||
it('never revokes a token this auth.json did not hold', async () => {
|
||||
// A run against a custom --output (or a second machine) must not touch the
|
||||
// credential the real install is using.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
{ id: 'tok-elsewhere', name: 'prime-agent-abc', status: 'active', tokenPrefix: 'mcpctl_pat_elsew' },
|
||||
]),
|
||||
post: vi.fn(async (url: string) => (url === '/api/v1/mcptokens' ? { token: 'mcpctl_pat_brandnew0000' } : {})),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const revokes = api.post.mock.calls.filter((c) => String(c[0]).includes('/revoke'));
|
||||
expect(revokes).toEqual([]);
|
||||
// ...but the user is told about it rather than left guessing.
|
||||
expect(output.join('\n')).toContain('prime-agent-abc');
|
||||
});
|
||||
|
||||
it('leaves settings.json untouched when the credential cannot be provisioned', async () => {
|
||||
// The active project must keep working when a switch fails: registering the
|
||||
// new project unmounts the old one, so it may not run before the mint.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const before = JSON.stringify({
|
||||
mcpServers: {
|
||||
homeautomation: { type: 'http', url: `${DEFAULT_MCPCTL_GATEWAY_URL}/projects/homeautomation/mcp`, mcpctlManaged: true },
|
||||
},
|
||||
});
|
||||
writeFileSync(settingsPath, before);
|
||||
const badClient = { ...client, get: vi.fn(async () => []), post: vi.fn(async () => ({})) } as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: badClient, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'labctl', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(readFileSync(settingsPath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('re-mints when the stored credential is no longer active', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_revoked000000' },
|
||||
}));
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
{ id: 'tok-1', name: 'prime-agent-old', status: 'revoked', tokenPrefix: 'mcpctl_pat_revo' },
|
||||
]),
|
||||
post: vi.fn(async () => ({ token: 'mcpctl_pat_fresh0000000' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:p'].key).toBe('mcpctl_pat_fresh0000000');
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps a stored credential that is still active', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(join(tmpDir, 'auth.json'), JSON.stringify({
|
||||
'mcp:p': { type: 'api_key', key: 'mcpctl_pat_liveaaaaaaaa' },
|
||||
}));
|
||||
const api = {
|
||||
get: vi.fn(async () => [
|
||||
// mcpd records the first 16 chars of the raw token as tokenPrefix.
|
||||
{ id: 'tok-1', name: 'prime-agent-x', status: 'active', tokenPrefix: 'mcpctl_pat_livea' },
|
||||
]),
|
||||
post: vi.fn(async () => ({ token: 'should-not-be-minted' })),
|
||||
put: vi.fn(async () => ({})),
|
||||
delete: vi.fn(async () => {}),
|
||||
} as unknown as ApiClient;
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client: api, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'p', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
const auth = JSON.parse(readFileSync(join(tmpDir, 'auth.json'), 'utf-8'));
|
||||
expect(auth['mcp:p'].key).toBe('mcpctl_pat_liveaaaaaaaa');
|
||||
});
|
||||
|
||||
it('tightens a pre-existing 0644 auth.json to 0600', async () => {
|
||||
// prime-agent creates auth.json itself with the default umask; writeFile's
|
||||
// `mode` is ignored for an existing file, so the write must chmod.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const authPath = join(tmpDir, 'auth.json');
|
||||
writeFileSync(authPath, JSON.stringify({ itaz: { type: 'api_key', key: 'sk-x' } }), { mode: 0o644 });
|
||||
chmodSync(authPath, 0o644);
|
||||
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'm', '-o', settingsPath, '--skip-skills', '--skip-extension'], { from: 'user' });
|
||||
|
||||
expect(statSync(authPath).mode & 0o777).toBe(0o600);
|
||||
// The provider credential is still there.
|
||||
expect(JSON.parse(readFileSync(authPath, 'utf-8')).itaz.key).toBe('sk-x');
|
||||
});
|
||||
|
||||
it('--skip-marker leaves the current directory alone', async () => {
|
||||
// The /mcpctl switcher runs from whatever directory prime-agent started in.
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'sre', '-o', settingsPath, '--skip-skills', '--skip-extension', '--skip-marker', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
expect(exceptionSafeRead(join(tmpDir, '.mcpctl-project'))).toBeNull();
|
||||
});
|
||||
|
||||
it('the installed switcher extension passes --skip-marker', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
const cmd = createConfigCommand(
|
||||
{ configDeps: { configDir: tmpDir }, log },
|
||||
{ client, credentialsDeps: { configDir: tmpDir }, log },
|
||||
);
|
||||
await cmd.parseAsync(['prime-agent', '--project', 'ha', '-o', settingsPath, '--skip-skills', '--token', 'mcpctl_pat_x'], { from: 'user' });
|
||||
|
||||
const ext = readFileSync(join(tmpDir, 'extensions', 'mcpctl-switch.ts'), 'utf-8');
|
||||
expect(ext).toContain("'--skip-extension', '--skip-marker'");
|
||||
});
|
||||
|
||||
it('merges a re-configured project entry, preserving user-added fields', async () => {
|
||||
const settingsPath = join(tmpDir, 'settings.json');
|
||||
writeFileSync(settingsPath, JSON.stringify({
|
||||
|
||||
@@ -207,6 +207,101 @@ describe('runPrimeAgentSkillsSync', () => {
|
||||
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n');
|
||||
});
|
||||
|
||||
it('does not delete legacy, ownership-less state belonging to another project', async () => {
|
||||
// State written by a CLI that predates the `project` field: the skill has
|
||||
// no recorded owner and the file records projA as the last syncing project.
|
||||
const legacyDir = join(installRoot, 'legacy-skill');
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
lastSync: '2026-01-01T00:00:00.000Z',
|
||||
lastSyncProject: 'projA',
|
||||
skills: {
|
||||
'legacy-skill': {
|
||||
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
|
||||
installDir: legacyDir, files: {}, postInstallHash: null,
|
||||
lastSyncedAt: '2026-01-01T00:00:00.000Z',
|
||||
// note: no `project` field
|
||||
},
|
||||
},
|
||||
}), 'utf-8');
|
||||
|
||||
// First sync after upgrading, for a *different* project.
|
||||
const client = mockClient({ visible: [], full: {} });
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.removed).toEqual([]);
|
||||
expect(existsSync(legacyDir)).toBe(true);
|
||||
});
|
||||
|
||||
it('cleans up legacy state once the owning project syncs again', async () => {
|
||||
const legacyDir = join(installRoot, 'legacy-skill');
|
||||
mkdirSync(legacyDir, { recursive: true });
|
||||
writeFileSync(join(legacyDir, 'SKILL.md'), '# legacy\n', 'utf-8');
|
||||
writeFileSync(statePath, JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
lastSync: '2026-01-01T00:00:00.000Z',
|
||||
lastSyncProject: 'projA',
|
||||
skills: {
|
||||
'legacy-skill': {
|
||||
id: 'l-1', semver: '1.0.0', contentHash: 'sha256:l', scope: 'project',
|
||||
installDir: legacyDir, files: {}, postInstallHash: null,
|
||||
lastSyncedAt: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
}), 'utf-8');
|
||||
|
||||
const client = mockClient({ visible: [], full: {} });
|
||||
const result = await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(client));
|
||||
|
||||
expect(result.removed).toContain('legacy-skill');
|
||||
expect(existsSync(legacyDir)).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps global skills updatable after switching projects', async () => {
|
||||
// A global installed while projA was active must not be pinned to projA —
|
||||
// globals are visible from every project.
|
||||
const gv = (hash: string) => [
|
||||
{ id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, metadata: {}, scope: 'global' },
|
||||
];
|
||||
const gf = (hash: string, body: string) => ({
|
||||
'g-1': { id: 'g-1', name: 'shared-global', description: 'd', semver: '1.0.0', contentHash: hash, content: body, files: {} },
|
||||
});
|
||||
|
||||
const clientA = mockClient({ visible: gv('sha256:v1'), full: gf('sha256:v1', '# v1\n') });
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(clientA));
|
||||
expect((await loadState(statePath)).skills['shared-global']?.project).toBeNull();
|
||||
|
||||
// Switch to projB; the global has been updated server-side.
|
||||
const clientB = mockClient({ visible: gv('sha256:v2'), full: gf('sha256:v2', '# v2\n') });
|
||||
const resultB = await runPrimeAgentSkillsSync({ project: 'projB', installRoot, statePath }, deps(clientB));
|
||||
|
||||
expect(resultB.updated).toContain('shared-global');
|
||||
expect(resultB.preserved).toEqual([]);
|
||||
expect(readFileSync(join(installRoot, 'shared-global', 'SKILL.md'), 'utf-8')).toBe('# v2\n');
|
||||
});
|
||||
|
||||
it('does not let a global-only sync clobber a project-owned skill', async () => {
|
||||
const av = [
|
||||
{ id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', metadata: {}, scope: 'project' },
|
||||
];
|
||||
const af = { 'a-1': { id: 'a-1', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:a', content: '# version-a\n', files: {} } };
|
||||
await runPrimeAgentSkillsSync({ project: 'projA', installRoot, statePath }, deps(mockClient({ visible: av, full: af })));
|
||||
|
||||
// A global of the same name shows up on a global-only sync.
|
||||
const gv = [
|
||||
{ id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', metadata: {}, scope: 'global' },
|
||||
];
|
||||
const gf = { 'g-9': { id: 'g-9', name: 'x-skill', description: 'd', semver: '1.0.0', contentHash: 'sha256:g', content: '# global\n', files: {} } };
|
||||
const empty = join(tmpDir, 'empty3');
|
||||
mkdirSync(empty, { recursive: true });
|
||||
const result = await runPrimeAgentSkillsSync({ cwd: empty, installRoot, statePath }, deps(mockClient({ visible: gv, full: gf })));
|
||||
|
||||
expect(result.preserved).toContain('x-skill');
|
||||
expect(readFileSync(join(installRoot, 'x-skill', 'SKILL.md'), 'utf-8')).toBe('# version-a\n');
|
||||
});
|
||||
|
||||
it('removes global orphans on a global-only sync', async () => {
|
||||
// First sync a global skill.
|
||||
const v = [
|
||||
|
||||
Reference in New Issue
Block a user