@tokenoftrust/cli 2.0.20 → 2.0.22
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/package.json +1 -1
- package/src/commands/clone.mjs +68 -29
- package/src/validate.mjs +157 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.22",
|
|
4
4
|
"description": "Token of Trust developer CLI — clone a tenant store, run it locally with save→reload, and submit it for preview. Installs the `tot` command.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Token of Trust",
|
package/src/commands/clone.mjs
CHANGED
|
@@ -275,6 +275,39 @@ export async function run(argv, ctx) {
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Is this client_switch failure the EXPECTED "that tenant is not one of your clients"
|
|
280
|
+
* refusal, as opposed to a broken session (transport, auth, malformed envelope, internal
|
|
281
|
+
* error)? The distinction is load-bearing: an expected refusal is a normal step on the
|
|
282
|
+
* staff-preview path and must be tolerated, while anything else means the session itself
|
|
283
|
+
* is unusable and must surface immediately rather than be mistaken for an authorization
|
|
284
|
+
* problem later.
|
|
285
|
+
*
|
|
286
|
+
* Matched on the server's STRUCTURED signal first (`serverCode` / status), because message
|
|
287
|
+
* text is not a contract. The text fallback exists only for servers predating a typed code
|
|
288
|
+
* and is deliberately narrow — when in doubt this returns false, which escalates. Treating
|
|
289
|
+
* an unknown failure as "expected" is the unsafe direction: it would let a dead connection
|
|
290
|
+
* pass for a routine refusal.
|
|
291
|
+
*
|
|
292
|
+
* Pure + exported so it is unit-tested without any I/O.
|
|
293
|
+
* @param {unknown} error
|
|
294
|
+
* @returns {boolean}
|
|
295
|
+
*/
|
|
296
|
+
export function isNotClientTenantRefusal(error) {
|
|
297
|
+
const e = /** @type {any} */ (error);
|
|
298
|
+
if (!e) return false;
|
|
299
|
+
// A transport/protocol-level failure is never an authorization refusal.
|
|
300
|
+
const category = e?.diagnostic?.category ?? e?.category;
|
|
301
|
+
if (category && category !== "tool_error") return false;
|
|
302
|
+
const code = e?.diagnostic?.serverCode ?? e?.serverCode;
|
|
303
|
+
if (typeof code === "string") {
|
|
304
|
+
return code === "not_client_tenant" || code === "over_scoped";
|
|
305
|
+
}
|
|
306
|
+
// Text fallback (pre-typed-code servers) — narrow on purpose.
|
|
307
|
+
const message = typeof e?.message === "string" ? e.message : "";
|
|
308
|
+
return /not one of your client tenants/i.test(message);
|
|
309
|
+
}
|
|
310
|
+
|
|
278
311
|
/**
|
|
279
312
|
* The privileged checkout core, composable in-process (used by `tot clone`
|
|
280
313
|
* and by `tot start`): client_switch → tenant_checkout → optionally clone. The
|
|
@@ -287,26 +320,37 @@ export async function run(argv, ctx) {
|
|
|
287
320
|
* cloned: boolean, dir: string|null, head: string|null }>}
|
|
288
321
|
*/
|
|
289
322
|
export async function checkoutTenant(client, { tenant, tag = "main", cloneDir = null, redact = (s) => s }) {
|
|
290
|
-
//
|
|
291
|
-
//
|
|
323
|
+
// tenant_checkout is the SOLE AUTHORITATIVE checkout decision. client_switch is a scope
|
|
324
|
+
// convenience whose refusal may be entirely expected: a staff human checking out a
|
|
325
|
+
// merchant tenant under a staff-preview authorization holds no client grant on it, so the
|
|
326
|
+
// switch MUST refuse while the checkout legitimately succeeds. `tot git-credential` mints
|
|
327
|
+
// through this same function, so treating the switch as fatal breaks every fetch and push
|
|
328
|
+
// in such a checkout, not only the clone.
|
|
292
329
|
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
// -
|
|
297
|
-
//
|
|
298
|
-
// grant
|
|
299
|
-
//
|
|
300
|
-
// `tot git-credential` mints through this same function, so a fatal switch also breaks
|
|
301
|
-
// every fetch and push in such a checkout, not just the initial clone.
|
|
330
|
+
// Two failure modes are therefore BOTH wrong, and the second is the dangerous one:
|
|
331
|
+
// - discarding the refusal loses the only good explanation when a tenant genuinely is
|
|
332
|
+
// not yours;
|
|
333
|
+
// - letting the refusal OVERRIDE a checkout denial replaces an authoritative answer
|
|
334
|
+
// ("your staff authorization expired / was revoked / does not cover this resource")
|
|
335
|
+
// with grant guidance that sends you chasing the wrong thing entirely.
|
|
336
|
+
// So an EXPECTED switch refusal may never speak over a checkout denial (see below).
|
|
302
337
|
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
let
|
|
338
|
+
// An UNEXPECTED switch failure — transport, auth, a malformed envelope — is not the
|
|
339
|
+
// not-a-client case at all. It means the session itself is broken, and carrying it
|
|
340
|
+
// silently would let a dead connection masquerade as an authorization problem, so it is
|
|
341
|
+
// raised immediately rather than remembered.
|
|
342
|
+
let expectedSwitchRefusal = null;
|
|
306
343
|
try {
|
|
307
344
|
await client.callTool("client_switch", { tenant });
|
|
308
345
|
} catch (e) {
|
|
309
|
-
|
|
346
|
+
if (isNotClientTenantRefusal(e)) {
|
|
347
|
+
expectedSwitchRefusal = e;
|
|
348
|
+
} else {
|
|
349
|
+
throw new CliError(redact(String(e?.message || e)), {
|
|
350
|
+
next: "the session could not be scoped — check your connection and run `tot whoami`",
|
|
351
|
+
cause: e,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
310
354
|
}
|
|
311
355
|
// vc-app-binding safety-net (08-18, §5): ensure THIS developer is bound to the
|
|
312
356
|
// storefront version-control app BEFORE checkout, so tenant_checkout can resolve
|
|
@@ -328,14 +372,15 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
328
372
|
try {
|
|
329
373
|
checkout = await client.callTool("tenant_checkout", { tenant, tag });
|
|
330
374
|
} catch (e) {
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
375
|
+
// A THROWN checkout is not a denial — it never rendered an authorization decision. Only
|
|
376
|
+
// here may the expected switch refusal speak, because it is the only explanation either
|
|
377
|
+
// call produced.
|
|
378
|
+
if (expectedSwitchRefusal) {
|
|
379
|
+
throw new CliError(redact(String(expectedSwitchRefusal?.message || expectedSwitchRefusal)), {
|
|
335
380
|
next:
|
|
336
381
|
"confirm you're entitled to this store — `tot grants` lists what your session can " +
|
|
337
382
|
"act on, and `tot login` refreshes a grant issued after you signed in",
|
|
338
|
-
cause:
|
|
383
|
+
cause: expectedSwitchRefusal,
|
|
339
384
|
});
|
|
340
385
|
}
|
|
341
386
|
throw e;
|
|
@@ -346,15 +391,9 @@ export async function checkoutTenant(client, { tenant, tag = "main", cloneDir =
|
|
|
346
391
|
// dump (James's 2026-07-19 first-experience failure).
|
|
347
392
|
const err = checkoutError(checkout);
|
|
348
393
|
if (err) {
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
next:
|
|
353
|
-
"confirm you're entitled to this store — `tot grants` lists what your session can " +
|
|
354
|
-
"act on, and `tot login` refreshes a grant issued after you signed in",
|
|
355
|
-
cause: switchRefusal,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
394
|
+
// AUTHORITATIVE: the checkout rendered a decision, so it is the answer — even when the
|
|
395
|
+
// switch also refused. Substituting grant guidance here is what would tell someone whose
|
|
396
|
+
// staff authorization had EXPIRED to go looking at their grants.
|
|
358
397
|
throw new CliError(redact(err.message), { next: err.next });
|
|
359
398
|
}
|
|
360
399
|
const gitRemote = checkout.gitRemote;
|
package/src/validate.mjs
CHANGED
|
@@ -313,15 +313,20 @@ function validateScriptsDoc(doc, file, config) {
|
|
|
313
313
|
return out;
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
-
|
|
316
|
+
// Standalone published-CLI mirror. packages/cli/test/validate.test.mjs pins it
|
|
317
|
+
// to extension-contract-values.mjs, the repository/runtime authority.
|
|
318
|
+
export const BLOCK_PALETTE_SUPPORTED_CONTRACTS = Object.freeze([
|
|
317
319
|
"block-palette@1",
|
|
318
320
|
"block-palette@2",
|
|
319
321
|
"block-palette@3",
|
|
320
322
|
"block-palette@4",
|
|
323
|
+
"block-palette@5",
|
|
321
324
|
]);
|
|
325
|
+
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
322
326
|
const KNOWN_BLOCKS = new Set([
|
|
323
327
|
"hero",
|
|
324
328
|
"promo_tiles",
|
|
329
|
+
"trust_strip",
|
|
325
330
|
"featured_collections",
|
|
326
331
|
"featured_products",
|
|
327
332
|
"editorial",
|
|
@@ -341,6 +346,7 @@ const KNOWN_BLOCKS = new Set([
|
|
|
341
346
|
const REQUIRED_BLOCK_PROPS = {
|
|
342
347
|
hero: ["headline"],
|
|
343
348
|
promo_tiles: ["tiles"],
|
|
349
|
+
trust_strip: ["items"],
|
|
344
350
|
featured_collections: ["handles"],
|
|
345
351
|
editorial: ["title"],
|
|
346
352
|
newsletter: ["title"],
|
|
@@ -368,6 +374,37 @@ const PRODUCT_SOURCE_KEYS = new Set([
|
|
|
368
374
|
"recentlyViewed",
|
|
369
375
|
]);
|
|
370
376
|
const PRODUCT_SOURCE_SHORTCUTS = new Set(["featured", "newest", "bestSelling", "onSale"]);
|
|
377
|
+
export const PROMO_TILE_PRESENTATIONS = Object.freeze(["overlay", "portrait-cards", "editorial-cards", "logo-strip"]);
|
|
378
|
+
const PROMO_TILE_PRESENTATION_SET = new Set(PROMO_TILE_PRESENTATIONS);
|
|
379
|
+
|
|
380
|
+
export function isSafeRehostedAssetPath(value) {
|
|
381
|
+
if (typeof value !== "string" || !value.startsWith("/")) return false;
|
|
382
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#")) return false;
|
|
383
|
+
let decoded;
|
|
384
|
+
try { decoded = decodeURIComponent(value); } catch { return false; }
|
|
385
|
+
if (decoded !== value || decoded.includes("..") || decoded.includes("//")) return false;
|
|
386
|
+
return /^\/(?:tenants\/[^/]+|shared)\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(value);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function isSafeRehostedAssetPathForTenant(value, tenantId) {
|
|
390
|
+
if (!isSafeRehostedAssetPath(value) || typeof tenantId !== "string" || !tenantId) return false;
|
|
391
|
+
return value.startsWith("/shared/") || value.startsWith(`/tenants/${tenantId}/`);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function isSafeStorefrontHref(value) {
|
|
395
|
+
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) return false;
|
|
396
|
+
if (value.includes("\\") || value.includes("?") || value.includes("#") || /[\u0000-\u001f\u007f]/.test(value)) return false;
|
|
397
|
+
try {
|
|
398
|
+
const decoded = decodeURIComponent(value);
|
|
399
|
+
if (decoded !== value || decoded.includes("\\") || /[\u0000-\u001f\u007f]/.test(decoded)) return false;
|
|
400
|
+
const segments = value.split("/");
|
|
401
|
+
return !segments.some((segment, index) =>
|
|
402
|
+
index > 0 && (segment === "." || segment === ".." || (segment === "" && index < segments.length - 1))
|
|
403
|
+
);
|
|
404
|
+
} catch {
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
371
408
|
|
|
372
409
|
function validateProductSource(value, file, at) {
|
|
373
410
|
const out = [];
|
|
@@ -403,7 +440,108 @@ function validateProductSource(value, file, at) {
|
|
|
403
440
|
return out;
|
|
404
441
|
}
|
|
405
442
|
|
|
406
|
-
function
|
|
443
|
+
function validateVisualAsset(value, file, at, out, tenantId) {
|
|
444
|
+
const safe = tenantId
|
|
445
|
+
? isSafeRehostedAssetPathForTenant(value, tenantId)
|
|
446
|
+
: isSafeRehostedAssetPath(value);
|
|
447
|
+
if (!safe) {
|
|
448
|
+
out.push(mk(ERROR, "asset-path", file, `${at} must be a /shared/... asset or belong to the active tenant`));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function validatePromoTiles(block, file, at, out, tenantId) {
|
|
453
|
+
if (block.presentation !== undefined && !PROMO_TILE_PRESENTATION_SET.has(block.presentation)) {
|
|
454
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.presentation must be overlay, portrait-cards, editorial-cards, or logo-strip`));
|
|
455
|
+
}
|
|
456
|
+
if (!Array.isArray(block.tiles)) {
|
|
457
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.tiles must be an array`));
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
block.tiles.forEach((tile, index) => {
|
|
461
|
+
const tileAt = `${at}.tiles[${index}]`;
|
|
462
|
+
if (tile == null || typeof tile !== "object" || Array.isArray(tile)) {
|
|
463
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt} must be an object`));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
for (const prop of ["label", "href"]) {
|
|
467
|
+
if (typeof tile[prop] !== "string" || !tile[prop].trim()) {
|
|
468
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a non-empty string`));
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
for (const prop of ["imageAlt", "meta"]) {
|
|
472
|
+
if (tile[prop] !== undefined && typeof tile[prop] !== "string") {
|
|
473
|
+
out.push(mk(ERROR, "block-prop", file, `${tileAt}.${prop} must be a string`));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (tile.image !== undefined) validateVisualAsset(tile.image, file, `${tileAt}.image`, out, tenantId);
|
|
477
|
+
if (typeof tile.href === "string" && !isSafeStorefrontHref(tile.href)) {
|
|
478
|
+
out.push(mk(ERROR, "navigation-href", file, `${tileAt}.href must be a same-storefront root-relative path`));
|
|
479
|
+
}
|
|
480
|
+
if ((block.presentation ?? "overlay") !== "overlay" && tile.image === undefined) {
|
|
481
|
+
out.push(mk(ERROR, "block-required", file, `${tileAt}.image is required for ${block.presentation}`));
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
if ((block.ctaLabel === undefined) !== (block.ctaHref === undefined)) {
|
|
485
|
+
out.push(mk(ERROR, "block-required", file, `${at}.ctaLabel and ${at}.ctaHref must be supplied together`));
|
|
486
|
+
}
|
|
487
|
+
for (const prop of ["ctaLabel", "ctaHref"]) {
|
|
488
|
+
if (block[prop] !== undefined && (typeof block[prop] !== "string" || !block[prop].trim())) {
|
|
489
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.${prop} must be a non-empty string`));
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (typeof block.ctaHref === "string" && !isSafeStorefrontHref(block.ctaHref)) {
|
|
493
|
+
out.push(mk(ERROR, "navigation-href", file, `${at}.ctaHref must be a same-storefront root-relative path`));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function validateTrustStrip(block, file, at, out, tenantId) {
|
|
498
|
+
if (!Array.isArray(block.items)) {
|
|
499
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
block.items.forEach((item, index) => {
|
|
503
|
+
const itemAt = `${at}.items[${index}]`;
|
|
504
|
+
if (item == null || typeof item !== "object" || Array.isArray(item) || !["claim", "rating"].includes(item.kind)) {
|
|
505
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.kind must be claim or rating`));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (item.kind === "claim") {
|
|
509
|
+
if (typeof item.label !== "string" || !item.label.trim()) {
|
|
510
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.label must be a non-empty string`));
|
|
511
|
+
}
|
|
512
|
+
if (item.icon !== undefined) validateVisualAsset(item.icon, file, `${itemAt}.icon`, out, tenantId);
|
|
513
|
+
if (item.iconAlt !== undefined && typeof item.iconAlt !== "string") {
|
|
514
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.iconAlt must be a string`));
|
|
515
|
+
}
|
|
516
|
+
} else if (
|
|
517
|
+
typeof item.average !== "number" || item.average <= 0 || item.average > 5 ||
|
|
518
|
+
typeof item.count !== "number" || item.count <= 0
|
|
519
|
+
) {
|
|
520
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} requires average 1–5 and a positive count`));
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function validateFaq(block, file, at, out) {
|
|
526
|
+
if (!Array.isArray(block.items)) {
|
|
527
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.items must be an array`));
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
block.items.forEach((item, index) => {
|
|
531
|
+
const itemAt = `${at}.items[${index}]`;
|
|
532
|
+
if (item == null || typeof item !== "object" || Array.isArray(item)) {
|
|
533
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt} must be an object`));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
for (const prop of ["q", "a"]) {
|
|
537
|
+
if (typeof item[prop] !== "string" || !item[prop].trim()) {
|
|
538
|
+
out.push(mk(ERROR, "block-prop", file, `${itemAt}.${prop} must be a non-empty string`));
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function validateHomeDoc(doc, file, tenantId) {
|
|
407
545
|
const out = [];
|
|
408
546
|
if (doc == null || typeof doc !== "object" || Array.isArray(doc)) {
|
|
409
547
|
return [mk(ERROR, "home-root", file, "home.json must be a JSON object")];
|
|
@@ -433,6 +571,9 @@ function validateHomeDoc(doc, file) {
|
|
|
433
571
|
for (const prop of REQUIRED_BLOCK_PROPS[component] || []) {
|
|
434
572
|
if (block[prop] === undefined) out.push(mk(ERROR, "block-required", file, `${at} (${component}) is missing "${prop}"`));
|
|
435
573
|
}
|
|
574
|
+
if (component === "featured_collections" && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
575
|
+
out.push(mk(ERROR, "block-prop", file, `${at}.handles must be a string array`));
|
|
576
|
+
}
|
|
436
577
|
if (component === "featured_products") {
|
|
437
578
|
if (block.source !== undefined) out.push(...validateProductSource(block.source, file, at));
|
|
438
579
|
if (block.handles !== undefined && (!Array.isArray(block.handles) || !block.handles.every((v) => typeof v === "string"))) {
|
|
@@ -442,6 +583,18 @@ function validateHomeDoc(doc, file) {
|
|
|
442
583
|
out.push(mk(ERROR, "block-prop", file, `${at}.limit must be a number`));
|
|
443
584
|
}
|
|
444
585
|
}
|
|
586
|
+
if (component === "hero" && block.image !== undefined) {
|
|
587
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
588
|
+
}
|
|
589
|
+
if (component === "editorial" && block.image !== undefined) {
|
|
590
|
+
validateVisualAsset(block.image, file, `${at}.image`, out, tenantId);
|
|
591
|
+
if (typeof block.imageAlt !== "string") {
|
|
592
|
+
out.push(mk(ERROR, "block-required", file, `${at}.imageAlt is required when ${at}.image is supplied`));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
if (component === "promo_tiles") validatePromoTiles(block, file, at, out, tenantId);
|
|
596
|
+
if (component === "trust_strip") validateTrustStrip(block, file, at, out, tenantId);
|
|
597
|
+
if (component === "faq") validateFaq(block, file, at, out);
|
|
445
598
|
});
|
|
446
599
|
return out;
|
|
447
600
|
}
|
|
@@ -852,6 +1005,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
852
1005
|
);
|
|
853
1006
|
}
|
|
854
1007
|
const scope = opts.scope || config?.scope;
|
|
1008
|
+
const activeTenantId = opts.tenantId || scope || config?.tenant;
|
|
855
1009
|
// platform-route awareness: which framework routes the host platform owns for this tenant
|
|
856
1010
|
const mode = opts.mode === "workspace" ? "workspace" : "monorepo";
|
|
857
1011
|
const hostIsPlatform = mode === "monorepo" || config?.hostPlatform === "tot-storefront";
|
|
@@ -910,7 +1064,7 @@ export function validateTenant(tenantDir, opts = {}) {
|
|
|
910
1064
|
if (error) {
|
|
911
1065
|
findings.push(mk(ERROR, "content-json-parse", `content/${name}`, `invalid JSON: ${error} (fails the build)`));
|
|
912
1066
|
} else if (name === "home.json") {
|
|
913
|
-
findings.push(...validateHomeDoc(value, "content/home.json"));
|
|
1067
|
+
findings.push(...validateHomeDoc(value, "content/home.json", activeTenantId));
|
|
914
1068
|
} else if (name === "chrome.json") {
|
|
915
1069
|
findings.push(...findDuplicateCtaIds(value, "content/chrome.json"));
|
|
916
1070
|
if (opts.previousChromeJson != null) {
|