diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 93f4e67..01b3b8f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -2,6 +2,7 @@ import { Hono } from 'hono'; import { HELM_VERSION } from '@helm/shared'; import { itemsRouter } from './routes/items.js'; import { productRouter } from './routes/product.js'; +import { productsRouter } from './routes/products.js'; import { webhooksRouter } from './routes/webhooks.js'; export const app = new Hono(); @@ -16,5 +17,6 @@ app.get('/health', (c) => ); app.route('/api', productRouter); +app.route('/api', productsRouter); app.route('/api', itemsRouter); app.route('/api', webhooksRouter); diff --git a/apps/api/src/routes/products.test.ts b/apps/api/src/routes/products.test.ts new file mode 100644 index 0000000..6c55bdf --- /dev/null +++ b/apps/api/src/routes/products.test.ts @@ -0,0 +1,171 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { app } from '../app.js'; +import { _resetForTests } from '../services/index.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const HELM_PRODUCT = { + helm_version: '0', + product: { slug: 'helm', name: 'Helm' }, + workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' }, +}; + +const PLAYGROUND_PRODUCT = { + helm_version: '0', + product: { slug: 'helm-playground', name: 'Helm Playground' }, + workflow: { stages_enabled: ['discovery', 'released'], designer_gate: 'skip', qa_gate: 'skip' }, +}; + +const HELM_ITEMS = [ + { + externalId: 'issue_1', + productSlug: 'helm', + currentStage: 'discovery', + history: [], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }, + { + externalId: 'issue_2', + productSlug: 'helm', + currentStage: 'released', + history: [], + createdAt: '2026-01-02T00:00:00Z', + updatedAt: '2026-01-02T00:00:00Z', + }, +]; + +const PLAYGROUND_ITEMS = [ + { + externalId: 'issue_10', + productSlug: 'helm-playground', + currentStage: 'discovery', + history: [], + createdAt: '2026-01-03T00:00:00Z', + updatedAt: '2026-01-03T00:00:00Z', + }, +]; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const { mockGetProductRegistry, mockList } = vi.hoisted(() => ({ + mockGetProductRegistry: vi.fn(), + mockList: vi.fn(), +})); + +vi.mock('../services/index.js', async (importOriginal) => { + const real = await importOriginal(); + return { + ...real, + getProductRegistry: mockGetProductRegistry, + getItemStore: vi.fn().mockResolvedValue({ list: mockList }), + }; +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /api/products', () => { + beforeEach(() => { + _resetForTests(); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('GET /api/products', () => { + it('returns all registered products', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + const res = await app.request('/api/products'); + expect(res.status).toBe(200); + const body = (await res.json()) as (typeof HELM_PRODUCT)[]; + expect(body).toHaveLength(2); + expect(body.map((p) => p.product.slug).sort()).toEqual(['helm', 'helm-playground']); + }); + + it('returns 500 when registry loading fails', async () => { + mockGetProductRegistry.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products'); + expect(res.status).toBe(500); + }); + }); + + describe('GET /api/products/:slug', () => { + it('returns 400 for invalid slug format', async () => { + const res = await app.request('/api/products/INVALID_SLUG!'); + expect(res.status).toBe(400); + }); + + it('returns the matching product', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + const res = await app.request('/api/products/helm-playground'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof PLAYGROUND_PRODUCT; + expect(body.product.slug).toBe('helm-playground'); + }); + + it('returns 404 for unknown slug', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + const res = await app.request('/api/products/nonexistent'); + expect(res.status).toBe(404); + }); + + it('returns 500 when registry loading fails', async () => { + mockGetProductRegistry.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products/helm'); + expect(res.status).toBe(500); + }); + }); + + describe('GET /api/products/:slug/items', () => { + it('returns only items belonging to the requested product', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]); + + const res = await app.request('/api/products/helm/items'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof HELM_ITEMS; + expect(body).toHaveLength(2); + expect(body.every((i) => i.productSlug === 'helm')).toBe(true); + }); + + it('returns only playground items when requesting helm-playground', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue([...HELM_ITEMS, ...PLAYGROUND_ITEMS]); + + const res = await app.request('/api/products/helm-playground/items'); + expect(res.status).toBe(200); + const body = (await res.json()) as typeof PLAYGROUND_ITEMS; + expect(body).toHaveLength(1); + expect(body[0]!.externalId).toBe('issue_10'); + }); + + it('returns empty array when the product has no items', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT, PLAYGROUND_PRODUCT]); + mockList.mockResolvedValue(HELM_ITEMS); + + const res = await app.request('/api/products/helm-playground/items'); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it('returns 400 for invalid slug format', async () => { + const res = await app.request('/api/products/BAD_SLUG!/items'); + expect(res.status).toBe(400); + }); + + it('returns 404 when the product slug does not exist', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + const res = await app.request('/api/products/ghost/items'); + expect(res.status).toBe(404); + }); + + it('returns 500 when item store fails', async () => { + mockGetProductRegistry.mockResolvedValue([HELM_PRODUCT]); + mockList.mockRejectedValue(new Error('disk error')); + const res = await app.request('/api/products/helm/items'); + expect(res.status).toBe(500); + }); + }); +}); diff --git a/apps/api/src/routes/products.ts b/apps/api/src/routes/products.ts new file mode 100644 index 0000000..c122d64 --- /dev/null +++ b/apps/api/src/routes/products.ts @@ -0,0 +1,70 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { getProductRegistry, getItemStore } from '../services/index.js'; + +export const productsRouter = new Hono(); + +// Slug format mirrors product.schema.ts: lowercase alphanumeric + hyphens. +const SlugParamsSchema = z + .object({ + slug: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'Invalid product slug format'), + }) + .strict(); + +function parseSlug(raw: string): string | null { + const result = SlugParamsSchema.safeParse({ slug: raw }); + return result.success ? result.data.slug : null; +} + +// ── GET /api/products ───────────────────────────────────────────────────────── + +productsRouter.get('/products', async (c) => { + try { + const products = await getProductRegistry(); + return c.json(products); + } catch (err) { + console.error('[products] Failed to load product registry:', err); + return c.json({ error: 'Failed to load product registry' }, 500); + } +}); + +// ── GET /api/products/:slug ─────────────────────────────────────────────────── + +productsRouter.get('/products/:slug', async (c) => { + const slug = parseSlug(c.req.param('slug')); + if (!slug) return c.json({ error: 'Invalid product slug' }, 400); + + try { + const products = await getProductRegistry(); + const product = products.find((p) => p.product.slug === slug); + if (!product) return c.json({ error: `Product not found: ${slug}` }, 404); + return c.json(product); + } catch (err) { + console.error(`[products] Failed to load product ${slug}:`, err); + return c.json({ error: 'Failed to load product registry' }, 500); + } +}); + +// ── GET /api/products/:slug/items ───────────────────────────────────────────── + +productsRouter.get('/products/:slug/items', async (c) => { + const slug = parseSlug(c.req.param('slug')); + if (!slug) return c.json({ error: 'Invalid product slug' }, 400); + + try { + const products = await getProductRegistry(); + const product = products.find((p) => p.product.slug === slug); + if (!product) return c.json({ error: `Product not found: ${slug}` }, 404); + + const store = await getItemStore(); + const allItems = await store.list(); + const items = allItems.filter((item) => item.productSlug === slug); + return c.json(items); + } catch (err) { + console.error(`[products] Failed to load items for ${slug}:`, err); + return c.json({ error: 'Failed to load items' }, 500); + } +}); diff --git a/apps/api/src/services/index.ts b/apps/api/src/services/index.ts index ea3fb73..ea6f258 100644 --- a/apps/api/src/services/index.ts +++ b/apps/api/src/services/index.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; import { ensureDataDir } from '@helm/storage'; -import { parseProductConfigFromFile } from '@helm/shared'; +import { parseProductConfigFromFile, loadProductRegistry, ProductConfigError } from '@helm/shared'; import type { Product } from '@helm/shared'; import { GitHubProjectsAdapter } from '@helm/adapters'; import { ItemStore } from './item-store.js'; @@ -101,6 +101,73 @@ export async function getGitHubAdapter(): Promise { } } +// ── Product registry (multi-product) ───────────────────────────────────────── + +let _productRegistry: Product[] | null = null; +let _productRegistryPromise: Promise | null = null; + +/** + * Returns all registered Products, initializing from the registry on first call. + * Single-flight: concurrent callers await the same promise. + * + * Discovery order: + * 1. If $HELM_KNOWLEDGE_REPO_PATH/.helm/products.yaml exists → load all products listed there. + * 2. Otherwise (ENOENT) → fall back to [getProductConfig()] for single-product backward compat. + * + * Paths in products.yaml are resolved relative to HELM_KNOWLEDGE_REPO_PATH itself + * (the knowledge repo root). Example: path "." → the repo itself; path + * "../helm-playground-knowledge" → a sibling repo. See ADR-004. + */ +export async function getProductRegistry(): Promise { + if (_productRegistry !== null) return _productRegistry.map((p) => structuredClone(p)); + if (_productRegistryPromise !== null) { + return _productRegistryPromise.then((products) => products.map((p) => structuredClone(p))); + } + + _productRegistryPromise = (async () => { + const knowledgePath = process.env.HELM_KNOWLEDGE_REPO_PATH?.trim(); + if (!knowledgePath) throw new Error('HELM_KNOWLEDGE_REPO_PATH environment variable not set'); + const SAFE_FS_PATH_REGEX = /^[A-Za-z0-9._/\-]+$/; + if (!SAFE_FS_PATH_REGEX.test(knowledgePath)) { + throw new Error('HELM_KNOWLEDGE_REPO_PATH contains invalid characters'); + } + + const registryFilePath = join(knowledgePath, '.helm', 'products.yaml'); + const baseDir = knowledgePath; // paths in products.yaml are relative to the knowledge repo itself + + let products: Product[]; + try { + products = await loadProductRegistry(registryFilePath, baseDir); + } catch (err) { + // Fall back to single-product mode ONLY when the registry file itself is absent. + // ProductConfigError wraps the fs error: .code is preserved directly, but .path + // lives on .cause (the original NodeJS.ErrnoException). Checking .cause.path + // ensures we don't swallow ENOENT errors from a referenced product.yaml being + // missing — those must propagate so the operator can diagnose the broken entry. + const isMissingRegistryFile = + err instanceof ProductConfigError && + err.code === 'ENOENT' && + (err.cause as NodeJS.ErrnoException | undefined)?.path === registryFilePath; + + if (isMissingRegistryFile) { + products = [await getProductConfig()]; + } else { + throw err; + } + } + + _productRegistry = products.map((p) => structuredClone(p)); + return _productRegistry.map((p) => structuredClone(p)); + })(); + + try { + const products = await _productRegistryPromise; + return products.map((p) => structuredClone(p)); + } finally { + _productRegistryPromise = null; + } +} + // ── Test utilities ──────────────────────────────────────────────────────────── /** @@ -114,4 +181,6 @@ export function _resetForTests(): void { _productInitPromise = null; _githubAdapter = null; _githubAdapterPromise = null; + _productRegistry = null; + _productRegistryPromise = null; } diff --git a/apps/api/src/services/product-registry.test.ts b/apps/api/src/services/product-registry.test.ts new file mode 100644 index 0000000..11ded8f --- /dev/null +++ b/apps/api/src/services/product-registry.test.ts @@ -0,0 +1,144 @@ +/** + * Focused regression tests for getProductRegistry() fallback logic. + * Uses a real tmpdir so the ENOENT originates from the actual filesystem, + * matching the production code path exactly. + */ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { _resetForTests, getProductRegistry } from './index.js'; + +// Minimal valid product.yaml +const PRODUCT_YAML = ` +helm_version: "0" +product: + slug: test-helm + name: Test Helm +issue_tracker: + provider: github_projects + org: test-org + project_number: 1 +code_repos: + - url: https://github.com/test-org/test-repo + default_branch: main + role: app +knowledge_repo: + url: https://github.com/test-org/test-knowledge + default_branch: main +workflow: + stages_enabled: [discovery, released] +specialists: + spec_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + plan_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + implementer: { runtime: claude_code, model: claude-opus-4-7 } + code_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + security_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + test_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + remediation: { runtime: claude_code, model: claude-sonnet-4-6 } +`.trim(); + +describe('getProductRegistry() ENOENT fallback', () => { + let tmpDir: string; + const origEnv = process.env.HELM_KNOWLEDGE_REPO_PATH; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-registry-test-${randomUUID()}`); + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), PRODUCT_YAML, 'utf-8'); + process.env.HELM_KNOWLEDGE_REPO_PATH = tmpDir; + _resetForTests(); + }); + + afterEach(async () => { + _resetForTests(); + if (origEnv === undefined) { + delete process.env.HELM_KNOWLEDGE_REPO_PATH; + } else { + process.env.HELM_KNOWLEDGE_REPO_PATH = origEnv; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('falls back to [getProductConfig()] when products.yaml is absent', async () => { + // No products.yaml in tmpDir/.helm/ — only product.yaml exists + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('returns all products when products.yaml is present with path "."', async () => { + // path: "." is relative to HELM_KNOWLEDGE_REPO_PATH itself (the knowledge repo root) + const productsYaml = `products:\n - path: .`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('propagates error when a referenced product.yaml is missing (not swallowed as fallback)', async () => { + const productsYaml = `products:\n - path: ./nonexistent-repo`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + await expect(getProductRegistry()).rejects.toThrow(); + }); +}); + +describe('getProductRegistry() path resolution semantics', () => { + let tmpDir: string; + const origEnv = process.env.HELM_KNOWLEDGE_REPO_PATH; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-registry-path-test-${randomUUID()}`); + // Primary knowledge repo: tmpDir itself + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), PRODUCT_YAML, 'utf-8'); + process.env.HELM_KNOWLEDGE_REPO_PATH = tmpDir; + _resetForTests(); + }); + + afterEach(async () => { + _resetForTests(); + if (origEnv === undefined) { + delete process.env.HELM_KNOWLEDGE_REPO_PATH; + } else { + process.env.HELM_KNOWLEDGE_REPO_PATH = origEnv; + } + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('path "." resolves to the knowledge repo itself (not its parent)', async () => { + const productsYaml = `products:\n - path: .`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-helm'); + }); + + it('path "../sibling" resolves to a sibling directory of the knowledge repo', async () => { + // Create a sibling repo next to tmpDir + const siblingDir = join(tmpdir(), `helm-sibling-${randomUUID()}`); + try { + await mkdir(join(siblingDir, '.helm'), { recursive: true }); + const siblingYaml = PRODUCT_YAML.replace('test-helm', 'sibling-product').replace( + 'Test Helm', + 'Sibling Product', + ); + await writeFile(join(siblingDir, '.helm', 'product.yaml'), siblingYaml, 'utf-8'); + + const siblingName = `../${siblingDir.split('/').at(-1)!}`; + const productsYaml = `products:\n - path: .\n - path: ${siblingName}`; + await writeFile(join(tmpDir, '.helm', 'products.yaml'), productsYaml, 'utf-8'); + + const products = await getProductRegistry(); + expect(products).toHaveLength(2); + const slugs = products.map((p) => p.product.slug).sort(); + expect(slugs).toEqual(['sibling-product', 'test-helm']); + } finally { + await rm(siblingDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/shared/src/config/index.ts b/packages/shared/src/config/index.ts index e8ec01c..17581d3 100644 --- a/packages/shared/src/config/index.ts +++ b/packages/shared/src/config/index.ts @@ -5,6 +5,9 @@ export { parseProductConfigFromFile, ProductConfigError, } from './product-parser.js'; +export { ProductRegistrySchema, ProductRegistryEntrySchema } from './product-registry-schema.js'; +export type { ProductRegistry, ProductRegistryEntry } from './product-registry-schema.js'; +export { parseProductRegistryYaml, loadProductRegistry } from './product-registry-parser.js'; // WorkflowStage and WORKFLOW_STAGES originate in @helm/workflow; re-exported // here so consumers of @helm/shared don't need to know the source package. export { WORKFLOW_STAGES, type WorkflowStage } from '@helm/workflow'; diff --git a/packages/shared/src/config/product-registry-parser.test.ts b/packages/shared/src/config/product-registry-parser.test.ts new file mode 100644 index 0000000..21c8096 --- /dev/null +++ b/packages/shared/src/config/product-registry-parser.test.ts @@ -0,0 +1,164 @@ +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { parseProductRegistryYaml, loadProductRegistry } from './product-registry-parser.js'; +import { ProductConfigError } from './product-parser.js'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const VALID_PRODUCT_YAML = ` +helm_version: "0" +product: + slug: test-product + name: Test Product +issue_tracker: + provider: github_projects + org: test-org + project_number: 1 +code_repos: + - url: https://github.com/test-org/test-repo + default_branch: main + role: app +knowledge_repo: + url: https://github.com/test-org/test-knowledge + default_branch: main +workflow: + stages_enabled: [discovery, spec-ready, released] +specialists: + spec_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + plan_writer: { runtime: claude_code, model: claude-sonnet-4-6 } + implementer: { runtime: claude_code, model: claude-opus-4-7 } + code_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + security_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + test_reviewer: { runtime: claude_code, model: claude-sonnet-4-6 } + remediation: { runtime: claude_code, model: claude-sonnet-4-6 } +`.trim(); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function makeKnowledgeRepo( + baseDir: string, + slug: string, + productYaml = VALID_PRODUCT_YAML, +): Promise { + const repoDir = join(baseDir, `${slug}-knowledge`); + await mkdir(join(repoDir, '.helm'), { recursive: true }); + const yaml = productYaml.replace('test-product', slug).replace('Test Product', slug); + await writeFile(join(repoDir, '.helm', 'product.yaml'), yaml, 'utf-8'); + return repoDir; +} + +// ── parseProductRegistryYaml ───────────────────────────────────────────────── + +describe('parseProductRegistryYaml', () => { + it('parses a valid registry with one entry', () => { + const yaml = `products:\n - path: .`; + const entries = parseProductRegistryYaml(yaml); + expect(entries).toHaveLength(1); + expect(entries[0]!.path).toBe('.'); + }); + + it('parses a registry with multiple entries', () => { + const yaml = `products:\n - path: .\n - path: ../other-knowledge`; + const entries = parseProductRegistryYaml(yaml); + expect(entries).toHaveLength(2); + expect(entries[1]!.path).toBe('../other-knowledge'); + }); + + it('throws ProductConfigError on invalid YAML syntax', () => { + expect(() => parseProductRegistryYaml(': invalid: yaml: [')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when products array is empty', () => { + expect(() => parseProductRegistryYaml('products: []')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when products key is missing', () => { + expect(() => parseProductRegistryYaml('entries:\n - path: .')).toThrow(ProductConfigError); + }); + + it('throws ProductConfigError when a path is empty string', () => { + expect(() => parseProductRegistryYaml('products:\n - path: ""')).toThrow(ProductConfigError); + }); +}); + +// ── loadProductRegistry ─────────────────────────────────────────────────────── + +describe('loadProductRegistry', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = join(tmpdir(), `helm-test-${randomUUID()}`); + await mkdir(tmpDir, { recursive: true }); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + it('loads a single product from the registry', async () => { + await makeKnowledgeRepo(tmpDir, 'alpha'); + const registryYaml = `products:\n - path: alpha-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('alpha'); + }); + + it('loads multiple products from the registry', async () => { + await makeKnowledgeRepo(tmpDir, 'alpha'); + await makeKnowledgeRepo(tmpDir, 'beta'); + const registryYaml = `products:\n - path: alpha-knowledge\n - path: beta-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(2); + const slugs = products.map((p) => p.product.slug).sort(); + expect(slugs).toEqual(['alpha', 'beta']); + }); + + it('resolves the "." path to the baseDir itself', async () => { + // Treat tmpDir as the primary knowledge repo (path = ".") + await mkdir(join(tmpDir, '.helm'), { recursive: true }); + await writeFile(join(tmpDir, '.helm', 'product.yaml'), VALID_PRODUCT_YAML, 'utf-8'); + const registryYaml = `products:\n - path: .`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + const products = await loadProductRegistry(registryPath, tmpDir); + expect(products).toHaveLength(1); + expect(products[0]!.product.slug).toBe('test-product'); + }); + + it('throws ProductConfigError (ENOENT) when registry file is missing', async () => { + await expect( + loadProductRegistry(join(tmpDir, 'nonexistent.yaml'), tmpDir), + ).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('throws ProductConfigError when a referenced product.yaml is missing', async () => { + const registryYaml = `products:\n - path: ghost-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + await expect(loadProductRegistry(registryPath, tmpDir)).rejects.toMatchObject({ + code: 'ENOENT', + }); + }); + + it('throws ProductConfigError when a referenced product.yaml is malformed', async () => { + const repoDir = join(tmpDir, 'bad-knowledge'); + await mkdir(join(repoDir, '.helm'), { recursive: true }); + await writeFile(join(repoDir, '.helm', 'product.yaml'), 'not_valid: yaml: [', 'utf-8'); + const registryYaml = `products:\n - path: bad-knowledge`; + const registryPath = join(tmpDir, 'products.yaml'); + await writeFile(registryPath, registryYaml, 'utf-8'); + + await expect(loadProductRegistry(registryPath, tmpDir)).rejects.toThrow(ProductConfigError); + }); +}); diff --git a/packages/shared/src/config/product-registry-parser.ts b/packages/shared/src/config/product-registry-parser.ts new file mode 100644 index 0000000..502f98f --- /dev/null +++ b/packages/shared/src/config/product-registry-parser.ts @@ -0,0 +1,75 @@ +import { readFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { ProductRegistrySchema } from './product-registry-schema.js'; +import { parseProductConfigFromFile, ProductConfigError } from './product-parser.js'; +import type { Product } from './product-schema.js'; + +/** + * Parses a YAML string into a validated ProductRegistry structure. + * Does NOT load individual product configs — use loadProductRegistry for that. + * Throws ProductConfigError on invalid YAML or schema violations. + */ +export function parseProductRegistryYaml(yamlContent: string): Array<{ path: string }> { + let raw: unknown; + try { + raw = parseYaml(yamlContent); + } catch (err) { + throw new ProductConfigError('Failed to parse products.yaml', err as Error); + } + + const result = ProductRegistrySchema.safeParse(raw); + if (!result.success) { + const firstIssue = result.error.issues[0]; + const path = firstIssue?.path.join('.') ?? '(root)'; + const message = firstIssue?.message ?? 'Unknown validation error'; + throw new ProductConfigError(`Invalid products.yaml — "${path}": ${message}`, result.error); + } + + return result.data.products; +} + +/** + * Loads and resolves all products listed in a products.yaml registry file. + * + * @param registryFilePath - absolute path to the products.yaml file + * @param baseDir - directory used to resolve relative paths listed in the registry. + * Typically the parent directory of the primary knowledge repo + * (i.e., path.dirname(HELM_KNOWLEDGE_REPO_PATH)). + * + * Each entry's `path` is resolved via path.resolve(baseDir, entry.path). + * Absolute paths in entries are used as-is (path.resolve handles this). + * + * Throws ProductConfigError if the registry file is unreadable or invalid. + * Throws ProductConfigError for any individual product.yaml that fails to load. + */ +export async function loadProductRegistry( + registryFilePath: string, + baseDir: string, +): Promise { + let content: string; + try { + content = await readFile(registryFilePath, 'utf-8'); + } catch (err) { + const fsErr = err as NodeJS.ErrnoException; + throw new ProductConfigError( + `Cannot read products.yaml: ${registryFilePath}`, + fsErr, + fsErr.code, + ); + } + + const entries = parseProductRegistryYaml(content); + const products: Product[] = []; + + for (const entry of entries) { + const repoPath = resolve(baseDir, entry.path); + const productYamlPath = join(repoPath, '.helm', 'product.yaml'); + // Let individual product parse errors propagate — caller decides whether + // to skip malformed products or treat it as a fatal error. + const product = await parseProductConfigFromFile(productYamlPath); + products.push(product); + } + + return products; +} diff --git a/packages/shared/src/config/product-registry-schema.ts b/packages/shared/src/config/product-registry-schema.ts new file mode 100644 index 0000000..c84d729 --- /dev/null +++ b/packages/shared/src/config/product-registry-schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +/** + * Schema for .helm/products.yaml — the multi-product registry file. + * + * Each entry lists a `path` to a knowledge repository that contains its own + * .helm/product.yaml. Paths are resolved relative to the parent directory of + * HELM_KNOWLEDGE_REPO_PATH (the "sibling layout" assumption documented in + * ADR-004). Absolute paths are also accepted. + * + * Example: + * products: + * - path: . # this repo + * - path: ../helm-playground-knowledge # sibling repo + */ +export const ProductRegistryEntrySchema = z + .object({ + path: z.string().min(1, 'path must not be empty'), + }) + .strict(); + +export const ProductRegistrySchema = z + .object({ + products: z.array(ProductRegistryEntrySchema).min(1, 'registry must list at least one product'), + }) + .strict(); + +export type ProductRegistryEntry = z.infer; +export type ProductRegistry = z.infer;