diff --git a/CHANGELOG.md b/CHANGELOG.md index d81b539d..d37674a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +- Hardened per-registry skill updates with direct cache-filter coverage, available-registry hints, and unique repository-name shorthand. + ## [0.52.0] - 2026-08-18 - [4690e3e](https://github.com/codeaholicguy/ai-devkit/pull/172) Simplified obsolete test cleanup guidance in the simplify-implementation skill. diff --git a/docs/ai/design/2026-08-18-feature-skill-update-registry.md b/docs/ai/design/2026-08-18-feature-skill-update-registry.md new file mode 100644 index 00000000..980d8c87 --- /dev/null +++ b/docs/ai/design/2026-08-18-feature-skill-update-registry.md @@ -0,0 +1,62 @@ +--- +phase: design +title: System Design & Architecture +description: Resolve and test cached registry update selection +--- + +# System Design & Architecture + +## Architecture Overview + +```mermaid +flowchart LR + CLI[skill update optional-id] --> SR[SkillRegistry.updateSkills] + Cache[(owner/repo cache)] --> Discover[discover cached registry IDs] + SR --> Discover + Discover --> Select{input form} + Select -->|none| All[all cached registries] + Select -->|owner/repo| Exact[exact match] + Select -->|repo| Unique[unique repo-name match] + Select -->|zero or ambiguous| Missing[NotFoundError + available IDs] + All --> Update[updateRegistry] + Exact --> Update + Unique --> Info[ui.info resolution] --> Update + Update --> Summary[UpdateSummary] +``` + +The existing command and public method signatures stay unchanged. `SkillRegistry.updateSkills()` first discovers all cache candidates, then resolves the optional selector, then updates only selected candidates and derives the existing summary. + +## Data Models and API + +- Cached candidate: `{ path: string; id: string }`, where `id` is `owner/repo`. +- Public API remains `updateSkills(registryId?: string): Promise`. +- Exact selectors match a full ID. Owner-less selectors compare against the substring after `/` and resolve only for one candidate. +- Failure details retain the requested `registryId`; the message appends `Available: .`. + +## Component Breakdown + +- `packages/cli/src/lib/SkillRegistry.ts`: candidate discovery, selector resolution, update loop, summary. +- `packages/cli/src/__tests__/lib/SkillRegistry.test.ts`: direct contract tests with a temporary home/cache and mocked Git boundary. +- `packages/cli/README.md`: all, exact, and shorthand usage. +- `CHANGELOG.md`: unreleased hardening entry following the current top-of-file convention. + +## Design Decisions + +- Discover before filtering so errors can enumerate all available candidates and shorthand can detect ambiguity. +- Sort available IDs for deterministic UX and assertions; update ordering may follow the same sorted list. +- Keep shorthand resolution local to cached candidates because the update operation cannot update an uncached registry. +- Inform only on successful shorthand resolution; exact IDs preserve existing output. +- Prefer real temporary directories with mocked `ensureGitInstalled`, `isGitRepository`, and `pullRepository` boundaries. + +### Alternatives considered + +- Merging configured registries was rejected because configuration does not guarantee a cache exists. +- Picking the first shorthand match was rejected because filesystem order is not a safe disambiguation rule. +- Moving resolution into the command was rejected because it would leave direct `SkillRegistry` callers with a different contract. + +## Non-Functional Requirements + +- Selection is linear in cached registry count and performs no additional network work. +- No cache contents are created, deleted, or rewritten by resolution. +- Errors occur before any registry pull for invalid or ambiguous selectors. +- Existing callers and `UpdateSummary` consumers remain source-compatible. diff --git a/docs/ai/implementation/2026-08-18-feature-skill-update-registry.md b/docs/ai/implementation/2026-08-18-feature-skill-update-registry.md new file mode 100644 index 00000000..76df56ab --- /dev/null +++ b/docs/ai/implementation/2026-08-18-feature-skill-update-registry.md @@ -0,0 +1,53 @@ +--- +phase: implementation +title: Implementation Guide +description: Implementation record for registry update hardening +--- + +# Implementation Guide + +## Development Setup + +- Worktree: `feature-skill-update-registry`. +- Task tracing unavailable: `npx ai-devkit@latest task list --name skill-update-registry --json` returns `unknown command 'task'`. +- Project-local built-in skill installation failed twice at `.agents/skills`; global skill instructions were used as authorized by the brief. + +## Code Structure + +- `packages/cli/src/lib/SkillRegistry.ts`: cached-candidate discovery and optional selector resolution. +- `packages/cli/src/__tests__/lib/SkillRegistry.test.ts`: direct public-contract coverage with an isolated temporary cache. +- `packages/cli/README.md` and `CHANGELOG.md`: usage and release documentation. + +## Implementation Notes + +- Candidate discovery now completes before filtering. IDs are sorted, enabling deterministic available-ID errors. +- Full `owner/repo` input still requires an exact match. +- Input without `/` compares with each cached repository directory name. Exactly one match resolves and emits `ui.info`; zero or multiple matches use the improved `NotFoundError`. +- Invalid selection fails before `updateRegistry()` and therefore before any pull. +- Update execution and `UpdateSummary` derivation remain unchanged. + +## TDD Evidence + +- Red: focused run produced 4 expected failures for missing available IDs and shorthand resolution; 4 baseline-contract tests passed. +- Green: focused run passed 8/8 tests. +- Regression proof: removing the production change reproduced the same 4 failures; restoring it returned 8/8 green. +- Focused coverage passed; whole-file coverage is 64.42% statements / 62% branches because unrelated fetch/clone paths are outside this hardening scope. All added selection branches are exercised. + +## Error Handling and Safety + +- `NotFoundError` retains code `NOT_FOUND` and requested `registryId` details. +- Available IDs reflect cached directories only and are sorted for stable output. +- No persistent cache data is mutated by the selector; tests clean their process-isolated temporary home. + +## Design Alignment + +Implementation matches the design without public API, schema, cache-layout, or command-parser changes. No security-sensitive inputs, credentials, migrations, or irreversible operations were introduced. + +## Final Validation + +- `npm ci`: exit 0; 1001 packages installed (npm reported 4 pre-existing high-severity audit findings). +- `npm run build`: exit 0; all 6 workspace project builds passed. +- `npx ai-devkit@latest lint` and `lint --feature skill-update-registry`: exit 0. +- `npm run lint`: exit 0; 6 project lints passed with 6 pre-existing unused-catch warnings and no errors. +- `npm test`: exit 0; 1,923 tests passed across 137 files and 6 projects. +- `node packages/cli/dist/cli.js skill update --help`: exit 0; optional `[registry-id]` documented. diff --git a/docs/ai/planning/2026-08-18-feature-skill-update-registry.md b/docs/ai/planning/2026-08-18-feature-skill-update-registry.md new file mode 100644 index 00000000..aea023dc --- /dev/null +++ b/docs/ai/planning/2026-08-18-feature-skill-update-registry.md @@ -0,0 +1,64 @@ +--- +phase: planning +title: Project Planning & Task Breakdown +description: TDD plan for registry update hardening +--- + +# Project Planning & Task Breakdown + +## Milestones + +- [x] Milestone 1: Baseline update contract has direct `SkillRegistry` coverage. +- [x] Milestone 2: Error and shorthand UX is implemented through red-green-refactor cycles. +- [x] Milestone 3: Docs, coverage, full gates, and final review are complete. + +## Task Breakdown + +### Task 1: Dedicated baseline contract tests + +- [x] Create an isolated `SkillRegistry.test.ts` fixture using a temporary cache. +- [x] Prove no-argument updates all candidates, exact IDs update one, non-Git candidates skip, and summaries count statuses correctly. +- Validation: focused Vitest run and assertions on Git/UI boundary calls. +- Dependencies: existing `SkillRegistry.updateSkills()` behavior. + +### Task 2: Helpful not-found errors + +- [x] **Red:** Assert unknown full IDs throw `NotFoundError` with sorted available IDs and perform no pulls. +- [x] **Green:** Discover candidates before selection and build the improved message. +- [x] **Refactor:** Remove duplicated selection/error formatting and rerun focused tests. +- Validation: focused test and changed-file coverage. + +### Task 3: Owner-less shorthand + +- [x] **Red:** Add unique, ambiguous, and zero-match repository-name scenarios. +- [x] **Green:** Resolve only a single repo-name match and emit `ui.info`; reuse the not-found path otherwise. +- [x] **Refactor:** Keep exact/full-ID and no-ID paths explicit and minimal. +- Validation: focused tests prove only the resolved registry pulls and invalid selectors pull none. + +### Task 4: Documentation and release note + +- [x] Document no-argument, exact-ID, and unique shorthand examples in CLI docs. +- [x] Add an unreleased changelog entry without altering released history. +- Validation: docs review and CLI help smoke test. + +### Task 5: Lifecycle verification and review + +- [x] Update implementation/testing docs with changed files and fresh evidence. +- [x] Run `npm ci` and `npm run build` before full gates. +- [x] Run focused coverage, lifecycle lint, full workspace gates, and final code review. +- [ ] Commit and push each completed phase, rebase on latest `origin/main`, revalidate, and open the requested PR. + +## Dependencies and Sequencing + +Task 1 establishes the harness. Tasks 2 and 3 follow strict red-green-refactor cycles. Task 4 follows green behavior. Task 5 is last and may send implementation back to Tasks 2-4 if review identifies a blocking gap. + +## Risks & Mitigation + +- Module-level `SKILL_CACHE_DIR` can leak the real home directory: mock `os.homedir()` before dynamic import and reset modules. +- Filesystem enumeration order can make messages flaky: sort IDs before formatting and asserting. +- Existing `SkillManager` tests may duplicate behavior through mocks: keep new tests on the `SkillRegistry` public API and use real temporary directories. +- Workspace hooks depend on built artifacts: run deterministic install and build before full validation or commits containing implementation. + +## Progress Summary + +All planned hardening, documentation, focused coverage, workspace gates, and final review are complete. Task tracing was unavailable because `npx ai-devkit@latest task ...` reports `unknown command 'task'`. No scope changes or blocking risks were discovered. diff --git a/docs/ai/requirements/2026-08-18-feature-skill-update-registry.md b/docs/ai/requirements/2026-08-18-feature-skill-update-registry.md new file mode 100644 index 00000000..8f14b420 --- /dev/null +++ b/docs/ai/requirements/2026-08-18-feature-skill-update-registry.md @@ -0,0 +1,53 @@ +--- +phase: requirements +title: Requirements & Problem Understanding +description: Harden the existing per-registry skill update contract +--- + +# Requirements & Problem Understanding + +## Problem Statement + +`ai-devkit skill update [registry-id]` and exact `owner/repo` filtering already work. The cache-level contract is not directly tested, unknown-registry errors do not identify valid choices, and users must always type the owner even when a repository name uniquely identifies one cached registry. + +## Goals & Objectives + +- Add dedicated `SkillRegistry` tests backed by an isolated temporary cache. +- Preserve no-argument updates of every cached registry and exact-ID updates of only the requested registry. +- Include sorted available registry IDs in unknown-registry errors. +- Resolve an owner-less repository name only when it matches exactly one cached registry and report the resolved ID through `ui.info`. +- Keep zero-match and ambiguous shorthand inputs as `NotFoundError` cases. +- Document exact and shorthand per-registry update forms and add a changelog entry. + +### Non-goals + +- Reimplementing the existing optional CLI argument or exact registry filter. +- Changing cache layout, cloning, pull behavior, summary structure, or command parsing. +- Choosing among ambiguous shorthand matches. + +## User Stories & Use Cases + +- As a user, I can update all cached registries with `ai-devkit skill update`. +- As a user, I can update one registry with its exact `owner/repo` ID. +- As a user, I can use a unique repository-name shorthand and see which full ID was selected. +- As a user who mistypes or supplies an ambiguous name, I see the available full registry IDs and no registry is updated. +- As a maintainer, I have direct regression tests for filtering, skipped non-Git directories, and summary counts. + +## Success Criteria + +- Dedicated tests prove all-cache, exact-ID, unique shorthand, ambiguous shorthand, zero-match, unknown full ID, non-Git skip, and summary-count behavior. +- Exact matching remains case-sensitive and uses the existing `owner/repo` cache layout. +- Available IDs are deterministic and comma-separated in `NotFoundError` messages. +- Unique shorthand emits an informational resolution message before updating only the resolved registry. +- Targeted tests, coverage, lifecycle lint, workspace lint/tests, and build pass. + +## Constraints & Assumptions + +- The cache is the source of truth for update candidates; configured but uncached registries are not listed. +- A shorthand is any supplied ID without `/`; its candidate key is the repository directory name. +- Cache entries are owner/repository directories. Non-directory entries are ignored and repository directories that are not Git repositories are counted as skipped. +- No material product questions remain; the brief fixes scope and behavior. + +## Questions & Open Items + +None. Per-registry updating is an established capability; this feature hardens its contract and UX only. diff --git a/docs/ai/testing/2026-08-18-feature-skill-update-registry.md b/docs/ai/testing/2026-08-18-feature-skill-update-registry.md new file mode 100644 index 00000000..6f1c5432 --- /dev/null +++ b/docs/ai/testing/2026-08-18-feature-skill-update-registry.md @@ -0,0 +1,60 @@ +--- +phase: testing +title: Testing Strategy +description: Direct coverage for cached registry update selection and summaries +--- + +# Testing Strategy + +## Test Coverage Goals + +- Cover every new selector branch and preserve the existing full-ID/no-ID contract. +- Exercise real temporary cache directory discovery while mocking only Git and terminal boundaries. +- Target 100% coverage of changed `SkillRegistry` selection logic. + +## Unit and Integration Tests + +### `SkillRegistry.updateSkills` + +- [x] No argument updates every cached registry. +- [x] Exact `owner/repo` updates only that registry. +- [x] Unknown full ID throws `NotFoundError` listing sorted available IDs before pulls. +- [x] Unique owner-less repo name resolves, emits `ui.info`, and updates only its full ID. +- [x] Ambiguous owner-less name throws and lists available IDs before pulls. +- [x] Zero-match owner-less name throws and lists available IDs before pulls. +- [x] Non-Git cache directories are skipped without a pull. +- [x] Mixed success, skip, and failure results produce correct totals and per-status counts. +- [x] Missing cache returns an empty summary without updates. + +## Test Data and Isolation + +- Override `os.homedir()` before importing `SkillRegistry` so its exported cache constant points into a per-test temporary directory. +- Seed `~/.ai-devkit/skills//` directories with `fs-extra`. +- Mock `ensureGitInstalled`, `isGitRepository`, `pullRepository`, and terminal UI methods; do not run Git or network operations. +- Clean the temporary directory and restore mocks/modules after each test. + +## Validation + +- Focused red/green: `npx vitest run src/__tests__/lib/SkillRegistry.test.ts` from `packages/cli`. +- Focused coverage: `npx vitest run src/__tests__/lib/SkillRegistry.test.ts --coverage --coverage.include=src/lib/SkillRegistry.ts`. +- Required bootstrap before full gates: `npm ci`, then `npm run build` from repository root. +- Full gates: lifecycle lint, workspace lint, tests, and build using repository scripts. +- CLI help smoke test confirms the documented optional registry argument. + +## Manual Testing + +No live registry pull is required; the orchestrator already verified the baseline exact-ID behavior. Automated tests cover the hardening changes without mutating the user cache. + +## Bug Tracking + +Any failure that permits an invalid/ambiguous selector to pull a registry is blocking. Cosmetic output differences are blocking when they omit the resolved ID or available IDs required by the contract. + +## Results + +- Focused test: 9/9 passed after the final missing-cache scenario was added. +- Focused coverage: exit 0; all selector behaviors are covered (whole `SkillRegistry.ts`: 64.42% statements, 62% branches, including unrelated fetch/clone paths). +- Lifecycle lint: base and feature checks passed. +- Workspace lint: all 6 projects passed with warnings only. +- Workspace tests: 1,923 tests passed across 137 files and 6 projects. +- Workspace build: all 6 projects passed after a clean `npm ci`. +- CLI help smoke test: optional `[registry-id]` displayed. diff --git a/packages/cli/README.md b/packages/cli/README.md index f6dd07bf..f5063396 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -88,6 +88,13 @@ ai-devkit lint --feature lint-command --json # Install a skill ai-devkit skill add [skill-name] +# Update every cached skill registry +ai-devkit skill update + +# Update one cached registry by its full ID or unique repository name +ai-devkit skill update codeaholicguy/ai-devkit +ai-devkit skill update ai-devkit + # List skills installed across known global environment paths ai-devkit skill list --global diff --git a/packages/cli/src/__tests__/lib/SkillRegistry.test.ts b/packages/cli/src/__tests__/lib/SkillRegistry.test.ts new file mode 100644 index 00000000..9de26679 --- /dev/null +++ b/packages/cli/src/__tests__/lib/SkillRegistry.test.ts @@ -0,0 +1,163 @@ +import fs from 'fs-extra'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ConfigManager } from '../../lib/Config.js'; +import type { GlobalConfigManager } from '../../lib/GlobalConfig.js'; +import { NotFoundError } from '../../util/errors.js'; +import * as git from '../../util/git.js'; +import { ui } from '../../util/terminal-ui.js'; + +const testPaths = vi.hoisted(() => ({ + home: `/tmp/ai-devkit-skill-registry-${process.pid}`, +})); + +vi.mock('os', async (importOriginal) => ({ + ...await importOriginal(), + homedir: () => testPaths.home, +})); + +vi.mock('../../util/git.js', () => ({ + ensureGitInstalled: vi.fn(), + cloneRepository: vi.fn(), + isGitRepository: vi.fn(), + pullRepository: vi.fn(), +})); + +vi.mock('../../util/terminal-ui.js', () => ({ + ui: { + error: vi.fn(), + info: vi.fn(), + success: vi.fn(), + summary: vi.fn(), + text: vi.fn(), + warning: vi.fn(), + }, +})); + +import { SkillRegistry, SKILL_CACHE_DIR } from '../../lib/SkillRegistry.js'; + +const mockedGit = vi.mocked(git); +const mockedUi = vi.mocked(ui); + +describe('SkillRegistry.updateSkills', () => { + let registry: SkillRegistry; + + const seedRegistry = async (id: string): Promise => { + const registryPath = path.join(SKILL_CACHE_DIR, id); + await fs.ensureDir(registryPath); + return registryPath; + }; + + beforeEach(async () => { + await fs.remove(testPaths.home); + vi.clearAllMocks(); + mockedGit.isGitRepository.mockResolvedValue(true); + mockedGit.pullRepository.mockResolvedValue(undefined); + registry = new SkillRegistry({} as ConfigManager, {} as GlobalConfigManager); + }); + + afterEach(async () => { + await fs.remove(testPaths.home); + }); + + it('returns an empty summary when the cache does not exist', async () => { + const summary = await registry.updateSkills(); + + expect(summary).toEqual({ total: 0, successful: 0, skipped: 0, failed: 0, results: [] }); + expect(mockedGit.pullRepository).not.toHaveBeenCalled(); + }); + + it('updates every cached registry when no filter is provided', async () => { + const first = await seedRegistry('codeaholicguy/ai-devkit'); + const second = await seedRegistry('vercel-labs/agent-skills'); + + const summary = await registry.updateSkills(); + + expect(mockedGit.pullRepository).toHaveBeenCalledTimes(2); + expect(mockedGit.pullRepository).toHaveBeenCalledWith(first); + expect(mockedGit.pullRepository).toHaveBeenCalledWith(second); + expect(summary).toMatchObject({ total: 2, successful: 2, skipped: 0, failed: 0 }); + }); + + it('updates only the exact owner/repo filter', async () => { + const selected = await seedRegistry('codeaholicguy/ai-devkit'); + await seedRegistry('vercel-labs/agent-skills'); + + const summary = await registry.updateSkills('codeaholicguy/ai-devkit'); + + expect(mockedGit.pullRepository).toHaveBeenCalledOnce(); + expect(mockedGit.pullRepository).toHaveBeenCalledWith(selected); + expect(summary.results.map(result => result.registryId)).toEqual(['codeaholicguy/ai-devkit']); + }); + + it('lists sorted available registries when an exact filter is unknown', async () => { + await seedRegistry('vercel-labs/agent-skills'); + await seedRegistry('codeaholicguy/skills'); + await seedRegistry('codeaholicguy/ai-devkit'); + + await expect(registry.updateSkills('missing/registry')).rejects.toEqual(expect.objectContaining({ + name: 'NotFoundError', + message: 'Registry "missing/registry" not found in cache. Available: codeaholicguy/ai-devkit, codeaholicguy/skills, vercel-labs/agent-skills.', + } satisfies Partial)); + expect(mockedGit.pullRepository).not.toHaveBeenCalled(); + }); + + it('resolves a unique owner-less repository name and reports the full id', async () => { + const selected = await seedRegistry('codeaholicguy/ai-devkit'); + await seedRegistry('vercel-labs/agent-skills'); + + const summary = await registry.updateSkills('ai-devkit'); + + expect(mockedUi.info).toHaveBeenCalledWith('Resolved registry "ai-devkit" to "codeaholicguy/ai-devkit".'); + expect(mockedGit.pullRepository).toHaveBeenCalledOnce(); + expect(mockedGit.pullRepository).toHaveBeenCalledWith(selected); + expect(summary.results[0].registryId).toBe('codeaholicguy/ai-devkit'); + }); + + it('rejects an ambiguous owner-less repository name', async () => { + await seedRegistry('first/skills'); + await seedRegistry('second/skills'); + + await expect(registry.updateSkills('skills')).rejects.toThrow( + 'Registry "skills" not found in cache. Available: first/skills, second/skills.' + ); + expect(mockedGit.pullRepository).not.toHaveBeenCalled(); + }); + + it('rejects an unmatched owner-less repository name', async () => { + await seedRegistry('codeaholicguy/ai-devkit'); + + await expect(registry.updateSkills('skills')).rejects.toThrow( + 'Registry "skills" not found in cache. Available: codeaholicguy/ai-devkit.' + ); + expect(mockedGit.pullRepository).not.toHaveBeenCalled(); + }); + + it('skips a non-git cache directory and counts it in the summary', async () => { + const gitRegistry = await seedRegistry('codeaholicguy/ai-devkit'); + const plainDirectory = await seedRegistry('local/skills'); + mockedGit.isGitRepository.mockImplementation(async candidate => candidate !== plainDirectory); + + const summary = await registry.updateSkills(); + + expect(mockedGit.pullRepository).toHaveBeenCalledOnce(); + expect(mockedGit.pullRepository).toHaveBeenCalledWith(gitRegistry); + expect(summary).toMatchObject({ total: 2, successful: 1, skipped: 1, failed: 0 }); + }); + + it('reports correct summary counts for success, skip, and failure results', async () => { + const successful = await seedRegistry('one/success'); + const skipped = await seedRegistry('two/skipped'); + const failed = await seedRegistry('three/failed'); + mockedGit.isGitRepository.mockImplementation(async candidate => candidate !== skipped); + mockedGit.pullRepository.mockImplementation(async candidate => { + if (candidate === failed) throw new Error('network unavailable'); + }); + + const summary = await registry.updateSkills(); + + expect(mockedGit.pullRepository).toHaveBeenCalledWith(successful); + expect(summary).toMatchObject({ total: 3, successful: 1, skipped: 1, failed: 1 }); + expect(summary.results.map(result => result.status).sort()).toEqual(['error', 'skipped', 'success']); + }); +}); diff --git a/packages/cli/src/lib/SkillRegistry.ts b/packages/cli/src/lib/SkillRegistry.ts index a1933152..05f50b49 100644 --- a/packages/cli/src/lib/SkillRegistry.ts +++ b/packages/cli/src/lib/SkillRegistry.ts @@ -126,7 +126,7 @@ export class SkillRegistry { } const entries = await fs.readdir(cacheDir, { withFileTypes: true }); - const registries: Array<{ path: string; id: string }> = []; + const cachedRegistries: Array<{ path: string; id: string }> = []; for (const entry of entries) { if (entry.isDirectory()) { @@ -137,19 +137,37 @@ export class SkillRegistry { if (repo.isDirectory()) { const fullRegistryId = `${entry.name}/${repo.name}`; - if (!registryId || fullRegistryId === registryId) { - registries.push({ - path: path.join(ownerPath, repo.name), - id: fullRegistryId, - }); - } + cachedRegistries.push({ + path: path.join(ownerPath, repo.name), + id: fullRegistryId, + }); } } } } - if (registryId && registries.length === 0) { - throw new NotFoundError(`Registry "${registryId}" not found in cache.`, { registryId }); + cachedRegistries.sort((left, right) => left.id.localeCompare(right.id)); + + let registries = cachedRegistries; + if (registryId) { + registries = cachedRegistries.filter(registry => registry.id === registryId); + + if (registries.length === 0 && !registryId.includes('/')) { + const shorthandMatches = cachedRegistries.filter( + registry => registry.id.slice(registry.id.indexOf('/') + 1) === registryId + ); + + if (shorthandMatches.length === 1) { + registries = shorthandMatches; + ui.info(`Resolved registry "${registryId}" to "${registries[0].id}".`); + } + } + + if (registries.length === 0) { + const available = cachedRegistries.map(registry => registry.id).join(', '); + const suffix = available ? ` Available: ${available}.` : ''; + throw new NotFoundError(`Registry "${registryId}" not found in cache.${suffix}`, { registryId }); + } } const results: UpdateResult[] = [];