@x12i/catalox 4.0.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,7 +9,9 @@ import { CATALOX_GCS_BACKUP_MANIFEST_FILENAME, createCataloxHelpersGcsClient } f
9
9
  import { cataloxGcsBackupManifestToFirestoreExportManifest } from "../catalox/catalox-gcs-backup-export-manifest.js";
10
10
  import { normalizeGcsPrefix, ndjsonObjectPath } from "../catalox/firestore-gcs-transfer.js";
11
11
  import { createCataloxFromEnv, testFirestoreConnectionFromEnv } from "../firebase/bootstrap.js";
12
- import { applyCataloxSeedPreset } from "../catalox/seed-preset.js";
12
+ import { applyCataloxSeedPreset, loadCataloxSeedManifestFromPath } from "../catalox/seed-preset.js";
13
+ import { resolveCataloxSeedPresetPath } from "../catalox/seed-preset-resolve.js";
14
+ import { normalizeNativePatchBody, normalizeNativeUpsertBody, parseJsonArrayOrNdjson, parseJsonObject, readUtf8FromFileOrStdin, } from "./item-json.js";
13
15
  import { buildCatalogListMarkdownMap, renderCatalogListMarkdown } from "../markdown/render-list-markdown.js";
14
16
  import { buildCatalogItemMarkdownMap, renderCatalogItemMarkdown } from "../markdown/render-item-markdown.js";
15
17
  import { renderCatalogRelationsMermaid } from "../diagrams/render-catalog-diagram.js";
@@ -268,6 +270,286 @@ seedCmd
268
270
  : await applyCataloxSeedPreset(catalox, ctx, { presetPath: fileRaw });
269
271
  await writeOrStdout(JSON.stringify(res, null, 2));
270
272
  });
