From 7f5ff4c7903cb03c94b40b211318d8e78b9620db Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Tue, 3 Mar 2026 10:01:40 -0800 Subject: [PATCH] feat(config): implement P0-1 - Product manifest specification (product.json schema) - Add ProductManifestSchema with Zod validation for product identity - Support theme tokens, cosmos containers, feature flags, ports - Add loadProductManifest(), validateProductManifest(), resolveTheme() - Export all schemas and types from config package index --- packages/config/src/index.ts | 21 ++ packages/config/src/product-manifest.ts | 252 ++++++++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 packages/config/src/product-manifest.ts diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 8768b6ce..30f5d073 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -13,3 +13,24 @@ export { type SecretMapping, type SecretsProviderType, } from './keyvault.js'; +export { + ProductManifestSchema, + PlatformSchema, + ThemeSchema, + ContainerDefSchema, + FeatureFlagSchema, + PortConfigSchema, + ExtendedProductManifestSchema, + DEFAULT_THEME, + loadProductManifest, + loadProductManifestSync, + resolveTheme, + validateProductManifest, + safeValidateProductManifest, + type ProductManifest, + type Platform, + type Theme, + type ContainerDef, + type FeatureFlag, + type PortConfig, +} from './product-manifest.js'; diff --git a/packages/config/src/product-manifest.ts b/packages/config/src/product-manifest.ts new file mode 100644 index 00000000..5077e5a9 --- /dev/null +++ b/packages/config/src/product-manifest.ts @@ -0,0 +1,252 @@ +/** + * 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; +}