diff --git a/README.md b/README.md index 79add05..b939fa9 100644 --- a/README.md +++ b/README.md @@ -398,6 +398,33 @@ libscope pack create --name "react-docs" --topic react libscope pack install ./react-docs.json ``` +### Pack Registries + +Share and discover knowledge packs through git-based registries. A registry is a git repo with a defined folder structure managed by libscope. + +```bash +# Add a registry +libscope registry add https://github.com/org/libscope-registry.git --name official + +# Search for packs across all registries +libscope registry search "react" + +# Install a pack by name (resolves from registries) +libscope pack install react-docs +libscope pack install react-docs@1.2.0 # specific version + +# Create your own registry +libscope registry create ./my-registry + +# Publish a pack file to your registry +libscope registry publish ./my-pack.json -r my-registry --version 1.0.0 + +# Submit a pack to someone else's registry (creates a feature branch) +libscope registry publish ./my-pack.json -r community --submit +``` + +Authentication is delegated to git — SSH keys and HTTPS credential helpers work automatically. Registries cache locally and support offline index lookups. See the [Pack Registries guide](/guide/pack-registries) for full details. + There's also a web dashboard at `http://localhost:3377` when you run `libscope serve`, with search, document browsing, topic navigation, and a knowledge graph visualization at `/graph`.
@@ -479,6 +506,19 @@ There's also a web dashboard at `http://localhost:3377` when you run `libscope s | `libscope add-repo ` | Index a GitHub/GitLab repo | | `libscope disconnect ` | Remove connector data | +**Registries** + +| Command | Description | +| ----------------------------------------------------- | ---------------------------------------- | +| `libscope registry add [-n ]` | Register a git repo as a pack registry | +| `libscope registry remove ` | Unregister a registry | +| `libscope registry list` | List configured registries | +| `libscope registry sync []` | Sync one or all registries | +| `libscope registry search [-r ]` | Search registry pack indexes | +| `libscope registry create ` | Initialize a new registry repo | +| `libscope registry publish -r ` | Publish a pack file to a registry | +| `libscope registry unpublish -r ` | Remove a pack version from a registry | + **Utilities** | Command | Description | diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 44d8cf1..6268a16 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -56,6 +56,7 @@ export default defineConfig({ { text: "MCP Setup", link: "/guide/mcp-setup" }, { text: "Connectors", link: "/guide/connectors" }, { text: "Knowledge Packs", link: "/guide/knowledge-packs" }, + { text: "Pack Registries", link: "/guide/pack-registries" }, { text: "Programmatic Usage", link: "/guide/programmatic-usage", @@ -77,6 +78,7 @@ export default defineConfig({ { text: "CLI Commands", link: "/reference/cli" }, { text: "MCP Tools", link: "/reference/mcp-tools" }, { text: "REST API", link: "/reference/rest-api" }, + { text: "Registry", link: "/reference/registry" }, { text: "Configuration", link: "/reference/configuration" }, ], }, diff --git a/docs/guide/pack-registries.md b/docs/guide/pack-registries.md new file mode 100644 index 0000000..0323c03 --- /dev/null +++ b/docs/guide/pack-registries.md @@ -0,0 +1,121 @@ +# Pack Registries + +Pack registries are git repositories with a well-defined folder structure that serve as shared catalogs of knowledge packs. You can add public or private registries, search them for packs, and install packs directly by name. If you maintain your own registry, you can publish packs to it — or submit packs to someone else's registry via a PR workflow. + +Authentication is handled entirely by git. If you have SSH keys or an HTTPS credential helper configured, private registries work automatically. + +## Adding a Registry + +```bash +# Add a public registry +libscope registry add https://github.com/org/libscope-registry.git + +# Add with a custom alias and priority +libscope registry add git@github.com:team/internal-packs.git --name team-packs --priority 5 + +# Add with auto-sync every 24 hours +libscope registry add https://github.com/org/registry.git --sync-interval 86400 + +# Add without cloning immediately +libscope registry add https://github.com/org/registry.git --no-sync + +# List configured registries +libscope registry list + +# Remove a registry +libscope registry remove team-packs +``` + +On first add, libscope clones the registry's index locally to `~/.libscope/registries//`. Subsequent syncs fetch only changes. + +## Searching Registries + +```bash +# Search all registries +libscope registry search "react" + +# Search a specific registry +libscope registry search "react" -r official +``` + +Results show the pack name, description, tags, latest version, and which registry it came from. + +## Installing Packs from a Registry + +The existing `pack install` command now resolves packs from your configured registries: + +```bash +# Install the latest version +libscope pack install react-docs + +# Install a specific version +libscope pack install react-docs --version 1.2.0 +# or +libscope pack install react-docs@1.2.0 + +# Install from a specific registry (skips conflict resolution) +libscope pack install react-docs --registry official +``` + +If multiple registries contain a pack with the same name, libscope resolves the conflict by priority (lower `priority` value wins). You can override this with `--registry `. + +### Offline Behavior + +If a registry is unreachable during install, libscope falls back to the cached index with a warning. If the registry has never been synced, it tells you to run `libscope registry sync` when online. + +## Syncing Registries + +```bash +# Sync all registries +libscope registry sync + +# Sync a specific registry +libscope registry sync official +``` + +Registries also auto-sync when the local cache is older than the configured `syncInterval` (in seconds). This happens automatically before pack installs when the cache is stale. + +## Creating Your Own Registry + +```bash +# Initialize a new registry repo +libscope registry create ./my-registry +cd my-registry && git remote add origin && git push -u origin main +``` + +This creates a git repo with the correct folder structure (`index.json`, `packs/` directory) and an initial commit. Push it to any git host to share it. + +## Publishing Packs + +```bash +# Publish a pack file to a registry you own +libscope registry publish ./my-pack.json -r my-registry --version 1.0.0 + +# Auto-bump patch version (from latest in registry) +libscope registry publish ./my-pack.json -r my-registry + +# Submit a pack to someone else's registry (creates a feature branch) +libscope registry publish ./my-pack.json -r community --submit + +# Unpublish a specific version +libscope registry unpublish my-pack -r my-registry --version 1.0.0 +``` + +Publishing assembles the pack into the registry's folder structure, generates a SHA-256 checksum, updates `index.json` and `pack.json`, and commits + pushes. The `--submit` flag pushes to a `feature/add-` branch instead — you then create a pull request manually. + +### Checksum Validation + +Every published pack version includes a `checksum.sha256` file. On install, libscope verifies the checksum before extracting. A mismatch fails the install with a clear error. + +## Versioning + +Pack versions follow [semver](https://semver.org/) (e.g. `1.0.0`, `1.2.3`). When you publish without `--version`, the patch version is auto-bumped from the latest. Old versions are preserved in the registry. `pack install` defaults to the latest version unless you specify one. + +## MCP Usage + +Your AI assistant can also work with registries through MCP: + +- `install-pack` — install from a registry by name +- `list-packs --available` — browse packs available in registries + +See the [Registry Reference](/reference/registry) for complete schema details, configuration format, and all CLI flags. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e7f4821..2919a2b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -240,11 +240,103 @@ libscope link --type see_also --label "Background context" | Command | Description | | ------------------------------------ | ------------------------------------------------- | -| `libscope pack install ` | Install a pack (from registry or file) | +| `libscope pack install ` | Install a pack (from registry, file, or by name) | | `libscope pack remove ` | Remove a pack and its documents | | `libscope pack list` | List installed packs (`--available` for registry) | | `libscope pack create` | Export documents as a pack file | +### `libscope pack install` (registry support) + +When no local file path is given, `pack install` searches configured registries: + +```bash +libscope pack install react-docs # latest from any registry +libscope pack install react-docs@1.2.0 # specific version +libscope pack install react-docs --version 1.2.0 +libscope pack install react-docs --registry official +``` + +| Option | Description | +| -------------------- | -------------------------------------------- | +| `--version ` | Install a specific version (default: latest) | +| `--registry ` | Install from a specific registry | + +## Pack Registries + +| Command | Description | +| ---------------------------------------------------------- | -------------------------------------- | +| `libscope registry add [-n ]` | Register a git repo as a pack registry | +| `libscope registry remove [-y]` | Unregister a registry | +| `libscope registry list` | List configured registries | +| `libscope registry sync []` | Sync one or all registries | +| `libscope registry search [-r ]` | Search registry pack indexes | +| `libscope registry create ` | Initialize a new registry repo | +| `libscope registry publish -r ` | Publish a pack file to a registry | +| `libscope registry unpublish -r --version ` | Remove a pack version from a registry | + +### `libscope registry add` + +```bash +libscope registry add https://github.com/org/registry.git +libscope registry add git@github.com:team/packs.git --name team --priority 5 +libscope registry add https://github.com/org/registry.git --sync-interval 86400 --no-sync +``` + +| Option | Description | +| ---------------------------- | -------------------------------------------------------- | +| `-n, --name ` | Short name for this registry (default: inferred from URL)| +| `--priority ` | Conflict resolution priority — lower wins (default: 10) | +| `--sync-interval ` | Auto-sync interval in seconds, 0 = manual (default: 0) | +| `--no-sync` | Skip initial sync after adding | + +### `libscope registry search` + +```bash +libscope registry search "react" +libscope registry search "kubernetes" -r official +``` + +### `libscope registry publish` + +```bash +# Direct publish (you have write access) +libscope registry publish ./my-pack.json -r my-registry --version 1.0.0 + +# Auto-bump patch version +libscope registry publish ./my-pack.json -r my-registry + +# Submit via feature branch (for PR workflow) +libscope registry publish ./my-pack.json -r community --submit +``` + +| Option | Description | +| ------------------------ | -------------------------------------------------------- | +| `-r, --registry ` | Target registry (required) | +| `--version ` | Version to publish as (default: auto-bump patch) | +| `-m, --message ` | Git commit message | +| `--submit` | Push to a feature branch instead of main | + +### `libscope registry unpublish` + +```bash +libscope registry unpublish my-pack -r my-registry --version 1.0.0 +``` + +| Option | Description | +| ------------------------ | --------------------------------- | +| `-r, --registry ` | Target registry (required) | +| `--version ` | Version to remove (required) | +| `-m, --message ` | Git commit message | +| `-y, --yes` | Skip confirmation prompt | + +### `libscope registry create` + +```bash +libscope registry create ./my-registry +``` + +Creates a git repo with the canonical registry folder structure. See the [Registry Reference](/reference/registry) for full schema details. + ## Connectors | Command | Description | diff --git a/docs/reference/registry.md b/docs/reference/registry.md new file mode 100644 index 0000000..d5b6a56 --- /dev/null +++ b/docs/reference/registry.md @@ -0,0 +1,358 @@ +# Registry Reference + +Complete reference for the git-based pack registry feature. + +## CLI Commands + +### `libscope registry add` + +Register a git repository as a pack registry. + +```bash +libscope registry add [options] +``` + +| Option | Description | +| ----------------------------- | -------------------------------------------------------- | +| `` | Git clone URL (HTTPS or SSH) | +| `-n, --name ` | Short name for this registry (default: inferred from URL)| +| `--priority ` | Priority for conflict resolution — lower wins (default: 10) | +| `--sync-interval ` | Auto-sync interval in seconds, 0 = manual only (default: 0) | +| `--no-sync` | Skip the initial sync after adding | + +```bash +# Examples +libscope registry add https://github.com/org/registry.git +libscope registry add git@github.com:team/packs.git --name team --priority 5 +libscope registry add https://github.com/org/registry.git --sync-interval 86400 +``` + +### `libscope registry remove` + +Unregister a registry and delete its local cache. + +```bash +libscope registry remove [-y, --yes] +``` + +| Option | Description | +| ------------ | -------------------------- | +| `-y, --yes` | Skip confirmation prompt | + +### `libscope registry list` + +List all configured registries with their sync status. + +```bash +libscope registry list +``` + +Output includes: name, URL, priority, pack count, and last synced timestamp. + +### `libscope registry sync` + +Manually sync one or all registries (git fetch + fast-forward). + +```bash +libscope registry sync [] +``` + +Without a name, syncs all registries. With a name, syncs only that registry. + +### `libscope registry search` + +Search across cached registry indexes. + +```bash +libscope registry search [-r, --registry ] +``` + +| Option | Description | +| --------------------- | ---------------------------------------------- | +| `` | Search term (matches name, description, tags, author) | +| `-r, --registry ` | Limit search to a specific registry | + +```bash +# Examples +libscope registry search "react" +libscope registry search "kubernetes" -r official +``` + +### `libscope registry create` + +Initialize a new empty registry repo with the correct folder structure. + +```bash +libscope registry create +``` + +Creates a git repo with: +- `index.json` — empty pack index (JSON array) +- `packs/` — directory for pack contents (with `.gitkeep`) +- An initial commit + +### `libscope registry publish` + +Publish a pack file to a registry. + +```bash +libscope registry publish -r [options] +``` + +| Option | Description | +| ----------------------- | -------------------------------------------------------- | +| `` | Path to the pack `.json` file to publish | +| `-r, --registry ` | Target registry (required) | +| `--version ` | Version to publish as (default: auto-bump patch) | +| `-m, --message ` | Git commit message | +| `--submit` | Push to a feature branch instead of main (for PR workflow) | + +**Direct publish** (you have write access): +```bash +libscope registry publish ./react-docs.json -r my-registry --version 1.0.0 +``` + +**Submit for inclusion** (you don't have write access): +```bash +libscope registry publish ./react-docs.json -r community --submit +``` + +The `--submit` flag creates a `feature/add-` branch and pushes it. You then create a pull request manually. + +### `libscope registry unpublish` + +Remove a specific pack version from a registry. + +```bash +libscope registry unpublish -r --version [options] +``` + +| Option | Description | +| ----------------------- | --------------------------------- | +| `` | Name of the pack to unpublish | +| `-r, --registry ` | Target registry (required) | +| `--version ` | Version to remove (required) | +| `-m, --message ` | Git commit message | +| `-y, --yes` | Skip confirmation prompt | + +If the last version of a pack is unpublished, the entire pack is removed from the registry index. + +### `libscope pack install` (extended) + +The existing `pack install` command is extended to resolve packs from registries. + +```bash +libscope pack install [--version ] [--registry ] +``` + +| Option | Description | +| -------------------- | ---------------------------------------------- | +| `--version ` | Install a specific version (default: latest) | +| `--registry ` | Install from a specific registry | + +```bash +# Install latest from any registry +libscope pack install react-docs + +# Install specific version +libscope pack install react-docs@1.2.0 +libscope pack install react-docs --version 1.2.0 + +# Install from a specific registry +libscope pack install react-docs --registry official +``` + +--- + +## Registry Folder Structure + +A registry repo has this canonical structure (managed by libscope, never hand-edited): + +``` +registry-root/ + index.json # Top-level index — JSON array of PackSummary + packs/ + / + pack.json # Full pack metadata + version history + 1.0.0/ + .json # The actual knowledge pack file + checksum.sha256 # SHA-256 checksum of the pack file + 1.1.0/ + .json + checksum.sha256 +``` + +--- + +## Schema: `index.json` + +A JSON array of pack summaries for fast search without traversing subdirectories. + +```json +[ + { + "name": "react-docs", + "description": "Official React documentation", + "tags": ["react", "frontend", "javascript"], + "latestVersion": "2.1.0", + "author": "react-team", + "updatedAt": "2026-03-10T14:30:00Z" + }, + { + "name": "kubernetes-ops", + "description": "Kubernetes operations runbooks", + "tags": ["kubernetes", "devops", "infrastructure"], + "latestVersion": "1.0.0", + "author": "platform-eng", + "updatedAt": "2026-02-28T09:00:00Z" + } +] +``` + +| Field | Type | Description | +| ---------------- | ---------- | ---------------------------------------- | +| `name` | `string` | Pack name (unique within the registry) | +| `description` | `string` | One-line description | +| `tags` | `string[]` | Tags/categories for search filtering | +| `latestVersion` | `string` | Latest published semver version | +| `author` | `string` | Author name or handle | +| `updatedAt` | `string` | ISO-8601 timestamp of last publish | + +## Schema: `pack.json` + +Per-pack manifest with full metadata and version history. + +```json +{ + "name": "react-docs", + "description": "Official React documentation", + "tags": ["react", "frontend", "javascript"], + "author": "react-team", + "license": "MIT", + "versions": [ + { + "version": "2.1.0", + "publishedAt": "2026-03-10T14:30:00Z", + "checksumPath": "2.1.0/checksum.sha256", + "checksum": "a1b2c3d4e5f6...", + "docCount": 42 + }, + { + "version": "2.0.0", + "publishedAt": "2026-02-15T10:00:00Z", + "checksumPath": "2.0.0/checksum.sha256", + "checksum": "f6e5d4c3b2a1...", + "docCount": 38 + } + ] +} +``` + +| Field | Type | Description | +| ------------------------ | ---------- | ---------------------------------------------- | +| `name` | `string` | Pack name | +| `description` | `string` | One-line description | +| `tags` | `string[]` | Tags/categories | +| `author` | `string` | Author name or handle | +| `license` | `string` | License identifier (e.g. "MIT") | +| `versions[].version` | `string` | Semver version string | +| `versions[].publishedAt` | `string` | ISO-8601 publish timestamp | +| `versions[].checksumPath`| `string` | Relative path to the checksum file | +| `versions[].checksum` | `string` | SHA-256 checksum hex value | +| `versions[].docCount` | `number` | Number of documents in this version | + +Versions are ordered newest first. + +--- + +## Configuration + +Registries are stored in `~/.libscope/config.json` under the `registries` key: + +```json +{ + "registries": [ + { + "name": "official", + "url": "git@github.com:org/libscope-registry.git", + "syncInterval": 86400, + "priority": 10, + "lastSyncedAt": "2026-03-10T14:30:00Z" + }, + { + "name": "team", + "url": "https://github.com/team/internal-packs.git", + "syncInterval": 0, + "priority": 5, + "lastSyncedAt": null + } + ] +} +``` + +| Field | Type | Description | Default | +| -------------- | ----------------- | -------------------------------------------------- | ------- | +| `name` | `string` | Local alias for the registry | — | +| `url` | `string` | Git clone URL (HTTPS or SSH) | — | +| `syncInterval` | `number` | Auto-sync interval in seconds (0 = manual only) | `0` | +| `priority` | `number` | Conflict resolution priority — lower wins | `10` | +| `lastSyncedAt` | `string \| null` | ISO-8601 timestamp of last sync, null if never | `null` | + +You can edit this file directly or use `libscope registry add/remove`. + +--- + +## Authentication + +libscope delegates all authentication to git. No special auth configuration is needed. + +- **SSH**: If you have SSH keys configured (`~/.ssh/id_rsa`, `~/.ssh/id_ed25519`, or via ssh-agent), SSH URLs (`git@github.com:...`) work automatically. +- **HTTPS**: If you have a git credential helper configured (`git config credential.helper`), HTTPS URLs work automatically. GitHub CLI (`gh auth setup-git`), macOS Keychain, and Windows Credential Manager are all supported. + +To test access: `git ls-remote `. If that works, libscope will too. + +--- + +## Offline Behavior + +Registries cache their index locally at `~/.libscope/registries//`. + +| Scenario | Behavior | +| ------------------------------------- | ------------------------------------------------------------------------------------------ | +| Registry unreachable, cache exists | Uses cached index with warning: "Registry '\' is unreachable. Using cached index from \." | +| Registry unreachable, no cache | Fails with: "Registry '\' has never been synced and is unreachable." | +| Cache stale, registry reachable | Auto-syncs before proceeding | + +Pack content downloads still require network access — only the index lookup can work offline. + +--- + +## Checksum Validation + +Every pack version includes a `checksum.sha256` file containing the SHA-256 hex hash of the pack file. + +- **On publish**: libscope generates the checksum automatically and writes it alongside the pack file. +- **On install**: libscope verifies the checksum before extracting. A mismatch fails with: "Checksum verification failed — the pack file may have been tampered with or corrupted." + +--- + +## Versioning + +Pack versions follow [semver](https://semver.org/): + +- Versions must be valid semver strings (e.g. `1.0.0`, `2.3.1`) +- `pack install ` installs the latest version +- `pack install @1.0.0` or `--version 1.0.0` installs a specific version +- Old versions are preserved in the registry — publishing a new version does not remove previous ones +- When publishing without `--version`, the patch version is auto-bumped from the latest +- The `latestVersion` in `index.json` always points to the most recently published version + +## Conflict Resolution + +When multiple registries contain a pack with the same name: + +- **Priority-based** (default): the registry with the lowest `priority` value wins +- **Explicit**: use `--registry ` to specify which registry to use +- **Interactive**: when running in a terminal without `--registry`, libscope prompts you to choose + +In non-interactive / CI mode, conflicts without `--registry` fail with an actionable error. diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts new file mode 100644 index 0000000..6c5cee4 --- /dev/null +++ b/src/cli/commands/registry.ts @@ -0,0 +1,414 @@ +/** + * CLI commands for managing pack registries. + * Registered as `libscope registry `. + */ + +import type { Command } from "commander"; +import { rmSync } from "node:fs"; +import { resolve as pathResolve } from "node:path"; +import { + loadRegistries, + addRegistry, + removeRegistry, + validateRegistryName, + validateGitUrl, +} from "../../registry/config.js"; +import { + cloneRegistry, + readIndex, + createRegistryRepo, + checkGitAvailable, +} from "../../registry/git.js"; +import { getRegistryCacheDir } from "../../registry/types.js"; +import { syncRegistryByName, syncAllRegistries } from "../../registry/sync.js"; +import { searchRegistries } from "../../registry/search.js"; +import { publishPack, publishPackToBranch, unpublishPack } from "../../registry/publish.js"; +import { confirmAction } from "../confirm.js"; + +/** Derive a short name from a git URL (e.g. "github.com/org/repo" → "repo"). */ +function deriveNameFromUrl(url: string): string { + // Handle SSH format: git@github.com:org/repo.git + const sshMatch = url.match(/:([^/]+\/)?([^/.]+?)(?:\.git)?$/); + if (sshMatch?.[2]) return sshMatch[2]; + // Handle HTTPS format + try { + const parsed = new URL(url); + const segments = parsed.pathname.split("/").filter(Boolean); + const last = segments[segments.length - 1] ?? "registry"; + return last.replace(/\.git$/, ""); + } catch { + return "registry"; + } +} + +/** Truncate a string to a max length, adding "..." if truncated. */ +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 3) + "..."; +} + +/** Pad columns for table output. */ +function padColumns(cols: string[]): string { + const widths = [24, 42, 22, 10, 16]; + return cols.map((col, i) => col.padEnd(widths[i] ?? 16)).join(" "); +} + +/** Register all `registry` subcommands on the given Commander program. */ +export function registerRegistryCommands(program: Command): void { + const registryCmd = program + .command("registry") + .description("Manage pack registries (git-backed)"); + + // --- registry add --- + registryCmd + .command("add ") + .description("Add a git-backed pack registry") + .option("-n, --name ", "Short name for this registry") + .option("--priority ", "Priority for conflict resolution (lower wins, default: 10)", "10") + .option("--sync-interval ", "Auto-sync interval in seconds (0 = manual)", "0") + .option("--no-sync", "Skip the initial sync after adding") + .action( + async ( + url: string, + opts: { + name?: string; + priority: string; + syncInterval: string; + sync: boolean; + }, + ) => { + if (!(await checkGitAvailable())) { + console.error("Error: git is not installed or not in PATH."); + process.exit(1); + return; + } + + const name = opts.name ?? deriveNameFromUrl(url); + const priority = parseInt(opts.priority, 10); + const syncInterval = parseInt(opts.syncInterval, 10); + + if (isNaN(priority) || priority < 0) { + console.error('Error: "--priority" must be a non-negative integer.'); + process.exit(1); + return; + } + if (isNaN(syncInterval) || syncInterval < 0) { + console.error('Error: "--sync-interval" must be a non-negative integer.'); + process.exit(1); + return; + } + + try { + validateRegistryName(name); + validateGitUrl(url); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + return; + } + + try { + addRegistry({ + name, + url, + syncInterval, + priority, + lastSyncedAt: null, + }); + + console.log(`Registry "${name}" added (${url}).`); + + // Initial sync + if (opts.sync !== false) { + const cacheDir = getRegistryCacheDir(name); + console.log(`Cloning registry to ${cacheDir}...`); + try { + await cloneRegistry(url, cacheDir); + const index = readIndex(cacheDir); + console.log(`Synced: ${index.length} pack(s) available.`); + } catch (syncErr) { + console.warn( + `Warning: initial sync failed (${syncErr instanceof Error ? syncErr.message : String(syncErr)}). ` + + 'You can retry with "libscope registry sync".', + ); + } + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }, + ); + + // --- registry remove --- + registryCmd + .command("remove ") + .description("Remove a registry and delete its local cache") + .option("-y, --yes", "Skip confirmation prompt") + .action(async (name: string, opts: { yes?: boolean }) => { + if ( + !(await confirmAction( + `Remove registry "${name}" and its local cache? This cannot be undone.`, + !!opts.yes, + )) + ) { + console.log("Cancelled."); + return; + } + + try { + removeRegistry(name); + + // Delete local cache + const cacheDir = getRegistryCacheDir(name); + try { + rmSync(cacheDir, { recursive: true, force: true }); + } catch (rmErr) { + console.warn( + `Warning: could not remove cache directory (${rmErr instanceof Error ? rmErr.message : String(rmErr)})`, + ); + } + + console.log(`Registry "${name}" removed.`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + + // --- registry list --- + registryCmd + .command("list") + .description("List all configured registries") + .action(() => { + const registries = loadRegistries(); + if (registries.length === 0) { + console.log("No registries configured. Use 'libscope registry add ' to add one."); + return; + } + + console.log("Configured registries:\n"); + for (const reg of registries) { + const syncInfo = reg.lastSyncedAt ? `last synced ${reg.lastSyncedAt}` : "never synced"; + + // Try to read index to get pack count + let packCount = "?"; + try { + const cacheDir = getRegistryCacheDir(reg.name); + const index = readIndex(cacheDir); + packCount = String(index.length); + } catch { + // Cache doesn't exist yet — packCount remains "?" + } + + console.log( + ` ${reg.name} — ${reg.url} (priority: ${reg.priority}, ${packCount} packs, ${syncInfo})`, + ); + } + }); + + // --- registry sync --- + registryCmd + .command("sync [name]") + .description("Sync one or all registries (git fetch + fast-forward)") + .action(async (name?: string) => { + if (!(await checkGitAvailable())) { + console.error("Error: git is not installed or not in PATH."); + process.exit(1); + return; + } + + if (name) { + const status = await syncRegistryByName(name); + if (status.status === "error") { + console.error(`Error: ${status.error}`); + process.exit(1); + return; + } + if (status.status === "offline") { + console.warn(`Warning: ${status.error}`); + console.warn( + `Registry "${name}" is unreachable. Using cached index from ${status.lastSyncedAt ?? "unknown"}.`, + ); + } else { + const cacheDir = getRegistryCacheDir(name); + const index = readIndex(cacheDir); + console.log(`Registry "${name}" synced: ${index.length} pack(s) available.`); + } + } else { + const results = await syncAllRegistries(); + if (results.length === 0) { + console.log("No registries configured."); + return; + } + for (const status of results) { + if (status.status === "success") { + const cacheDir = getRegistryCacheDir(status.registryName); + const index = readIndex(cacheDir); + console.log(` ${status.registryName}: synced (${index.length} packs)`); + } else if (status.status === "offline") { + console.warn(` ${status.registryName}: offline (using cached data)`); + } else { + console.error(` ${status.registryName}: error — ${status.error}`); + } + } + } + }); + + // --- registry search --- + registryCmd + .command("search ") + .description("Search for packs across all configured registries") + .option("-r, --registry ", "Search only in a specific registry") + .action((query: string, opts: { registry?: string }) => { + const { results, warnings } = searchRegistries(query, { + registryName: opts.registry, + }); + + for (const w of warnings) { + console.warn(`Warning: ${w}`); + } + + if (results.length === 0) { + console.log(`No packs found matching "${query}".`); + return; + } + + console.log(`Found ${results.length} pack(s) matching "${query}":\n`); + + // Column header + const header = padColumns(["Pack", "Description", "Tags", "Version", "Registry"]); + console.log(header); + console.log("-".repeat(header.length)); + + for (const r of results) { + const tags = r.pack.tags.length > 0 ? r.pack.tags.join(", ") : "-"; + console.log( + padColumns([ + r.pack.name, + truncate(r.pack.description, 40), + truncate(tags, 20), + r.pack.latestVersion, + r.registryName, + ]), + ); + } + }); + + // --- registry create --- + registryCmd + .command("create ") + .description("Initialize a new registry git repo with canonical folder structure") + .action(async (rawPath: string) => { + if (!(await checkGitAvailable())) { + console.error("Error: git is not installed or not in PATH."); + process.exit(1); + return; + } + + const resolved = pathResolve(rawPath); + try { + await createRegistryRepo(resolved); + console.log(`Registry repo initialized at ${resolved}`); + console.log("Push to a git remote, then add it with 'libscope registry add '."); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }); + + // --- registry publish --- + registryCmd + .command("publish ") + .description("Publish a pack file to a registry") + .requiredOption("-r, --registry ", "Target registry name") + .option("--version ", "Version to publish as (default: auto-bump patch)") + .option("-m, --message ", "Git commit message") + .option("--submit", "Push to a feature branch instead of main (for PR workflow)") + .action( + async ( + packFile: string, + opts: { registry: string; version?: string; message?: string; submit?: boolean }, + ) => { + if (!(await checkGitAvailable())) { + console.error("Error: git is not installed or not in PATH."); + process.exit(1); + return; + } + + const resolved = pathResolve(packFile); + + try { + if (opts.submit) { + const result = await publishPackToBranch({ + registryName: opts.registry, + packFilePath: resolved, + version: opts.version, + commitMessage: opts.message, + }); + console.log( + `Pack "${result.packName}@${result.version}" pushed to branch "${result.branch}".`, + ); + console.log("Create a pull request to merge it into the registry."); + } else { + const result = await publishPack({ + registryName: opts.registry, + packFilePath: resolved, + version: opts.version, + commitMessage: opts.message, + }); + console.log( + `Pack "${result.packName}@${result.version}" published to "${result.registryName}" (checksum: ${result.checksum.slice(0, 12)}...).`, + ); + } + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }, + ); + + // --- registry unpublish --- + registryCmd + .command("unpublish ") + .description("Remove a pack version from a registry") + .requiredOption("-r, --registry ", "Target registry name") + .requiredOption("--version ", "Version to unpublish") + .option("-m, --message ", "Git commit message") + .option("-y, --yes", "Skip confirmation prompt") + .action( + async ( + packName: string, + opts: { registry: string; version: string; message?: string; yes?: boolean }, + ) => { + if (!(await checkGitAvailable())) { + console.error("Error: git is not installed or not in PATH."); + process.exit(1); + return; + } + + if ( + !(await confirmAction( + `Unpublish "${packName}@${opts.version}" from "${opts.registry}"? This cannot be undone.`, + !!opts.yes, + )) + ) { + console.log("Cancelled."); + return; + } + + try { + await unpublishPack({ + registryName: opts.registry, + packName, + version: opts.version, + commitMessage: opts.message, + }); + console.log(`Pack "${packName}@${opts.version}" unpublished from "${opts.registry}".`); + } catch (err) { + console.error(`Error: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + }, + ); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 6daa1ba..6d1e1af 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -93,6 +93,10 @@ import { signPayload, } from "../core/webhooks.js"; import type { WebhookEvent } from "../core/webhooks.js"; +import { registerRegistryCommands } from "./commands/registry.js"; +import { parsePackSpecifier, resolvePackFromRegistries } from "../registry/resolve.js"; +import { loadRegistries } from "../registry/config.js"; +import { createInterface } from "node:readline"; // Graceful shutdown const handleShutdown = (): void => { @@ -1614,15 +1618,29 @@ const packCmd = program.command("pack").description("Manage knowledge packs"); packCmd .command("install ") - .description("Install a knowledge pack from registry or local .json/.json.gz file") - .option("--registry ", "Custom registry URL") + .description( + "Install a knowledge pack from a git registry, URL registry, or local .json/.json.gz file. " + + "Supports name@version syntax.", + ) + .option("--registry ", "Custom registry URL (for URL-based registries)") + .option("--from-registry ", "Install from a specific git registry by name") + .option("--version ", "Install a specific version (for git registries)") + .option("-y, --yes", "Non-interactive mode (fail on conflicts instead of prompting)") .option("--batch-size ", "Number of documents to embed per batch (default: 10)") .option("--resume-from ", "Skip the first N documents (resume a partial install)") .option("--concurrency ", "Number of batches to embed in parallel (default: 4)") .action( async ( nameOrPath: string, - opts: { registry?: string; batchSize?: string; resumeFrom?: string; concurrency?: string }, + opts: { + registry?: string; + fromRegistry?: string; + version?: string; + yes?: boolean; + batchSize?: string; + resumeFrom?: string; + concurrency?: string; + }, ) => { const { db, provider } = initializeAppWithEmbedding(); const globalOpts = program.opts(); @@ -1644,6 +1662,122 @@ packCmd } try { + // Check if this is a local file or URL-based registry install + const isLocalFile = nameOrPath.endsWith(".json") || nameOrPath.endsWith(".json.gz"); + + // Try git registry resolution if not a local file and we have registries configured + if (!isLocalFile && loadRegistries().length > 0) { + const { name: packName, version: specVersion } = parsePackSpecifier(nameOrPath); + const version = opts.version ?? specVersion; + + const { resolved, conflict, warnings } = resolvePackFromRegistries(packName, { + version, + registryName: opts.fromRegistry, + conflictResolution: opts.fromRegistry + ? { strategy: "explicit", registryName: opts.fromRegistry } + : opts.yes + ? { strategy: "priority" } + : undefined, + }); + + for (const w of warnings) { + reporter.log(`Warning: ${w}`); + } + + if (conflict && !resolved) { + // Multiple registries have this pack — prompt or fail + if (opts.yes) { + const names = conflict.sources.map((s) => s.registryName).join(", "); + reporter.log( + `Error: Pack "${packName}" found in multiple registries: ${names}. ` + + "Use --from-registry to disambiguate.", + ); + closeDatabase(); + process.exit(1); + return; + } + + // Interactive prompt + console.log(`Pack "${packName}" found in multiple registries:`); + for (let i = 0; i < conflict.sources.length; i++) { + const s = conflict.sources[i]!; + console.log( + ` [${i + 1}] ${s.registryName} (v${s.version}, priority: ${s.priority})`, + ); + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((resolve) => { + rl.question("Select registry [1]: ", (ans) => { + rl.close(); + resolve(ans.trim() || "1"); + }); + }); + + const choice = parseInt(answer, 10) - 1; + if (isNaN(choice) || choice < 0 || choice >= conflict.sources.length) { + reporter.log("Invalid selection. Cancelled."); + closeDatabase(); + process.exit(1); + return; + } + + const chosen = conflict.sources[choice]!; + const retryResult = resolvePackFromRegistries(packName, { + version, + registryName: chosen.registryName, + conflictResolution: { strategy: "explicit", registryName: chosen.registryName }, + }); + + if (retryResult.resolved) { + // Install from resolved local path + const result = await installPack(db, provider, retryResult.resolved.dataPath, { + batchSize, + resumeFrom, + concurrency, + onProgress: (current, total, docTitle) => { + reporter.progress(current, total, docTitle); + }, + }); + reporter.clearProgress(); + if (result.alreadyInstalled) { + reporter.log(`Pack "${result.packName}" is already installed.`); + } else { + const errMsg = result.errors > 0 ? ` (${result.errors} errors)` : ""; + reporter.success( + `Pack "${result.packName}" installed from ${chosen.registryName}: ${result.documentsInstalled} documents${errMsg}.`, + ); + } + return; + } + } + + if (resolved) { + // Install from resolved local path + const result = await installPack(db, provider, resolved.dataPath, { + batchSize, + resumeFrom, + concurrency, + onProgress: (current, total, docTitle) => { + reporter.progress(current, total, docTitle); + }, + }); + reporter.clearProgress(); + if (result.alreadyInstalled) { + reporter.log(`Pack "${result.packName}" is already installed.`); + } else { + const errMsg = result.errors > 0 ? ` (${result.errors} errors)` : ""; + reporter.success( + `Pack "${result.packName}" installed from ${resolved.registryName}: ${result.documentsInstalled} documents${errMsg}.`, + ); + } + return; + } + + // Fall through to URL-based registry install if git registry resolution failed + } + + // Original URL-based or local file install const result = await installPack(db, provider, nameOrPath, { registryUrl: opts.registry, batchSize, @@ -2667,4 +2801,7 @@ scheduleCmd console.log(`✓ Schedule removed for ${connector}`); }); +// Registry commands +registerRegistryCommands(program); + program.parse(); diff --git a/src/registry/checksum.ts b/src/registry/checksum.ts new file mode 100644 index 0000000..f62557b --- /dev/null +++ b/src/registry/checksum.ts @@ -0,0 +1,71 @@ +/** + * Checksum generation and verification for registry packs. + * Uses SHA-256 of sorted file contents for deterministic hashing. + */ + +import { createHash } from "node:crypto"; +import { createReadStream, readFileSync, writeFileSync, existsSync } from "node:fs"; +import { ValidationError } from "../errors.js"; +import { getLogger } from "../logger.js"; + +/** + * Compute SHA-256 checksum of a pack file's contents using streaming. + * Pipes the file through crypto.createHash to avoid loading the entire file into memory. + */ +export async function computeChecksum(filePath: string): Promise { + if (!existsSync(filePath)) { + throw new ValidationError(`File not found: ${filePath}`); + } + return new Promise((resolve, reject) => { + const hash = createHash("sha256"); + const stream = createReadStream(filePath); + stream.on("data", (chunk: Buffer) => hash.update(chunk)); + stream.on("end", () => resolve(hash.digest("hex"))); + stream.on("error", reject); + }); +} + +/** + * Compute a deterministic SHA-256 checksum from a JSON pack object. + * Sorts keys to ensure deterministic output regardless of property order. + */ +export function computePackChecksum(packData: unknown): string { + const json = JSON.stringify(packData, Object.keys(packData as object).sort(), 0); + return createHash("sha256").update(json, "utf-8").digest("hex"); +} + +/** + * Write a checksum file at the given path. + */ +export function writeChecksumFile(checksumPath: string, checksum: string): void { + writeFileSync(checksumPath, checksum + "\n", "utf-8"); +} + +/** + * Read a checksum from a checksum file. + */ +export function readChecksumFile(checksumPath: string): string | null { + if (!existsSync(checksumPath)) return null; + return readFileSync(checksumPath, "utf-8").trim(); +} + +/** + * Verify a pack file against its expected checksum. + * Returns true if valid, throws on mismatch. + */ +export async function verifyChecksum(filePath: string, expectedChecksum: string): Promise { + const log = getLogger(); + const actual = await computeChecksum(filePath); + + if (actual !== expectedChecksum) { + log.error({ filePath, expected: expectedChecksum, actual }, "Checksum verification failed"); + throw new ValidationError( + `Checksum verification failed for "${filePath}": ` + + `expected ${expectedChecksum}, got ${actual}. ` + + "The pack file may have been tampered with or corrupted.", + ); + } + + log.debug({ filePath, checksum: actual }, "Checksum verified"); + return true; +} diff --git a/src/registry/config.ts b/src/registry/config.ts new file mode 100644 index 0000000..08086ae --- /dev/null +++ b/src/registry/config.ts @@ -0,0 +1,113 @@ +/** + * Registry configuration management. + * Reads/writes the "registries" array in ~/.libscope/config.json. + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { ConfigError, ValidationError } from "../errors.js"; +import { getLogger } from "../logger.js"; +import type { RegistryEntry } from "./types.js"; + +/** Path to the user config file. */ +function getUserConfigPath(): string { + return join(homedir(), ".libscope", "config.json"); +} + +/** Validate a registry name (alphanumeric, hyphens, underscores). */ +export function validateRegistryName(name: string): void { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new ValidationError(`Invalid registry name "${name}": must match /^[a-zA-Z0-9_-]+$/`); + } +} + +/** Validate a git URL (https or ssh). */ +export function validateGitUrl(url: string): void { + // Accept https:// URLs and SSH-style git@host:path URLs + const isHttps = url.startsWith("https://"); + const isSsh = /^git@[\w.-]+:/.test(url); + if (!isHttps && !isSsh) { + throw new ValidationError("Registry URL must use https:// or SSH (git@host:path) format"); + } +} + +/** Read the raw config JSON from disk. */ +function readRawConfig(): Record { + const configPath = getUserConfigPath(); + if (!existsSync(configPath)) return {}; + try { + const raw = readFileSync(configPath, "utf-8"); + return JSON.parse(raw) as Record; + } catch (err) { + throw new ConfigError("Failed to read config file", err); + } +} + +/** Write the raw config JSON to disk, preserving all other keys. */ +function writeRawConfig(config: Record): void { + const dir = join(homedir(), ".libscope"); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(getUserConfigPath(), JSON.stringify(config, null, 2), "utf-8"); +} + +/** Load all registry entries from config. */ +export function loadRegistries(): RegistryEntry[] { + const config = readRawConfig(); + const registries = config["registries"]; + if (!Array.isArray(registries)) return []; + return registries as RegistryEntry[]; +} + +/** Save registry entries to config (merges with existing config keys). */ +export function saveRegistries(registries: RegistryEntry[]): void { + const config = readRawConfig(); + config["registries"] = registries; + writeRawConfig(config); +} + +/** Find a registry by name. Returns undefined if not found. */ +export function getRegistry(name: string): RegistryEntry | undefined { + return loadRegistries().find((r) => r.name === name); +} + +/** Add a new registry entry. Throws if name already exists. */ +export function addRegistry(entry: RegistryEntry): void { + const log = getLogger(); + validateRegistryName(entry.name); + validateGitUrl(entry.url); + + const registries = loadRegistries(); + if (registries.some((r) => r.name === entry.name)) { + throw new ValidationError(`Registry "${entry.name}" already exists`); + } + + registries.push(entry); + saveRegistries(registries); + log.info({ registry: entry.name, url: entry.url }, "Registry added to config"); +} + +/** Remove a registry entry by name. Throws if not found. */ +export function removeRegistry(name: string): void { + const log = getLogger(); + const registries = loadRegistries(); + const index = registries.findIndex((r) => r.name === name); + if (index === -1) { + throw new ValidationError(`Registry "${name}" not found`); + } + registries.splice(index, 1); + saveRegistries(registries); + log.info({ registry: name }, "Registry removed from config"); +} + +/** Update the lastSyncedAt timestamp for a registry. */ +export function updateRegistrySyncTime(name: string): void { + const registries = loadRegistries(); + const entry = registries.find((r) => r.name === name); + if (entry) { + entry.lastSyncedAt = new Date().toISOString(); + saveRegistries(registries); + } +} diff --git a/src/registry/git.ts b/src/registry/git.ts new file mode 100644 index 0000000..6f3a64c --- /dev/null +++ b/src/registry/git.ts @@ -0,0 +1,147 @@ +/** + * Low-level git helpers for the registry feature. + * Uses child_process.execFile exclusively — no shell interpolation. + */ + +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { getLogger } from "../logger.js"; +import { FetchError, ValidationError } from "../errors.js"; +import type { PackSummary } from "./types.js"; +import { INDEX_FILE, PACKS_DIR } from "./types.js"; + +const execFile = promisify(execFileCb); + +/** Default timeout for git operations (60 seconds). */ +const GIT_TIMEOUT_MS = 60_000; + +/** Execute a git command safely via execFile. */ +export async function git( + args: string[], + options?: { cwd?: string; timeout?: number }, +): Promise { + const log = getLogger(); + const cwd = options?.cwd; + const timeout = options?.timeout ?? GIT_TIMEOUT_MS; + + log.debug({ args, cwd }, "Running git command"); + + try { + const { stdout } = await execFile("git", args, { cwd, timeout }); + return stdout.trim(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error({ args, cwd, err: message }, "Git command failed"); + throw new FetchError(`Git command failed: git ${args.join(" ")}: ${message}`); + } +} + +/** Clone a git repository to a destination directory. */ +export async function cloneRegistry(url: string, dest: string): Promise { + const log = getLogger(); + log.info({ url, dest }, "Cloning registry"); + await git(["clone", "--depth", "1", url, dest]); +} + +/** Fetch latest changes for an already-cloned registry. */ +export async function fetchRegistry(cachedPath: string): Promise { + const log = getLogger(); + log.info({ cachedPath }, "Fetching registry updates"); + await git(["fetch", "--depth", "1", "origin"], { cwd: cachedPath }); + await git(["reset", "--hard", "origin/HEAD"], { cwd: cachedPath }); +} + +/** + * In-memory cache of parsed index.json files, keyed by cache directory path. + * Avoids re-reading and re-parsing from disk on every search/resolve call + * within a single CLI session. + */ +const indexCache = new Map(); + +/** Clear the in-memory index cache (e.g. after a sync updates the files on disk). */ +export function clearIndexCache(cachedPath?: string): void { + if (cachedPath) { + indexCache.delete(cachedPath); + } else { + indexCache.clear(); + } +} + +/** Read and parse the index.json from a local registry cache. */ +export function readIndex(cachedPath: string): PackSummary[] { + const cached = indexCache.get(cachedPath); + if (cached) return cached; + + const indexPath = join(cachedPath, INDEX_FILE); + if (!existsSync(indexPath)) { + return []; + } + try { + const raw = readFileSync(indexPath, "utf-8"); + const data: unknown = JSON.parse(raw); + if (!Array.isArray(data)) { + throw new ValidationError("Registry index.json is not an array"); + } + const result = data as PackSummary[]; + indexCache.set(cachedPath, result); + return result; + } catch (err) { + if (err instanceof ValidationError) throw err; + throw new ValidationError( + `Failed to read registry index: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +/** + * Initialize a new registry git repository with the canonical folder structure. + * Creates: index.json, packs/ directory, and an initial commit. + */ +export async function createRegistryRepo(path: string): Promise { + const log = getLogger(); + + if (existsSync(path)) { + throw new ValidationError(`Path already exists: ${path}`); + } + + mkdirSync(path, { recursive: true }); + + // Initialize git repo + await git(["init"], { cwd: path }); + + // Create canonical structure + const indexPath = join(path, INDEX_FILE); + const emptyIndex: PackSummary[] = []; + writeFileSync(indexPath, JSON.stringify(emptyIndex, null, 2), "utf-8"); + + const packsDir = join(path, PACKS_DIR); + mkdirSync(packsDir, { recursive: true }); + + // Add a .gitkeep so packs/ is tracked + writeFileSync(join(packsDir, ".gitkeep"), "", "utf-8"); + + // Stage and commit + await git(["add", "."], { cwd: path }); + await git(["commit", "-m", "Initial registry structure"], { cwd: path }); + + log.info({ path }, "Registry repo initialized"); +} + +/** Check if git is available on the system. */ +export async function checkGitAvailable(): Promise { + try { + await execFile("git", ["--version"]); + return true; + } catch { + return false; + } +} + +/** Add, commit, and push changes in a registry repo. */ +export async function commitAndPush(repoPath: string, message: string): Promise { + await git(["add", "."], { cwd: repoPath }); + await git(["commit", "-m", message], { cwd: repoPath }); + await git(["push"], { cwd: repoPath }); +} diff --git a/src/registry/publish.ts b/src/registry/publish.ts new file mode 100644 index 0000000..d8c67c9 --- /dev/null +++ b/src/registry/publish.ts @@ -0,0 +1,335 @@ +/** + * Publish and unpublish packs to/from git-based registries. + */ + +import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { getLogger } from "../logger.js"; +import { ValidationError } from "../errors.js"; +import type { + PublishOptions, + PublishResult, + UnpublishOptions, + PackManifest, + PackSummary, + PackVersionEntry, +} from "./types.js"; +import { + PACKS_DIR, + PACK_MANIFEST_FILE, + CHECKSUM_FILE, + INDEX_FILE, + getRegistryCacheDir, +} from "./types.js"; +import { getRegistry } from "./config.js"; +import { commitAndPush, fetchRegistry, git } from "./git.js"; +import { computeChecksum, writeChecksumFile } from "./checksum.js"; +import type { KnowledgePack } from "../core/packs.js"; + +/** + * Increment the patch version of a semver string. + */ +function bumpPatchVersion(version: string): string { + const parts = version.split("."); + if (parts.length !== 3) return "1.0.1"; + const patch = parseInt(parts[2]!, 10); + return `${parts[0]}.${parts[1]}.${isNaN(patch) ? 1 : patch + 1}`; +} + +/** + * Read a pack JSON file (plain or gzip). + */ +function readPackJson(filePath: string): KnowledgePack { + const raw = readFileSync(filePath, "utf-8"); + return JSON.parse(raw) as KnowledgePack; +} + +/** + * Publish a pack to a registry. + * Creates the canonical folder structure, generates checksum, updates index and manifest, commits and pushes. + */ +export async function publishPack(options: PublishOptions): Promise { + const log = getLogger(); + const { registryName, packFilePath, commitMessage } = options; + + // Validate registry exists + const entry = getRegistry(registryName); + if (!entry) { + throw new ValidationError(`Registry "${registryName}" not found. Add it first.`); + } + + const cacheDir = getRegistryCacheDir(registryName); + if (!existsSync(cacheDir)) { + throw new ValidationError( + `Registry "${registryName}" has no local cache. Run: libscope registry sync ${registryName}`, + ); + } + + // Fetch latest before publishing + try { + await fetchRegistry(cacheDir); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Could not fetch latest registry state before publish — proceeding with cached state", + ); + } + + // Read the pack file + if (!existsSync(packFilePath)) { + throw new ValidationError(`Pack file not found: ${packFilePath}`); + } + const pack = readPackJson(packFilePath); + if (!pack.name || !pack.version) { + throw new ValidationError("Pack file must have 'name' and 'version' fields"); + } + + // Determine version + const packDir = join(cacheDir, PACKS_DIR, pack.name); + const manifestPath = join(packDir, PACK_MANIFEST_FILE); + + let manifest: PackManifest; + let version: string; + + if (existsSync(manifestPath)) { + manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as PackManifest; + + if (options.version) { + version = options.version; + } else { + // Bump patch from latest + const latestVersion = manifest.versions[0]?.version ?? pack.version; + version = bumpPatchVersion(latestVersion); + } + + // Check version doesn't already exist + if (manifest.versions.some((v) => v.version === version)) { + throw new ValidationError( + `Version ${version} of "${pack.name}" already exists in "${registryName}". ` + + "Use --version to specify a different version.", + ); + } + } else { + version = options.version ?? pack.version; + manifest = { + name: pack.name, + description: pack.description, + tags: [], + author: pack.metadata.author, + license: pack.metadata.license, + versions: [], + }; + } + + // Create version directory + const versionDir = join(packDir, version); + if (existsSync(versionDir)) { + throw new ValidationError(`Version directory already exists: ${versionDir}`); + } + mkdirSync(versionDir, { recursive: true }); + + // Copy pack file + const destFile = join(versionDir, `${pack.name}.json`); + copyFileSync(packFilePath, destFile); + + // Generate checksum (streaming — doesn't buffer entire file) + const checksum = await computeChecksum(destFile); + const checksumPath = join(versionDir, CHECKSUM_FILE); + writeChecksumFile(checksumPath, checksum); + + // Update manifest + const versionEntry: PackVersionEntry = { + version, + publishedAt: new Date().toISOString(), + checksumPath: `${version}/${CHECKSUM_FILE}`, + checksum, + docCount: pack.documents.length, + }; + manifest.versions.unshift(versionEntry); + manifest.description = pack.description; + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); + + // Update index.json + const indexPath = join(cacheDir, INDEX_FILE); + let index: PackSummary[] = []; + if (existsSync(indexPath)) { + try { + index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; + } catch (err) { + log.warn( + { indexPath, err: err instanceof Error ? err.message : String(err) }, + "Failed to parse index.json, resetting to empty", + ); + index = []; + } + } + + const existingIdx = index.findIndex((p) => p.name === pack.name); + const summary: PackSummary = { + name: pack.name, + description: pack.description, + tags: manifest.tags, + latestVersion: version, + author: pack.metadata.author, + updatedAt: new Date().toISOString(), + }; + + if (existingIdx >= 0) { + index[existingIdx] = summary; + } else { + index.push(summary); + } + + writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf-8"); + + // Commit and push + const msg = commitMessage ?? `publish: ${pack.name}@${version}`; + await commitAndPush(cacheDir, msg); + + log.info({ registry: registryName, pack: pack.name, version, checksum }, "Pack published"); + + return { packName: pack.name, version, checksum, registryName }; +} + +/** + * Publish to a feature branch instead of main (for PR workflow). + */ +export async function publishPackToBranch( + options: PublishOptions, +): Promise { + const log = getLogger(); + const { registryName, packFilePath } = options; + + const entry = getRegistry(registryName); + if (!entry) { + throw new ValidationError(`Registry "${registryName}" not found.`); + } + + const cacheDir = getRegistryCacheDir(registryName); + if (!existsSync(cacheDir)) { + throw new ValidationError( + `Registry "${registryName}" has no local cache. Run: libscope registry sync ${registryName}`, + ); + } + + const pack = readPackJson(packFilePath); + const branchName = `feature/add-${pack.name}`; + + // Create and checkout branch + await git(["checkout", "-b", branchName], { cwd: cacheDir }); + + try { + // Reuse the normal publish flow (which commits) + const result = await publishPack({ + ...options, + commitMessage: options.commitMessage ?? `feat: add ${pack.name}@${pack.version}`, + }); + + // Push the branch + await git(["push", "-u", "origin", branchName], { cwd: cacheDir }); + + log.info({ branch: branchName, registry: registryName }, "Pack published to feature branch"); + + return { ...result, branch: branchName }; + } catch (err) { + // Try to go back to main branch on failure + try { + await git(["checkout", "main"], { cwd: cacheDir }); + await git(["branch", "-D", branchName], { cwd: cacheDir }); + } catch (cleanupErr) { + log.warn( + { err: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr) }, + "Failed to clean up feature branch after publish failure", + ); + } + throw err; + } +} + +/** + * Unpublish a pack version from a registry. + */ +export async function unpublishPack(options: UnpublishOptions): Promise { + const log = getLogger(); + const { registryName, packName, version, commitMessage } = options; + + const entry = getRegistry(registryName); + if (!entry) { + throw new ValidationError(`Registry "${registryName}" not found.`); + } + + const cacheDir = getRegistryCacheDir(registryName); + if (!existsSync(cacheDir)) { + throw new ValidationError( + `Registry "${registryName}" has no local cache. Run: libscope registry sync ${registryName}`, + ); + } + + // Fetch latest + try { + await fetchRegistry(cacheDir); + } catch (err) { + log.warn( + { err: err instanceof Error ? err.message : String(err) }, + "Could not fetch latest registry state before unpublish", + ); + } + + const packDir = join(cacheDir, PACKS_DIR, packName); + const manifestPath = join(packDir, PACK_MANIFEST_FILE); + + if (!existsSync(manifestPath)) { + throw new ValidationError(`Pack "${packName}" not found in registry "${registryName}".`); + } + + const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as PackManifest; + const versionIdx = manifest.versions.findIndex((v) => v.version === version); + if (versionIdx === -1) { + throw new ValidationError( + `Version ${version} of "${packName}" not found in registry "${registryName}".`, + ); + } + + // Remove version directory + const versionDir = join(packDir, version); + if (existsSync(versionDir)) { + rmSync(versionDir, { recursive: true, force: true }); + } + + // Update manifest + manifest.versions.splice(versionIdx, 1); + + if (manifest.versions.length === 0) { + // Remove entire pack + rmSync(packDir, { recursive: true, force: true }); + + // Remove from index + const indexPath = join(cacheDir, INDEX_FILE); + if (existsSync(indexPath)) { + const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; + const filtered = index.filter((p) => p.name !== packName); + writeFileSync(indexPath, JSON.stringify(filtered, null, 2), "utf-8"); + } + } else { + // Update manifest with remaining versions + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); + + // Update index with new latest version + const indexPath = join(cacheDir, INDEX_FILE); + if (existsSync(indexPath)) { + const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; + const indexEntry = index.find((p) => p.name === packName); + if (indexEntry && manifest.versions[0]) { + indexEntry.latestVersion = manifest.versions[0].version; + indexEntry.updatedAt = new Date().toISOString(); + } + writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf-8"); + } + } + + // Commit and push + const msg = commitMessage ?? `unpublish: ${packName}@${version}`; + await commitAndPush(cacheDir, msg); + + log.info({ registry: registryName, pack: packName, version }, "Pack version unpublished"); +} diff --git a/src/registry/resolve.ts b/src/registry/resolve.ts new file mode 100644 index 0000000..435642b --- /dev/null +++ b/src/registry/resolve.ts @@ -0,0 +1,203 @@ +/** + * Registry pack resolution: find and resolve a pack from configured registries. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { getLogger } from "../logger.js"; +import type { + RegistryEntry, + PackSummary, + PackManifest, + RegistryConflict, + ConflictResolution, +} from "./types.js"; +import { + getRegistryCacheDir, + getPackManifestPath, + getPackDataPath, + PACK_MANIFEST_FILE, +} from "./types.js"; +import { loadRegistries } from "./config.js"; +import { readIndex } from "./git.js"; + +/** Parse a pack specifier like "name@1.2.0" into name and optional version. */ +export function parsePackSpecifier(specifier: string): { name: string; version?: string } { + const atIndex = specifier.lastIndexOf("@"); + if (atIndex > 0) { + return { + name: specifier.slice(0, atIndex), + version: specifier.slice(atIndex + 1), + }; + } + return { name: specifier }; +} + +/** Result of resolving a pack from registries. */ +export interface ResolvedPack { + registryName: string; + registryUrl: string; + packName: string; + version: string; + /** Path to the pack data file in the local cache. */ + dataPath: string; +} + +/** + * Find all registries that have a pack with the given name. + */ +export function findPackInRegistries(packName: string): { + matches: Array<{ entry: RegistryEntry; pack: PackSummary }>; + warnings: string[]; +} { + const warnings: string[] = []; + const matches: Array<{ entry: RegistryEntry; pack: PackSummary }> = []; + + const registries = loadRegistries(); + for (const entry of registries) { + const cacheDir = getRegistryCacheDir(entry.name); + if (!existsSync(cacheDir)) { + warnings.push( + `Registry "${entry.name}" has never been synced — skipping. Run: libscope registry sync ${entry.name}`, + ); + continue; + } + + try { + const index = readIndex(cacheDir); + const found = index.find((p) => p.name === packName); + if (found) { + matches.push({ entry, pack: found }); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Failed to read index for "${entry.name}": ${msg}`); + } + } + + return { matches, warnings }; +} + +/** + * Read a pack manifest from the local cache. + */ +export function readPackManifest(registryName: string, packName: string): PackManifest | null { + const manifestPath = getPackManifestPath(registryName, packName); + if (!existsSync(manifestPath)) { + // Fall back: try reading from the packs directory directly + const cacheDir = getRegistryCacheDir(registryName); + const altPath = join(cacheDir, "packs", packName, PACK_MANIFEST_FILE); + if (!existsSync(altPath)) return null; + try { + return JSON.parse(readFileSync(altPath, "utf-8")) as PackManifest; + } catch (err) { + const log = getLogger(); + log.warn( + { registryName, packName, err: err instanceof Error ? err.message : String(err) }, + "Failed to parse pack manifest (alt path)", + ); + return null; + } + } + try { + return JSON.parse(readFileSync(manifestPath, "utf-8")) as PackManifest; + } catch (err) { + const log = getLogger(); + log.warn( + { registryName, packName, err: err instanceof Error ? err.message : String(err) }, + "Failed to parse pack manifest", + ); + return null; + } +} + +/** + * Resolve a pack from registries, handling version selection and conflicts. + * + * @param packName - Pack name (no version suffix) + * @param options - Resolution options + * @returns Resolved pack info, or null if not found + */ +export function resolvePackFromRegistries( + packName: string, + options?: { + version?: string | undefined; + registryName?: string | undefined; + conflictResolution?: ConflictResolution | undefined; + }, +): { resolved: ResolvedPack | null; conflict?: RegistryConflict; warnings: string[] } { + const log = getLogger(); + const { matches, warnings } = findPackInRegistries(packName); + + if (matches.length === 0) { + return { resolved: null, warnings }; + } + + // Filter to specific registry if requested + let candidates = matches; + if (options?.registryName) { + candidates = matches.filter((m) => m.entry.name === options.registryName); + if (candidates.length === 0) { + warnings.push(`Pack "${packName}" not found in registry "${options.registryName}".`); + return { resolved: null, warnings }; + } + } + + // Handle conflict: multiple registries have this pack + if (candidates.length > 1) { + const conflict: RegistryConflict = { + packName, + sources: candidates.map((c) => ({ + registryName: c.entry.name, + registryUrl: c.entry.url, + version: c.pack.latestVersion, + priority: c.entry.priority, + })), + }; + + const resolution = options?.conflictResolution ?? { strategy: "priority" }; + + if (resolution.strategy === "priority") { + // Sort by priority (lower wins), pick first + candidates.sort((a, b) => a.entry.priority - b.entry.priority); + candidates = [candidates[0]!]; + log.info( + { packName, registry: candidates[0]!.entry.name }, + "Resolved pack conflict by priority", + ); + } else if (resolution.strategy === "explicit") { + const explicit = candidates.find((c) => c.entry.name === resolution.registryName); + if (!explicit) { + return { resolved: null, conflict, warnings }; + } + candidates = [explicit]; + } else { + // interactive — caller must handle the conflict + return { resolved: null, conflict, warnings }; + } + } + + const match = candidates[0]!; + const version = options?.version ?? match.pack.latestVersion; + + // Try to find the pack data file + const dataPath = getPackDataPath(match.entry.name, packName, version); + if (!existsSync(dataPath)) { + warnings.push( + `Pack "${packName}@${version}" not found in local cache for registry "${match.entry.name}". ` + + "Try syncing first: libscope registry sync", + ); + return { resolved: null, warnings }; + } + + return { + resolved: { + registryName: match.entry.name, + registryUrl: match.entry.url, + packName, + version, + dataPath, + }, + warnings, + }; +} diff --git a/src/registry/search.ts b/src/registry/search.ts new file mode 100644 index 0000000..ebfb3c3 --- /dev/null +++ b/src/registry/search.ts @@ -0,0 +1,116 @@ +/** + * Registry search: find packs across all configured registries. + */ + +import { existsSync } from "node:fs"; +import { getLogger } from "../logger.js"; +import type { RegistryEntry, PackSummary, RegistrySearchResult } from "./types.js"; +import { getRegistryCacheDir } from "./types.js"; +import { loadRegistries } from "./config.js"; +import { readIndex } from "./git.js"; + +/** + * Compute a relevance score for a pack against a query. + * Higher = better match. Returns 0 for no match. + */ +function scoreMatch(pack: PackSummary, query: string): number { + const q = query.toLowerCase(); + const name = pack.name.toLowerCase(); + const desc = pack.description.toLowerCase(); + const tags = pack.tags.map((t) => t.toLowerCase()); + + let score = 0; + + // Exact name match + if (name === q) { + score += 100; + } else if (name.includes(q)) { + score += 50; + } + + // Description match + if (desc.includes(q)) { + score += 20; + } + + // Tag match + for (const tag of tags) { + if (tag === q) { + score += 30; + } else if (tag.includes(q)) { + score += 15; + } + } + + // Author match + if (pack.author.toLowerCase().includes(q)) { + score += 10; + } + + return score; +} + +/** + * Search for packs across all (or a specific) registry. + * Returns results sorted by relevance score (highest first). + */ +export function searchRegistries( + query: string, + options?: { registryName?: string | undefined }, +): { results: RegistrySearchResult[]; warnings: string[] } { + const log = getLogger(); + const warnings: string[] = []; + const results: RegistrySearchResult[] = []; + + let registries: RegistryEntry[]; + if (options?.registryName) { + const all = loadRegistries(); + const entry = all.find((r) => r.name === options.registryName); + if (!entry) { + warnings.push(`Registry "${options.registryName}" not found.`); + return { results, warnings }; + } + registries = [entry]; + } else { + registries = loadRegistries(); + } + + for (const entry of registries) { + const cacheDir = getRegistryCacheDir(entry.name); + if (!existsSync(cacheDir)) { + warnings.push( + `Registry "${entry.name}" has never been synced. Run: libscope registry sync ${entry.name}`, + ); + continue; + } + + let packs: PackSummary[]; + try { + packs = readIndex(cacheDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Failed to read index for "${entry.name}": ${msg}`); + log.warn({ registry: entry.name, err: msg }, "Failed to read registry index during search"); + continue; + } + + for (const pack of packs) { + const score = scoreMatch(pack, query); + if (score > 0) { + results.push({ + registryName: entry.name, + pack, + score, + }); + } + } + } + + // Sort by score descending, then by name + results.sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return a.pack.name.localeCompare(b.pack.name); + }); + + return { results, warnings }; +} diff --git a/src/registry/sync.ts b/src/registry/sync.ts new file mode 100644 index 0000000..db2a49a --- /dev/null +++ b/src/registry/sync.ts @@ -0,0 +1,182 @@ +/** + * Registry sync engine: keeps local caches up to date and handles offline gracefully. + */ + +import { existsSync } from "node:fs"; +import { getLogger } from "../logger.js"; +import type { RegistryEntry, PackSummary, RegistrySyncStatus } from "./types.js"; +import { getRegistryCacheDir } from "./types.js"; +import { loadRegistries, updateRegistrySyncTime } from "./config.js"; +import { cloneRegistry, fetchRegistry, readIndex, clearIndexCache } from "./git.js"; + +/** + * Sync a single registry: clone if missing, fetch if already cached. + * Returns the sync status. On failure, falls back to cached data with a warning. + */ +export async function syncRegistry(entry: RegistryEntry): Promise { + const log = getLogger(); + const cacheDir = getRegistryCacheDir(entry.name); + + const result: RegistrySyncStatus = { + registryName: entry.name, + status: "syncing", + lastSyncedAt: entry.lastSyncedAt, + }; + + try { + if (existsSync(cacheDir)) { + await fetchRegistry(cacheDir); + } else { + await cloneRegistry(entry.url, cacheDir); + } + + // Invalidate cached index so next readIndex() picks up fresh data + clearIndexCache(cacheDir); + + updateRegistrySyncTime(entry.name); + result.status = "success"; + result.lastSyncedAt = new Date().toISOString(); + log.info({ registry: entry.name }, "Registry synced successfully"); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + + if (existsSync(cacheDir)) { + // We have a cached version — fall back to it + result.status = "offline"; + result.error = message; + log.warn( + { registry: entry.name, err: message }, + `Registry "${entry.name}" is unreachable. Using cached index from ${entry.lastSyncedAt ?? "unknown"}.`, + ); + } else { + // No cache at all + result.status = "error"; + result.error = message; + log.error( + { registry: entry.name, err: message }, + `Registry "${entry.name}" has never been synced and is unreachable.`, + ); + } + } + + return result; +} + +/** + * Sync a named registry. Throws if registry not found. + */ +export async function syncRegistryByName(name: string): Promise { + const registries = loadRegistries(); + const entry = registries.find((r) => r.name === name); + if (!entry) { + return { + registryName: name, + status: "error", + lastSyncedAt: null, + error: `Registry "${name}" not found. Run 'libscope registry add ' first.`, + }; + } + return syncRegistry(entry); +} + +/** Maximum number of concurrent git fetch operations. */ +const SYNC_CONCURRENCY = 3; + +/** + * Run async tasks with a concurrency limit (worker-pool pattern). + * Returns results in the same order as the input tasks. + */ +async function runConcurrent(tasks: Array<() => Promise>, concurrency: number): Promise { + const results: T[] = Array.from({ length: tasks.length }); + let nextIndex = 0; + + async function worker(): Promise { + while (nextIndex < tasks.length) { + const index = nextIndex++; + results[index] = await tasks[index]!(); + } + } + + const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +/** + * Sync all configured registries concurrently. Returns status for each. + */ +export async function syncAllRegistries(): Promise { + const registries = loadRegistries(); + if (registries.length === 0) return []; + + return runConcurrent( + registries.map((entry) => () => syncRegistry(entry)), + SYNC_CONCURRENCY, + ); +} + +/** + * Check if a registry is stale (syncInterval > 0 and time since last sync exceeds interval). + */ +export function isRegistryStale(entry: RegistryEntry): boolean { + if (entry.syncInterval <= 0) return false; + if (!entry.lastSyncedAt) return true; + + const lastSync = new Date(entry.lastSyncedAt).getTime(); + const now = Date.now(); + const intervalMs = entry.syncInterval * 1000; + return now - lastSync > intervalMs; +} + +/** + * Sync all stale registries concurrently. Intended for non-blocking startup check. + * Returns status array; errors are logged but not thrown. + */ +export async function syncStaleRegistries(): Promise { + const registries = loadRegistries(); + const stale = registries.filter(isRegistryStale); + if (stale.length === 0) return []; + + const log = getLogger(); + log.debug({ count: stale.length }, "Syncing stale registries concurrently"); + + return runConcurrent( + stale.map((entry) => () => syncRegistry(entry)), + SYNC_CONCURRENCY, + ); +} + +/** + * Read the cached index for a registry. + * If the cache is stale, syncs first. On sync failure, uses cached data. + * Returns null with an error message if no cache exists and sync fails. + */ +export async function getRegistryIndex( + entry: RegistryEntry, +): Promise<{ packs: PackSummary[]; warning?: string }> { + const cacheDir = getRegistryCacheDir(entry.name); + + // Auto-sync if stale + if (isRegistryStale(entry) || !existsSync(cacheDir)) { + const status = await syncRegistry(entry); + if (status.status === "error") { + return { + packs: [], + warning: + status.error ?? + `Registry "${entry.name}" has never been synced and is unreachable. Run: libscope registry sync when online.`, + }; + } + if (status.status === "offline") { + const packs = readIndex(cacheDir); + return { + packs, + warning: `Registry "${entry.name}" is unreachable. Using cached index from ${entry.lastSyncedAt ?? "unknown"}.`, + }; + } + } + + // Read from cache + const packs = readIndex(cacheDir); + return { packs }; +} diff --git a/src/registry/types.ts b/src/registry/types.ts new file mode 100644 index 0000000..35cf95e --- /dev/null +++ b/src/registry/types.ts @@ -0,0 +1,228 @@ +/** + * Types and interfaces for the git-based pack registry feature. + * + * Registry folder structure (local cache at ~/.libscope/registries//): + * + * index.json — array of PackSummary (top-level registry index) + * packs/ + * / + * pack.json — PackManifest (versions, metadata) + * / + * .json — the actual KnowledgePack file + * checksum.sha256 — SHA-256 checksum of sorted file contents + * + * Remote git repository mirrors the same structure. + */ + +import { join } from "node:path"; +import { homedir } from "node:os"; + +// --------------------------------------------------------------------------- +// Folder structure constants +// --------------------------------------------------------------------------- + +/** Root directory for all registry caches. */ +export const REGISTRIES_DIR = join(homedir(), ".libscope", "registries"); + +/** Name of the top-level index file in each registry. */ +export const INDEX_FILE = "index.json"; + +/** Directory within a registry cache that contains pack folders. */ +export const PACKS_DIR = "packs"; + +/** Name of the pack manifest file inside each pack folder. */ +export const PACK_MANIFEST_FILE = "pack.json"; + +/** Name of the checksum file inside each version folder. */ +export const CHECKSUM_FILE = "checksum.sha256"; + +// --------------------------------------------------------------------------- +// Registry configuration (stored in ~/.libscope/config.json) +// --------------------------------------------------------------------------- + +/** A single registry entry as stored in config. */ +export interface RegistryEntry { + /** User-chosen short name (e.g. "official", "team-internal"). */ + name: string; + /** Git remote URL (https or ssh). */ + url: string; + /** How often to auto-sync, in seconds. 0 = manual only. */ + syncInterval: number; + /** Priority for conflict resolution — lower wins. */ + priority: number; + /** ISO-8601 timestamp of last successful sync, or null if never synced. */ + lastSyncedAt: string | null; +} + +/** Shape of the "registries" key in ~/.libscope/config.json. */ +export interface RegistryConfigBlock { + registries: RegistryEntry[]; +} + +// --------------------------------------------------------------------------- +// Registry index (index.json at repo/cache root) +// --------------------------------------------------------------------------- + +/** Summary of a single pack as listed in index.json. */ +export interface PackSummary { + /** Pack name (unique within the registry). */ + name: string; + /** One-line description. */ + description: string; + /** Tags/categories for search filtering. */ + tags: string[]; + /** Latest published semver version string. */ + latestVersion: string; + /** Author name or handle. */ + author: string; + /** ISO-8601 timestamp of last publish. */ + updatedAt: string; +} + +// --------------------------------------------------------------------------- +// Pack manifest (packs//pack.json) +// --------------------------------------------------------------------------- + +/** A single published version within a pack manifest. */ +export interface PackVersionEntry { + /** Semver version string (e.g. "1.2.0"). */ + version: string; + /** ISO-8601 publish timestamp. */ + publishedAt: string; + /** Relative path to the checksum file for this version. */ + checksumPath: string; + /** SHA-256 checksum value (hex). */ + checksum: string; + /** Number of documents in this version. */ + docCount: number; +} + +/** Full manifest for a pack (stored in packs//pack.json). */ +export interface PackManifest { + /** Pack name. */ + name: string; + /** One-line description. */ + description: string; + /** Tags/categories. */ + tags: string[]; + /** Author name or handle. */ + author: string; + /** License identifier (e.g. "MIT"). */ + license: string; + /** Ordered list of published versions, newest first. */ + versions: PackVersionEntry[]; +} + +// --------------------------------------------------------------------------- +// Search results +// --------------------------------------------------------------------------- + +/** A pack search result, combining summary info with registry source. */ +export interface RegistrySearchResult { + /** Which registry this result came from. */ + registryName: string; + /** Pack summary from that registry's index. */ + pack: PackSummary; + /** Relevance score (higher = better match). */ + score: number; +} + +// --------------------------------------------------------------------------- +// Conflict resolution +// --------------------------------------------------------------------------- + +/** When multiple registries offer the same pack, the user must choose. */ +export interface RegistryConflict { + packName: string; + /** One entry per registry that has this pack. */ + sources: Array<{ + registryName: string; + registryUrl: string; + version: string; + priority: number; + }>; +} + +/** Resolution strategy for pack conflicts. */ +export type ConflictResolution = + | { strategy: "priority" } + | { strategy: "interactive" } + | { strategy: "explicit"; registryName: string }; + +// --------------------------------------------------------------------------- +// Sync state +// --------------------------------------------------------------------------- + +/** Status of a registry sync operation. */ +export interface RegistrySyncStatus { + registryName: string; + status: "syncing" | "success" | "error" | "offline"; + lastSyncedAt: string | null; + error?: string; +} + +// --------------------------------------------------------------------------- +// Publish +// --------------------------------------------------------------------------- + +/** Options for publishing a pack to a registry. */ +export interface PublishOptions { + /** Name of the target registry. */ + registryName: string; + /** Path to the .json or .json.gz pack file. */ + packFilePath: string; + /** Semver version to publish as (defaults to pack's version field). */ + version?: string | undefined; + /** Commit message for the git push. */ + commitMessage?: string | undefined; +} + +/** Result of a publish operation. */ +export interface PublishResult { + packName: string; + version: string; + checksum: string; + registryName: string; +} + +/** Options for unpublishing a pack version. */ +export interface UnpublishOptions { + registryName: string; + packName: string; + version: string; + commitMessage?: string | undefined; +} + +// --------------------------------------------------------------------------- +// Helper: build paths from constants +// --------------------------------------------------------------------------- + +/** Get the local cache directory for a named registry. */ +export function getRegistryCacheDir(registryName: string): string { + return join(REGISTRIES_DIR, registryName); +} + +/** Get the path to a registry's local index.json. */ +export function getRegistryIndexPath(registryName: string): string { + return join(REGISTRIES_DIR, registryName, INDEX_FILE); +} + +/** Get the path to a pack's manifest within a registry cache. */ +export function getPackManifestPath(registryName: string, packName: string): string { + return join(REGISTRIES_DIR, registryName, PACKS_DIR, packName, PACK_MANIFEST_FILE); +} + +/** Get the directory for a specific pack version within a registry cache. */ +export function getPackVersionDir(registryName: string, packName: string, version: string): string { + return join(REGISTRIES_DIR, registryName, PACKS_DIR, packName, version); +} + +/** Get the path to the pack data file for a specific version. */ +export function getPackDataPath(registryName: string, packName: string, version: string): string { + return join(REGISTRIES_DIR, registryName, PACKS_DIR, packName, version, `${packName}.json`); +} + +/** Get the path to the checksum file for a specific pack version. */ +export function getChecksumPath(registryName: string, packName: string, version: string): string { + return join(REGISTRIES_DIR, registryName, PACKS_DIR, packName, version, CHECKSUM_FILE); +} diff --git a/tests/integration/registry/registry-conflict.test.ts b/tests/integration/registry/registry-conflict.test.ts new file mode 100644 index 0000000..bb753fa --- /dev/null +++ b/tests/integration/registry/registry-conflict.test.ts @@ -0,0 +1,280 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-conflict-int-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { loadRegistries, saveRegistries, getRegistry } = + await import("../../../src/registry/config.js"); +const { syncRegistry } = await import("../../../src/registry/sync.js"); +const { resolvePackFromRegistries } = await import("../../../src/registry/resolve.js"); + +function makeEntry(name: string, url: string, priority = 1): RegistryEntry { + return { name, url, syncInterval: 3600, priority, lastSyncedAt: null }; +} + +function addTestRegistry(entry: RegistryEntry): void { + const registries = loadRegistries(); + registries.push(entry); + saveRegistries(registries); +} + +function createBareRepoWithPacks(dir: string, packs: PackSummary[]): string { + const bareDir = join(dir, `registry-${randomUUID()}.git`); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + const workDir = join(dir, `work-${randomUUID()}`); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + + writeFileSync(join(workDir, "index.json"), JSON.stringify(packs, null, 2), "utf-8"); + + for (const pack of packs) { + const packDir = join(workDir, "packs", pack.name); + mkdirSync(packDir, { recursive: true }); + writeFileSync( + join(packDir, "pack.json"), + JSON.stringify({ + name: pack.name, + description: pack.description, + tags: pack.tags, + author: pack.author, + license: "MIT", + versions: [ + { + version: pack.latestVersion, + publishedAt: pack.updatedAt, + checksumPath: `${pack.latestVersion}/checksum.sha256`, + checksum: "placeholder", + docCount: 1, + }, + ], + }), + "utf-8", + ); + + const versionDir = join(packDir, pack.latestVersion); + mkdirSync(versionDir, { recursive: true }); + writeFileSync( + join(versionDir, `${pack.name}.json`), + JSON.stringify({ + name: pack.name, + version: pack.latestVersion, + description: pack.description, + documents: [{ title: "Doc", content: "Content from " + pack.author, source: "test" }], + metadata: { author: pack.author, license: "MIT", createdAt: pack.updatedAt }, + }), + "utf-8", + ); + } + + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + return bareDir; +} + +describe("integration: registry conflict resolution", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-conflict-int-")); + tempHome = join(tempDir, "home"); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("should detect conflict when two registries have the same pack name", async () => { + const sharedPack: PackSummary = { + name: "shared-pack", + description: "Shared", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }; + + const repo1 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo2 = createBareRepoWithPacks(tempDir, [{ ...sharedPack, author: "other-author" }]); + + addTestRegistry(makeEntry("reg1", repo1, 2)); + addTestRegistry(makeEntry("reg2", repo2, 1)); + + await syncRegistry(getRegistry("reg1")!); + await syncRegistry(getRegistry("reg2")!); + + // With interactive resolution, should get conflict back + const { resolved, conflict } = resolvePackFromRegistries("shared-pack", { + conflictResolution: { strategy: "interactive" }, + }); + + expect(resolved).toBeNull(); + expect(conflict).toBeDefined(); + expect(conflict!.sources).toHaveLength(2); + expect(conflict!.sources.map((s) => s.registryName).sort()).toEqual(["reg1", "reg2"]); + }); + + it("should resolve conflict with explicit --registry flag", async () => { + const sharedPack: PackSummary = { + name: "shared-pack", + description: "Shared", + tags: [], + latestVersion: "1.0.0", + author: "author-1", + updatedAt: "2026-01-01", + }; + + const repo1 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo2 = createBareRepoWithPacks(tempDir, [{ ...sharedPack, author: "author-2" }]); + + addTestRegistry(makeEntry("reg1", repo1)); + addTestRegistry(makeEntry("reg2", repo2)); + await syncRegistry(getRegistry("reg1")!); + await syncRegistry(getRegistry("reg2")!); + + const { resolved } = resolvePackFromRegistries("shared-pack", { + registryName: "reg1", + }); + + expect(resolved).not.toBeNull(); + expect(resolved!.registryName).toBe("reg1"); + }); + + it("should resolve conflict by priority (lower wins)", async () => { + const sharedPack: PackSummary = { + name: "priority-pack", + description: "Priority test", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }; + + const repo1 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo2 = createBareRepoWithPacks(tempDir, [sharedPack]); + + addTestRegistry(makeEntry("high-priority", repo1, 10)); + addTestRegistry(makeEntry("low-priority", repo2, 1)); + await syncRegistry(getRegistry("high-priority")!); + await syncRegistry(getRegistry("low-priority")!); + + const { resolved } = resolvePackFromRegistries("priority-pack", { + conflictResolution: { strategy: "priority" }, + }); + + expect(resolved).not.toBeNull(); + expect(resolved!.registryName).toBe("low-priority"); + }); + + it("should not conflict when packs have different names", async () => { + const repo1 = createBareRepoWithPacks(tempDir, [ + { + name: "pack-a", + description: "Pack A", + tags: [], + latestVersion: "1.0.0", + author: "a", + updatedAt: "2026-01-01", + }, + ]); + const repo2 = createBareRepoWithPacks(tempDir, [ + { + name: "pack-b", + description: "Pack B", + tags: [], + latestVersion: "1.0.0", + author: "b", + updatedAt: "2026-01-01", + }, + ]); + + addTestRegistry(makeEntry("no-conflict-1", repo1)); + addTestRegistry(makeEntry("no-conflict-2", repo2)); + await syncRegistry(getRegistry("no-conflict-1")!); + await syncRegistry(getRegistry("no-conflict-2")!); + + const { resolved: r1, conflict: c1 } = resolvePackFromRegistries("pack-a"); + expect(r1).not.toBeNull(); + expect(c1).toBeUndefined(); + + const { resolved: r2, conflict: c2 } = resolvePackFromRegistries("pack-b"); + expect(r2).not.toBeNull(); + expect(c2).toBeUndefined(); + }); + + it("should handle conflict with three registries", async () => { + const sharedPack: PackSummary = { + name: "triple-pack", + description: "Three-way conflict", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }; + + const repo1 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo2 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo3 = createBareRepoWithPacks(tempDir, [sharedPack]); + + addTestRegistry(makeEntry("triple-1", repo1)); + addTestRegistry(makeEntry("triple-2", repo2)); + addTestRegistry(makeEntry("triple-3", repo3)); + await syncRegistry(getRegistry("triple-1")!); + await syncRegistry(getRegistry("triple-2")!); + await syncRegistry(getRegistry("triple-3")!); + + const { conflict } = resolvePackFromRegistries("triple-pack", { + conflictResolution: { strategy: "interactive" }, + }); + + expect(conflict).toBeDefined(); + expect(conflict!.sources).toHaveLength(3); + }); + + it("should list all conflicting registries in conflict object", async () => { + const sharedPack: PackSummary = { + name: "info-pack", + description: "Info test", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }; + + const repo1 = createBareRepoWithPacks(tempDir, [sharedPack]); + const repo2 = createBareRepoWithPacks(tempDir, [sharedPack]); + + addTestRegistry(makeEntry("info-reg1", repo1, 2)); + addTestRegistry(makeEntry("info-reg2", repo2, 1)); + await syncRegistry(getRegistry("info-reg1")!); + await syncRegistry(getRegistry("info-reg2")!); + + const { conflict } = resolvePackFromRegistries("info-pack", { + conflictResolution: { strategy: "interactive" }, + }); + + expect(conflict!.packName).toBe("info-pack"); + for (const source of conflict!.sources) { + expect(source.registryName).toBeTruthy(); + expect(source.registryUrl).toBeTruthy(); + expect(source.version).toBe("1.0.0"); + expect(typeof source.priority).toBe("number"); + } + }); +}); diff --git a/tests/integration/registry/registry-lifecycle.test.ts b/tests/integration/registry/registry-lifecycle.test.ts new file mode 100644 index 0000000..8c31a02 --- /dev/null +++ b/tests/integration/registry/registry-lifecycle.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-lifecycle-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { removeRegistry, loadRegistries, getRegistry, saveRegistries } = + await import("../../../src/registry/config.js"); +const { syncRegistry } = await import("../../../src/registry/sync.js"); +const { searchRegistries } = await import("../../../src/registry/search.js"); +const { getRegistryCacheDir } = await import("../../../src/registry/types.js"); +const { readIndex } = await import("../../../src/registry/git.js"); + +function makeEntry(name: string, url: string): RegistryEntry { + return { + name, + url, + syncInterval: 3600, + priority: 1, + lastSyncedAt: null, + }; +} + +/** Add a registry entry bypassing URL validation (for local bare repo paths). */ +function addTestRegistry(entry: RegistryEntry): void { + const registries = loadRegistries(); + registries.push(entry); + saveRegistries(registries); +} + +/** + * Create a local bare git repo populated with an index.json and a sample pack. + */ +function createBareRegistryRepo(dir: string, packs: PackSummary[]): string { + const bareDir = join(dir, `registry-${randomUUID()}.git`); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + const workDir = join(dir, `work-${randomUUID()}`); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + + // Write index.json + writeFileSync(join(workDir, "index.json"), JSON.stringify(packs, null, 2), "utf-8"); + + // Write pack.json for each pack + const packsDir = join(workDir, "packs"); + mkdirSync(packsDir, { recursive: true }); + for (const pack of packs) { + const packDir = join(packsDir, pack.name); + mkdirSync(packDir, { recursive: true }); + writeFileSync( + join(packDir, "pack.json"), + JSON.stringify({ + name: pack.name, + description: pack.description, + tags: pack.tags, + author: pack.author, + license: "MIT", + versions: [ + { + version: pack.latestVersion, + publishedAt: pack.updatedAt, + checksumPath: `${pack.latestVersion}/checksum.sha256`, + checksum: "placeholder", + docCount: 1, + }, + ], + }), + "utf-8", + ); + + // Create a version directory with a pack data file + const versionDir = join(packDir, pack.latestVersion); + mkdirSync(versionDir, { recursive: true }); + writeFileSync( + join(versionDir, `${pack.name}.json`), + JSON.stringify({ + name: pack.name, + version: pack.latestVersion, + description: pack.description, + documents: [{ title: "Doc 1", content: "Content 1", source: "test" }], + metadata: { author: pack.author, license: "MIT", createdAt: pack.updatedAt }, + }), + "utf-8", + ); + } + + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + + return bareDir; +} + +describe("integration: registry lifecycle", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-lifecycle-")); + tempHome = join(tempDir, "home"); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("should add a registry and persist it in config", () => { + const bareRepo = createBareRegistryRepo(tempDir, []); + addTestRegistry(makeEntry("test-reg", bareRepo)); + + const registries = loadRegistries(); + expect(registries).toHaveLength(1); + expect(registries[0]!.name).toBe("test-reg"); + expect(registries[0]!.url).toBe(bareRepo); + }); + + it("should sync a registry and populate local cache", async () => { + const samplePack: PackSummary = { + name: "sample-pack", + description: "A sample pack", + tags: ["test"], + latestVersion: "1.0.0", + author: "tester", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + const bareRepo = createBareRegistryRepo(tempDir, [samplePack]); + addTestRegistry(makeEntry("sync-test", bareRepo)); + + const entry = getRegistry("sync-test")!; + const status = await syncRegistry(entry); + + expect(status.status).toBe("success"); + + // Verify cache dir has index.json + const cacheDir = getRegistryCacheDir("sync-test"); + expect(existsSync(cacheDir)).toBe(true); + const packs = readIndex(cacheDir); + expect(packs).toHaveLength(1); + expect(packs[0]!.name).toBe("sample-pack"); + }); + + it("should search packs from a synced registry", async () => { + const packs: PackSummary[] = [ + { + name: "react-docs", + description: "React documentation", + tags: ["react"], + latestVersion: "1.0.0", + author: "team", + updatedAt: "2026-01-01", + }, + { + name: "vue-docs", + description: "Vue documentation", + tags: ["vue"], + latestVersion: "2.0.0", + author: "team", + updatedAt: "2026-01-01", + }, + ]; + const bareRepo = createBareRegistryRepo(tempDir, packs); + addTestRegistry(makeEntry("search-test", bareRepo)); + + const entry = getRegistry("search-test")!; + await syncRegistry(entry); + + const { results } = searchRegistries("react"); + expect(results.length).toBeGreaterThan(0); + expect(results[0]!.pack.name).toBe("react-docs"); + }); + + it("should remove a registry and clean up cache", async () => { + const bareRepo = createBareRegistryRepo(tempDir, []); + addTestRegistry(makeEntry("removable", bareRepo)); + + const entry = getRegistry("removable")!; + await syncRegistry(entry); + + const cacheDir = getRegistryCacheDir("removable"); + expect(existsSync(cacheDir)).toBe(true); + + removeRegistry("removable"); + expect(loadRegistries()).toHaveLength(0); + // Note: removeRegistry only removes from config, cache cleanup is separate + }); + + it("should re-sync and update cache when registry content changes", async () => { + // Start with one pack + const bareRepo = createBareRegistryRepo(tempDir, [ + { + name: "initial-pack", + description: "Initial", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }, + ]); + addTestRegistry(makeEntry("evolving", bareRepo)); + + const entry = getRegistry("evolving")!; + await syncRegistry(entry); + + let packs = readIndex(getRegistryCacheDir("evolving")); + expect(packs).toHaveLength(1); + + // Push a new pack to the bare repo + const workDir = join(tempDir, `update-work-${randomUUID()}`); + execSync(`git clone "${bareRepo}" "${workDir}"`, { stdio: "pipe" }); + const index = JSON.parse(readFileSync(join(workDir, "index.json"), "utf-8")) as PackSummary[]; + index.push({ + name: "new-pack", + description: "Newly added", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-02-01", + }); + writeFileSync(join(workDir, "index.json"), JSON.stringify(index), "utf-8"); + execSync("git add . && git commit -m 'add new pack' && git push", { + cwd: workDir, + stdio: "pipe", + }); + + // Re-sync + await syncRegistry(entry); + packs = readIndex(getRegistryCacheDir("evolving")); + expect(packs).toHaveLength(2); + expect(packs.map((p) => p.name)).toContain("new-pack"); + }); + + it("should handle adding multiple registries", async () => { + const bareRepo1 = createBareRegistryRepo(tempDir, [ + { + name: "pack-from-reg1", + description: "From registry 1", + tags: [], + latestVersion: "1.0.0", + author: "a", + updatedAt: "2026-01-01", + }, + ]); + const bareRepo2 = createBareRegistryRepo(tempDir, [ + { + name: "pack-from-reg2", + description: "From registry 2", + tags: [], + latestVersion: "1.0.0", + author: "b", + updatedAt: "2026-01-01", + }, + ]); + + addTestRegistry(makeEntry("multi-1", bareRepo1)); + addTestRegistry(makeEntry("multi-2", bareRepo2)); + + const e1 = getRegistry("multi-1")!; + const e2 = getRegistry("multi-2")!; + await syncRegistry(e1); + await syncRegistry(e2); + + // Search across both + const { results } = searchRegistries("pack"); + expect(results.length).toBe(2); + expect(results.map((r) => r.pack.name).sort()).toEqual(["pack-from-reg1", "pack-from-reg2"]); + }); +}); diff --git a/tests/integration/registry/registry-offline.test.ts b/tests/integration/registry/registry-offline.test.ts new file mode 100644 index 0000000..979e1cd --- /dev/null +++ b/tests/integration/registry/registry-offline.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-offline-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { loadRegistries, saveRegistries, getRegistry } = + await import("../../../src/registry/config.js"); +const { syncRegistry, getRegistryIndex } = await import("../../../src/registry/sync.js"); +const { searchRegistries } = await import("../../../src/registry/search.js"); +const { getRegistryCacheDir } = await import("../../../src/registry/types.js"); + +function makeEntry(name: string, url: string): RegistryEntry { + return { name, url, syncInterval: 3600, priority: 1, lastSyncedAt: null }; +} + +function addTestRegistry(entry: RegistryEntry): void { + const registries = loadRegistries(); + registries.push(entry); + saveRegistries(registries); +} + +function createBareRepo(dir: string, packs: PackSummary[] = []): string { + const bareDir = join(dir, `registry-${randomUUID()}.git`); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + const workDir = join(dir, `work-${randomUUID()}`); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + writeFileSync(join(workDir, "index.json"), JSON.stringify(packs), "utf-8"); + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + return bareDir; +} + +describe("integration: registry offline / unreachable remote", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-offline-")); + tempHome = join(tempDir, "home"); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("should return error status when syncing an unreachable remote", async () => { + const nonexistentUrl = join(tempDir, "does-not-exist.git"); + addTestRegistry(makeEntry("unreachable", nonexistentUrl)); + + const entry = getRegistry("unreachable")!; + const status = await syncRegistry(entry); + + expect(status.status).toBe("error"); + expect(status.error).toBeTruthy(); + }); + + it("should fall back to stale cache when remote becomes unreachable", async () => { + const packs: PackSummary[] = [ + { + name: "cached-pack", + description: "A pack that was cached", + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }, + ]; + const bareRepo = createBareRepo(tempDir, packs); + addTestRegistry(makeEntry("fallback", bareRepo)); + + // Sync successfully first + const entry = getRegistry("fallback")!; + const firstSync = await syncRegistry(entry); + expect(firstSync.status).toBe("success"); + + // Break the remote by renaming it + const brokenPath = bareRepo + ".broken"; + renameSync(bareRepo, brokenPath); + + // Re-sync — should fall back to cached + const updatedEntry = getRegistry("fallback")!; + const secondSync = await syncRegistry(updatedEntry); + expect(secondSync.status).toBe("offline"); + expect(secondSync.error).toBeTruthy(); + + // Verify cache still usable + const cacheDir = getRegistryCacheDir("fallback"); + expect(existsSync(cacheDir)).toBe(true); + }); + + it("should include registry name in offline error message", async () => { + const nonexistentUrl = join(tempDir, "no-such-repo.git"); + addTestRegistry(makeEntry("named-error", nonexistentUrl)); + + const entry = getRegistry("named-error")!; + const status = await syncRegistry(entry); + + expect(status.registryName).toBe("named-error"); + expect(status.status).toBe("error"); + }); + + it("should allow search against stale cache after sync failure", async () => { + const packs: PackSummary[] = [ + { + name: "stale-searchable", + description: "Can still be searched", + tags: ["test"], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01", + }, + ]; + const bareRepo = createBareRepo(tempDir, packs); + addTestRegistry(makeEntry("stale-search", bareRepo)); + + // Sync successfully + await syncRegistry(getRegistry("stale-search")!); + + // Break remote + renameSync(bareRepo, bareRepo + ".gone"); + + // Search should still return results from cache + const { results } = searchRegistries("stale"); + expect(results.length).toBeGreaterThan(0); + expect(results[0]!.pack.name).toBe("stale-searchable"); + }); + + it("should fail clearly when no cache exists and remote is unreachable", async () => { + addTestRegistry(makeEntry("no-cache", join(tempDir, "nonexistent.git"))); + + const entry = getRegistry("no-cache")!; + const status = await syncRegistry(entry); + + expect(status.status).toBe("error"); + expect(status.error).toBeTruthy(); + // No cache should have been created + expect(existsSync(getRegistryCacheDir("no-cache"))).toBe(false); + }); + + it("should return error with getRegistryIndex when never synced and unreachable", async () => { + addTestRegistry(makeEntry("never-synced", join(tempDir, "ghost.git"))); + + const entry = getRegistry("never-synced")!; + const { packs, warning } = await getRegistryIndex(entry); + + expect(packs).toEqual([]); + expect(warning).toBeTruthy(); + }); +}); diff --git a/tests/integration/registry/registry-publish.test.ts b/tests/integration/registry/registry-publish.test.ts new file mode 100644 index 0000000..e675e18 --- /dev/null +++ b/tests/integration/registry/registry-publish.test.ts @@ -0,0 +1,325 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary, PackManifest } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-publish-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { loadRegistries, saveRegistries, getRegistry } = + await import("../../../src/registry/config.js"); +const { syncRegistry } = await import("../../../src/registry/sync.js"); +const { publishPack, unpublishPack } = await import("../../../src/registry/publish.js"); +const { verifyChecksum } = await import("../../../src/registry/checksum.js"); +const { getRegistryCacheDir } = await import("../../../src/registry/types.js"); + +function makeEntry(name: string, url: string): RegistryEntry { + return { name, url, syncInterval: 3600, priority: 1, lastSyncedAt: null }; +} + +function addTestRegistry(entry: RegistryEntry): void { + const registries = loadRegistries(); + registries.push(entry); + saveRegistries(registries); +} + +/** + * Create a bare git repo with an initial commit (empty index + packs dir). + */ +function createBareRepo(dir: string): string { + const bareDir = join(dir, `registry-${randomUUID()}.git`); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + const workDir = join(dir, `work-${randomUUID()}`); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + writeFileSync(join(workDir, "index.json"), "[]", "utf-8"); + mkdirSync(join(workDir, "packs"), { recursive: true }); + writeFileSync(join(workDir, "packs", ".gitkeep"), "", "utf-8"); + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + return bareDir; +} + +/** Create a valid pack JSON file */ +function createPackFile(dir: string, name: string, version = "1.0.0"): string { + const filePath = join(dir, `${name}.json`); + writeFileSync( + filePath, + JSON.stringify({ + name, + version, + description: `The ${name} knowledge pack`, + documents: [ + { title: "Getting Started", content: "# Guide\n\nIntro content.", source: "test" }, + { title: "API Ref", content: "# API\n\nEndpoints.", source: "test" }, + ], + metadata: { author: "test-author", license: "MIT", createdAt: "2026-01-01T00:00:00.000Z" }, + }), + "utf-8", + ); + return filePath; +} + +describe("integration: registry publish", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-publish-")); + tempHome = join(tempDir, "home"); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("should publish a pack to the registry and update index + manifest", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("pub-reg", bareRepo)); + const entry = getRegistry("pub-reg")!; + await syncRegistry(entry); + + const packFile = createPackFile(tempDir, "my-pack"); + const result = await publishPack({ + registryName: "pub-reg", + packFilePath: packFile, + version: "1.0.0", + }); + + expect(result.packName).toBe("my-pack"); + expect(result.version).toBe("1.0.0"); + expect(result.checksum).toHaveLength(64); + expect(result.registryName).toBe("pub-reg"); + + // Verify index.json updated + const cacheDir = getRegistryCacheDir("pub-reg"); + const index = JSON.parse(readFileSync(join(cacheDir, "index.json"), "utf-8")) as PackSummary[]; + expect(index).toHaveLength(1); + expect(index[0]!.name).toBe("my-pack"); + expect(index[0]!.latestVersion).toBe("1.0.0"); + + // Verify manifest + const manifest = JSON.parse( + readFileSync(join(cacheDir, "packs", "my-pack", "pack.json"), "utf-8"), + ) as PackManifest; + expect(manifest.versions).toHaveLength(1); + expect(manifest.versions[0]!.version).toBe("1.0.0"); + expect(manifest.versions[0]!.checksum).toBe(result.checksum); + }); + + it("should generate and store checksum on publish", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("cs-reg", bareRepo)); + await syncRegistry(getRegistry("cs-reg")!); + + const packFile = createPackFile(tempDir, "cs-pack"); + const result = await publishPack({ + registryName: "cs-reg", + packFilePath: packFile, + version: "1.0.0", + }); + + // Verify checksum file + const cacheDir = getRegistryCacheDir("cs-reg"); + const checksumPath = join(cacheDir, "packs", "cs-pack", "1.0.0", "checksum.sha256"); + expect(existsSync(checksumPath)).toBe(true); + const storedChecksum = readFileSync(checksumPath, "utf-8").trim(); + expect(storedChecksum).toBe(result.checksum); + }); + + it("should verify checksum round-trip (publish then verify)", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("rt-reg", bareRepo)); + await syncRegistry(getRegistry("rt-reg")!); + + const packFile = createPackFile(tempDir, "rt-pack"); + const result = await publishPack({ + registryName: "rt-reg", + packFilePath: packFile, + version: "1.0.0", + }); + + // Verify the published file passes checksum + const cacheDir = getRegistryCacheDir("rt-reg"); + const publishedFile = join(cacheDir, "packs", "rt-pack", "1.0.0", "rt-pack.json"); + expect(await verifyChecksum(publishedFile, result.checksum)).toBe(true); + }); + + it("should unpublish a pack version", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("unpub-reg", bareRepo)); + await syncRegistry(getRegistry("unpub-reg")!); + + const packFile = createPackFile(tempDir, "unpub-pack"); + await publishPack({ + registryName: "unpub-reg", + packFilePath: packFile, + version: "1.0.0", + }); + + await unpublishPack({ + registryName: "unpub-reg", + packName: "unpub-pack", + version: "1.0.0", + }); + + // Verify removed from index + const cacheDir = getRegistryCacheDir("unpub-reg"); + const index = JSON.parse(readFileSync(join(cacheDir, "index.json"), "utf-8")) as PackSummary[]; + expect(index.find((p) => p.name === "unpub-pack")).toBeUndefined(); + }); + + it("should reject publish with non-existent pack file", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("err-reg", bareRepo)); + await syncRegistry(getRegistry("err-reg")!); + + await expect( + publishPack({ + registryName: "err-reg", + packFilePath: join(tempDir, "nonexistent.json"), + }), + ).rejects.toThrow(/not found/); + }); + + it("should reject publish to non-existent registry", async () => { + const packFile = createPackFile(tempDir, "orphan"); + await expect( + publishPack({ registryName: "nonexistent", packFilePath: packFile }), + ).rejects.toThrow(/not found/); + }); + + it("should reject duplicate version publish", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("dup-reg", bareRepo)); + await syncRegistry(getRegistry("dup-reg")!); + + const packFile = createPackFile(tempDir, "dup-pack"); + await publishPack({ + registryName: "dup-reg", + packFilePath: packFile, + version: "1.0.0", + }); + + await expect( + publishPack({ + registryName: "dup-reg", + packFilePath: packFile, + version: "1.0.0", + }), + ).rejects.toThrow(/already exists/); + }); + + it("should update existing pack version on re-publish with different version", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("multi-ver-reg", bareRepo)); + await syncRegistry(getRegistry("multi-ver-reg")!); + + const packFile = createPackFile(tempDir, "multi-ver"); + await publishPack({ + registryName: "multi-ver-reg", + packFilePath: packFile, + version: "1.0.0", + }); + await publishPack({ + registryName: "multi-ver-reg", + packFilePath: packFile, + version: "1.1.0", + }); + + const cacheDir = getRegistryCacheDir("multi-ver-reg"); + const manifest = JSON.parse( + readFileSync(join(cacheDir, "packs", "multi-ver", "pack.json"), "utf-8"), + ) as PackManifest; + expect(manifest.versions).toHaveLength(2); + // Newest first + expect(manifest.versions[0]!.version).toBe("1.1.0"); + expect(manifest.versions[1]!.version).toBe("1.0.0"); + }); + + it("should unpublish one version while keeping others", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("partial-unpub", bareRepo)); + await syncRegistry(getRegistry("partial-unpub")!); + + const packFile = createPackFile(tempDir, "multi-ver-unpub"); + await publishPack({ + registryName: "partial-unpub", + packFilePath: packFile, + version: "1.0.0", + }); + await publishPack({ + registryName: "partial-unpub", + packFilePath: packFile, + version: "2.0.0", + }); + + // Unpublish only v1.0.0 + await unpublishPack({ + registryName: "partial-unpub", + packName: "multi-ver-unpub", + version: "1.0.0", + }); + + const cacheDir = getRegistryCacheDir("partial-unpub"); + const manifest = JSON.parse( + readFileSync(join(cacheDir, "packs", "multi-ver-unpub", "pack.json"), "utf-8"), + ) as PackManifest; + expect(manifest.versions).toHaveLength(1); + expect(manifest.versions[0]!.version).toBe("2.0.0"); + + // Index should still list the pack with updated latestVersion + const index = JSON.parse(readFileSync(join(cacheDir, "index.json"), "utf-8")) as PackSummary[]; + const entry = index.find((p) => p.name === "multi-ver-unpub"); + expect(entry).toBeDefined(); + expect(entry!.latestVersion).toBe("2.0.0"); + }); + + it("should reject unpublish for non-existent version", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("bad-unpub", bareRepo)); + await syncRegistry(getRegistry("bad-unpub")!); + + const packFile = createPackFile(tempDir, "unpub-err"); + await publishPack({ + registryName: "bad-unpub", + packFilePath: packFile, + version: "1.0.0", + }); + + await expect( + unpublishPack({ + registryName: "bad-unpub", + packName: "unpub-err", + version: "9.9.9", + }), + ).rejects.toThrow(/not found/); + }); + + it("should reject unpublish for non-existent pack", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("no-pack-unpub", bareRepo)); + await syncRegistry(getRegistry("no-pack-unpub")!); + + await expect( + unpublishPack({ + registryName: "no-pack-unpub", + packName: "nonexistent", + version: "1.0.0", + }), + ).rejects.toThrow(/not found/); + }); +}); diff --git a/tests/unit/registry/checksum.test.ts b/tests/unit/registry/checksum.test.ts new file mode 100644 index 0000000..08dc011 --- /dev/null +++ b/tests/unit/registry/checksum.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { initLogger } from "../../../src/logger.js"; +import { + computeChecksum, + computePackChecksum, + writeChecksumFile, + readChecksumFile, + verifyChecksum, +} from "../../../src/registry/checksum.js"; + +describe("registry checksum", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-checksum-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("computeChecksum", () => { + it("should produce a deterministic checksum for the same file content", async () => { + const filePath = join(tempDir, "test.json"); + writeFileSync(filePath, '{"name":"test"}', "utf-8"); + + const hash1 = await computeChecksum(filePath); + const hash2 = await computeChecksum(filePath); + expect(hash1).toBe(hash2); + }); + + it("should produce different checksums for different content", async () => { + const file1 = join(tempDir, "a.json"); + const file2 = join(tempDir, "b.json"); + writeFileSync(file1, '{"name":"a"}', "utf-8"); + writeFileSync(file2, '{"name":"b"}', "utf-8"); + + expect(await computeChecksum(file1)).not.toBe(await computeChecksum(file2)); + }); + + it("should return a 64-character hex string (SHA-256)", async () => { + const filePath = join(tempDir, "test.json"); + writeFileSync(filePath, "content", "utf-8"); + + const hash = await computeChecksum(filePath); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + + it("should throw for non-existent file", async () => { + await expect(computeChecksum(join(tempDir, "nonexistent.json"))).rejects.toThrow( + /File not found/, + ); + }); + + it("should handle empty file", async () => { + const filePath = join(tempDir, "empty.json"); + writeFileSync(filePath, "", "utf-8"); + + const hash = await computeChecksum(filePath); + expect(hash).toHaveLength(64); + }); + + it("should handle large content", async () => { + const filePath = join(tempDir, "large.json"); + writeFileSync(filePath, "x".repeat(10_000_000), "utf-8"); + + const hash = await computeChecksum(filePath); + expect(hash).toHaveLength(64); + }); + }); + + describe("computePackChecksum", () => { + it("should produce a deterministic checksum for the same object", () => { + const data = { name: "test", version: "1.0.0" }; + expect(computePackChecksum(data)).toBe(computePackChecksum(data)); + }); + + it("should be key-order-independent (sorted keys)", () => { + const a = { name: "test", version: "1.0.0" }; + const b = { version: "1.0.0", name: "test" }; + expect(computePackChecksum(a)).toBe(computePackChecksum(b)); + }); + + it("should produce different checksums for different content", () => { + const a = { name: "pack-a" }; + const b = { name: "pack-b" }; + expect(computePackChecksum(a)).not.toBe(computePackChecksum(b)); + }); + + it("should return a 64-character hex string", () => { + const hash = computePackChecksum({ name: "test" }); + expect(hash).toHaveLength(64); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe("writeChecksumFile / readChecksumFile", () => { + it("should round-trip a checksum value", () => { + const checksumPath = join(tempDir, "checksum.sha256"); + const value = "abcdef1234567890".repeat(4); + + writeChecksumFile(checksumPath, value); + const read = readChecksumFile(checksumPath); + expect(read).toBe(value); + }); + + it("should return null for non-existent file", () => { + expect(readChecksumFile(join(tempDir, "nope.sha256"))).toBeNull(); + }); + + it("should write with trailing newline", () => { + const checksumPath = join(tempDir, "cs.sha256"); + writeChecksumFile(checksumPath, "abc"); + const raw = readFileSync(checksumPath, "utf-8"); + expect(raw).toBe("abc\n"); + }); + }); + + describe("verifyChecksum", () => { + it("should return true when checksum matches", async () => { + const filePath = join(tempDir, "good.json"); + writeFileSync(filePath, "pack content", "utf-8"); + const expected = await computeChecksum(filePath); + + expect(await verifyChecksum(filePath, expected)).toBe(true); + }); + + it("should throw when checksum does not match", async () => { + const filePath = join(tempDir, "bad.json"); + writeFileSync(filePath, "pack content", "utf-8"); + + await expect(verifyChecksum(filePath, "wrong_checksum_value")).rejects.toThrow( + /Checksum verification failed/, + ); + }); + + it("should throw with informative message including file path", async () => { + const filePath = join(tempDir, "tampered.json"); + writeFileSync(filePath, "original", "utf-8"); + const originalChecksum = await computeChecksum(filePath); + + // Tamper with file + writeFileSync(filePath, "tampered", "utf-8"); + + await expect(verifyChecksum(filePath, originalChecksum)).rejects.toThrow(filePath); + }); + + it("should detect even single-byte changes", async () => { + const filePath = join(tempDir, "exact.json"); + writeFileSync(filePath, "hello world", "utf-8"); + const hash = await computeChecksum(filePath); + + writeFileSync(filePath, "hello worlD", "utf-8"); + await expect(verifyChecksum(filePath, hash)).rejects.toThrow(/Checksum verification failed/); + }); + }); +}); diff --git a/tests/unit/registry/config.test.ts b/tests/unit/registry/config.test.ts new file mode 100644 index 0000000..d20f68f --- /dev/null +++ b/tests/unit/registry/config.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry } from "../../../src/registry/types.js"; + +// Mock homedir before importing registry config module +let tempHome: string = join(tmpdir(), `libscope-reg-cfg-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { + loadRegistries, + saveRegistries, + addRegistry, + removeRegistry, + getRegistry, + updateRegistrySyncTime, + validateRegistryName, + validateGitUrl, +} = await import("../../../src/registry/config.js"); + +function makeEntry(overrides: Partial = {}): RegistryEntry { + return { + name: "test-reg", + url: "https://github.com/org/registry.git", + syncInterval: 3600, + priority: 1, + lastSyncedAt: null, + ...overrides, + }; +} + +describe("registry config", () => { + beforeEach(() => { + initLogger("silent"); + tempHome = join(tmpdir(), `libscope-reg-cfg-${randomUUID()}`); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); + }); + + describe("validateRegistryName", () => { + it("should accept alphanumeric names with hyphens and underscores", () => { + expect(() => validateRegistryName("my-registry_01")).not.toThrow(); + }); + + it("should reject names with spaces", () => { + expect(() => validateRegistryName("bad name")).toThrow(/Invalid registry name/); + }); + + it("should reject names with special characters", () => { + expect(() => validateRegistryName("bad!name")).toThrow(/Invalid registry name/); + }); + + it("should reject empty string", () => { + expect(() => validateRegistryName("")).toThrow(/Invalid registry name/); + }); + }); + + describe("validateGitUrl", () => { + it("should accept https:// URLs", () => { + expect(() => validateGitUrl("https://github.com/org/repo.git")).not.toThrow(); + }); + + it("should accept SSH git@host:path URLs", () => { + expect(() => validateGitUrl("git@github.com:org/repo.git")).not.toThrow(); + }); + + it("should reject http:// URLs", () => { + expect(() => validateGitUrl("http://github.com/org/repo.git")).toThrow(); + }); + + it("should reject arbitrary strings", () => { + expect(() => validateGitUrl("not-a-url")).toThrow(); + }); + }); + + describe("loadRegistries", () => { + it("should return empty array when no config file exists", () => { + const registries = loadRegistries(); + expect(registries).toEqual([]); + }); + + it("should return empty array when config has no registries key", () => { + const dir = join(tempHome, ".libscope"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify({ other: true }), "utf-8"); + expect(loadRegistries()).toEqual([]); + }); + + it("should load valid registries array from config", () => { + const dir = join(tempHome, ".libscope"); + mkdirSync(dir, { recursive: true }); + const entries = [makeEntry({ name: "reg1" }), makeEntry({ name: "reg2" })]; + writeFileSync(join(dir, "config.json"), JSON.stringify({ registries: entries }), "utf-8"); + + const result = loadRegistries(); + expect(result).toHaveLength(2); + expect(result[0]!.name).toBe("reg1"); + expect(result[1]!.name).toBe("reg2"); + }); + + it("should throw on corrupted JSON", () => { + const dir = join(tempHome, ".libscope"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), "not-json!!!", "utf-8"); + expect(() => loadRegistries()).toThrow(); + }); + }); + + describe("saveRegistries", () => { + it("should write registries to config file", () => { + const entries = [makeEntry()]; + saveRegistries(entries); + const loaded = loadRegistries(); + expect(loaded).toHaveLength(1); + expect(loaded[0]!.name).toBe("test-reg"); + }); + + it("should create .libscope directory if it does not exist", () => { + saveRegistries([makeEntry()]); + const configPath = join(tempHome, ".libscope", "config.json"); + const raw = readFileSync(configPath, "utf-8"); + const parsed = JSON.parse(raw) as { registries: RegistryEntry[] }; + expect(parsed.registries).toHaveLength(1); + }); + + it("should preserve other config keys when saving registries", () => { + const dir = join(tempHome, ".libscope"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "config.json"), JSON.stringify({ otherKey: "keep-me" }), "utf-8"); + + saveRegistries([makeEntry()]); + + const raw = JSON.parse(readFileSync(join(dir, "config.json"), "utf-8")) as Record< + string, + unknown + >; + expect(raw["otherKey"]).toBe("keep-me"); + expect(raw["registries"]).toBeTruthy(); + }); + }); + + describe("addRegistry", () => { + it("should add a new registry entry", () => { + addRegistry(makeEntry({ name: "new-reg" })); + const registries = loadRegistries(); + expect(registries).toHaveLength(1); + expect(registries[0]!.name).toBe("new-reg"); + }); + + it("should reject duplicate registry name", () => { + addRegistry(makeEntry({ name: "dup" })); + expect(() => addRegistry(makeEntry({ name: "dup" }))).toThrow(/already exists/); + }); + + it("should reject invalid name", () => { + expect(() => addRegistry(makeEntry({ name: "bad name!" }))).toThrow(/Invalid registry name/); + }); + + it("should reject invalid URL", () => { + expect(() => addRegistry(makeEntry({ name: "valid-name", url: "ftp://bad" }))).toThrow(); + }); + }); + + describe("removeRegistry", () => { + it("should remove an existing registry", () => { + addRegistry(makeEntry({ name: "to-remove" })); + expect(loadRegistries()).toHaveLength(1); + removeRegistry("to-remove"); + expect(loadRegistries()).toHaveLength(0); + }); + + it("should throw when removing non-existent registry", () => { + expect(() => removeRegistry("nonexistent")).toThrow(/not found/); + }); + }); + + describe("getRegistry", () => { + it("should return registry entry by name", () => { + addRegistry(makeEntry({ name: "find-me" })); + const entry = getRegistry("find-me"); + expect(entry).toBeDefined(); + expect(entry!.name).toBe("find-me"); + }); + + it("should return undefined for non-existent name", () => { + expect(getRegistry("nope")).toBeUndefined(); + }); + }); + + describe("updateRegistrySyncTime", () => { + it("should update lastSyncedAt for a registry", () => { + addRegistry(makeEntry({ name: "sync-me" })); + expect(getRegistry("sync-me")!.lastSyncedAt).toBeNull(); + + updateRegistrySyncTime("sync-me"); + + const updated = getRegistry("sync-me"); + expect(updated!.lastSyncedAt).toBeTruthy(); + // Should be a valid ISO timestamp + expect(() => new Date(updated!.lastSyncedAt!)).not.toThrow(); + }); + + it("should be a no-op for non-existent registry", () => { + // Should not throw + expect(() => updateRegistrySyncTime("nonexistent")).not.toThrow(); + }); + }); +}); diff --git a/tests/unit/registry/conflict.test.ts b/tests/unit/registry/conflict.test.ts new file mode 100644 index 0000000..c7151bd --- /dev/null +++ b/tests/unit/registry/conflict.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-conflict-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { findPackInRegistries, resolvePackFromRegistries, parsePackSpecifier } = + await import("../../../src/registry/resolve.js"); +const { saveRegistries } = await import("../../../src/registry/config.js"); +const { getRegistryCacheDir, getPackDataPath } = await import("../../../src/registry/types.js"); +const { clearIndexCache } = await import("../../../src/registry/git.js"); + +function makeEntry(name: string, overrides: Partial = {}): RegistryEntry { + return { + name, + url: "https://github.com/org/registry.git", + syncInterval: 3600, + priority: 1, + lastSyncedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makePack(name: string, overrides: Partial = {}): PackSummary { + return { + name, + description: `The ${name} pack`, + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +function setupRegistry(regName: string, packs: PackSummary[]): void { + const cacheDir = getRegistryCacheDir(regName); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, "index.json"), JSON.stringify(packs), "utf-8"); +} + +function setupPackDataFile(regName: string, packName: string, version: string): void { + const dataPath = getPackDataPath(regName, packName, version); + mkdirSync(join(dataPath, ".."), { recursive: true }); + writeFileSync( + dataPath, + JSON.stringify({ + name: packName, + version, + description: "test", + documents: [], + metadata: { author: "test", license: "MIT", createdAt: "2026-01-01" }, + }), + "utf-8", + ); +} + +describe("registry conflict resolution", () => { + beforeEach(() => { + initLogger("silent"); + clearIndexCache(); + tempHome = join(tmpdir(), `libscope-conflict-${randomUUID()}`); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); + }); + + describe("parsePackSpecifier", () => { + it("should parse 'name' as name only", () => { + expect(parsePackSpecifier("react-docs")).toEqual({ name: "react-docs" }); + }); + + it("should parse 'name@version' into name and version", () => { + expect(parsePackSpecifier("react-docs@1.2.0")).toEqual({ + name: "react-docs", + version: "1.2.0", + }); + }); + + it("should handle scoped-like names with @ at the start", () => { + // Last @ is the version delimiter + expect(parsePackSpecifier("@org/pack@2.0.0")).toEqual({ + name: "@org/pack", + version: "2.0.0", + }); + }); + + it("should return just name when no @ after first character", () => { + expect(parsePackSpecifier("simple-pack")).toEqual({ name: "simple-pack" }); + }); + }); + + describe("findPackInRegistries", () => { + it("should return empty matches when no registries configured", () => { + const { matches } = findPackInRegistries("anything"); + expect(matches).toEqual([]); + }); + + it("should find pack in single registry", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("react-docs")]); + + const { matches } = findPackInRegistries("react-docs"); + expect(matches).toHaveLength(1); + expect(matches[0]!.entry.name).toBe("reg1"); + expect(matches[0]!.pack.name).toBe("react-docs"); + }); + + it("should find pack in multiple registries", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + + const { matches } = findPackInRegistries("shared-pack"); + expect(matches).toHaveLength(2); + }); + + it("should warn for unsynced registries", () => { + saveRegistries([makeEntry("unsynced")]); + // No cache dir created + + const { matches, warnings } = findPackInRegistries("anything"); + expect(matches).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("never been synced"); + }); + + it("should return empty matches when pack not found", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("other-pack")]); + + const { matches } = findPackInRegistries("nonexistent"); + expect(matches).toEqual([]); + }); + }); + + describe("resolvePackFromRegistries", () => { + it("should resolve pack from single registry", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("test-pack")]); + setupPackDataFile("reg1", "test-pack", "1.0.0"); + + const { resolved } = resolvePackFromRegistries("test-pack"); + expect(resolved).not.toBeNull(); + expect(resolved!.registryName).toBe("reg1"); + expect(resolved!.packName).toBe("test-pack"); + expect(resolved!.version).toBe("1.0.0"); + }); + + it("should return null when pack not found anywhere", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("other-pack")]); + + const { resolved } = resolvePackFromRegistries("nonexistent"); + expect(resolved).toBeNull(); + }); + + it("should detect conflict when pack exists in multiple registries", () => { + saveRegistries([makeEntry("reg1", { priority: 2 }), makeEntry("reg2", { priority: 1 })]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + setupPackDataFile("reg2", "shared-pack", "1.0.0"); + + // Default resolution is "priority" — reg2 has lower priority (wins) + const { resolved } = resolvePackFromRegistries("shared-pack"); + expect(resolved).not.toBeNull(); + expect(resolved!.registryName).toBe("reg2"); + }); + + it("should resolve conflict by priority (lower wins)", () => { + saveRegistries([makeEntry("reg-a", { priority: 10 }), makeEntry("reg-b", { priority: 1 })]); + setupRegistry("reg-a", [makePack("shared-pack")]); + setupRegistry("reg-b", [makePack("shared-pack")]); + setupPackDataFile("reg-b", "shared-pack", "1.0.0"); + + const { resolved } = resolvePackFromRegistries("shared-pack", { + conflictResolution: { strategy: "priority" }, + }); + expect(resolved!.registryName).toBe("reg-b"); + }); + + it("should resolve conflict with explicit registry", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + setupPackDataFile("reg1", "shared-pack", "1.0.0"); + + const { resolved } = resolvePackFromRegistries("shared-pack", { + conflictResolution: { strategy: "explicit", registryName: "reg1" }, + }); + expect(resolved!.registryName).toBe("reg1"); + }); + + it("should return conflict for interactive strategy without resolving", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + + const { resolved, conflict } = resolvePackFromRegistries("shared-pack", { + conflictResolution: { strategy: "interactive" }, + }); + expect(resolved).toBeNull(); + expect(conflict).toBeDefined(); + expect(conflict!.sources).toHaveLength(2); + expect(conflict!.packName).toBe("shared-pack"); + }); + + it("should return null when explicit registry doesn't have the pack", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + + const { resolved } = resolvePackFromRegistries("shared-pack", { + registryName: "reg3", + }); + expect(resolved).toBeNull(); + }); + + it("should filter to specified registryName option", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("shared-pack")]); + setupRegistry("reg2", [makePack("shared-pack")]); + setupPackDataFile("reg1", "shared-pack", "1.0.0"); + + const { resolved } = resolvePackFromRegistries("shared-pack", { + registryName: "reg1", + }); + expect(resolved!.registryName).toBe("reg1"); + }); + + it("should use specified version", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("test-pack", { latestVersion: "2.0.0" })]); + setupPackDataFile("reg1", "test-pack", "1.0.0"); + + const { resolved } = resolvePackFromRegistries("test-pack", { version: "1.0.0" }); + expect(resolved!.version).toBe("1.0.0"); + }); + + it("should include all candidate registries in conflict", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2"), makeEntry("reg3")]); + setupRegistry("reg1", [makePack("shared")]); + setupRegistry("reg2", [makePack("shared")]); + setupRegistry("reg3", [makePack("shared")]); + + const { conflict } = resolvePackFromRegistries("shared", { + conflictResolution: { strategy: "interactive" }, + }); + expect(conflict!.sources).toHaveLength(3); + }); + + it("should not conflict when packs have different names", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("pack-a")]); + setupRegistry("reg2", [makePack("pack-b")]); + setupPackDataFile("reg1", "pack-a", "1.0.0"); + + const { resolved, conflict } = resolvePackFromRegistries("pack-a"); + expect(resolved).not.toBeNull(); + expect(conflict).toBeUndefined(); + }); + }); +}); diff --git a/tests/unit/registry/git.test.ts b/tests/unit/registry/git.test.ts new file mode 100644 index 0000000..d953ca5 --- /dev/null +++ b/tests/unit/registry/git.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import { + readIndex, + createRegistryRepo, + checkGitAvailable, + cloneRegistry, + fetchRegistry, + commitAndPush, +} from "../../../src/registry/git.js"; + +describe("registry git helpers", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-git-test-")); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("checkGitAvailable", () => { + it("should return true when git is available", async () => { + const result = await checkGitAvailable(); + expect(result).toBe(true); + }); + }); + + describe("readIndex", () => { + it("should return empty array when index.json does not exist", () => { + const result = readIndex(tempDir); + expect(result).toEqual([]); + }); + + it("should parse a valid index.json array", () => { + const index = [ + { + name: "react-docs", + description: "React documentation", + tags: ["react"], + latestVersion: "1.0.0", + author: "team", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + ]; + writeFileSync(join(tempDir, "index.json"), JSON.stringify(index), "utf-8"); + + const result = readIndex(tempDir); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("react-docs"); + }); + + it("should throw when index.json is not an array", () => { + writeFileSync(join(tempDir, "index.json"), JSON.stringify({ not: "array" }), "utf-8"); + expect(() => readIndex(tempDir)).toThrow(/not an array/); + }); + + it("should throw when index.json is invalid JSON", () => { + writeFileSync(join(tempDir, "index.json"), "bad json!!!", "utf-8"); + expect(() => readIndex(tempDir)).toThrow(/Failed to read/); + }); + + it("should parse an empty array", () => { + writeFileSync(join(tempDir, "index.json"), "[]", "utf-8"); + const result = readIndex(tempDir); + expect(result).toEqual([]); + }); + + it("should parse index with multiple packs", () => { + const index = [ + { + name: "pack-a", + description: "First", + tags: [], + latestVersion: "1.0.0", + author: "a", + updatedAt: "2026-01-01", + }, + { + name: "pack-b", + description: "Second", + tags: ["tag1"], + latestVersion: "2.0.0", + author: "b", + updatedAt: "2026-02-01", + }, + ]; + writeFileSync(join(tempDir, "index.json"), JSON.stringify(index), "utf-8"); + + const result = readIndex(tempDir); + expect(result).toHaveLength(2); + expect(result.map((r) => r.name)).toEqual(["pack-a", "pack-b"]); + }); + }); + + describe("createRegistryRepo", () => { + it("should create a git repo with index.json and packs/ dir", async () => { + const repoPath = join(tempDir, "new-registry"); + await createRegistryRepo(repoPath); + + // Verify git repo + expect(existsSync(join(repoPath, ".git"))).toBe(true); + + // Verify index.json + const indexContent = readFileSync(join(repoPath, "index.json"), "utf-8"); + expect(JSON.parse(indexContent)).toEqual([]); + + // Verify packs/ dir + expect(existsSync(join(repoPath, "packs"))).toBe(true); + expect(existsSync(join(repoPath, "packs", ".gitkeep"))).toBe(true); + }); + + it("should throw when path already exists", async () => { + const repoPath = join(tempDir, "exists"); + mkdirSync(repoPath); + await expect(createRegistryRepo(repoPath)).rejects.toThrow(/already exists/); + }); + + it("should have an initial commit", async () => { + const repoPath = join(tempDir, "committed-registry"); + await createRegistryRepo(repoPath); + + // Check git log + const log = execSync("git log --oneline", { cwd: repoPath, encoding: "utf-8" }); + expect(log).toContain("Initial registry structure"); + }); + }); + + describe("cloneRegistry + fetchRegistry", () => { + it("should clone a bare repo and fetch updates", async () => { + // Create a bare repo with content + const bareDir = join(tempDir, "bare.git"); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + // Set up a work dir, add content, push + const workDir = join(tempDir, "work"); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + writeFileSync(join(workDir, "index.json"), "[]", "utf-8"); + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + + // Clone via our helper + const cloneDir = join(tempDir, "cloned"); + await cloneRegistry(bareDir, cloneDir); + expect(existsSync(join(cloneDir, "index.json"))).toBe(true); + + // Push new content to bare + writeFileSync(join(workDir, "index.json"), '[{"name":"new"}]', "utf-8"); + execSync("git add . && git commit -m 'update'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + + // Fetch via our helper + await fetchRegistry(cloneDir); + const content = readFileSync(join(cloneDir, "index.json"), "utf-8"); + expect(content).toContain("new"); + }); + }); + + describe("commitAndPush", () => { + it("should commit and push changes to bare repo", async () => { + // Create bare repo + const bareDir = join(tempDir, "push-bare.git"); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + + // Clone and add initial commit + const workDir = join(tempDir, "push-work"); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + writeFileSync(join(workDir, "file.txt"), "initial", "utf-8"); + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + + // Modify and use commitAndPush + writeFileSync(join(workDir, "file.txt"), "updated", "utf-8"); + await commitAndPush(workDir, "test commit"); + + // Verify by cloning fresh + const verifyDir = join(tempDir, "verify"); + execSync(`git clone "${bareDir}" "${verifyDir}"`, { stdio: "pipe" }); + const content = readFileSync(join(verifyDir, "file.txt"), "utf-8"); + expect(content).toBe("updated"); + }); + }); +}); diff --git a/tests/unit/registry/search.test.ts b/tests/unit/registry/search.test.ts new file mode 100644 index 0000000..0f9522e --- /dev/null +++ b/tests/unit/registry/search.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +// Mock homedir before importing any registry modules — REGISTRIES_DIR is module-level +let tempHome: string = join(tmpdir(), `libscope-search-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +// Import AFTER mock is set up — getRegistryCacheDir picks up mocked homedir +const { searchRegistries } = await import("../../../src/registry/search.js"); +const { saveRegistries } = await import("../../../src/registry/config.js"); +const { getRegistryCacheDir } = await import("../../../src/registry/types.js"); +const { clearIndexCache } = await import("../../../src/registry/git.js"); + +function makeEntry(name: string, overrides: Partial = {}): RegistryEntry { + return { + name, + url: "https://github.com/org/registry.git", + syncInterval: 3600, + priority: 1, + lastSyncedAt: new Date().toISOString(), + ...overrides, + }; +} + +function makePack(name: string, overrides: Partial = {}): PackSummary { + return { + name, + description: `The ${name} pack`, + tags: [], + latestVersion: "1.0.0", + author: "author", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }; +} + +/** Helper: set up a fake registry cache with given packs in index.json */ +function setupRegistry(name: string, packs: PackSummary[]): void { + const cacheDir = getRegistryCacheDir(name); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, "index.json"), JSON.stringify(packs), "utf-8"); +} + +describe("registry search", () => { + beforeEach(() => { + initLogger("silent"); + clearIndexCache(); + tempHome = join(tmpdir(), `libscope-search-${randomUUID()}`); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempHome, { recursive: true, force: true }); + }); + + it("should return empty results when no registries configured", () => { + const { results, warnings } = searchRegistries("anything"); + expect(results).toEqual([]); + expect(warnings).toEqual([]); + }); + + it("should warn when a registry has never been synced", () => { + saveRegistries([makeEntry("unsynced")]); + // Don't create cache dir + + const { results, warnings } = searchRegistries("test"); + expect(results).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("never been synced"); + }); + + it("should find pack by exact name match", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("react-docs")]); + + const { results } = searchRegistries("react-docs"); + expect(results).toHaveLength(1); + expect(results[0]!.pack.name).toBe("react-docs"); + // 100 (exact name) + 20 (description contains "react-docs" via default desc) + expect(results[0]!.score).toBeGreaterThanOrEqual(100); + }); + + it("should find pack by partial name match", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("react-docs")]); + + const { results } = searchRegistries("react"); + expect(results).toHaveLength(1); + expect(results[0]!.score).toBeGreaterThanOrEqual(50); // partial name match + }); + + it("should find pack by description match", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("my-pack", { description: "React documentation pack" })]); + + const { results } = searchRegistries("documentation"); + expect(results).toHaveLength(1); + expect(results[0]!.score).toBeGreaterThanOrEqual(20); + }); + + it("should find pack by tag exact match", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("my-pack", { tags: ["react", "frontend"] })]); + + const { results } = searchRegistries("react"); + expect(results).toHaveLength(1); + expect(results[0]!.score).toBeGreaterThanOrEqual(30); + }); + + it("should find pack by author match", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("some-pack", { author: "john-doe" })]); + + const { results } = searchRegistries("john"); + expect(results).toHaveLength(1); + expect(results[0]!.score).toBeGreaterThanOrEqual(10); + }); + + it("should return no results for non-matching query", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("react-docs")]); + + const { results } = searchRegistries("completely-unrelated-xyz"); + expect(results).toEqual([]); + }); + + it("should search across multiple registries", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("react-docs")]); + setupRegistry("reg2", [makePack("vue-docs")]); + + const { results } = searchRegistries("docs"); + expect(results).toHaveLength(2); + expect(results.map((r) => r.pack.name).sort()).toEqual(["react-docs", "vue-docs"]); + }); + + it("should sort results by score descending", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [ + makePack("react", { description: "React framework", tags: ["react"] }), + makePack("react-docs", { description: "Docs for React" }), + ]); + + const { results } = searchRegistries("react"); + // "react" has exact name match (100) + more → higher score + // "react-docs" has partial name match (50) + less + expect(results[0]!.pack.name).toBe("react"); + expect(results[0]!.score).toBeGreaterThan(results[1]!.score); + }); + + it("should be case-insensitive", () => { + saveRegistries([makeEntry("reg1")]); + setupRegistry("reg1", [makePack("React-Docs")]); + + const { results } = searchRegistries("REACT"); + expect(results).toHaveLength(1); + }); + + it("should filter by specific registry when registryName option is provided", () => { + saveRegistries([makeEntry("reg1"), makeEntry("reg2")]); + setupRegistry("reg1", [makePack("react-docs")]); + setupRegistry("reg2", [makePack("vue-docs")]); + + const { results } = searchRegistries("docs", { registryName: "reg1" }); + expect(results).toHaveLength(1); + expect(results[0]!.registryName).toBe("reg1"); + }); + + it("should warn when specified registryName does not exist", () => { + const { results, warnings } = searchRegistries("test", { registryName: "nonexistent" }); + expect(results).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("not found"); + }); + + it("should include registryName in results", () => { + saveRegistries([makeEntry("my-registry")]); + setupRegistry("my-registry", [makePack("test-pack")]); + + const { results } = searchRegistries("test"); + expect(results[0]!.registryName).toBe("my-registry"); + }); + + it("should handle corrupted index.json gracefully", () => { + saveRegistries([makeEntry("bad-reg")]); + const cacheDir = getRegistryCacheDir("bad-reg"); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(join(cacheDir, "index.json"), "invalid json!", "utf-8"); + + const { results, warnings } = searchRegistries("test"); + expect(results).toEqual([]); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain("Failed to read"); + }); +}); diff --git a/tests/unit/registry/stale-cache.test.ts b/tests/unit/registry/stale-cache.test.ts new file mode 100644 index 0000000..834215e --- /dev/null +++ b/tests/unit/registry/stale-cache.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { RegistryEntry } from "../../../src/registry/types.js"; +import { isRegistryStale } from "../../../src/registry/sync.js"; + +function makeEntry(overrides: Partial = {}): RegistryEntry { + return { + name: "test-reg", + url: "https://github.com/org/registry.git", + syncInterval: 3600, // 1 hour + priority: 1, + lastSyncedAt: null, + ...overrides, + }; +} + +describe("registry stale-cache detection", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe("isRegistryStale", () => { + it("should return true when lastSyncedAt is null (never synced)", () => { + const entry = makeEntry({ lastSyncedAt: null }); + expect(isRegistryStale(entry)).toBe(true); + }); + + it("should return false when syncInterval is 0 (manual only)", () => { + const entry = makeEntry({ syncInterval: 0, lastSyncedAt: null }); + expect(isRegistryStale(entry)).toBe(false); + }); + + it("should return false when syncInterval is negative", () => { + const entry = makeEntry({ syncInterval: -1, lastSyncedAt: null }); + expect(isRegistryStale(entry)).toBe(false); + }); + + it("should return true when last sync was longer ago than syncInterval", () => { + const now = new Date("2026-03-11T12:00:00.000Z"); + vi.setSystemTime(now); + + // Last synced 2 hours ago, interval is 1 hour + const entry = makeEntry({ + syncInterval: 3600, + lastSyncedAt: "2026-03-11T10:00:00.000Z", + }); + expect(isRegistryStale(entry)).toBe(true); + }); + + it("should return false when last sync was within syncInterval", () => { + const now = new Date("2026-03-11T12:00:00.000Z"); + vi.setSystemTime(now); + + // Last synced 30 minutes ago, interval is 1 hour + const entry = makeEntry({ + syncInterval: 3600, + lastSyncedAt: "2026-03-11T11:30:00.000Z", + }); + expect(isRegistryStale(entry)).toBe(false); + }); + + it("should return true at exactly the boundary (1 ms past interval)", () => { + // syncInterval = 60 seconds = 60000ms + const entry = makeEntry({ syncInterval: 60 }); + const baseTime = new Date("2026-03-11T12:00:00.000Z"); + entry.lastSyncedAt = baseTime.toISOString(); + + // Set time to 60001ms later (1ms past the boundary) + vi.setSystemTime(new Date(baseTime.getTime() + 60001)); + expect(isRegistryStale(entry)).toBe(true); + }); + + it("should return false at exactly the boundary (exactly syncInterval)", () => { + const entry = makeEntry({ syncInterval: 60 }); + const baseTime = new Date("2026-03-11T12:00:00.000Z"); + entry.lastSyncedAt = baseTime.toISOString(); + + // Set time to exactly 60000ms later + vi.setSystemTime(new Date(baseTime.getTime() + 60000)); + expect(isRegistryStale(entry)).toBe(false); + }); + + it("should handle very short syncInterval (1 second)", () => { + const entry = makeEntry({ syncInterval: 1 }); + const baseTime = new Date("2026-03-11T12:00:00.000Z"); + entry.lastSyncedAt = baseTime.toISOString(); + + vi.setSystemTime(new Date(baseTime.getTime() + 2000)); + expect(isRegistryStale(entry)).toBe(true); + }); + + it("should handle very large syncInterval (24 hours)", () => { + const entry = makeEntry({ syncInterval: 86400 }); + const now = new Date("2026-03-11T12:00:00.000Z"); + vi.setSystemTime(now); + + // Synced 12 hours ago — still fresh + entry.lastSyncedAt = "2026-03-11T00:00:00.000Z"; + expect(isRegistryStale(entry)).toBe(false); + + // Synced 25 hours ago — stale + entry.lastSyncedAt = "2026-03-10T11:00:00.000Z"; + expect(isRegistryStale(entry)).toBe(true); + }); + }); +}); diff --git a/tests/unit/registry/sync.test.ts b/tests/unit/registry/sync.test.ts new file mode 100644 index 0000000..37a1c33 --- /dev/null +++ b/tests/unit/registry/sync.test.ts @@ -0,0 +1,204 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { mkdirSync, writeFileSync, rmSync, mkdtempSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { randomUUID } from "node:crypto"; +import { execSync } from "node:child_process"; +import { initLogger } from "../../../src/logger.js"; +import type { RegistryEntry, PackSummary } from "../../../src/registry/types.js"; + +let tempHome: string = join(tmpdir(), `libscope-sync-test-${process.pid}`); +mkdirSync(tempHome, { recursive: true }); + +vi.mock("node:os", async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + homedir: () => tempHome, + }; +}); + +const { + syncRegistry, + syncAllRegistries, + syncStaleRegistries, + syncRegistryByName, + getRegistryIndex, +} = await import("../../../src/registry/sync.js"); +const { loadRegistries, saveRegistries } = await import("../../../src/registry/config.js"); + +function makeEntry( + name: string, + url: string, + overrides: Partial = {}, +): RegistryEntry { + return { + name, + url, + syncInterval: 3600, + priority: 1, + lastSyncedAt: null, + ...overrides, + }; +} + +function addTestRegistry(entry: RegistryEntry): void { + const registries = loadRegistries(); + registries.push(entry); + saveRegistries(registries); +} + +function createBareRepo(dir: string, packs: PackSummary[] = []): string { + const bareDir = join(dir, `registry-${randomUUID()}.git`); + execSync(`git init --bare "${bareDir}"`, { stdio: "pipe" }); + const workDir = join(dir, `work-${randomUUID()}`); + execSync(`git clone "${bareDir}" "${workDir}"`, { stdio: "pipe" }); + writeFileSync(join(workDir, "index.json"), JSON.stringify(packs), "utf-8"); + execSync("git add . && git commit -m 'init'", { cwd: workDir, stdio: "pipe" }); + execSync("git push", { cwd: workDir, stdio: "pipe" }); + return bareDir; +} + +describe("registry sync functions", () => { + let tempDir: string; + + beforeEach(() => { + initLogger("silent"); + tempDir = mkdtempSync(join(tmpdir(), "libscope-sync-")); + tempHome = join(tempDir, "home"); + mkdirSync(tempHome, { recursive: true }); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("syncRegistryByName", () => { + it("should return error for non-existent registry name", async () => { + const status = await syncRegistryByName("nonexistent"); + expect(status.status).toBe("error"); + expect(status.error).toContain("not found"); + }); + + it("should sync an existing registry by name", async () => { + const bareRepo = createBareRepo(tempDir); + addTestRegistry(makeEntry("by-name", bareRepo)); + const status = await syncRegistryByName("by-name"); + expect(status.status).toBe("success"); + }); + }); + + describe("syncAllRegistries", () => { + it("should return empty array when no registries configured", async () => { + const results = await syncAllRegistries(); + expect(results).toEqual([]); + }); + + it("should sync all configured registries", async () => { + const repo1 = createBareRepo(tempDir); + const repo2 = createBareRepo(tempDir); + addTestRegistry(makeEntry("all-1", repo1)); + addTestRegistry(makeEntry("all-2", repo2)); + + const results = await syncAllRegistries(); + expect(results).toHaveLength(2); + expect(results.every((r) => r.status === "success")).toBe(true); + }); + }); + + describe("syncStaleRegistries", () => { + it("should return empty array when no registries are stale", async () => { + const repo = createBareRepo(tempDir); + addTestRegistry( + makeEntry("fresh", repo, { + syncInterval: 99999, + lastSyncedAt: new Date().toISOString(), + }), + ); + const results = await syncStaleRegistries(); + expect(results).toEqual([]); + }); + + it("should sync registries that are stale", async () => { + const repo = createBareRepo(tempDir); + addTestRegistry( + makeEntry("stale-one", repo, { + syncInterval: 1, + lastSyncedAt: "2020-01-01T00:00:00.000Z", // very old + }), + ); + const results = await syncStaleRegistries(); + expect(results).toHaveLength(1); + expect(results[0]!.status).toBe("success"); + }); + + it("should return empty when all registries have syncInterval=0 (manual)", async () => { + const repo = createBareRepo(tempDir); + addTestRegistry(makeEntry("manual", repo, { syncInterval: 0 })); + const results = await syncStaleRegistries(); + expect(results).toEqual([]); + }); + }); + + describe("getRegistryIndex", () => { + it("should return packs from a synced registry", async () => { + const packs: PackSummary[] = [ + { + name: "test-pack", + description: "Test", + tags: [], + latestVersion: "1.0.0", + author: "a", + updatedAt: "2026-01-01", + }, + ]; + const repo = createBareRepo(tempDir, packs); + addTestRegistry(makeEntry("idx-test", repo)); + await syncRegistry(makeEntry("idx-test", repo)); + + const entry = makeEntry("idx-test", repo, { + syncInterval: 0, // manual, won't auto-sync + lastSyncedAt: new Date().toISOString(), + }); + const { packs: result, warning } = await getRegistryIndex(entry); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("test-pack"); + expect(warning).toBeUndefined(); + }); + + it("should return warning when remote unreachable and has stale cache", async () => { + const packs: PackSummary[] = [ + { + name: "offline-pack", + description: "Offline", + tags: [], + latestVersion: "1.0.0", + author: "a", + updatedAt: "2026-01-01", + }, + ]; + const repo = createBareRepo(tempDir, packs); + const entry = makeEntry("offline-idx", repo, { + syncInterval: 1, + lastSyncedAt: "2020-01-01T00:00:00.000Z", + }); + addTestRegistry(entry); + + // Sync once to populate cache + await syncRegistry(entry); + + // Break the remote + const { renameSync } = await import("node:fs"); + renameSync(repo, repo + ".broken"); + + // getRegistryIndex should fall back to cache with warning + const staleEntry = makeEntry("offline-idx", repo, { + syncInterval: 1, + lastSyncedAt: "2020-01-01T00:00:00.000Z", + }); + const { packs: result, warning } = await getRegistryIndex(staleEntry); + expect(result).toHaveLength(1); + expect(warning).toContain("unreachable"); + }); + }); +}); diff --git a/tests/unit/registry/types.test.ts b/tests/unit/registry/types.test.ts new file mode 100644 index 0000000..e5e0c6f --- /dev/null +++ b/tests/unit/registry/types.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from "vitest"; +import { + INDEX_FILE, + PACKS_DIR, + PACK_MANIFEST_FILE, + CHECKSUM_FILE, + getRegistryCacheDir, + getRegistryIndexPath, + getPackManifestPath, + getPackVersionDir, + getPackDataPath, + getChecksumPath, +} from "../../../src/registry/types.js"; +import type { + RegistryEntry, + PackSummary, + PackManifest, + RegistryConfigBlock, + ConflictResolution, + RegistrySyncStatus, +} from "../../../src/registry/types.js"; + +describe("registry types — constants", () => { + it("should export correct file name constants", () => { + expect(INDEX_FILE).toBe("index.json"); + expect(PACK_MANIFEST_FILE).toBe("pack.json"); + expect(CHECKSUM_FILE).toBe("checksum.sha256"); + expect(PACKS_DIR).toBe("packs"); + }); +}); + +describe("registry types — path helpers", () => { + it("getRegistryCacheDir should return path under ~/.libscope/registries/", () => { + const dir = getRegistryCacheDir("official"); + expect(dir).toContain("registries"); + expect(dir).toContain("official"); + }); + + it("getRegistryIndexPath should end with index.json", () => { + const p = getRegistryIndexPath("my-reg"); + expect(p).toMatch(/my-reg[/\\]index\.json$/); + }); + + it("getPackManifestPath should include packs//pack.json", () => { + const p = getPackManifestPath("my-reg", "react-pack"); + expect(p).toContain("packs"); + expect(p).toContain("react-pack"); + expect(p).toMatch(/pack\.json$/); + }); + + it("getPackVersionDir should include packs//", () => { + const p = getPackVersionDir("my-reg", "react-pack", "1.2.0"); + expect(p).toContain("react-pack"); + expect(p).toContain("1.2.0"); + }); + + it("getPackDataPath should return /.json", () => { + const p = getPackDataPath("my-reg", "react-pack", "1.0.0"); + expect(p).toMatch(/1\.0\.0[/\\]react-pack\.json$/); + }); + + it("getChecksumPath should return /checksum.sha256", () => { + const p = getChecksumPath("my-reg", "react-pack", "2.0.0"); + expect(p).toMatch(/2\.0\.0[/\\]checksum\.sha256$/); + }); +}); + +describe("registry types — type shape validation", () => { + // These tests verify that objects conforming to the interfaces compile and have expected structure. + // Parsing/validation functions will be tested once implemented in Tasks 2-6. + + it("RegistryEntry should have all required fields", () => { + const entry: RegistryEntry = { + name: "official", + url: "https://github.com/org/registry.git", + syncInterval: 3600, + priority: 1, + lastSyncedAt: null, + }; + expect(entry.name).toBe("official"); + expect(entry.syncInterval).toBe(3600); + expect(entry.lastSyncedAt).toBeNull(); + }); + + it("PackSummary should have name, description, tags, latestVersion, author, updatedAt", () => { + const summary: PackSummary = { + name: "react-docs", + description: "React documentation pack", + tags: ["react", "frontend"], + latestVersion: "1.0.0", + author: "team", + updatedAt: "2026-01-01T00:00:00.000Z", + }; + expect(summary.tags).toHaveLength(2); + expect(summary.latestVersion).toBe("1.0.0"); + }); + + it("PackManifest should include versions array with PackVersionEntry items", () => { + const manifest: PackManifest = { + name: "react-docs", + description: "React documentation", + tags: ["react"], + author: "team", + license: "MIT", + versions: [ + { + version: "1.0.0", + publishedAt: "2026-01-01T00:00:00.000Z", + checksumPath: "1.0.0/checksum.sha256", + checksum: "abc123", + docCount: 5, + }, + ], + }; + expect(manifest.versions).toHaveLength(1); + expect(manifest.versions[0]!.docCount).toBe(5); + }); + + it("RegistryConfigBlock should wrap registries array", () => { + const block: RegistryConfigBlock = { + registries: [], + }; + expect(block.registries).toEqual([]); + }); + + it("ConflictResolution should support 'priority', 'interactive', and 'explicit' strategies", () => { + const byPriority: ConflictResolution = { strategy: "priority" }; + const interactive: ConflictResolution = { strategy: "interactive" }; + const explicit: ConflictResolution = { strategy: "explicit", registryName: "official" }; + expect(byPriority.strategy).toBe("priority"); + expect(interactive.strategy).toBe("interactive"); + expect(explicit.strategy).toBe("explicit"); + }); + + it("RegistrySyncStatus should support all status values", () => { + const statuses: RegistrySyncStatus["status"][] = ["syncing", "success", "error", "offline"]; + expect(statuses).toHaveLength(4); + }); +}); + +// TODO: Once parse/validate functions are implemented (Tasks 2-6), add: +// describe("parseRegistryIndex") — validate index.json shape, reject malformed input +// describe("parsePackManifest") — validate pack.json shape, reject missing fields +// describe("validateRegistryEntry") — reject empty name, invalid URL, etc.