273
+ seedCmd
274
+ .command("validate")
275
+ .description("Parse and AJV-validate a seed manifest (--file or --preset); no Firestore writes")
276
+ .option("--file <path>", "Path to preset JSON manifest")
277
+ .option("--preset <id>", "Logical preset: builtin:native-map-v1, registry alias, npm package name, or path to .json")
278
+ .action(async (opts) => {
279
+ const fileRaw = opts.file != null ? String(opts.file).trim() : "";
280
+ const presetRaw = opts.preset != null ? String(opts.preset).trim() : "";
281
+ if ((fileRaw !== "") === (presetRaw !== "")) {
282
+ throw new Error("Specify exactly one of --file <path> or --preset <id>");
283
+ }
284
+ const manifest = presetRaw
285
+ ? await loadCataloxSeedManifestFromPath(resolveCataloxSeedPresetPath(presetRaw))
286
+ : await loadCataloxSeedManifestFromPath(fileRaw);
287
+ await writeOrStdout(JSON.stringify({
288
+ ok: true,
289
+ presetVersion: manifest.presetVersion,
290
+ id: manifest.id ?? null,
291
+ name: manifest.name ?? null,
292
+ counts: {
293
+ catalogs: manifest.catalogs.length,
294
+ descriptors: manifest.descriptors?.length ?? 0,
295
+ bindings: manifest.bindings?.length ?? 0,
296
+ items: manifest.items?.length ?? 0,
297
+ },
298
+ }, null, 2));
299
+ });
300
+ const itemsCmd = program.command("items").description("Catalog items: validate, list, CRUD, batch upsert, export (native + mapped read paths)");
301
+ itemsCmd
302
+ .command("validate")
303
+ .description("validateCatalog (catalog) and/or validateCatalogItem (relations vs descriptor)")
304
+ .requiredOption("--app <appId>", "AppId context")
305
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
306
+ .requiredOption("--catalog <catalogId>", "Catalog id")
307
+ .option("--item <itemId>", "When set, validateCatalogItem; otherwise validateCatalog")
308
+ .option("--out <path>", "Write JSON to file instead of stdout")
309
+ .option("--god", "God mode (bypass binding checks where applicable)", false)
310
+ .action(async (opts) => {
311
+ const catalox = createCatalox();
312
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
313
+ const catalogId = String(opts.catalog);
314
+ if (opts.item != null && String(opts.item).trim() !== "") {
315
+ const report = await catalox.validateCatalogItem(ctx, catalogId, String(opts.item).trim());
316
+ await writeOrStdout(JSON.stringify({ scope: "item", catalogId, itemId: String(opts.item).trim(), ...report }, null, 2), opts.out);
317
+ if (!report.isValid)
318
+ process.exitCode = 1;
319
+ return;
320
+ }
321
+ const report = await catalox.validateCatalog(ctx, catalogId);
322
+ await writeOrStdout(JSON.stringify({ scope: "catalog", catalogId, ...report }, null, 2), opts.out);
323
+ if (!report.isValid)
324
+ process.exitCode = 1;
325
+ });
326
+ itemsCmd
327
+ .command("get")
328
+ .description("getCatalogItem JSON (outcome: found | not_found | mapping_blocked)")
329
+ .requiredOption("--app <appId>", "AppId context")
330
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
331
+ .requiredOption("--catalog <catalogId>", "Catalog id")
332
+ .requiredOption("--item <itemId>", "Logical item id")
333
+ .option("--storage-doc-id <id>", "Native: target a specific physical storage doc id")
334
+ .option("--out <path>", "Write JSON to file instead of stdout")
335
+ .option("--god", "God mode", false)
336
+ .action(async (opts) => {
337
+ const catalox = createCatalox();
338
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
339
+ const catalogId = String(opts.catalog);
340
+ const itemId = String(opts.item);
341
+ const storageDocId = opts.storageDocId != null && String(opts.storageDocId).trim() ? String(opts.storageDocId).trim() : undefined;
342
+ const got = await catalox.getCatalogItem(ctx, catalogId, itemId, {
343
+ ...(storageDocId ? { storageDocId } : {}),
344
+ });
345
+ await writeOrStdout(JSON.stringify(got, null, 2), opts.out);
346
+ if (got.outcome !== "found")
347
+ process.exitCode = 1;
348
+ });
349
+ itemsCmd
350
+ .command("list")
351
+ .description("listCatalogItemsWithOutcome (explicit outcome for scripts)")
352
+ .requiredOption("--app <appId>", "AppId context")
353
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
354
+ .requiredOption("--catalog <catalogId>", "Catalog id")
355
+ .option("--limit <n>", "Page size", (v) => Number(v), 50)
356
+ .option("--offset <n>", "Offset (native)", (v) => Number(v), 0)
357
+ .option("--cursor <token>", "Mapped catalogs: pass nextCursor from a prior response")
358
+ .option("--filter-json <json>", "JSON object merged into list filter (compact empty strings ignored by server)")
359
+ .option("--out <path>", "Write JSON to file instead of stdout")
360
+ .option("--god", "God mode", false)
361
+ .action(async (opts) => {
362
+ const catalox = createCatalox();
363
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
364
+ const catalogId = String(opts.catalog);
365
+ let filter;
366
+ const fj = opts.filterJson != null ? String(opts.filterJson).trim() : "";
367
+ if (fj) {
368
+ const parsed = JSON.parse(fj);
369
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
370
+ throw new Error("--filter-json must be a JSON object");
371
+ }
372
+ filter = parsed;
373
+ }
374
+ const res = await catalox.listCatalogItemsWithOutcome(ctx, catalogId, {
375
+ limit: opts.limit,
376
+ offset: opts.offset,
377
+ ...(filter ? { filter } : {}),
378
+ ...(opts.cursor != null && String(opts.cursor).trim()
379
+ ? { cursor: String(opts.cursor).trim() }
380
+ : {}),
381
+ });
382
+ await writeOrStdout(JSON.stringify(res, null, 2), opts.out);
383
+ if (res.outcome !== "ok" && res.outcome !== "empty")
384
+ process.exitCode = 1;
385
+ });
386
+ itemsCmd
387
+ .command("upsert")
388
+ .description("upsertNativeCatalogItem: JSON body is item fields at top level, or { data, scope?, relations? }")
389
+ .requiredOption("--app <appId>", "AppId context")
390
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
391
+ .requiredOption("--catalog <catalogId>", "Native catalog id")
392
+ .option("--file <path>", "JSON file (otherwise read stdin)")
393
+ .option("--out <path>", "Write result JSON to file instead of stdout")
394
+ .option("--god", "God mode (non-global scope rows require super-admin)", false)
395
+ .action(async (opts) => {
396
+ const raw = await readUtf8FromFileOrStdin(opts.file != null ? String(opts.file).trim() : undefined);
397
+ const body = normalizeNativeUpsertBody(parseJsonObject(raw, "items upsert"));
398
+ const catalox = createCatalox();
399
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
400
+ const item = await catalox.upsertNativeCatalogItem(ctx, String(opts.catalog), body);
401
+ await writeOrStdout(JSON.stringify(item, null, 2), opts.out);
402
+ });
403
+ itemsCmd
404
+ .command("patch")
405
+ .description("updateNativeCatalogItem: shallow-merge patch into existing item data (+ optional scope/relations)")
406
+ .requiredOption("--app <appId>", "AppId context")
407
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
408
+ .requiredOption("--catalog <catalogId>", "Native catalog id")
409
+ .requiredOption("--item <itemId>", "Logical item id")
410
+ .option("--file <path>", "JSON patch file (otherwise read stdin)")
411
+ .option("--storage-doc-id <id>", "Disambiguate physical row when multiple scopes share logical id")
412
+ .option("--out <path>", "Write result JSON to file instead of stdout")
413
+ .option("--god", "God mode", false)
414
+ .action(async (opts) => {
415
+ const raw = await readUtf8FromFileOrStdin(opts.file != null ? String(opts.file).trim() : undefined);
416
+ const patch = normalizeNativePatchBody(parseJsonObject(raw, "items patch"));
417
+ const catalox = createCatalox();
418
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
419
+ const storageDocId = opts.storageDocId != null && String(opts.storageDocId).trim() ? String(opts.storageDocId).trim() : undefined;
420
+ const item = await catalox.updateNativeCatalogItem(ctx, String(opts.catalog), String(opts.item).trim(), patch, { ...(storageDocId ? { storageDocId } : {}) });
421
+ await writeOrStdout(JSON.stringify(item, null, 2), opts.out);
422
+ });
423
+ itemsCmd
424
+ .command("delete")
425
+ .description("deleteNativeCatalogItem (requires --confirm)")
426
+ .requiredOption("--app <appId>", "AppId context")
427
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
428
+ .requiredOption("--catalog <catalogId>", "Native catalog id")
429
+ .requiredOption("--item <itemId>", "Logical item id")
430
+ .option("--storage-doc-id <id>", "Target a specific physical storage doc id")
431
+ .option("--confirm", "Must pass --confirm to run", false)
432
+ .option("--god", "God mode", false)
433
+ .action(async (opts) => {
434
+ if (!opts.confirm) {
435
+ // eslint-disable-next-line no-console
436
+ console.error("Refusing to delete without --confirm");
437
+ process.exitCode = 1;
438
+ return;
439
+ }
440
+ const catalox = createCatalox();
441
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
442
+ const storageDocId = opts.storageDocId != null && String(opts.storageDocId).trim() ? String(opts.storageDocId).trim() : undefined;
443
+ await catalox.deleteNativeCatalogItem(ctx, String(opts.catalog), String(opts.item).trim(), {
444
+ ...(storageDocId ? { storageDocId } : {}),
445
+ });
446
+ await writeOrStdout(JSON.stringify({ ok: true, catalogId: String(opts.catalog), itemId: String(opts.item).trim() }, null, 2));
447
+ });
448
+ itemsCmd
449
+ .command("batch-upsert")
450
+ .description("batchUpsertNativeCatalogItems: JSON array or NDJSON lines (≤450 rows per Firestore batch internally). Item shape matches items upsert.")
451
+ .requiredOption("--app <appId>", "AppId context")
452
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
453
+ .requiredOption("--catalog <catalogId>", "Native catalog id")
454
+ .option("--file <path>", "JSON array or NDJSON file (otherwise read stdin)")
455
+ .option("--god", "God mode (non-global scope rows require super-admin)", false)
456
+ .action(async (opts) => {
457
+ const raw = await readUtf8FromFileOrStdin(opts.file != null ? String(opts.file).trim() : undefined);
458
+ const rows = parseJsonArrayOrNdjson(raw, "items batch-upsert");
459
+ const catalox = createCatalox();
460
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
461
+ await catalox.batchUpsertNativeCatalogItems(ctx, String(opts.catalog), rows);
462
+ await writeOrStdout(JSON.stringify({ ok: true, count: rows.length, catalogId: String(opts.catalog) }, null, 2));
463
+ });
464
+ itemsCmd
465
+ .command("export")
466
+ .description("Export one catalog's items to { catalogId, items } JSON (paginates nextCursor when present)")
467
+ .requiredOption("--app <appId>", "AppId context")
468
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
469
+ .requiredOption("--catalog <catalogId>", "Catalog id")
470
+ .option("--page-size <n>", "Per request", (v) => Number(v), 200)
471
+ .option("--max-total <n>", "Stop after this many items (safety cap)", (v) => Number(v), 10000)
472
+ .option("--pretty", "Pretty-print JSON", false)
473
+ .option("--out <path>", "Write JSON to file instead of stdout")
474
+ .option("--god", "God mode", false)
475
+ .action(async (opts) => {
476
+ const catalox = createCatalox();
477
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
478
+ const catalogId = String(opts.catalog);
479
+ const pageSize = Math.max(1, Math.min(500, Number(opts.pageSize) || 200));
480
+ const maxTotal = Math.max(1, Number(opts.maxTotal) || 10000);
481
+ const items = [];
482
+ let offset = 0;
483
+ let cursor;
484
+ const indent = opts.pretty ? 2 : undefined;
485
+ for (;;) {
486
+ const page = await catalox.listCatalogItems(ctx, catalogId, {
487
+ limit: pageSize,
488
+ ...(cursor ? { cursor: cursor } : { offset }),
489
+ });
490
+ if (page.listOutcome === "mapping_blocked") {
491
+ await writeOrStdout(JSON.stringify({ catalogId, error: "mapping_blocked", issues: page.issues ?? [] }, null, indent), opts.out);
492
+ process.exitCode = 1;
493
+ return;
494
+ }
495
+ for (const it of page.items) {
496
+ items.push(it);
497
+ if (items.length >= maxTotal)
498
+ break;
499
+ }
500
+ if (items.length >= maxTotal)
501
+ break;
502
+ if (page.nextCursor) {
503
+ cursor = page.nextCursor;
504
+ offset = 0;
505
+ continue;
506
+ }
507
+ if (!page.items.length)
508
+ break;
509
+ if (page.items.length < pageSize)
510
+ break;
511
+ offset += page.items.length;
512
+ cursor = undefined;
513
+ }
514
+ const payload = {
515
+ catalogId,
516
+ exportedCount: items.length,
517
+ truncated: items.length >= maxTotal,
518
+ items,
519
+ };
520
+ await writeOrStdout(JSON.stringify(payload, null, indent), opts.out);
521
+ });
522
+ const itemsRelationCmd = itemsCmd.command("relation").description("Item↔item relations (catalogReferences)");
523
+ itemsRelationCmd
524
+ .command("upsert")
525
+ .description("Create or update one relation edge")
526
+ .requiredOption("--app <appId>", "AppId context")
527
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
528
+ .option("--file <path>", "JSON file (otherwise stdin): fromCatalogId, fromItemId, toCatalogId, toItemId, relationType, optional label/metadata")
529
+ .option("--god", "God mode", false)
530
+ .action(async (opts) => {
531
+ const raw = await readUtf8FromFileOrStdin(opts.file != null ? String(opts.file).trim() : undefined);
532
+ const input = parseJsonObject(raw, "items relation upsert");
533
+ const catalox = createCatalox();
534
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
535
+ const res = await catalox.upsertCatalogItemRelation(ctx, input);
536
+ await writeOrStdout(JSON.stringify(res, null, 2));
537
+ });
538
+ itemsRelationCmd
539
+ .command("delete")
540
+ .description("Delete by referenceId or by endpoint tuple (fromCatalogId, fromItemId, toCatalogId, toItemId, relationType)")
541
+ .requiredOption("--app <appId>", "AppId context")
542
+ .option("--store <storeId>", "StoreId context (overrides CATALOX_STORE_ID)")
543
+ .option("--file <path>", "JSON file (otherwise stdin)")
544
+ .option("--god", "God mode", false)
545
+ .action(async (opts) => {
546
+ const raw = await readUtf8FromFileOrStdin(opts.file != null ? String(opts.file).trim() : undefined);
547
+ const input = parseJsonObject(raw, "items relation delete");
548
+ const catalox = createCatalox();
549
+ const ctx = baseContext({ app: opts.app, store: opts.store, god: opts.god });
550
+ await catalox.deleteCatalogItemRelation(ctx, input);
551
+ await writeOrStdout(JSON.stringify({ ok: true }, null, 2));
552
+ });
271
553
  const firestoreCmd = program
