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:
parent
cf20f0f591
commit
a0dafcd693
467
packages/config/src/__tests__/product-manifest.test.ts
Normal file
467
packages/config/src/__tests__/product-manifest.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -20,6 +20,8 @@ export {
|
||||
ContainerDefSchema,
|
||||
FeatureFlagSchema,
|
||||
PortConfigSchema,
|
||||
BundleIdSchema,
|
||||
AppStoreSchema,
|
||||
ExtendedProductManifestSchema,
|
||||
DEFAULT_THEME,
|
||||
loadProductManifest,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user