/** * 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 { 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', 'mac']); /** * 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().min(1), partitionKey: z.string().min(1), ttlSeconds: z.number().positive().optional(), uniqueKeys: z.array(z.string()).optional(), }); /** * Feature flag default */ export const FeatureFlagSchema = z.object({ key: z.string().min(1), 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(), }); /** * 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 * { * "productId": "lysnrai", * "displayName": "LysnrAI", * "bundleId": "com.saravana.lysnrai", * "domain": "lysnrai.app", * "description": "Voice-to-text dictation platform", * "backendPort": 4015, * "platforms": ["web", "ios", "android", "desktop"], * "cosmos": { * "containers": [ * { "name": "transcripts", "partitionKey": "/userId" }, * { "name": "sessions", "partitionKey": "/userId" } * ] * }, * "ports": { * "service": 4015, * "dashboard": 3002 * } * } * ``` */ 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', }), displayName: z.string().min(1).max(50), // 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']), 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 map (key → boolean/string/number) features: z.record(z.string(), z.boolean().or(z.string()).or(z.number())).optional(), // Cosmos containers cosmos: z .object({ containers: z.array(ContainerDefSchema).default([]), }) .optional(), // Feature flags with defaults flags: z.array(FeatureFlagSchema).default([]), // 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. */ export const ExtendedProductManifestSchema = BaseManifestSchema.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.productId); // 'lysnrai' * ``` */ export async function loadProductManifest(path: string): Promise { const content = await 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 content = 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; }