@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.
- package/README.md +932 -898
- package/dist/src/cli/index.js +435 -1
- package/dist/src/cli/index.js.map +1 -1
- package/dist/src/cli/item-json.d.ts +17 -0
- package/dist/src/cli/item-json.d.ts.map +1 -0
- package/dist/src/cli/item-json.js +97 -0
- package/dist/src/cli/item-json.js.map +1 -0
- package/dist/src/contracts/errors.d.ts.map +1 -1
- package/dist/src/contracts/errors.js +12 -1
- package/dist/src/contracts/errors.js.map +1 -1
- package/docs/cli-items.md +93 -0
- package/docs/cli-toolbox.md +166 -0
- package/package.json +3 -1
package/dist/src/cli/index.js
CHANGED
|
@@ -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);
|