diff --git a/docs/registry.md b/docs/registry.md index 521f4d0a..4d9a7385 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -16,8 +16,11 @@ is the `registry/dist/` folder of the SDK's repository served as static JSON. When that host cannot be reached, `lousho add` reads the same committed folder from GitHub instead: the release tag of the installed SDK version (`https://raw.githubusercontent.com/LinuxDevil/agent-sdk//registry/dist/index.json`), -then the same tag with a `v` prefix (older releases), then `main`; -only the default falls back, never a registry you named. +then the same tag with a `v` prefix (older releases), then `main`. +If GitHub cannot be reached either (offline, an outage, a version whose tag was +never pushed), `lousho add` reads the copy of `registry/dist/` that ships inside +the installed package and prints a one-line notice that this bundled registry +may be older than the online one. Only the default falls back, never a registry you named. `--registry` and `LOUSHO_REGISTRY` point it at another registry, and the value `none` (in either place) disables the registry entirely — `lousho add` then fails with `LOUSHO_CONFIG_INVALID`, for offline or locked-down use. diff --git a/llms-full.txt b/llms-full.txt index 3225539a..ee6d4b2c 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -15777,8 +15777,11 @@ is the `registry/dist/` folder of the SDK's repository served as static JSON. When that host cannot be reached, `lousho add` reads the same committed folder from GitHub instead: the release tag of the installed SDK version (`https://raw.githubusercontent.com/LinuxDevil/agent-sdk//registry/dist/index.json`), -then the same tag with a `v` prefix (older releases), then `main`; -only the default falls back, never a registry you named. +then the same tag with a `v` prefix (older releases), then `main`. +If GitHub cannot be reached either (offline, an outage, a version whose tag was +never pushed), `lousho add` reads the copy of `registry/dist/` that ships inside +the installed package and prints a one-line notice that this bundled registry +may be older than the online one. Only the default falls back, never a registry you named. `--registry` and `LOUSHO_REGISTRY` point it at another registry, and the value `none` (in either place) disables the registry entirely — `lousho add` then fails with `LOUSHO_CONFIG_INVALID`, for offline or locked-down use. diff --git a/package.json b/package.json index 1604e781..9b8ec66f 100644 --- a/package.json +++ b/package.json @@ -288,6 +288,7 @@ "!src/**/__snapshots__", "!src/**/__cassettes__", "bin", + "registry/dist", "apps/agent-forge/package.json", "apps/agent-forge/dist", "apps/agent-forge/dist-server", diff --git a/src/cli/add.test.ts b/src/cli/add.test.ts index c38bf142..a54a68d1 100644 --- a/src/cli/add.test.ts +++ b/src/cli/add.test.ts @@ -245,12 +245,23 @@ describe('lousho add', () => { expect(explicit.code).toBe(1); expect(explicit.err).toContain('LOUSHO_REGISTRY_UNREACHABLE'); - // Everything down: one error naming every URL tried. - const down = await add(['--list'], { fetch: stubWhere(undefined, []) }); - expect(down.code).toBe(1); - expect(down.err).toContain('the default registry is unreachable'); - for (const url of [DEFAULT_REGISTRY, ...DEFAULT_REGISTRY_FALLBACKS]) expect(down.err).toContain(url); - expect(down.err).toContain('--registry'); + // Everything down: the registry bundled in the package answers (index and item), with a notice. + const seenDown: string[] = []; + const down = await add(['--list'], { fetch: stubWhere(undefined, seenDown) }); + expect(down.code).toBe(0); + expect(seenDown).toEqual([DEFAULT_REGISTRY, ...DEFAULT_REGISTRY_FALLBACKS]); + expect(down.err).toContain('using the registry bundled with this lousho version'); + expect(down.out).toContain('changelog'); + const offline = await add(['code-review', '--yes', '--dir', 'agent'], { fetch: stubWhere(undefined, []) }); + expect(offline.code).toBe(0); + expect(offline.out).toContain('Added code-review'); + expect(readReceipt().items['code-review'].registry).toBe(path.join(dist, 'index.json')); + + // An explicit registry still fails when it is down, with no bundled fallback. + const explicitDown = await add(['--list', '--registry', 'https://example.invalid/index.json'], { fetch: stubWhere(undefined, []) }); + expect(explicitDown.code).toBe(1); + expect(explicitDown.err).toContain('LOUSHO_REGISTRY_UNREACHABLE'); + expect(explicitDown.err).not.toContain('bundled'); }); it('suggests the closest name for an unknown item', async () => { diff --git a/src/cli/add.ts b/src/cli/add.ts index b967ecb1..12ac60b6 100644 --- a/src/cli/add.ts +++ b/src/cli/add.ts @@ -263,7 +263,8 @@ async function install(args: AddArgs, registry: string, item: RegistryItem, io: } async function runParsed(args: AddArgs, io: AddIo): Promise { - const { registry, index } = await openRegistry({ registry: args.registry, env: io.env, fetch: io.fetch }); + const { registry, index } = await openRegistry({ registry: args.registry, env: io.env, fetch: io.fetch, onNotice: (message) => io.stderr.write(`${message} +`) }); if (args.list || !args.name) { io.stdout.write(`${listLines(index).join('\n')}\n`); return 0; diff --git a/src/cli/registry.ts b/src/cli/registry.ts index 4029d5eb..bf1f64a6 100644 --- a/src/cli/registry.ts +++ b/src/cli/registry.ts @@ -3,9 +3,11 @@ * document per item, at a URL or a local path. Reading is all this does; nothing * from a registry is executed or imported. See docs/registry.md. */ +import { existsSync } from 'node:fs'; import { readFile } from 'node:fs/promises'; import * as path from 'node:path'; import { z } from 'zod'; +import { findSdkRoot } from '../deploy/bundle'; import { SDKError } from '../execution/errors'; import { isHostPattern } from '../security/hostPattern'; import { closestMatch } from '../utils/closestMatch'; @@ -112,6 +114,8 @@ export interface RegistryOptions { registry?: string; env?: Record; fetch?: typeof fetch; + /** Called with a one-line notice when the default registry is served from the copy bundled in the package. */ + onNotice?: (message: string) => void; } const isHttp = (source: string): boolean => /^https?:\/\//i.test(source); @@ -170,6 +174,16 @@ function registrySource(options: RegistryOptions): string { return source || DEFAULT_REGISTRY; } +/** The `registry/dist/index.json` shipped inside the installed package, or undefined when this copy has none (e.g. a trimmed install). */ +function bundledIndexPath(): string | undefined { + try { + const file = path.join(findSdkRoot(), 'registry', 'dist', 'index.json'); + return existsSync(file) ? file : undefined; + } catch { + return undefined; + } +} + async function loadIndex(registry: string, options: RegistryOptions): Promise { return readJson(registry, IndexSchema, options); } @@ -178,7 +192,8 @@ async function loadIndex(registry: string, options: RegistryOptions): Promise { const registry = registrySource(options); @@ -186,22 +201,40 @@ export async function openRegistry(options: RegistryOptions): Promise<{ registry return { registry, index: await loadIndex(registry, options) }; } catch (error) { if (registry !== DEFAULT_REGISTRY || !(error instanceof SDKError) || error.code !== 'LOUSHO_REGISTRY_UNREACHABLE') throw error; - const reason = (e: SDKError) => e.detail.replace(/^lousho add: /, ''); - const failures: string[] = []; - for (const fallback of DEFAULT_REGISTRY_FALLBACKS) { - try { - return { registry: fallback, index: await loadIndex(fallback, options) }; - } catch (fallbackError) { - if (!(fallbackError instanceof SDKError) || fallbackError.code !== 'LOUSHO_REGISTRY_UNREACHABLE') throw fallbackError; - failures.push(reason(fallbackError)); - } + return openDefaultFallback(error, options); + } +} + +const reasonOf = (error: SDKError): string => error.detail.replace(/^lousho add: /, ''); + +/** The default registry's host is down: try each GitHub fallback, then the copy bundled in the package. */ +async function openDefaultFallback(unreachableError: SDKError, options: RegistryOptions): Promise<{ registry: string; index: RegistryIndex }> { + const failures: string[] = []; + for (const fallback of DEFAULT_REGISTRY_FALLBACKS) { + try { + return { registry: fallback, index: await loadIndex(fallback, options) }; + } catch (fallbackError) { + if (!(fallbackError instanceof SDKError) || fallbackError.code !== 'LOUSHO_REGISTRY_UNREACHABLE') throw fallbackError; + failures.push(reasonOf(fallbackError)); + } + } + const bundled = bundledIndexPath(); + if (bundled) { + try { + const index = await loadIndex(bundled, options); + options.onNotice?.( + `lousho add: the default registry is unreachable; using the registry bundled with this lousho version (${VERSION}), which may be older than the online one.` + ); + return { registry: bundled, index }; + } catch { + // an unreadable bundled copy is the same as none: report the network failure below } - throw new SDKError( - `lousho add: the default registry is unreachable: ${reason(error)}; and its fallbacks: ${failures.join('; ')}`, - 'LOUSHO_REGISTRY_UNREACHABLE', - { cause: error, hint: 'Check that you are online, or pass --registry (e.g. a local copy of registry/dist/index.json).' } - ); } + throw new SDKError( + `lousho add: the default registry is unreachable: ${reasonOf(unreachableError)}; and its fallbacks: ${failures.join('; ')}`, + 'LOUSHO_REGISTRY_UNREACHABLE', + { cause: unreachableError, hint: 'Check that you are online, or pass --registry (e.g. a local copy of registry/dist/index.json).' } + ); } /** Resolves an index entry's `url` / `path` against the registry's own location. */