272
554
  .command("firestore")
273
555
  .description("Firestore backup (GCS), restore-from-GCS, native layout migration, and connectivity probe");
@@ -781,6 +1063,158 @@ catalogCmd
781
1063
  if (!res.ok)
782
1064
  process.exitCode = 1;
783
1065
  });
1066
+ const toolboxCmd = program
1067
+ .command("toolbox")
1068
+ .description("Operator helpers: diagnose app↔catalog access (bindings) and repair common mistakes");
1069
+ toolboxCmd
1070
+ .command("check-access")
1071
+ .description("Read Firestore catalogBindings doc, catalog row, and run listCatalogItemsWithOutcome (same auth rules as runtime)")
1072
+ .requiredOption("--app <appId>", "App id (must match CataloxContext.appId in your host)")
1073
+ .requiredOption("--catalog <catalogId>", "Catalog id (e.g. entities)")
1074
+ .option("--show-all-bindings", "List every catalogBindings row for this catalogId (who else is bound)", false)
1075
+ .action(async (opts) => {
1076
+ const { catalox, firestore } = createCataloxFromEnv();
1077
+ const appId = String(opts.app).trim();
1078
+ const catalogId = String(opts.catalog).trim();
1079
+ const bindingDocId = `${appId}:${catalogId}`;
1080
+ const bindingSnap = await firestore.collection("catalogBindings").doc(bindingDocId).get();
1081
+ const catalog = await catalox.getCatalog({ appId }, catalogId);
1082
+ const listSim = await catalox.listCatalogItemsWithOutcome({ appId }, catalogId, { limit: 1 });
1083
+ const recommendations = [];
1084
+ if (!catalog) {
1085
+ recommendations.push("Catalog document missing under `catalogs` (typo in catalogId, or catalog never created in this project).");
1086
+ }
1087
+ if (!bindingSnap.exists) {
1088
+ recommendations.push(`No binding document at catalogBindings/${bindingDocId}. Fix: catalox toolbox ensure-binding --app ${appId} --catalog ${catalogId} (use --god if your CLI context app differs from --app).`);
1089
+ }
1090
+ else {
1091
+ const b = bindingSnap.data();
1092
+ const status = b?.status;
1093
+ const access = b?.access;
1094
+ if (status !== "active") {
1095
+ recommendations.push(`Binding status is "${String(status)}" (expected active). Fix: catalox toolbox repair-binding --god --app ${appId} --catalog ${catalogId}`);
1096
+ }
1097
+ if (!access?.canRead) {
1098
+ recommendations.push("Binding has canRead false or missing; readers will be denied.");
1099
+ }
1100
+ }
1101
+ if (listSim.outcome === "denied") {
1102
+ recommendations.push("Simulated list returned denied — matches missing/inactive binding or insufficient access flags.");
1103
+ }
1104
+ if (listSim.outcome === "misconfigured") {
1105
+ recommendations.push("Simulated list returned misconfigured (catalog/adapter/descriptor issue after authz).");
1106
+ }
1107
+ if (listSim.outcome === "mapping_blocked") {
1108
+ recommendations.push("Simulated list returned mapping_blocked (mapping spec / adapter execution).");
1109
+ }
1110
+ let allBindingsForCatalog;
1111
+ if (opts.showAllBindings) {
1112
+ const q = await firestore.collection("catalogBindings").where("catalogId", "==", catalogId).get();
1113
+ allBindingsForCatalog = q.docs.map((d) => ({ id: d.id, ...d.data() }));
1114
+ }
1115
+ const payload = {
1116
+ appId,
1117
+ catalogId,
1118
+ bindingDocId,
1119
+ bindingDocExists: bindingSnap.exists,
1120
+ binding: bindingSnap.exists ? bindingSnap.data() : null,
1121
+ catalogExists: Boolean(catalog),
1122
+ ...(catalog ? { catalogMetadataStatus: catalog.metadata?.status } : {}),
1123
+ simulatedListOutcome: listSim.outcome,
1124
+ ...(listSim.outcome === "denied" && "error" in listSim
1125
+ ? { deniedMessage: listSim.error?.message }
1126
+ : {}),
1127
+ ...(allBindingsForCatalog ? { allBindingsForCatalog } : {}),
1128
+ recommendations,
1129
+ };
1130
+ await writeOrStdout(JSON.stringify(payload, null, 2));
1131
+ if (listSim.outcome !== "ok" && listSim.outcome !== "empty")
1132
+ process.exitCode = 1;
1133
+ });
1134
+ toolboxCmd
1135
+ .command("ensure-binding")
1136
+ .description("Create catalogBindings/{appId:catalogId} if absent (Catalox ensureBinding). Does not upgrade access if a row already exists.")
1137
+ .requiredOption("--app <appId>", "App id that should receive catalog access")
1138
+ .requiredOption("--catalog <catalogId>", "Catalog id")
1139
+ .option("--write", "Set canWrite true (default false)", false)
1140
+ .option("--admin", "Set canAdmin true", false)
1141
+ .option("--god", "Super-admin context: required when CLI context app is not the same as --app (see ensureBinding rules)", false)
1142
+ .action(async (opts) => {
1143
+ const catalox = createCatalox();
1144
+ const appId = String(opts.app).trim();
1145
+ const ctx = baseContext({ app: appId, god: Boolean(opts.god) });
1146
+ await catalox.ensureBinding(ctx, {
1147
+ appId,
1148
+ catalogId: String(opts.catalog).trim(),
1149
+ access: {
1150
+ canRead: true,
1151
+ canWrite: Boolean(opts.write),
1152
+ ...(opts.admin ? { canAdmin: true } : {}),
1153
+ },
1154
+ });
1155
+ await writeOrStdout(JSON.stringify({
1156
+ ok: true,
1157
+ hint: "If a binding already existed, ensureBinding is a no-op (use toolbox repair-binding to change access or reactivate).",
1158
+ }, null, 2));
1159
+ });
1160
+ toolboxCmd
1161
+ .command("repair-binding")
1162
+ .description("Merge-write catalogBindings doc to status=active and access flags (use when ensure-binding no-ops on a disabled or wrong-access row)")
1163
+ .requiredOption("--app <appId>", "App id")
1164
+ .requiredOption("--catalog <catalogId>", "Catalog id")
1165
+ .option("--write", "Set canWrite true", false)
1166
+ .option("--admin", "Set canAdmin true", false)
1167
+ .option("--god", "Required: confirms operator intent (writes Firestore as Admin SDK)", false)
1168
+ .action(async (opts) => {
1169
+ if (!opts.god) {
1170
+ // eslint-disable-next-line no-console
1171
+ console.error("repair-binding requires --god");
1172
+ process.exitCode = 1;
1173
+ return;
1174
+ }
1175
+ const { firestore } = createCataloxFromEnv();
1176
+ const appId = String(opts.app).trim();
1177
+ const catalogId = String(opts.catalog).trim();
1178
+ const bindingDocId = `${appId}:${catalogId}`;
1179
+ const now = new Date().toISOString();
1180
+ const snap = await firestore.collection("catalogBindings").doc(bindingDocId).get();
1181
+ const prev = snap.exists ? snap.data() : {};
1182
+ const createdAt = typeof prev.createdAt === "string" ? prev.createdAt : now;
1183
+ await firestore
1184
+ .collection("catalogBindings")
1185
+ .doc(bindingDocId)
1186
+ .set({
1187
+ bindingId: bindingDocId,
1188
+ appId,
1189
+ catalogId,
1190
+ access: {
1191
+ canRead: true,
1192
+ canWrite: Boolean(opts.write),
1193
+ ...(opts.admin ? { canAdmin: true } : {}),
1194
+ },
1195
+ status: "active",
1196
+ createdAt,
1197
+ updatedAt: now,
1198
+ }, { merge: true });
1199
+ await writeOrStdout(JSON.stringify({ ok: true, bindingDocId, updatedAt: now }, null, 2));
1200
+ });
1201
+ toolboxCmd
1202
+ .command("unbind-catalog")
1203
+ .description("Disable app↔catalog binding (sets catalogBindings status to disabled; same row id as ensure-binding)")
1204
+ .requiredOption("--app <appId>", "App id")
1205
+ .requiredOption("--catalog <catalogId>", "Catalog id")
1206
+ .option("--god", "Super-admin context: required when CLI context app is not the same as --app (see bindCatalogToApp / unbind rules)", false)
1207
+ .action(async (opts) => {
1208
+ const catalox = createCatalox();
1209
+ const appId = String(opts.app).trim();
1210
+ const ctx = baseContext({ app: appId, god: Boolean(opts.god) });
1211
+ await catalox.unbindCatalogFromApp(ctx, appId, String(opts.catalog).trim());
1212
+ await writeOrStdout(JSON.stringify({
1213
+ ok: true,
1214
+ bindingDocId: `${appId}:${String(opts.catalog).trim()}`,
1215
+ hint: "Binding row is disabled (not deleted). Re-enable with toolbox repair-binding --god ...",
1216
+ }, null, 2));
1217
+ });
784
1218
  program.parseAsync(process.argv).catch((err) => {
785
1219
  // eslint-disable-next-line no-console
786
1220
  console.error(err instanceof Error ? err.message : err);