From a0dafcd693b1a371884ecfbf2c00c03db3a3e51b Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 19 Mar 2026 19:43:46 -0700 Subject: [PATCH] feat(config): overhaul product manifest schema + 51 tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Redesigned schema to match real-world product.json files across ecosystem - Changed 'id' → 'productId' (matches all existing files) - Support bundleId as string OR per-platform object - Added backendPort, tagline, primarySurface, appStore, bundleIds fields - Added legacy identity fields (licensePrefix, configDirName, envVarPrefix, etc.) - Added duplicate container name validation via superRefine - Fixed loadProductManifestSync: require() → readFileSync (ESM-safe) - Added BundleIdSchema and AppStoreSchema exports - Added 'mac' platform option - 51 new tests covering all schemas, validation, file loading, real-world manifests --- .../src/__tests__/product-manifest.test.ts | 467 ++++++++++++++++++ packages/config/src/index.ts | 2 + packages/config/src/product-manifest.ts | 175 ++++--- 3 files changed, 583 insertions(+), 61 deletions(-) create mode 100644 packages/config/src/__tests__/product-manifest.test.ts diff --git a/packages/config/src/__tests__/product-manifest.test.ts b/packages/config/src/__tests__/product-manifest.test.ts new file mode 100644 index 00000000..b1abd845 --- /dev/null +++ b/packages/config/src/__tests__/product-manifest.test.ts @@ -0,0 +1,467 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + ProductManifestSchema, + ExtendedProductManifestSchema, + PlatformSchema, + ThemeSchema, + ContainerDefSchema, + FeatureFlagSchema, + BundleIdSchema, + DEFAULT_THEME, + loadProductManifest, + loadProductManifestSync, + resolveTheme, + validateProductManifest, + safeValidateProductManifest, +} from '../index.js'; + +// ── Minimal valid manifest ────────────────────────────────────────────────── + +const MINIMAL = { + productId: 'testprod', + displayName: 'TestProd', +}; + +// ── Full manifest (matches FlowMonk-style) ────────────────────────────────── + +const FULL = { + productId: 'flowmonk', + displayName: 'FlowMonk', + name: 'FlowMonk', + tagline: 'Agent-first planning and execution', + domain: 'flowmonk.app', + backendPort: 4017, + primarySurface: 'web', + mobileCompanion: true, + platforms: ['web', 'ios', 'android'], + bundleIds: { + ios: 'com.saravana.flowmonk', + android: 'com.saravana.flowmonk', + web: 'flowmonk.app', + }, + appStore: { + category: 'Productivity', + subcategory: 'Task Management', + ageRating: '4+', + privacyUrl: 'https://flowmonk.app/privacy', + termsUrl: 'https://flowmonk.app/terms', + supportUrl: 'https://flowmonk.app/support', + }, + cosmos: { + containers: [ + { name: 'zones', partitionKey: '/userId' }, + { name: 'flows', partitionKey: '/userId' }, + { name: 'tasks', partitionKey: '/userId' }, + ], + }, + flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }], + ports: { service: 4017 }, + version: '0.1.0', +}; + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('ProductManifestSchema', () => { + it('parses a minimal manifest (just productId + displayName)', () => { + const result = ProductManifestSchema.parse(MINIMAL); + expect(result.productId).toBe('testprod'); + expect(result.displayName).toBe('TestProd'); + expect(result.platforms).toEqual(['web']); // default + expect(result.flags).toEqual([]); // default + }); + + it('parses a full manifest', () => { + const result = ProductManifestSchema.parse(FULL); + expect(result.productId).toBe('flowmonk'); + expect(result.backendPort).toBe(4017); + expect(result.cosmos?.containers).toHaveLength(3); + expect(result.flags).toHaveLength(1); + expect(result.appStore?.category).toBe('Productivity'); + }); + + it('rejects missing productId', () => { + expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow(); + }); + + it('rejects missing displayName', () => { + expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow(); + }); + + it('rejects invalid productId (uppercase)', () => { + expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow( + /lowercase/ + ); + }); + + it('rejects productId starting with number', () => { + expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow(); + }); + + it('allows hyphens in productId', () => { + const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' }); + expect(result.productId).toBe('my-app'); + }); + + it('rejects backendPort below 1024', () => { + expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow(); + }); + + it('rejects backendPort above 65535', () => { + expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow(); + }); + + it('accepts valid backendPort', () => { + const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 }); + expect(result.backendPort).toBe(4016); + }); + + it('parses LysnrAI-style manifest (legacy fields)', () => { + const lysnrai = { + displayName: 'LysnrAI', + productId: 'lysnrai', + licensePrefix: 'LYSNR', + configDirName: '.LysnrAI', + envVarPrefix: 'LYSNR', + bundleIdSuffix: 'LysnrAI', + packageName: 'lysnrai', + }; + const result = ProductManifestSchema.parse(lysnrai); + expect(result.licensePrefix).toBe('LYSNR'); + expect(result.envVarPrefix).toBe('LYSNR'); + }); + + it('parses NoteLett-style manifest (bundleId as object)', () => { + const notelett = { + productId: 'notelett', + displayName: 'NoteLett', + bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, + backendPort: 4016, + appGroup: 'group.com.bytelyst.notelett', + }; + const result = ProductManifestSchema.parse(notelett); + expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }); + expect(result.appGroup).toBe('group.com.bytelyst.notelett'); + }); + + it('parses NomGap-style manifest (bundleId as string)', () => { + const nomgap = { + productId: 'nomgap', + displayName: 'NomGap', + bundleId: 'com.saravana.nomgap', + domain: 'nomgap.app', + }; + const result = ProductManifestSchema.parse(nomgap); + expect(result.bundleId).toBe('com.saravana.nomgap'); + }); + + it('parses ActionTrail-style manifest (no mobile)', () => { + const actiontrail = { + productId: 'actiontrail', + displayName: 'ActionTrail', + tagline: 'AI Activity Oversight', + domain: 'actiontrail.dev', + backendPort: 4018, + primarySurface: 'web', + mobileCompanion: false, + }; + const result = ProductManifestSchema.parse(actiontrail); + expect(result.mobileCompanion).toBe(false); + expect(result.primarySurface).toBe('web'); + }); +}); + +describe('duplicate container validation', () => { + it('rejects duplicate container names', () => { + const manifest = { + ...MINIMAL, + cosmos: { + containers: [ + { name: 'users', partitionKey: '/userId' }, + { name: 'users', partitionKey: '/email' }, + ], + }, + }; + expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/); + }); + + it('allows unique container names', () => { + const manifest = { + ...MINIMAL, + cosmos: { + containers: [ + { name: 'users', partitionKey: '/userId' }, + { name: 'sessions', partitionKey: '/userId' }, + ], + }, + }; + const result = ProductManifestSchema.parse(manifest); + expect(result.cosmos?.containers).toHaveLength(2); + }); + + it('allows single container (no duplicate check needed)', () => { + const manifest = { + ...MINIMAL, + cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] }, + }; + const result = ProductManifestSchema.parse(manifest); + expect(result.cosmos?.containers).toHaveLength(1); + }); +}); + +describe('ExtendedProductManifestSchema', () => { + it('allows unknown keys (passthrough)', () => { + const result = ExtendedProductManifestSchema.parse({ + ...MINIMAL, + customField: 'hello', + nestedCustom: { x: 1 }, + }); + expect((result as Record).customField).toBe('hello'); + }); +}); + +describe('PlatformSchema', () => { + it('accepts valid platforms', () => { + for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) { + expect(PlatformSchema.parse(p)).toBe(p); + } + }); + + it('rejects invalid platform', () => { + expect(() => PlatformSchema.parse('windows')).toThrow(); + }); +}); + +describe('ThemeSchema', () => { + it('validates hex colors', () => { + const result = ThemeSchema.parse(DEFAULT_THEME); + expect(result.primary).toBe('#5AE68C'); + }); + + it('rejects non-hex colors', () => { + expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/); + }); + + it('rejects 3-digit hex', () => { + expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow(); + }); +}); + +describe('ContainerDefSchema', () => { + it('parses valid container', () => { + const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' }); + expect(result.name).toBe('users'); + }); + + it('accepts optional ttlSeconds and uniqueKeys', () => { + const result = ContainerDefSchema.parse({ + name: 'sessions', + partitionKey: '/userId', + ttlSeconds: 86400, + uniqueKeys: ['/email'], + }); + expect(result.ttlSeconds).toBe(86400); + expect(result.uniqueKeys).toEqual(['/email']); + }); + + it('rejects empty name', () => { + expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow(); + }); + + it('rejects negative ttlSeconds', () => { + expect(() => + ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 }) + ).toThrow(); + }); +}); + +describe('FeatureFlagSchema', () => { + it('accepts boolean default', () => { + const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false }); + expect(result.defaultValue).toBe(false); + }); + + it('accepts string default', () => { + const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' }); + expect(result.defaultValue).toBe('free'); + }); + + it('accepts number default', () => { + const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 }); + expect(result.defaultValue).toBe(100); + }); + + it('rejects empty key', () => { + expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow(); + }); +}); + +describe('BundleIdSchema', () => { + it('accepts string bundle ID', () => { + expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app'); + }); + + it('accepts per-platform bundle IDs', () => { + const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' }); + expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' }); + }); + + it('rejects empty string', () => { + expect(() => BundleIdSchema.parse('')).toThrow(); + }); +}); + +describe('resolveTheme', () => { + it('returns defaults when no theme specified', () => { + const manifest = ProductManifestSchema.parse(MINIMAL); + const theme = resolveTheme(manifest); + expect(theme).toEqual(DEFAULT_THEME); + }); + + it('merges partial theme with defaults', () => { + const manifest = ProductManifestSchema.parse({ + ...MINIMAL, + theme: { primary: '#FF0000' }, + }); + const theme = resolveTheme(manifest); + expect(theme.primary).toBe('#FF0000'); + expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved + }); +}); + +describe('validateProductManifest', () => { + it('validates a valid object', () => { + const result = validateProductManifest(MINIMAL); + expect(result.productId).toBe('testprod'); + }); + + it('throws on invalid object', () => { + expect(() => validateProductManifest({ bad: true })).toThrow(); + }); +}); + +describe('safeValidateProductManifest', () => { + it('returns manifest on valid input', () => { + const result = safeValidateProductManifest(MINIMAL); + expect(result).not.toBeNull(); + expect(result!.productId).toBe('testprod'); + }); + + it('returns null on invalid input', () => { + const result = safeValidateProductManifest({ bad: true }); + expect(result).toBeNull(); + }); +}); + +describe('loadProductManifest / loadProductManifestSync', () => { + const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`); + const validPath = join(tmpDir, 'valid.json'); + const invalidPath = join(tmpDir, 'invalid.json'); + + beforeEach(() => { + mkdirSync(tmpDir, { recursive: true }); + writeFileSync(validPath, JSON.stringify(FULL)); + writeFileSync(invalidPath, JSON.stringify({ bad: true })); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('loads and validates from file (async)', async () => { + const result = await loadProductManifest(validPath); + expect(result.productId).toBe('flowmonk'); + expect(result.cosmos?.containers).toHaveLength(3); + }); + + it('loads and validates from file (sync)', () => { + const result = loadProductManifestSync(validPath); + expect(result.productId).toBe('flowmonk'); + }); + + it('throws on invalid file (async)', async () => { + await expect(loadProductManifest(invalidPath)).rejects.toThrow(); + }); + + it('throws on invalid file (sync)', () => { + expect(() => loadProductManifestSync(invalidPath)).toThrow(); + }); + + it('throws on missing file (async)', async () => { + await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow(); + }); + + it('throws on missing file (sync)', () => { + expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow(); + }); +}); + +describe('real-world product.json files parse cleanly', () => { + it('parses LysnrAI product.json', () => { + const json = { + displayName: 'LysnrAI', + productId: 'lysnrai', + licensePrefix: 'LYSNR', + configDirName: '.LysnrAI', + envVarPrefix: 'LYSNR', + bundleIdSuffix: 'LysnrAI', + packageName: 'lysnrai', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses NomGap product.json', () => { + const json = { + productId: 'nomgap', + displayName: 'NomGap', + bundleId: 'com.saravana.nomgap', + domain: 'nomgap.app', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses NoteLett product.json', () => { + const json = { + productId: 'notelett', + displayName: 'NoteLett', + licensePrefix: 'NOTELETT', + configDirName: '.NoteLett', + envVarPrefix: 'NOTELETT', + bundleIdSuffix: 'notelett', + packageName: 'notelett', + domain: 'notelett.app', + bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' }, + appGroup: 'group.com.bytelyst.notelett', + backendPort: 4016, + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); + + it('parses FlowMonk product.json', () => { + expect(() => ProductManifestSchema.parse(FULL)).not.toThrow(); + }); + + it('parses ActionTrail product.json', () => { + const json = { + productId: 'actiontrail', + displayName: 'ActionTrail', + tagline: 'AI Activity Oversight', + domain: 'actiontrail.dev', + backendPort: 4018, + primarySurface: 'web', + mobileCompanion: false, + bundleIds: { web: 'actiontrail.dev' }, + appStore: { + category: 'Developer Tools', + subcategory: 'AI Monitoring', + privacyUrl: 'https://actiontrail.dev/privacy', + termsUrl: 'https://actiontrail.dev/terms', + supportUrl: 'https://actiontrail.dev/support', + }, + version: '0.1.0', + }; + expect(() => ProductManifestSchema.parse(json)).not.toThrow(); + }); +}); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 30f5d073..1184ecb5 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -20,6 +20,8 @@ export { ContainerDefSchema, FeatureFlagSchema, PortConfigSchema, + BundleIdSchema, + AppStoreSchema, ExtendedProductManifestSchema, DEFAULT_THEME, loadProductManifest, diff --git a/packages/config/src/product-manifest.ts b/packages/config/src/product-manifest.ts index 5077e5a9..35982049 100644 --- a/packages/config/src/product-manifest.ts +++ b/packages/config/src/product-manifest.ts @@ -7,12 +7,14 @@ * @module @bytelyst/config/product-manifest */ +import { readFileSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; import { z } from 'zod'; /** * Platform identifiers */ -export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch']); +export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch', 'mac']); /** * Theme color token @@ -40,9 +42,9 @@ export const ThemeSchema = z.object({ * Cosmos container definition */ export const ContainerDefSchema = z.object({ - name: z.string(), - partitionKey: z.string(), - ttlSeconds: z.number().optional(), + name: z.string().min(1), + partitionKey: z.string().min(1), + ttlSeconds: z.number().positive().optional(), uniqueKeys: z.array(z.string()).optional(), }); @@ -50,7 +52,7 @@ export const ContainerDefSchema = z.object({ * Feature flag default */ export const FeatureFlagSchema = z.object({ - key: z.string(), + key: z.string().min(1), defaultValue: z.union([z.boolean(), z.string(), z.number()]), description: z.string().optional(), }); @@ -64,46 +66,55 @@ export const PortConfigSchema = z.object({ web: z.number().optional(), }); +/** + * Bundle ID — either a single reverse-DNS string or per-platform object + */ +export const BundleIdSchema = z.union([ + z.string().min(1), + z.object({ + ios: z.string().optional(), + android: z.string().optional(), + web: z.string().optional(), + }), +]); + +/** + * App store metadata (optional) + */ +export const AppStoreSchema = z + .object({ + category: z.string().optional(), + subcategory: z.string().optional(), + ageRating: z.string().optional(), + privacyUrl: z.string().url().optional(), + termsUrl: z.string().url().optional(), + supportUrl: z.string().url().optional(), + }) + .optional(); + /** * Product manifest schema (Zod) * + * Designed to accommodate the real-world variety of product.json files + * across the ByteLyst ecosystem. All products use `productId` as the + * primary identifier. + * * Example product.json: * ```json * { - * "id": "lysnrai", - * "name": "LysnrAI", + * "productId": "lysnrai", * "displayName": "LysnrAI", * "bundleId": "com.saravana.lysnrai", * "domain": "lysnrai.app", * "description": "Voice-to-text dictation platform", + * "backendPort": 4015, * "platforms": ["web", "ios", "android", "desktop"], - * "theme": { - * "primary": "#5AE68C", - * "secondary": "#5A8CFF", - * "accent": "#2EE6D6", - * "background": "#06070A", - * "surface": "#121725", - * "text": "#EFF4FF", - * "error": "#FF6E6E", - * "warning": "#F59E0B", - * "success": "#34D399" - * }, - * "features": { - * "voiceDictation": true, - * "realtimeTranscription": true, - * "keyboardExtension": true, - * "cloudSync": true - * }, * "cosmos": { * "containers": [ * { "name": "transcripts", "partitionKey": "/userId" }, * { "name": "sessions", "partitionKey": "/userId" } * ] * }, - * "flags": [ - * { "key": "new-ui-enabled", "defaultValue": false }, - * { "key": "max-audio-duration", "defaultValue": 300 } - * ], * "ports": { * "service": 4015, * "dashboard": 3002 @@ -111,51 +122,95 @@ export const PortConfigSchema = z.object({ * } * ``` */ -export const ProductManifestSchema = z.object({ - // Identity (all required) - id: z.string().regex(/^[a-z][a-z0-9]*$/, { - message: 'Product ID must be lowercase alphanumeric, starting with letter', +const BaseManifestSchema = z.object({ + // Identity (productId required, rest optional for minimal manifests) + productId: z.string().regex(/^[a-z][a-z0-9-]*$/, { + message: 'Product ID must be lowercase alphanumeric/hyphens, starting with letter', }), - name: z.string().min(1).max(50), displayName: z.string().min(1).max(50), - bundleId: z.string().regex(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)+$/, { - message: 'Bundle ID must be valid reverse-DNS format', - }), - domain: z.string().regex(/^[a-z0-9-]+\.[a-z]{2,}$/, { - message: 'Domain must be valid format like "product.app"', - }), - // Optional metadata - description: z.string().max(200).optional(), + // Optional identity fields + name: z.string().min(1).max(50).optional(), + bundleId: BundleIdSchema.optional(), + domain: z.string().optional(), + tagline: z.string().max(200).optional(), + description: z.string().max(500).optional(), + version: z + .string() + .regex(/^\d+\.\d+\.\d+$/) + .optional(), + + // Platforms (defaults to web) platforms: z.array(PlatformSchema).default(['web']), - version: z.string().regex(/^\d+\.\d+\.\d+$/).optional(), + primarySurface: PlatformSchema.optional(), + mobileCompanion: z.boolean().optional(), + + // Backend port (convenience — also available in ports.service) + backendPort: z.number().min(1024).max(65535).optional(), + + // Legacy identity fields from older product.json files + licensePrefix: z.string().optional(), + configDirName: z.string().optional(), + envVarPrefix: z.string().optional(), + bundleIdSuffix: z.string().optional(), + packageName: z.string().optional(), + appGroup: z.string().optional(), + + // Per-platform bundle IDs (alternative to bundleId) + bundleIds: z.record(z.string(), z.string()).optional(), + + // App store metadata + appStore: AppStoreSchema, // Theming (optional, uses defaults if not specified) theme: ThemeSchema.partial().optional(), - // Feature flags (optional) - features: z.record( - z.string(), - z.boolean().or(z.string()).or(z.number()) - ).optional(), + // Feature map (key → boolean/string/number) + features: z.record(z.string(), z.boolean().or(z.string()).or(z.number())).optional(), - // Cosmos containers (optional) - cosmos: z.object({ - containers: z.array(ContainerDefSchema).default([]), - }).optional(), + // Cosmos containers + cosmos: z + .object({ + containers: z.array(ContainerDefSchema).default([]), + }) + .optional(), - // Feature flags with defaults (optional) + // Feature flags with defaults flags: z.array(FeatureFlagSchema).default([]), - // Port configuration (optional) + // Port configuration ports: PortConfigSchema.optional(), + + // Agent/AI fields (used by FlowMonk, ActionTrail) + backendAuthority: z.string().optional(), + planningEngine: z.string().optional(), + aiRole: z.array(z.string()).optional(), +}); + +export const ProductManifestSchema = BaseManifestSchema.superRefine((data, ctx) => { + // Validate no duplicate container names + const containers = data.cosmos?.containers; + if (containers && containers.length > 1) { + const names = containers.map(c => c.name); + const seen = new Set(); + for (let i = 0; i < names.length; i++) { + if (seen.has(names[i])) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate container name: ${names[i]}`, + path: ['cosmos', 'containers', i, 'name'], + }); + } + seen.add(names[i]); + } + } }); /** - * Extended manifest that allows additional unknown keys - * Use this when you need to access custom fields not in the schema + * Extended manifest that allows additional unknown keys. + * Use this when you need to access custom fields not in the schema. */ -export const ExtendedProductManifestSchema = ProductManifestSchema.passthrough(); +export const ExtendedProductManifestSchema = BaseManifestSchema.passthrough(); /** * Inferred TypeScript type for ProductManifest @@ -192,12 +247,11 @@ export const DEFAULT_THEME: Theme = { * @example * ```ts * const manifest = await loadProductManifest('./product.json'); - * console.log(manifest.id); // 'lysnrai' + * console.log(manifest.productId); // 'lysnrai' * ``` */ export async function loadProductManifest(path: string): Promise { - const fs = await import('fs/promises'); - const content = await fs.readFile(path, 'utf-8'); + const content = await readFile(path, 'utf-8'); const json = JSON.parse(content); return ProductManifestSchema.parse(json); } @@ -210,8 +264,7 @@ export async function loadProductManifest(path: string): Promise