import { readFileSync, realpathSync, statSync } from 'node:fs';
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
import { canonicalJson, sha256 } from './canonical-json.mjs';
const BINARY_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const BUNDLE_ID = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
const WINDOWS_RESERVED_BASE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
const ROOT_FIELDS = new Set(['$schema', 'schemaVersion', 'localeRoot', 'members']);
const MEMBERS_FIELDS = new Set(['desktop', 'cli']);
const COMMON_MEMBER_FIELDS = new Set(['displayNameKey', 'binaryName']);
const DESKTOP_MEMBER_FIELDS = new Set([...COMMON_MEMBER_FIELDS, 'bundleId']);
export class ProductDefinitionError extends Error {
constructor(code, message, action) {
super(`${code}: ${message}\nAction: ${action}`);
this.name = 'ProductDefinitionError';
this.code = code;
this.action = action;
}
}
function fail(code, message, action) {
throw new ProductDefinitionError(code, message, action);
}
export function parseJsonc(source, sourceName = 'product.jsonc') {
let output = '';
let inString = false;
let escaped = false;
let lineComment = false;
let blockComment = false;
for (let index = 0; index < source.length; index += 1) {
const current = source[index];
const next = source[index + 1];
if (lineComment) {
output += current === '\n' || current === '\r' ? current : ' ';
if (current === '\n' || current === '\r') lineComment = false;
} else if (blockComment) {
if (current === '*' && next === '/') {
output += ' ';
index += 1;
blockComment = false;
} else {
output += current === '\n' || current === '\r' ? current : ' ';
}
} else if (inString) {
output += current;
if (escaped) escaped = false;
else if (current === '\\') escaped = true;
else if (current === '"') inString = false;
} else if (current === '"') {
inString = true;
output += current;
} else if (current === '/' && next === '/') {
output += ' ';
index += 1;
lineComment = true;
} else if (current === '/' && next === '*') {
output += ' ';
index += 1;
blockComment = true;
} else {
output += current;
}
}
if (blockComment) fail('invalid_jsonc', `${sourceName} has an unterminated block comment.`, 'Close the block comment.');
let withoutTrailingCommas = '';
inString = false;
escaped = false;
for (let index = 0; index < output.length; index += 1) {
const current = output[index];
if (inString) {
withoutTrailingCommas += current;
if (escaped) escaped = false;
else if (current === '\\') escaped = true;
else if (current === '"') inString = false;
continue;
}
if (current === '"') {
inString = true;
withoutTrailingCommas += current;
continue;
}
if (current === ',') {
let next = index + 1;
while (next < output.length && /\s/.test(output[next])) next += 1;
if (output[next] === '}' || output[next] === ']') continue;
}
withoutTrailingCommas += current;
}
try {
return JSON.parse(withoutTrailingCommas);
} catch (error) {
fail('invalid_jsonc', `${sourceName} is not valid JSONC: ${error.message}`, 'Correct the JSON syntax.');
}
}
function requireObject(value, owner) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
fail('invalid_field_type', `${owner} must be an object.`, `Set ${owner} to a JSON object.`);
}
return value;
}
function rejectUnknownFields(value, allowed, owner) {
for (const key of Object.keys(value)) {
if (!allowed.has(key)) fail('unknown_field', `${owner}.${key} is not supported in schema version 1.`, `Remove ${owner}.${key}.`);
}
}
function requiredString(value, owner) {
if (typeof value !== 'string' || value.trim() === '') {
fail('missing_required_field', `${owner} must be a non-empty string.`, `Set ${owner}.`);
}
return value;
}
function binaryName(value, owner) {
const result = requiredString(value, owner);
if (!BINARY_NAME.test(result) || result.endsWith('.') || WINDOWS_RESERVED_BASE.test(result)) {
fail('invalid_binary_name', `${owner} is not a safe executable name.`, `Use letters, digits, dot, dash, or underscore for ${owner}.`);
}
return result;
}
function bundleId(value, owner) {
const result = requiredString(value, owner);
if (!BUNDLE_ID.test(result)) {
fail('invalid_bundle_id', `${owner} is not a reverse-domain identifier.`, `Set ${owner} to a value such as com.example.product.`);
}
return result;
}
function inside(root, candidate) {
const path = relative(root, candidate);
return path === '' || (!path.startsWith(`..${sep}`) && path !== '..' && !isAbsolute(path));
}
function ownedDirectory(definitionDir, declaredPath) {
const lexical = resolve(definitionDir, requiredString(declaredPath, 'localeRoot'));
if (!inside(definitionDir, lexical)) {
fail('resource_path_escape', 'localeRoot escapes the product definition directory.', 'Keep product locales beside the definition.');
}
let canonical;
try {
canonical = realpathSync.native(lexical);
} catch {
fail('resource_not_found', `Locale directory does not exist: ${lexical}`, 'Create the locale directory or correct localeRoot.');
}
if (!inside(definitionDir, canonical)) {
fail('resource_path_escape', 'localeRoot resolves outside the product definition directory.', 'Remove the escaping link.');
}
if (!statSync(canonical).isDirectory()) fail('invalid_resource_type', 'localeRoot is not a directory.', 'Point it to a directory.');
return canonical;
}
function ownedLocaleFile(localeRoot, locale) {
const lexical = join(localeRoot, `${locale}.json`);
let canonical;
try {
canonical = realpathSync.native(lexical);
} catch {
fail('invalid_locale_resource', `Cannot read ${lexical}.`, 'Add valid JSON for every supported locale.');
}
if (!inside(localeRoot, canonical) || !statSync(canonical).isFile()) {
fail('resource_path_escape', `${locale}.json is not an owned locale file.`, 'Remove the escaping link.');
}
return canonical;
}
function validateMember(raw, member) {
const owner = `members.${member}`;
const value = requireObject(raw, owner);
rejectUnknownFields(value, member === 'desktop' ? DESKTOP_MEMBER_FIELDS : COMMON_MEMBER_FIELDS, owner);
const result = {
displayNameKey: requiredString(value.displayNameKey, `${owner}.displayNameKey`),
binaryName: binaryName(value.binaryName, `${owner}.binaryName`),
};
if (member === 'desktop') result.bundleId = bundleId(value.bundleId, `${owner}.bundleId`);
return result;
}
function loadProductNames(rootDir, localeRoot, displayNameKeys) {
const contract = JSON.parse(readFileSync(join(rootDir, 'src', 'shared', 'i18n', 'contract', 'locales.json'), 'utf8'));
const resources = {};
let expectedKeys;
for (const locale of contract.locales.map(({ id }) => id)) {
const path = ownedLocaleFile(localeRoot, locale);
let terms;
try {
terms = requireObject(JSON.parse(readFileSync(path, 'utf8')), `locale ${locale}`);
} catch (error) {
if (error instanceof ProductDefinitionError) throw error;
fail('invalid_locale_resource', `Cannot read ${path}: ${error.message}`, 'Add valid JSON for every supported locale.');
}
const keys = Object.keys(terms).sort();
if (!expectedKeys) expectedKeys = keys;
else if (canonicalJson(keys) !== canonicalJson(expectedKeys)) {
fail('locale_key_mismatch', `${locale} has a different product-name key set.`, 'Use the same keys in every product locale.');
}
for (const [key, value] of Object.entries(terms)) requiredString(value, `locale ${locale}.${key}`);
resources[locale] = terms;
}
for (const key of displayNameKeys) {
if (!expectedKeys?.includes(key)) fail('missing_product_name_key', `${key} is absent from product locales.`, `Add ${key} to every locale.`);
}
return {
resources,
digest: sha256(canonicalJson(resources)),
defaultLocale: contract.defaultLocale,
fallbackLocale: contract.fallbackLocale,
};
}
export function resolveProductDefinition({ rootDir, productConfig, member }) {
if (!['desktop', 'cli'].includes(member)) fail('invalid_member', `Unsupported product member: ${member}`, 'Use desktop or cli.');
const canonicalRoot = realpathSync.native(resolve(rootDir));
const defaultPath = realpathSync.native(join(canonicalRoot, 'products', 'bitfun', 'product.jsonc'));
const selectedPath = resolve(productConfig || defaultPath);
let sourcePath;
try {
sourcePath = realpathSync.native(selectedPath);
} catch {
fail('definition_not_found', `Product definition does not exist: ${selectedPath}`, 'Pass an existing JSONC file to --product-config.');
}
const sourceBytes = readFileSync(sourcePath);
const raw = requireObject(parseJsonc(sourceBytes.toString('utf8'), sourcePath), 'product definition');
rejectUnknownFields(raw, ROOT_FIELDS, 'product definition');
if (raw.schemaVersion !== 1) fail('unsupported_schema_version', `schemaVersion ${raw.schemaVersion} is unsupported.`, 'Set schemaVersion to 1.');
const definitionDir = realpathSync.native(resolve(sourcePath, '..'));
const localeRoot = ownedDirectory(definitionDir, raw.localeRoot);
const members = requireObject(raw.members, 'members');
rejectUnknownFields(members, MEMBERS_FIELDS, 'members');
const normalizedMembers = {
desktop: validateMember(members.desktop, 'desktop'),
cli: validateMember(members.cli, 'cli'),
};
const locales = loadProductNames(
canonicalRoot,
localeRoot,
[normalizedMembers.desktop.displayNameKey, normalizedMembers.cli.displayNameKey],
);
const selected = normalizedMembers[member];
const assemblyContent = {
schemaVersion: 1,
sourceDigest: sha256(sourceBytes),
member,
displayNameKey: selected.displayNameKey,
binaryName: selected.binaryName,
localeDigest: locales.digest,
defaultLocale: locales.defaultLocale,
fallbackLocale: locales.fallbackLocale,
};
if (selected.bundleId) assemblyContent.bundleId = selected.bundleId;
const assembly = { ...assemblyContent, assemblyDigest: sha256(canonicalJson(assemblyContent)) };
return {
rootDir: canonicalRoot,
sourcePath,
productNames: Object.fromEntries(
Object.entries(locales.resources).map(([locale, terms]) => [locale, terms[selected.displayNameKey]]),
),
assembly,
outputDir: join(canonicalRoot, 'target', 'product-assembly', assembly.assemblyDigest, member),
isDefaultProduct: sourcePath === defaultPath,
};
}