import fs from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' import { loadCatalog, serviceRoot } from './catalog.mjs' import { validateCatalog } from './validate.mjs' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const contextBindingsPath = path.resolve(__dirname, '..', 'catalog', 'context-bindings.json') function usage() { return [ 'Usage:', ' node src/registry.mjs list-contexts', ' node src/registry.mjs list-bindings', ' node src/registry.mjs add-context --input-json examples/engine-context.example.json [--dry-run]', ' node src/registry.mjs add-binding --input-json examples/ops-engine-binding.example.json [--dry-run]', ' node src/registry.mjs import-engine-context --input-json examples/engine-workflow-manifest.example.json [--dry-run]', ].join('\n') } function argValue(name) { const index = process.argv.indexOf(name) if (index < 0) return '' return process.argv[index + 1] || '' } async function readInputJson() { const inputPath = argValue('--input-json') const inputRaw = argValue('--input') if (inputRaw) return JSON.parse(inputRaw) if (!inputPath) throw new Error('missing --input-json or --input') const fullPath = path.resolve(process.cwd(), inputPath) return JSON.parse(await fs.readFile(fullPath, 'utf8')) } async function readContextBindings() { return JSON.parse(await fs.readFile(contextBindingsPath, 'utf8')) } async function writeContextBindings(next) { const raw = `${JSON.stringify(next, null, 2)}\n` await fs.writeFile(contextBindingsPath, raw, 'utf8') } function required(value, label) { if (value === undefined || value === null || String(value).trim() === '') { throw new Error(`${label} is required`) } } function isObject(value) { return value && typeof value === 'object' && !Array.isArray(value) } function slugPart(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9а-яё]+/giu, '_') .replace(/^_+|_+$/g, '') .slice(0, 64) || 'unknown' } function validateDraftContext(draft, catalog, registry) { required(draft.id, 'context.id') required(draft.surface, 'context.surface') required(draft.entityId, 'context.entityId') required(draft.sourceSystem, 'context.sourceSystem') required(draft.label, 'context.label') required(draft.status, 'context.status') if (!catalog.entityById.has(draft.entityId)) throw new Error(`unknown context.entityId: ${draft.entityId}`) if (!registry.contextStatuses.includes(draft.status)) throw new Error(`invalid context.status: ${draft.status}`) if (!isObject(draft.refs)) throw new Error('context.refs must be an object') if (draft.parentContextId && !catalog.contextById.has(draft.parentContextId)) { throw new Error(`unknown context.parentContextId: ${draft.parentContextId}`) } } function validateDraftBinding(draft, catalog, registry) { required(draft.id, 'binding.id') required(draft.typeId, 'binding.typeId') required(draft.status, 'binding.status') required(draft.fromContextId, 'binding.fromContextId') required(draft.toContextId, 'binding.toContextId') if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`) if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`) if (!catalog.contextById.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`) if (!catalog.contextById.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`) } function validateDraftBindingWithKnownContextIds(draft, catalog, registry, knownContextIds) { required(draft.id, 'binding.id') required(draft.typeId, 'binding.typeId') required(draft.status, 'binding.status') required(draft.fromContextId, 'binding.fromContextId') required(draft.toContextId, 'binding.toContextId') if (!registry.bindingStatuses.includes(draft.status)) throw new Error(`invalid binding.status: ${draft.status}`) if (!catalog.bindingTypeById.has(draft.typeId)) throw new Error(`unknown binding.typeId: ${draft.typeId}`) if (!knownContextIds.has(draft.fromContextId)) throw new Error(`unknown binding.fromContextId: ${draft.fromContextId}`) if (!knownContextIds.has(draft.toContextId)) throw new Error(`unknown binding.toContextId: ${draft.toContextId}`) } function upsertById(list, item) { const index = list.findIndex((existing) => existing.id === item.id) if (index >= 0) { const next = [...list] next[index] = item return { list: next, action: 'updated' } } return { list: [...list, item], action: 'created' } } async function updateRegistry(mutator, dryRun) { const beforeRaw = await fs.readFile(contextBindingsPath, 'utf8') const registry = JSON.parse(beforeRaw) const next = structuredClone(registry) const result = mutator(next) if (dryRun) return { dryRun: true, ...result, registry: next } await writeContextBindings(next) const validation = await validateCatalog() if (!validation.ok) { await fs.writeFile(contextBindingsPath, beforeRaw, 'utf8') const err = new Error('registry validation failed; reverted context-bindings.json') err.validation = validation throw err } return { dryRun: false, ...result, validation: validation.counts } } async function listContexts() { const catalog = await loadCatalog() return catalog.contextBindings.contexts.map((context) => ({ id: context.id, surface: context.surface, entityId: context.entityId, sourceSystem: context.sourceSystem, label: context.label, status: context.status, refs: context.refs, parentContextId: context.parentContextId || null, })) } async function listBindings() { const catalog = await loadCatalog() return catalog.contextBindings.bindings.map((binding) => ({ id: binding.id, typeId: binding.typeId, status: binding.status, fromContextId: binding.fromContextId, toContextId: binding.toContextId, summary: binding.summary || '', })) } async function addContext(dryRun) { const draft = await readInputJson() const catalog = await loadCatalog() const registry = await readContextBindings() validateDraftContext(draft, catalog, registry) return updateRegistry((next) => { const out = upsertById(next.contexts, draft) next.contexts = out.list return { action: out.action, context: draft } }, dryRun) } async function addBinding(dryRun) { const draft = await readInputJson() const catalog = await loadCatalog() const registry = await readContextBindings() validateDraftBinding(draft, catalog, registry) return updateRegistry((next) => { const out = upsertById(next.bindings, draft) next.bindings = out.list return { action: out.action, binding: draft } }, dryRun) } function buildEngineContextFromManifest(manifest) { if (!isObject(manifest)) throw new Error('manifest must be an object') const workflow = manifest.workflow || {} required(workflow.id, 'workflow.id') required(workflow.label, 'workflow.label') const refs = { workflow_id: String(workflow.id), } if (workflow.nodeId) refs.node_id = String(workflow.nodeId) if (workflow.runtimeWorkflowId) refs.runtime_workflow_id = String(workflow.runtimeWorkflowId) if (workflow.runtimeInstanceId) refs.runtime_instance_id = String(workflow.runtimeInstanceId) return { id: `context.engine.${slugPart(workflow.id)}`, surface: 'engine', entityId: workflow.runtimeWorkflowId ? 'engine.workflow_l2' : 'engine.workflow_l1', sourceSystem: String(manifest.context?.sourceSystem || 'nodedc-engine'), label: String(workflow.label), status: String(manifest.context?.status || 'planned'), refs, } } function buildOpsEngineBindingFromManifest(manifest, engineContext) { const bind = manifest.bindToOps || {} if (bind.enabled !== true) return null required(bind.fromContextId, 'bindToOps.fromContextId') return { id: `binding.ops.${slugPart(bind.fromContextId)}.engine.${slugPart(engineContext.id)}`, typeId: 'binding_type.ops_card_to_engine_workflow', status: String(bind.status || 'planned'), fromContextId: String(bind.fromContextId), toContextId: engineContext.id, summary: String(bind.summary || `Binds ${bind.fromContextId} to ${engineContext.id}.`), } } async function importEngineContext(dryRun) { const manifest = await readInputJson() const catalog = await loadCatalog() const registry = await readContextBindings() const engineContext = buildEngineContextFromManifest(manifest) const binding = buildOpsEngineBindingFromManifest(manifest, engineContext) validateDraftContext(engineContext, catalog, registry) if (binding) { const knownContextIds = new Set([ ...catalog.contextById.keys(), engineContext.id, ]) validateDraftBindingWithKnownContextIds(binding, catalog, registry, knownContextIds) } return updateRegistry((next) => { const contextOut = upsertById(next.contexts, engineContext) next.contexts = contextOut.list let bindingOut = null if (binding) { bindingOut = upsertById(next.bindings, binding) next.bindings = bindingOut.list } return { action: bindingOut ? `${contextOut.action}_context_${bindingOut.action}_binding` : `${contextOut.action}_context`, context: engineContext, binding, } }, dryRun) } async function main() { const command = process.argv[2] const dryRun = process.argv.includes('--dry-run') let output if (command === 'list-contexts') output = await listContexts() else if (command === 'list-bindings') output = await listBindings() else if (command === 'add-context') output = await addContext(dryRun) else if (command === 'add-binding') output = await addBinding(dryRun) else if (command === 'import-engine-context') output = await importEngineContext(dryRun) else { console.error(usage()) process.exit(2) } console.log(JSON.stringify({ ok: true, serviceRoot, command, output }, null, 2)) } if (import.meta.url === `file://${process.argv[1]}`) { main().catch((err) => { console.error(JSON.stringify({ ok: false, error: err.message || String(err), validation: err.validation || undefined, }, null, 2)) process.exit(1) }) }