570 lines
24 KiB
JavaScript
570 lines
24 KiB
JavaScript
/** Build static cookbook catalogs from authored YAML. No FastVideo or Python imports. */
|
|
import {
|
|
existsSync,
|
|
lstatSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseDocument, stringify } from "yaml";
|
|
import { createValidator } from "./cookbook-validator.mjs";
|
|
import { resolveConfig } from "./cookbook-config.mjs";
|
|
import { WORKLOAD_IDS } from "./cookbook-workloads.mjs";
|
|
import { validateCapacityMetadata } from "./cookbook-capacity-schema.mjs";
|
|
|
|
export const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
const reserved = new Set(["__proto__", "constructor", "prototype"]);
|
|
const annotations = new Set(["default", "examples", "enum", "const"]);
|
|
const protectedPaths = new Set([
|
|
"generator.model_path",
|
|
"generator.pipeline.workload_type",
|
|
"generator.pipeline.preset",
|
|
"generator.pipeline.preset_version",
|
|
"generator.pipeline.components.vae_weights",
|
|
"default_request.output.save_video",
|
|
"default_request.output.return_frames",
|
|
"default_request.output.output_path",
|
|
]);
|
|
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
const clone = (value) => structuredClone(value);
|
|
const requireValue = (condition, message) => {
|
|
if (!condition) throw new Error(message);
|
|
};
|
|
const text = (value) => typeof value === "string" && value.trim().length > 0;
|
|
|
|
/** Object keys merge recursively; arrays and scalar values replace, without mutating inputs. */
|
|
export function deepMerge(base, overlay) {
|
|
if (!object(base) || !object(overlay)) return clone(overlay);
|
|
return Object.fromEntries(
|
|
[...new Set([...Object.keys(base), ...Object.keys(overlay)])].map((key) => [
|
|
key,
|
|
Object.hasOwn(overlay, key) ? deepMerge(base[key], overlay[key]) : clone(base[key]),
|
|
]),
|
|
);
|
|
}
|
|
|
|
export function readYaml(path) {
|
|
let source;
|
|
try {
|
|
source = readFileSync(path, "utf8");
|
|
} catch (failure) {
|
|
if (failure.code !== "ENOENT") throw failure;
|
|
throw new Error(`${path}: YAML file does not exist; check the authored file reference`, { cause: failure });
|
|
}
|
|
const document = parseDocument(source, { uniqueKeys: true, intAsBigInt: true, merge: true });
|
|
requireValue(!document.errors.length, `${path}: ${document.errors.map((error) => error.message).join("; ")}`);
|
|
requireValue(!document.warnings.length, `${path}: ${document.warnings.map((warning) => warning.message).join("; ")}`);
|
|
const active = new Set();
|
|
function jsonValue(value) {
|
|
if (typeof value === "bigint") {
|
|
requireValue(
|
|
value >= BigInt(Number.MIN_SAFE_INTEGER) && value <= BigInt(Number.MAX_SAFE_INTEGER),
|
|
`${path}: integer exceeds browser-safe range`,
|
|
);
|
|
return Number(value);
|
|
}
|
|
if (typeof value === "number") {
|
|
requireValue(Number.isFinite(value), `${path}: non-finite number`);
|
|
requireValue(
|
|
!Number.isInteger(value) || Number.isSafeInteger(value),
|
|
`${path}: integer exceeds browser-safe range`,
|
|
);
|
|
}
|
|
if (value === null || typeof value !== "object") return value;
|
|
requireValue(!active.has(value), `${path}: cyclic YAML aliases are not JSON configuration`);
|
|
active.add(value);
|
|
const result = Array.isArray(value)
|
|
? value.map(jsonValue)
|
|
: Object.fromEntries(Object.entries(value).map(([key, child]) => [key, jsonValue(child)]));
|
|
active.delete(value);
|
|
return result;
|
|
}
|
|
return jsonValue(document.toJS({ maxAliasCount: 100 }));
|
|
}
|
|
|
|
function keys(value, required, optional, context) {
|
|
requireValue(object(value), `${context}: expected a mapping`);
|
|
for (const key of required) requireValue(Object.hasOwn(value, key), `${context}: missing required key '${key}'`);
|
|
const allowed = [...required, ...optional];
|
|
for (const key of Object.keys(value))
|
|
requireValue(allowed.includes(key), `${context}: unknown key '${key}'; allowed keys: ${allowed.join(", ")}`);
|
|
}
|
|
|
|
function expected(value, predicate, description, context) {
|
|
const kind = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
requireValue(predicate(value), `${context}: expected ${description}; received ${kind} ${JSON.stringify(value)}`);
|
|
}
|
|
|
|
function validateRuntime(runtime, id, context) {
|
|
keys(
|
|
runtime,
|
|
["id", "label", "backend", "interface", "launch_argv", "server_defaults"],
|
|
["install_url", "client_guide_url", "hardware_label", "topology_defaults"],
|
|
context,
|
|
);
|
|
requireValue(runtime.id === id, `${context}.id: must match runtime key '${id}'`);
|
|
for (const key of ["label", "backend"]) expected(runtime[key], text, "nonempty string", `${context}.${key}`);
|
|
expected(
|
|
runtime.interface,
|
|
(value) => ["rest", "websocket"].includes(value),
|
|
"rest or websocket",
|
|
`${context}.interface`,
|
|
);
|
|
expected(
|
|
runtime.launch_argv,
|
|
(value) => Array.isArray(value) && value.length > 0 && value.every(text),
|
|
"nonempty list of command arguments",
|
|
`${context}.launch_argv`,
|
|
);
|
|
keys(runtime.server_defaults, ["host", "port"], ["served_model_name"], `${context}.server_defaults`);
|
|
expected(runtime.server_defaults.host, text, "nonempty string", `${context}.server_defaults.host`);
|
|
expected(runtime.server_defaults.port, Number.isInteger, "integer", `${context}.server_defaults.port`);
|
|
if (Object.hasOwn(runtime.server_defaults, "served_model_name"))
|
|
expected(
|
|
runtime.server_defaults.served_model_name,
|
|
(value) => value === null || typeof value === "string",
|
|
"string or null",
|
|
`${context}.server_defaults.served_model_name`,
|
|
);
|
|
for (const key of ["install_url", "client_guide_url", "hardware_label"]) {
|
|
if (Object.hasOwn(runtime, key)) expected(runtime[key], text, "nonempty string", `${context}.${key}`);
|
|
}
|
|
if (runtime.topology_defaults != null) {
|
|
const fields = ["num_gpus", "tp_size", "sp_size", "hsdp_replicate_dim", "hsdp_shard_dim"];
|
|
keys(runtime.topology_defaults, fields, [], `${context}.topology_defaults`);
|
|
for (const key of fields)
|
|
expected(runtime.topology_defaults[key], Number.isInteger, "integer", `${context}.topology_defaults.${key}`);
|
|
}
|
|
}
|
|
|
|
function safePath(path) {
|
|
requireValue(
|
|
typeof path === "string" &&
|
|
/^(generator|server|default_request|streaming)(\.[A-Za-z_][A-Za-z0-9_]*)*$/.test(path) &&
|
|
path.includes(".") &&
|
|
!path.split(".").some((part) => reserved.has(part)),
|
|
`Invalid configuration path: ${path}`,
|
|
);
|
|
}
|
|
|
|
function declaredTypes(schema) {
|
|
if (typeof schema.type === "string") return [schema.type];
|
|
if (Array.isArray(schema.type)) return [...schema.type].sort();
|
|
const alternatives = schema.anyOf || schema.oneOf;
|
|
if (!Array.isArray(alternatives)) return null;
|
|
const branches = alternatives.map(declaredTypes);
|
|
return branches.every(Boolean) ? [...new Set(branches.flat())].sort() : null;
|
|
}
|
|
|
|
/** Merge metadata separately from control selection. A partial override never enables a field. */
|
|
export function mergeOptions(base, overrides, context = "options") {
|
|
requireValue(object(base) && object(overrides), `${context}: options must be a path-to-schema mapping`);
|
|
for (const [path, schema] of Object.entries(overrides)) {
|
|
safePath(path);
|
|
requireValue(object(schema), `${context}: ${path} must contain a schema mapping`);
|
|
}
|
|
const result = deepMerge(base, overrides);
|
|
for (const [path, schema] of Object.entries(result)) {
|
|
const previous = base[path] && declaredTypes(base[path]);
|
|
if (previous)
|
|
requireValue(
|
|
JSON.stringify(previous) === JSON.stringify(declaredTypes(schema)),
|
|
`${context}: ${path} cannot change its declared type`,
|
|
);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Validate self-contained fragments, including bounds/defaults that schema syntax alone cannot check. */
|
|
function validateSchema(schema, context) {
|
|
requireValue(object(schema) && declaredTypes(schema), `${context}: a complete schema needs an explicit type`);
|
|
function visit(node) {
|
|
if (Array.isArray(node)) {
|
|
node.forEach(visit);
|
|
return;
|
|
}
|
|
if (!object(node)) return;
|
|
requireValue(!Object.hasOwn(node, "$ref"), `${context}: $ref is unsupported; use a self-contained schema`);
|
|
for (const [minimum, maximum] of [
|
|
["minLength", "maxLength"],
|
|
["minItems", "maxItems"],
|
|
["minProperties", "maxProperties"],
|
|
["minContains", "maxContains"],
|
|
]) {
|
|
if (minimum in node && maximum in node)
|
|
requireValue(node[minimum] <= node[maximum], `${context}: ${minimum} exceeds ${maximum}`);
|
|
}
|
|
const lower = Math.max(node.minimum ?? -Infinity, node.exclusiveMinimum ?? -Infinity);
|
|
const upper = Math.min(node.maximum ?? Infinity, node.exclusiveMaximum ?? Infinity);
|
|
requireValue(
|
|
lower <= upper && !(lower === upper && (node.exclusiveMinimum === lower || node.exclusiveMaximum === upper)),
|
|
`${context}: minimum exceeds maximum`,
|
|
);
|
|
for (const [key, child] of Object.entries(node)) {
|
|
if (annotations.has(key)) continue;
|
|
if (["properties", "$defs", "patternProperties", "dependentSchemas"].includes(key))
|
|
Object.values(child).forEach(visit);
|
|
else visit(child);
|
|
}
|
|
if (Object.hasOwn(node, "default")) {
|
|
const validate = createValidator(node);
|
|
requireValue(validate(node.default), `${context}: default does not satisfy its schema`);
|
|
}
|
|
if (Array.isArray(node.enum)) {
|
|
const withoutEnum = { ...node };
|
|
delete withoutEnum.enum;
|
|
const validate = createValidator(withoutEnum);
|
|
requireValue(
|
|
node.enum.every((value) => validate(value)),
|
|
`${context}: enum value does not satisfy its schema`,
|
|
);
|
|
}
|
|
}
|
|
try {
|
|
visit(schema);
|
|
createValidator(schema);
|
|
} catch (failure) {
|
|
throw new Error(`${context}: ${failure.message}`, { cause: failure });
|
|
}
|
|
}
|
|
|
|
function validateOptions(options, context) {
|
|
for (const [path, schema] of Object.entries(options)) {
|
|
safePath(path);
|
|
validateSchema(schema, `${context}: ${path}`);
|
|
}
|
|
}
|
|
|
|
export function within(root, source, folder, extensions) {
|
|
requireValue(text(source) && !isAbsolute(source), `Expected a repository-relative path under ${folder}`);
|
|
let path;
|
|
try {
|
|
path = realpathSync(resolve(root, source));
|
|
} catch (failure) {
|
|
if (failure.code !== "ENOENT") throw failure;
|
|
throw new Error(`${source}: referenced file does not exist; select an existing file under ${folder}`, {
|
|
cause: failure,
|
|
});
|
|
}
|
|
const base = realpathSync(join(root, folder));
|
|
const child = relative(base, path);
|
|
requireValue(
|
|
child &&
|
|
child !== ".." &&
|
|
!child.startsWith(`..${sep}`) &&
|
|
!isAbsolute(child) &&
|
|
extensions.includes(extname(path)) &&
|
|
lstatSync(path).isFile(),
|
|
`Expected an existing ${extensions.join("/")} file under ${folder}: ${source}`,
|
|
);
|
|
return path;
|
|
}
|
|
|
|
function guideLink(value, root) {
|
|
if (value === undefined || value === null) return null;
|
|
const path = within(root, value, "docs", [".md"]);
|
|
const parts = relative(realpathSync(join(root, "docs")), path).split(sep);
|
|
const leaf = parts.pop();
|
|
if (leaf !== "index.md") parts.push(leaf.slice(0, -3));
|
|
return { url: `../../${parts.map(encodeURIComponent).join("/")}${parts.length ? "/" : ""}` };
|
|
}
|
|
|
|
/** Match canonical field paths with '*' and optional leading '!' exclusion. */
|
|
function optionPattern(selector) {
|
|
requireValue(typeof selector === "string" && selector.length > 0, `Invalid option pattern: ${selector}`);
|
|
const exclude = selector.startsWith("!"),
|
|
pattern = exclude ? selector.slice(1) : selector;
|
|
requireValue(
|
|
!pattern.includes("**") &&
|
|
pattern.split(".").every((part) => /^[A-Za-z_*][A-Za-z0-9_*]*$/.test(part) && !reserved.has(part)),
|
|
`Invalid option pattern: ${selector}`,
|
|
);
|
|
const expression = pattern
|
|
.split("*")
|
|
.map((part) => part.replace(/\./g, "\\."))
|
|
.join(".*");
|
|
return { exclude, match: new RegExp(`^${expression}$`) };
|
|
}
|
|
|
|
/** Apply selection rules before checking the final editable fields. */
|
|
function expandOptions(options, selectors, runtime) {
|
|
requireValue(Array.isArray(selectors), "options must be an ordered list of paths or patterns");
|
|
const available = Object.keys(options),
|
|
selected = new Set();
|
|
for (const selector of selectors) {
|
|
const { exclude, match } = optionPattern(selector);
|
|
const paths = available.filter((path) => match.test(path));
|
|
requireValue(paths.length, `Unknown option pattern: ${selector}`);
|
|
for (const path of paths) {
|
|
if (exclude) selected.delete(path);
|
|
else selected.add(path);
|
|
}
|
|
}
|
|
const forbidden = [
|
|
...protectedPaths,
|
|
...(runtime.interface === "websocket" ? ["server.output_dir", "server.served_model_name"] : []),
|
|
];
|
|
const seen = new Set(),
|
|
controls = [];
|
|
for (const path of selected) {
|
|
requireValue(
|
|
!path.startsWith("streaming.") || runtime.interface === "websocket",
|
|
`Streaming option ${path} requires a WebSocket deployment`,
|
|
);
|
|
requireValue(
|
|
!forbidden.some((item) => item === path || item.startsWith(`${path}.`) || path.startsWith(`${item}.`)),
|
|
`Protected configuration option: ${path}`,
|
|
);
|
|
requireValue(
|
|
![...seen].some((item) => item.startsWith(`${path}.`) || path.startsWith(`${item}.`)),
|
|
`Overlapping options: ${path}`,
|
|
);
|
|
seen.add(path);
|
|
controls.push({ path, schema: clone(options[path]) });
|
|
}
|
|
return controls;
|
|
}
|
|
|
|
export function buildCatalogs({
|
|
root = ROOT,
|
|
recipesDir = join(root, "docs/cookbook/recipes"),
|
|
catalogPath = join(root, "docs/cookbook/options.yaml"),
|
|
} = {}) {
|
|
const declarations = readYaml(catalogPath);
|
|
keys(declarations, ["options", "runtimes"], [], catalogPath);
|
|
expected(declarations.options, object, "path-to-schema mapping", `${catalogPath}: options`);
|
|
expected(declarations.runtimes, object, "runtime mapping", `${catalogPath}: runtimes`);
|
|
const runtimes = new Map();
|
|
for (const [id, definition] of Object.entries(declarations.runtimes)) {
|
|
const context = `${catalogPath}: runtimes.${id}`;
|
|
keys(definition, ["metadata", "workloads", "options"], [], context);
|
|
const runtime = definition.metadata;
|
|
validateRuntime(runtime, id, `${context}.metadata`);
|
|
expected(
|
|
definition.workloads,
|
|
(value) => Array.isArray(value) && value.length > 0 && value.every((workload) => WORKLOAD_IDS.includes(workload)),
|
|
"nonempty list of supported workloads",
|
|
`${context}.workloads`,
|
|
);
|
|
const options = mergeOptions(declarations.options, definition.options, context);
|
|
validateOptions(options, context);
|
|
runtimes.set(id, { runtime, workloads: definition.workloads, options });
|
|
}
|
|
const catalogs = [],
|
|
modelKeys = new Set(),
|
|
modelOwners = new Map();
|
|
const paths = readdirSync(recipesDir)
|
|
.filter((name) => /\.ya?ml$/.test(name))
|
|
.sort();
|
|
requireValue(paths.length, `No serving recipe manifests in ${recipesDir}`);
|
|
for (const name of paths) {
|
|
const modelKey = name.replace(/\.ya?ml$/, "");
|
|
requireValue(ID.test(modelKey) && !modelKeys.has(modelKey), `Invalid or duplicate model key: ${modelKey}`);
|
|
modelKeys.add(modelKey);
|
|
const manifest = readYaml(join(recipesDir, name));
|
|
keys(manifest, ["title", "deployments"], ["summary", "guide", "capacity"], name);
|
|
requireValue(
|
|
text(manifest.title) && object(manifest.deployments) && Object.keys(manifest.deployments).length,
|
|
`${name}: title and at least one deployment are required`,
|
|
);
|
|
let modelId;
|
|
for (const [deploymentKey, deployment] of Object.entries(manifest.deployments)) {
|
|
const id = `${modelKey}/${deploymentKey}`;
|
|
try {
|
|
requireValue(ID.test(deploymentKey), `Invalid deployment key: ${id}`);
|
|
keys(
|
|
deployment,
|
|
["runtime", "workload", "defaults", "options"],
|
|
["label", "summary", "guide", "env", "requirements", "overrides"],
|
|
id,
|
|
);
|
|
requireValue(runtimes.has(deployment.runtime), `${id}: unknown runtime ${deployment.runtime}`);
|
|
const { runtime, workloads, options } = runtimes.get(deployment.runtime);
|
|
requireValue(workloads.includes(deployment.workload), `${id}: unsupported workload ${deployment.workload}`);
|
|
const baseline = readYaml(within(root, deployment.defaults, "examples/serving", [".yaml", ".yml"]));
|
|
requireValue(
|
|
object(baseline) && text(baseline.generator?.model_path),
|
|
`${id}: baseline needs generator.model_path`,
|
|
);
|
|
const currentModel = baseline.generator.model_path;
|
|
requireValue(
|
|
modelId === undefined || modelId === currentModel,
|
|
`${id}: deployments must use the same model_path`,
|
|
);
|
|
requireValue(
|
|
!modelOwners.has(currentModel) || modelOwners.get(currentModel) === modelKey,
|
|
`${id}: duplicate model identity; add the deployment to ${modelOwners.get(currentModel)}.yaml`,
|
|
);
|
|
modelId = currentModel;
|
|
modelOwners.set(modelId, modelKey);
|
|
const streaming = baseline.streaming !== undefined && baseline.streaming !== null;
|
|
requireValue(!streaming || object(baseline.streaming), `${id}: streaming must be a configuration mapping`);
|
|
requireValue(
|
|
(runtime.interface === "websocket") === streaming,
|
|
`${id}: streaming block disagrees with runtime interface`,
|
|
);
|
|
if (baseline.runtime !== undefined)
|
|
requireValue(baseline.runtime === runtime.backend, `${id}: baseline runtime disagrees with backend`);
|
|
if (baseline.generator.pipeline?.workload_type !== undefined)
|
|
requireValue(
|
|
baseline.generator.pipeline.workload_type === deployment.workload,
|
|
`${id}: baseline workload disagrees with deployment`,
|
|
);
|
|
const env = deployment.env ?? {},
|
|
requirements = deployment.requirements ?? [];
|
|
requireValue(
|
|
object(env) &&
|
|
Object.entries(env).every(
|
|
([key, value]) =>
|
|
/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) && typeof value === "string" && !value.includes("\0"),
|
|
),
|
|
`${id}: invalid environment mapping`,
|
|
);
|
|
requireValue(Array.isArray(requirements) && requirements.every(text), `${id}: requirements must be text notes`);
|
|
for (const value of [manifest.summary, deployment.summary])
|
|
requireValue(value === undefined || typeof value === "string", `${id}: summary must be text`);
|
|
requireValue(deployment.label === undefined || text(deployment.label), `${id}: label must be nonempty text`);
|
|
const merged = mergeOptions(options, deployment.overrides ?? {}, id);
|
|
validateOptions(merged, id);
|
|
const controls = expandOptions(merged, deployment.options, runtime);
|
|
const selected = new Set(controls.map((control) => control.path));
|
|
for (const path of Object.keys(deployment.overrides ?? {}))
|
|
requireValue(
|
|
selected.has(path),
|
|
`${id}: override '${path}' is not selected by options; select it after exclusions or remove the override`,
|
|
);
|
|
const catalog = {
|
|
id,
|
|
title: manifest.title,
|
|
summary: deployment.summary ?? manifest.summary ?? "",
|
|
source_config: deployment.defaults,
|
|
model: { id: modelId, key: modelKey, title: manifest.title },
|
|
deployment: { id: deploymentKey, label: deployment.label ?? runtime.label },
|
|
workload: deployment.workload,
|
|
runtime: clone(runtime),
|
|
env: clone(env),
|
|
base_config: baseline,
|
|
controls,
|
|
requirements: clone(requirements),
|
|
guide: guideLink(Object.hasOwn(deployment, "guide") ? deployment.guide : manifest.guide, root),
|
|
};
|
|
resolveConfig(catalog);
|
|
catalogs.push(catalog);
|
|
} catch (failure) {
|
|
throw new Error(`${name}: ${id}: ${failure.message}`, { cause: failure });
|
|
}
|
|
}
|
|
if (manifest.capacity !== undefined)
|
|
validateCapacityMetadata(manifest.capacity, { modelId, context: `${name}: capacity` });
|
|
}
|
|
return catalogs;
|
|
}
|
|
|
|
/** Render one validated deployment catalog as YAML without writing generated files. */
|
|
export function previewCatalog(recipeId, options = {}) {
|
|
requireValue(text(recipeId), "Preview requires a model/deployment ID");
|
|
const catalogs = buildCatalogs(options),
|
|
catalog = catalogs.find((item) => item.id === recipeId);
|
|
const available = catalogs.map((item) => item.id).join(", ");
|
|
requireValue(catalog, `Unknown cookbook deployment: ${recipeId}. Available: ${available}`);
|
|
return stringify(catalog, { aliasDuplicateObjects: false });
|
|
}
|
|
|
|
function writeDocument(path, value) {
|
|
const document = `${JSON.stringify(value, null, 2)}\n`;
|
|
if (existsSync(path) && readFileSync(path, "utf8") === document) return;
|
|
writeFileSync(`${path}.tmp`, document);
|
|
renameSync(`${path}.tmp`, path);
|
|
}
|
|
|
|
export function exportCatalogs({
|
|
root = ROOT,
|
|
outputDir = join(root, "docs/assets/cookbook-config"),
|
|
...options
|
|
} = {}) {
|
|
const catalogs = buildCatalogs({ root, ...options }),
|
|
index = { models: [] },
|
|
models = new Map();
|
|
const expected = new Set();
|
|
const directories = [
|
|
outputDir,
|
|
join(outputDir, "recipes"),
|
|
...catalogs.map((catalog) => join(outputDir, "recipes", catalog.model.key)),
|
|
];
|
|
requireValue(
|
|
directories.every((path) => !existsSync(path) || !lstatSync(path).isSymbolicLink()),
|
|
"Generated directories must not be symlinks",
|
|
);
|
|
for (const catalog of catalogs) {
|
|
const url = `recipes/${catalog.id}.json`;
|
|
expected.add(url);
|
|
const directory = join(outputDir, "recipes", catalog.model.key);
|
|
mkdirSync(directory, { recursive: true });
|
|
writeDocument(join(outputDir, url), catalog);
|
|
if (!models.has(catalog.model.key)) {
|
|
const model = { id: catalog.model.key, title: catalog.model.title, model_id: catalog.model.id, deployments: [] };
|
|
models.set(model.id, model);
|
|
index.models.push(model);
|
|
}
|
|
models.get(catalog.model.key).deployments.push({
|
|
id: catalog.id,
|
|
label: catalog.deployment.label,
|
|
workload: catalog.workload,
|
|
runtime: catalog.runtime.id,
|
|
catalog_url: url,
|
|
});
|
|
}
|
|
writeDocument(join(outputDir, "index.json"), index);
|
|
for (const model of readdirSync(join(outputDir, "recipes"), { withFileTypes: true })) {
|
|
if (!model.isDirectory() || !ID.test(model.name)) continue;
|
|
for (const file of readdirSync(join(outputDir, "recipes", model.name))) {
|
|
const url = `recipes/${model.name}/${file}`;
|
|
if (file.endsWith(".json") && ID.test(file.slice(0, -5)) && !expected.has(url)) unlinkSync(join(outputDir, url));
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
try {
|
|
const options = {};
|
|
const flags = {
|
|
"--root": "root",
|
|
"--output-dir": "outputDir",
|
|
"--recipes-dir": "recipesDir",
|
|
"--catalog": "catalogPath",
|
|
"--preview": "previewRecipe",
|
|
};
|
|
for (let index = 2; index < process.argv.length; index += 2) {
|
|
const flag = process.argv[index],
|
|
value = process.argv[index + 1];
|
|
requireValue(
|
|
Object.hasOwn(flags, flag) && value && !value.startsWith("--"),
|
|
"Use --preview MODEL/DEPLOYMENT, --root PATH, --output-dir PATH, --recipes-dir PATH or --catalog PATH",
|
|
);
|
|
options[flags[flag]] = flag === "--preview" ? value : resolve(value);
|
|
}
|
|
if (Object.hasOwn(options, "previewRecipe")) {
|
|
requireValue(
|
|
!Object.hasOwn(options, "outputDir"),
|
|
"--output-dir cannot be used with --preview; redirect stdout to save YAML",
|
|
);
|
|
const { previewRecipe, ...builderOptions } = options;
|
|
process.stdout.write(previewCatalog(previewRecipe, builderOptions));
|
|
} else {
|
|
exportCatalogs(options);
|
|
console.log(join(options.outputDir || join(options.root || ROOT, "docs/assets/cookbook-config"), "index.json"));
|
|
}
|
|
} catch (failure) {
|
|
console.error(failure.message);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|