- 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
306 lines
8.3 KiB
TypeScript
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;
|
|
}
|