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
This commit is contained in:
saravanakumardb1 2026-03-19 19:43:46 -07:00
parent cf20f0f591
commit a0dafcd693
3 changed files with 583 additions and 61 deletions

View File

@ -0,0 +1,467 @@
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ProductManifestSchema,
ExtendedProductManifestSchema,
PlatformSchema,
ThemeSchema,
ContainerDefSchema,
FeatureFlagSchema,
BundleIdSchema,
DEFAULT_THEME,
loadProductManifest,
loadProductManifestSync,
resolveTheme,
validateProductManifest,
safeValidateProductManifest,
} from '../index.js';
// ── Minimal valid manifest ──────────────────────────────────────────────────
const MINIMAL = {
productId: 'testprod',
displayName: 'TestProd',
};
// ── Full manifest (matches FlowMonk-style) ──────────────────────────────────
const FULL = {
productId: 'flowmonk',
displayName: 'FlowMonk',
name: 'FlowMonk',
tagline: 'Agent-first planning and execution',
domain: 'flowmonk.app',
backendPort: 4017,
primarySurface: 'web',
mobileCompanion: true,
platforms: ['web', 'ios', 'android'],
bundleIds: {
ios: 'com.saravana.flowmonk',
android: 'com.saravana.flowmonk',
web: 'flowmonk.app',
},
appStore: {
category: 'Productivity',
subcategory: 'Task Management',
ageRating: '4+',
privacyUrl: 'https://flowmonk.app/privacy',
termsUrl: 'https://flowmonk.app/terms',
supportUrl: 'https://flowmonk.app/support',
},
cosmos: {
containers: [
{ name: 'zones', partitionKey: '/userId' },
{ name: 'flows', partitionKey: '/userId' },
{ name: 'tasks', partitionKey: '/userId' },
],
},
flags: [{ key: 'new-scheduler', defaultValue: false, description: 'Enable v2 scheduler' }],
ports: { service: 4017 },
version: '0.1.0',
};
// ── Tests ────────────────────────────────────────────────────────────────────
describe('ProductManifestSchema', () => {
it('parses a minimal manifest (just productId + displayName)', () => {
const result = ProductManifestSchema.parse(MINIMAL);
expect(result.productId).toBe('testprod');
expect(result.displayName).toBe('TestProd');
expect(result.platforms).toEqual(['web']); // default
expect(result.flags).toEqual([]); // default
});
it('parses a full manifest', () => {
const result = ProductManifestSchema.parse(FULL);
expect(result.productId).toBe('flowmonk');
expect(result.backendPort).toBe(4017);
expect(result.cosmos?.containers).toHaveLength(3);
expect(result.flags).toHaveLength(1);
expect(result.appStore?.category).toBe('Productivity');
});
it('rejects missing productId', () => {
expect(() => ProductManifestSchema.parse({ displayName: 'X' })).toThrow();
});
it('rejects missing displayName', () => {
expect(() => ProductManifestSchema.parse({ productId: 'x' })).toThrow();
});
it('rejects invalid productId (uppercase)', () => {
expect(() => ProductManifestSchema.parse({ productId: 'BadId', displayName: 'X' })).toThrow(
/lowercase/
);
});
it('rejects productId starting with number', () => {
expect(() => ProductManifestSchema.parse({ productId: '1bad', displayName: 'X' })).toThrow();
});
it('allows hyphens in productId', () => {
const result = ProductManifestSchema.parse({ productId: 'my-app', displayName: 'My App' });
expect(result.productId).toBe('my-app');
});
it('rejects backendPort below 1024', () => {
expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 80 })).toThrow();
});
it('rejects backendPort above 65535', () => {
expect(() => ProductManifestSchema.parse({ ...MINIMAL, backendPort: 70000 })).toThrow();
});
it('accepts valid backendPort', () => {
const result = ProductManifestSchema.parse({ ...MINIMAL, backendPort: 4016 });
expect(result.backendPort).toBe(4016);
});
it('parses LysnrAI-style manifest (legacy fields)', () => {
const lysnrai = {
displayName: 'LysnrAI',
productId: 'lysnrai',
licensePrefix: 'LYSNR',
configDirName: '.LysnrAI',
envVarPrefix: 'LYSNR',
bundleIdSuffix: 'LysnrAI',
packageName: 'lysnrai',
};
const result = ProductManifestSchema.parse(lysnrai);
expect(result.licensePrefix).toBe('LYSNR');
expect(result.envVarPrefix).toBe('LYSNR');
});
it('parses NoteLett-style manifest (bundleId as object)', () => {
const notelett = {
productId: 'notelett',
displayName: 'NoteLett',
bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' },
backendPort: 4016,
appGroup: 'group.com.bytelyst.notelett',
};
const result = ProductManifestSchema.parse(notelett);
expect(result.bundleId).toEqual({ ios: 'com.bytelyst.notelett', android: 'com.notelett.app' });
expect(result.appGroup).toBe('group.com.bytelyst.notelett');
});
it('parses NomGap-style manifest (bundleId as string)', () => {
const nomgap = {
productId: 'nomgap',
displayName: 'NomGap',
bundleId: 'com.saravana.nomgap',
domain: 'nomgap.app',
};
const result = ProductManifestSchema.parse(nomgap);
expect(result.bundleId).toBe('com.saravana.nomgap');
});
it('parses ActionTrail-style manifest (no mobile)', () => {
const actiontrail = {
productId: 'actiontrail',
displayName: 'ActionTrail',
tagline: 'AI Activity Oversight',
domain: 'actiontrail.dev',
backendPort: 4018,
primarySurface: 'web',
mobileCompanion: false,
};
const result = ProductManifestSchema.parse(actiontrail);
expect(result.mobileCompanion).toBe(false);
expect(result.primarySurface).toBe('web');
});
});
describe('duplicate container validation', () => {
it('rejects duplicate container names', () => {
const manifest = {
...MINIMAL,
cosmos: {
containers: [
{ name: 'users', partitionKey: '/userId' },
{ name: 'users', partitionKey: '/email' },
],
},
};
expect(() => ProductManifestSchema.parse(manifest)).toThrow(/Duplicate container name: users/);
});
it('allows unique container names', () => {
const manifest = {
...MINIMAL,
cosmos: {
containers: [
{ name: 'users', partitionKey: '/userId' },
{ name: 'sessions', partitionKey: '/userId' },
],
},
};
const result = ProductManifestSchema.parse(manifest);
expect(result.cosmos?.containers).toHaveLength(2);
});
it('allows single container (no duplicate check needed)', () => {
const manifest = {
...MINIMAL,
cosmos: { containers: [{ name: 'users', partitionKey: '/userId' }] },
};
const result = ProductManifestSchema.parse(manifest);
expect(result.cosmos?.containers).toHaveLength(1);
});
});
describe('ExtendedProductManifestSchema', () => {
it('allows unknown keys (passthrough)', () => {
const result = ExtendedProductManifestSchema.parse({
...MINIMAL,
customField: 'hello',
nestedCustom: { x: 1 },
});
expect((result as Record<string, unknown>).customField).toBe('hello');
});
});
describe('PlatformSchema', () => {
it('accepts valid platforms', () => {
for (const p of ['web', 'ios', 'android', 'desktop', 'watch', 'mac']) {
expect(PlatformSchema.parse(p)).toBe(p);
}
});
it('rejects invalid platform', () => {
expect(() => PlatformSchema.parse('windows')).toThrow();
});
});
describe('ThemeSchema', () => {
it('validates hex colors', () => {
const result = ThemeSchema.parse(DEFAULT_THEME);
expect(result.primary).toBe('#5AE68C');
});
it('rejects non-hex colors', () => {
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: 'red' })).toThrow(/hex/);
});
it('rejects 3-digit hex', () => {
expect(() => ThemeSchema.parse({ ...DEFAULT_THEME, primary: '#FFF' })).toThrow();
});
});
describe('ContainerDefSchema', () => {
it('parses valid container', () => {
const result = ContainerDefSchema.parse({ name: 'users', partitionKey: '/userId' });
expect(result.name).toBe('users');
});
it('accepts optional ttlSeconds and uniqueKeys', () => {
const result = ContainerDefSchema.parse({
name: 'sessions',
partitionKey: '/userId',
ttlSeconds: 86400,
uniqueKeys: ['/email'],
});
expect(result.ttlSeconds).toBe(86400);
expect(result.uniqueKeys).toEqual(['/email']);
});
it('rejects empty name', () => {
expect(() => ContainerDefSchema.parse({ name: '', partitionKey: '/x' })).toThrow();
});
it('rejects negative ttlSeconds', () => {
expect(() =>
ContainerDefSchema.parse({ name: 'x', partitionKey: '/x', ttlSeconds: -1 })
).toThrow();
});
});
describe('FeatureFlagSchema', () => {
it('accepts boolean default', () => {
const result = FeatureFlagSchema.parse({ key: 'beta', defaultValue: false });
expect(result.defaultValue).toBe(false);
});
it('accepts string default', () => {
const result = FeatureFlagSchema.parse({ key: 'tier', defaultValue: 'free' });
expect(result.defaultValue).toBe('free');
});
it('accepts number default', () => {
const result = FeatureFlagSchema.parse({ key: 'max-items', defaultValue: 100 });
expect(result.defaultValue).toBe(100);
});
it('rejects empty key', () => {
expect(() => FeatureFlagSchema.parse({ key: '', defaultValue: true })).toThrow();
});
});
describe('BundleIdSchema', () => {
it('accepts string bundle ID', () => {
expect(BundleIdSchema.parse('com.example.app')).toBe('com.example.app');
});
it('accepts per-platform bundle IDs', () => {
const result = BundleIdSchema.parse({ ios: 'com.ios.app', android: 'com.android.app' });
expect(result).toEqual({ ios: 'com.ios.app', android: 'com.android.app' });
});
it('rejects empty string', () => {
expect(() => BundleIdSchema.parse('')).toThrow();
});
});
describe('resolveTheme', () => {
it('returns defaults when no theme specified', () => {
const manifest = ProductManifestSchema.parse(MINIMAL);
const theme = resolveTheme(manifest);
expect(theme).toEqual(DEFAULT_THEME);
});
it('merges partial theme with defaults', () => {
const manifest = ProductManifestSchema.parse({
...MINIMAL,
theme: { primary: '#FF0000' },
});
const theme = resolveTheme(manifest);
expect(theme.primary).toBe('#FF0000');
expect(theme.secondary).toBe(DEFAULT_THEME.secondary); // default preserved
});
});
describe('validateProductManifest', () => {
it('validates a valid object', () => {
const result = validateProductManifest(MINIMAL);
expect(result.productId).toBe('testprod');
});
it('throws on invalid object', () => {
expect(() => validateProductManifest({ bad: true })).toThrow();
});
});
describe('safeValidateProductManifest', () => {
it('returns manifest on valid input', () => {
const result = safeValidateProductManifest(MINIMAL);
expect(result).not.toBeNull();
expect(result!.productId).toBe('testprod');
});
it('returns null on invalid input', () => {
const result = safeValidateProductManifest({ bad: true });
expect(result).toBeNull();
});
});
describe('loadProductManifest / loadProductManifestSync', () => {
const tmpDir = join(tmpdir(), `manifest-test-${Date.now()}`);
const validPath = join(tmpDir, 'valid.json');
const invalidPath = join(tmpDir, 'invalid.json');
beforeEach(() => {
mkdirSync(tmpDir, { recursive: true });
writeFileSync(validPath, JSON.stringify(FULL));
writeFileSync(invalidPath, JSON.stringify({ bad: true }));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('loads and validates from file (async)', async () => {
const result = await loadProductManifest(validPath);
expect(result.productId).toBe('flowmonk');
expect(result.cosmos?.containers).toHaveLength(3);
});
it('loads and validates from file (sync)', () => {
const result = loadProductManifestSync(validPath);
expect(result.productId).toBe('flowmonk');
});
it('throws on invalid file (async)', async () => {
await expect(loadProductManifest(invalidPath)).rejects.toThrow();
});
it('throws on invalid file (sync)', () => {
expect(() => loadProductManifestSync(invalidPath)).toThrow();
});
it('throws on missing file (async)', async () => {
await expect(loadProductManifest('/nonexistent/path.json')).rejects.toThrow();
});
it('throws on missing file (sync)', () => {
expect(() => loadProductManifestSync('/nonexistent/path.json')).toThrow();
});
});
describe('real-world product.json files parse cleanly', () => {
it('parses LysnrAI product.json', () => {
const json = {
displayName: 'LysnrAI',
productId: 'lysnrai',
licensePrefix: 'LYSNR',
configDirName: '.LysnrAI',
envVarPrefix: 'LYSNR',
bundleIdSuffix: 'LysnrAI',
packageName: 'lysnrai',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses NomGap product.json', () => {
const json = {
productId: 'nomgap',
displayName: 'NomGap',
bundleId: 'com.saravana.nomgap',
domain: 'nomgap.app',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses NoteLett product.json', () => {
const json = {
productId: 'notelett',
displayName: 'NoteLett',
licensePrefix: 'NOTELETT',
configDirName: '.NoteLett',
envVarPrefix: 'NOTELETT',
bundleIdSuffix: 'notelett',
packageName: 'notelett',
domain: 'notelett.app',
bundleId: { ios: 'com.bytelyst.notelett', android: 'com.notelett.app' },
appGroup: 'group.com.bytelyst.notelett',
backendPort: 4016,
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
it('parses FlowMonk product.json', () => {
expect(() => ProductManifestSchema.parse(FULL)).not.toThrow();
});
it('parses ActionTrail product.json', () => {
const json = {
productId: 'actiontrail',
displayName: 'ActionTrail',
tagline: 'AI Activity Oversight',
domain: 'actiontrail.dev',
backendPort: 4018,
primarySurface: 'web',
mobileCompanion: false,
bundleIds: { web: 'actiontrail.dev' },
appStore: {
category: 'Developer Tools',
subcategory: 'AI Monitoring',
privacyUrl: 'https://actiontrail.dev/privacy',
termsUrl: 'https://actiontrail.dev/terms',
supportUrl: 'https://actiontrail.dev/support',
},
version: '0.1.0',
};
expect(() => ProductManifestSchema.parse(json)).not.toThrow();
});
});

View File

@ -20,6 +20,8 @@ export {
ContainerDefSchema,
FeatureFlagSchema,
PortConfigSchema,
BundleIdSchema,
AppStoreSchema,
ExtendedProductManifestSchema,
DEFAULT_THEME,
loadProductManifest,

View File

@ -7,12 +7,14 @@
* @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']);
export const PlatformSchema = z.enum(['web', 'ios', 'android', 'desktop', 'watch', 'mac']);
/**
* Theme color token
@ -40,9 +42,9 @@ export const ThemeSchema = z.object({
* Cosmos container definition
*/
export const ContainerDefSchema = z.object({
name: z.string(),
partitionKey: z.string(),
ttlSeconds: z.number().optional(),
name: z.string().min(1),
partitionKey: z.string().min(1),
ttlSeconds: z.number().positive().optional(),
uniqueKeys: z.array(z.string()).optional(),
});
@ -50,7 +52,7 @@ export const ContainerDefSchema = z.object({
* Feature flag default
*/
export const FeatureFlagSchema = z.object({
key: z.string(),
key: z.string().min(1),
defaultValue: z.union([z.boolean(), z.string(), z.number()]),
description: z.string().optional(),
});
@ -64,46 +66,55 @@ export const PortConfigSchema = z.object({
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
* {
* "id": "lysnrai",
* "name": "LysnrAI",
* "productId": "lysnrai",
* "displayName": "LysnrAI",
* "bundleId": "com.saravana.lysnrai",
* "domain": "lysnrai.app",
* "description": "Voice-to-text dictation platform",
* "backendPort": 4015,
* "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
@ -111,51 +122,95 @@ export const PortConfigSchema = z.object({
* }
* ```
*/
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',
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',
}),
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(),
// 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']),
version: z.string().regex(/^\d+\.\d+\.\d+$/).optional(),
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 flags (optional)
features: z.record(
z.string(),
z.boolean().or(z.string()).or(z.number())
).optional(),
// Feature map (key → boolean/string/number)
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(),
// Cosmos containers
cosmos: z
.object({
containers: z.array(ContainerDefSchema).default([]),
})
.optional(),
// Feature flags with defaults (optional)
// Feature flags with defaults
flags: z.array(FeatureFlagSchema).default([]),
// Port configuration (optional)
// 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
* 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();
export const ExtendedProductManifestSchema = BaseManifestSchema.passthrough();
/**
* Inferred TypeScript type for ProductManifest
@ -192,12 +247,11 @@ export const DEFAULT_THEME: Theme = {
* @example
* ```ts
* const manifest = await loadProductManifest('./product.json');
* console.log(manifest.id); // 'lysnrai'
* console.log(manifest.productId); // 'lysnrai'
* ```
*/
export async function loadProductManifest(path: string): Promise<ProductManifest> {
const fs = await import('fs/promises');
const content = await fs.readFile(path, 'utf-8');
const content = await readFile(path, 'utf-8');
const json = JSON.parse(content);
return ProductManifestSchema.parse(json);
}
@ -210,8 +264,7 @@ export async function loadProductManifest(path: string): Promise<ProductManifest
* @throws ZodError if validation fails
*/
export function loadProductManifestSync(path: string): ProductManifest {
const fs = require('fs');
const content = fs.readFileSync(path, 'utf-8');
const content = readFileSync(path, 'utf-8');
const json = JSON.parse(content);
return ProductManifestSchema.parse(json);
}