/** * Product Manifest Specification * * Defines the JSON schema for product.json files that capture everything * about a ByteLyst product — identity, theme, features, containers, flags, ports. * * @module @bytelyst/config/product-manifest */ import { z } from 'zod'; /** * Platform identifiers */ export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch']); /** * Theme color token */ export const ColorTokenSchema = z.string().regex(/^#[0-9A-Fa-f]{6}$/, { message: 'Color must be a hex value like #5AE68C', }); /** * Theme specification */ export const ThemeSchema = z.object({ primary: ColorTokenSchema, secondary: ColorTokenSchema, accent: ColorTokenSchema, background: ColorTokenSchema, surface: ColorTokenSchema, text: ColorTokenSchema, error: ColorTokenSchema, warning: ColorTokenSchema, success: ColorTokenSchema, }); /** * Cosmos container definition */ export const ContainerDefSchema = z.object({ name: z.string(), partitionKey: z.string(), ttlSeconds: z.number().optional(), uniqueKeys: z.array(z.string()).optional(), }); /** * Feature flag default */ export const FeatureFlagSchema = z.object({ key: z.string(), defaultValue: z.union([z.boolean(), z.string(), z.number()]), description: z.string().optional(), }); /** * Port configuration */ export const PortConfigSchema = z.object({ service: z.number().optional(), dashboard: z.number().optional(), web: z.number().optional(), }); /** * Product manifest schema (Zod) * * Example product.json: * ```json * { * "id": "lysnrai", * "name": "LysnrAI", * "displayName": "LysnrAI", * "bundleId": "com.saravana.lysnrai", * "domain": "lysnrai.app", * "description": "Voice-to-text dictation platform", * "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 * } * } * ``` */ 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', }), 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(), platforms: z.array(PlatformSchema).default(['web']), version: z.string().regex(/^\d+\.\d+\.\d+$/).optional(), // 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(), // Cosmos containers (optional) cosmos: z.object({ containers: z.array(ContainerDefSchema).default([]), }).optional(), // Feature flags with defaults (optional) flags: z.array(FeatureFlagSchema).default([]), // Port configuration (optional) ports: PortConfigSchema.optional(), }); /** * 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(); /** * Inferred TypeScript type for ProductManifest */ export type ProductManifest = z.infer; export type Platform = z.infer; export type Theme = z.infer; export type ContainerDef = z.infer; export type FeatureFlag = z.infer; export type PortConfig = z.infer; /** * Default theme colors (ByteLyst brand palette) */ export const DEFAULT_THEME: Theme = { primary: '#5AE68C', secondary: '#5A8CFF', accent: '#2EE6D6', background: '#06070A', surface: '#121725', text: '#EFF4FF', error: '#FF6E6E', warning: '#F59E0B', success: '#34D399', }; /** * Load and validate a product manifest from a file path * * @param path Path to product.json file * @returns Validated ProductManifest * @throws ZodError if validation fails * * @example * ```ts * const manifest = await loadProductManifest('./product.json'); * console.log(manifest.id); // 'lysnrai' * ``` */ export async function loadProductManifest(path: string): Promise { const fs = await import('fs/promises'); const content = await fs.readFile(path, 'utf-8'); const json = JSON.parse(content); return ProductManifestSchema.parse(json); } /** * Synchronous version of loadProductManifest (for startup use) * * @param path Path to product.json file * @returns Validated ProductManifest * @throws ZodError if validation fails */ export function loadProductManifestSync(path: string): ProductManifest { const fs = require('fs'); const content = fs.readFileSync(path, 'utf-8'); const json = JSON.parse(content); return ProductManifestSchema.parse(json); } /** * Merge manifest theme with defaults * * @param manifest Product manifest * @returns Complete theme with defaults filled in */ export function resolveTheme(manifest: ProductManifest): Theme { return { ...DEFAULT_THEME, ...manifest.theme, }; } /** * Validate a product manifest object without loading from file * * @param json Parsed JSON object * @returns Validated ProductManifest * @throws ZodError if validation fails */ export function validateProductManifest(json: unknown): ProductManifest { return ProductManifestSchema.parse(json); } /** * Safe validation that returns null on failure * * @param json Parsed JSON object * @returns Validated ProductManifest or null */ export function safeValidateProductManifest(json: unknown): ProductManifest | null { const result = ProductManifestSchema.safeParse(json); return result.success ? result.data : null; }