learning_ai_common_plat/packages/config/src/product-manifest.ts
saravanakumardb1 a0dafcd693 feat(config): overhaul product manifest schema + 51 tests
- 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
2026-03-19 19:43:46 -07:00

306 lines
8.3 KiB
TypeScript

/**
* 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<string>();
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<typeof ProductManifestSchema>;
export type Platform = z.infer<typeof PlatformSchema>;
export type Theme = z.infer<typeof ThemeSchema>;
export type ContainerDef = z.infer<typeof ContainerDefSchema>;
export type FeatureFlag = z.infer<typeof FeatureFlagSchema>;
export type PortConfig = z.infer<typeof PortConfigSchema>;
/**
* 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<ProductManifest> {
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;
}