A gated bundle builds outside public/, and a gate keeps it out of the tree
key-cities is a gated profile: generate-tree leaves it out of the manifest and the pages. Its bundle file was still in public/dx-maps/key-cities/key-cities.js, committed, and the landing freshness pass rebuilt it into public/ every time it looked "stale", so any host that serves or drops in the tree folder whole shipped it. - vite.config.js builds a gated bundle into .gated/<tree>/ (ignored by git) instead of public/. - check:composite-freshness skips gated bundles: they have no committed bytes to be fresh against. - The committed public/dx-maps/key-cities/ is removed. History is not rewritten. - generate-tree removes a gated folder it finds in the tree (an older build, or a landing that kept the tree's side of a conflict), so the tree it writes is clean whatever came before. Measured: a planted public/dx-maps/key-cities/key-cities.js fails the gate, and one regeneration removes it. - The gated list is read in one place, internal/_tree-name.mjs, beside the tree name. - check:gated-not-in-tree (gates:pre) fails if a gated name has a folder in the tree, a manifest entry, or a page referencing its script, and proves each arm on a planted leak first. It FAILED on the tree before this change (key-cities/ is in the tree) and PASSES after. The data tables are left alone on purpose: key-cities.json also feeds the production Cities with Internet map, and whether that dataset should be public is a separate decision.
This commit is contained in:
parent
dcd1c13f55
commit
24f965b6a4
8 changed files with 129 additions and 390 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -152,4 +152,6 @@ data-private/
|
|||
/.pb/
|
||||
# bun run desktop:build <plugin>: the staged plugin and the built sidecar engines (never committed)
|
||||
/.desktop/
|
||||
# gated bundles (gatedProfiles) build here, never into public/ (vite.config.js)
|
||||
/.gated/
|
||||
/deployment/src-tauri/binaries/
|
||||
|
|
|
|||
|
|
@ -39,6 +39,17 @@ function read() {
|
|||
}
|
||||
|
||||
export const TREE_NAME = read()
|
||||
// GATED profiles (the config's gatedProfiles): bundles that build but must never reach a public surface.
|
||||
// generate-tree leaves them out of the manifest and pages; vite.config.js builds them OUTSIDE public/
|
||||
// (.gated/<tree>/); check:gated-not-in-tree fails if one appears in the tree anyway.
|
||||
function readGated() {
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(BUILD_CONFIG_FILE, 'utf8'))
|
||||
return Array.isArray(cfg.gatedProfiles) ? cfg.gatedProfiles.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []
|
||||
} catch { return [] }
|
||||
}
|
||||
export const GATED_PROFILES = readGated()
|
||||
|
||||
// The name escaped for use inside a RegExp source (a '.' in a future name must not match any char).
|
||||
export const TREE_NAME_RE = TREE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ import { spawnSync } from 'node:child_process'
|
|||
import { tmpdir } from 'node:os'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME } from './_tree-name.mjs'
|
||||
import { TREE_NAME, GATED_PROFILES } from './_tree-name.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const DESIGNS = join(ROOT, 'plugin-designs')
|
||||
|
|
@ -67,7 +67,10 @@ const byTag = new Map()
|
|||
for (const b of manifest.bundles || []) for (const t of b.elements || []) byTag.set(t, b.bundle)
|
||||
// A plugin that is not built into the web tree (plugin-targets.mjs: targets without "web") has no committed
|
||||
// bundle by design, so it is not held to one.
|
||||
const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')) && isWebBuilt(join(DESIGNS, d))))
|
||||
// A GATED bundle (gatedProfiles) builds outside public/ on purpose (vite.config.js), so it has no committed
|
||||
// bytes to be fresh against. Holding it to them is what rebuilt key-cities into public/ on every landing.
|
||||
const bundles = new Set(readdirSync(DESIGNS).filter((d) => existsSync(join(DESIGNS, d, 'index.js')) && isWebBuilt(join(DESIGNS, d))
|
||||
&& !GATED_PROFILES.includes(d)))
|
||||
// A bare PROFILE name of the same bundle (gospel-resources composes "resources-map", "data-tables" —
|
||||
// its own sibling profiles) resolves to that bundle, as the studio resolves it.
|
||||
const profilesOf = new Map((manifest.bundles || []).map((b) => [b.bundle, new Set((b.profiles || []).map((p) => p.profile))]))
|
||||
|
|
|
|||
93
internal/check-gated-not-in-tree.mjs
Normal file
93
internal/check-gated-not-in-tree.mjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-gated-not-in-tree — a GATED profile (the build config's gatedProfiles) never reaches the public tree.
|
||||
*
|
||||
* A gated bundle builds, but must appear on no public surface. generate-tree already left it out of the
|
||||
* manifest and the pages; its bundle file did not: public/<tree>/key-cities/key-cities.js was committed,
|
||||
* rebuilt into public/ by every landing that found it "stale", and copied by any host that serves or
|
||||
* drops in the tree folder whole. vite.config.js now builds a gated bundle into .gated/<tree>/ (ignored
|
||||
* by git), and this asserts the result where it matters: in the built tree.
|
||||
*
|
||||
* FAILS if, for any gated name: <tree>/<name>/ exists, manifest.json lists it as a bundle or a profile,
|
||||
* or any page in the tree references <name>/<name>.js. Data tables are NOT in scope: a table can be
|
||||
* shared with a public map (key-cities.json feeds Cities with Internet), so whether a dataset is public
|
||||
* is a separate decision, not a property of the gated bundle.
|
||||
* Two-sided: each arm is first run against a planted leak in a temporary copy.
|
||||
*
|
||||
* Node only, no browser. Exit 0 pass · 1 fail.
|
||||
*/
|
||||
import { existsSync, readdirSync, readFileSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { join, resolve, dirname } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { TREE_NAME, GATED_PROFILES } from './_tree-name.mjs'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const TREE = join(ROOT, 'public', TREE_NAME)
|
||||
|
||||
function htmlFiles(dir, out = []) {
|
||||
for (const f of readdirSync(dir)) {
|
||||
const p = join(dir, f)
|
||||
if (statSync(p).isDirectory()) { if (f !== 'assets') htmlFiles(p, out) }
|
||||
else if (f.endsWith('.html')) out.push(p)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function gatedLeaks(treeDir, gated) {
|
||||
const out = []
|
||||
for (const g of gated) {
|
||||
if (existsSync(join(treeDir, g))) out.push(`${TREE_NAME}/${g}/ is in the tree (${readdirSync(join(treeDir, g)).join(', ') || 'empty'})`)
|
||||
}
|
||||
const mf = join(treeDir, 'manifest.json')
|
||||
if (existsSync(mf)) {
|
||||
const m = JSON.parse(readFileSync(mf, 'utf8'))
|
||||
for (const b of m.bundles || []) {
|
||||
if (gated.includes(b.bundle)) out.push(`manifest.json lists the gated bundle "${b.bundle}"`)
|
||||
for (const p of b.profiles || []) if (gated.includes(p.profile)) out.push(`manifest.json lists the gated profile "${p.profile}" (in ${b.bundle})`)
|
||||
}
|
||||
}
|
||||
if (existsSync(treeDir)) {
|
||||
for (const f of htmlFiles(treeDir)) {
|
||||
const s = readFileSync(f, 'utf8')
|
||||
for (const g of gated) if (s.includes(`${g}/${g}.js`)) out.push(`${f.slice(treeDir.length + 1)} references ${g}/${g}.js`)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const fails = []
|
||||
if (!existsSync(join(TREE, 'manifest.json'))) {
|
||||
console.error(`GATED-NOT-IN-TREE: FAIL — no built tree at public/${TREE_NAME}/manifest.json`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Planted leaks first, in a temporary tree: a gated bundle folder, a manifest entry, a page reference.
|
||||
const probe = GATED_PROFILES[0] || 'gated-probe'
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'gated-probe-'))
|
||||
try {
|
||||
const clean = { bundles: [{ bundle: 'open-map', profiles: [{ profile: 'open-map' }] }] }
|
||||
writeFileSync(join(tmp, 'manifest.json'), JSON.stringify(clean))
|
||||
if (gatedLeaks(tmp, [probe]).length) fails.push('SELF-TEST: a clean probe tree was reported as leaking')
|
||||
mkdirSync(join(tmp, probe)); writeFileSync(join(tmp, probe, `${probe}.js`), '/* planted */')
|
||||
if (!gatedLeaks(tmp, [probe]).some((l) => l.includes(`${probe}/ is in the tree`))) fails.push('SELF-TEST: a planted gated bundle folder was not caught')
|
||||
rmSync(join(tmp, probe), { recursive: true, force: true })
|
||||
writeFileSync(join(tmp, 'manifest.json'), JSON.stringify({ bundles: [...clean.bundles, { bundle: probe, profiles: [{ profile: probe }] }] }))
|
||||
if (!gatedLeaks(tmp, [probe]).some((l) => l.includes('manifest.json lists'))) fails.push('SELF-TEST: a planted manifest entry was not caught')
|
||||
writeFileSync(join(tmp, 'manifest.json'), JSON.stringify(clean))
|
||||
mkdirSync(join(tmp, 'open-map')); writeFileSync(join(tmp, 'open-map', 'index.html'), `<script src="../${probe}/${probe}.js"></script>`)
|
||||
if (!gatedLeaks(tmp, [probe]).some((l) => l.includes('references'))) fails.push('SELF-TEST: a planted page reference was not caught')
|
||||
} finally { rmSync(tmp, { recursive: true, force: true }) }
|
||||
|
||||
// The real tree.
|
||||
const leaks = gatedLeaks(TREE, GATED_PROFILES)
|
||||
for (const l of leaks) fails.push(l)
|
||||
|
||||
if (fails.length) {
|
||||
console.error(`GATED-NOT-IN-TREE: FAIL — ${fails.length} problem(s)`)
|
||||
for (const f of fails) console.error(' - ' + f)
|
||||
console.error(' A gated bundle builds into .gated/ (vite.config.js); remove its folder from public/ and regenerate the tree.')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`GATED-NOT-IN-TREE: PASS — ${GATED_PROFILES.length ? GATED_PROFILES.join(', ') : 'no gated profiles'}: no folder in public/${TREE_NAME}/, `
|
||||
+ 'no manifest entry, no page reference (3 planted leaks each caught).')
|
||||
|
|
@ -180,6 +180,14 @@ const MAPBOX_GL_JS = CDN.mapboxGlJs.url // the ONE registry the bundle's loade
|
|||
|
||||
const isBundleDir = (name) => !name.startsWith('_') && !name.startsWith('.') && !DEST_CONFIG.gatedProfiles.includes(name) && !SCAFFOLD_BUNDLES.includes(name)
|
||||
|
||||
// ★ A GATED bundle's folder never stays in the tree. vite.config.js builds it into .gated/ already; this
|
||||
// removes one that arrived anyway (an older build, or a landing that kept the tree's side of a conflict),
|
||||
// so the tree this writes is clean whatever came before. check:gated-not-in-tree asserts the result.
|
||||
for (const g of DEST_CONFIG.gatedProfiles) {
|
||||
const dir = join(DEFAULT_ROOT, g)
|
||||
if (existsSync(dir)) { rmSync(dir, { recursive: true, force: true }); console.log(`[generate-tree] removed gated bundle folder ${TREE_NAME}/${g}/ from the tree`) }
|
||||
}
|
||||
|
||||
// ★ CUSTOM-ELEMENT NAME RULE (HTML spec): lowercase, must contain a hyphen, no spaces.
|
||||
// @element is NOT a comment — the build copies it VERBATIM into manifest.json and consumers feed
|
||||
// it straight to document.querySelector(). Anything else is a downstream SyntaxError.
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@
|
|||
"check:gallery-embed:js": "node internal/check-research-gallery-embed.mjs --mode js",
|
||||
"check:gallery-embed:live-cors": "node internal/check-research-gallery-embed.mjs --live-cors",
|
||||
"check:gallery-embed:maplibre": "node internal/check-research-gallery-embed.mjs --provider maplibre",
|
||||
"check:gated-not-in-tree": "node internal/check-gated-not-in-tree.mjs",
|
||||
"check:gospel-all": "node internal/check-gospel-all.mjs",
|
||||
"check:gospel-internet": "node plugin-designs/gospel-internet/scripts/check-gospel-internet-model.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-i18n.mjs && node plugin-designs/gospel-internet/scripts/check-language-tree.mjs && node plugin-designs/gospel-internet/scripts/check-gospel-internet-render.mjs",
|
||||
"check:gospel-tables-staged": "node internal/check-gospel-tables-staged.mjs",
|
||||
|
|
@ -314,7 +315,7 @@
|
|||
"fix:sri": "node internal/fix-sri.mjs",
|
||||
"gallery": "node internal/generate-gallery.mjs",
|
||||
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:poster-print-fallback check:country-names check:reduced-motion check:no-dead-library",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable",
|
||||
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree",
|
||||
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
|
||||
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
|
||||
"library-index": "node internal/generate-library-index.mjs",
|
||||
|
|
@ -423,6 +424,7 @@
|
|||
"check:frozen-labels": "a label resolved ONCE that never re-resolves on a mid-session locale switch: t() or tStatic() stored at module/setup scope, or tStatic() inside a computed (it reads a plain global the computed cannot track). Parsed, not regexed. Scoped by PROFILE stage: a production profile or library/ FAILS, an experimental profile is REPORTED only. In gates:post. The :self-test plants 4 bad shapes and 6 good ones (14 assertions).",
|
||||
"check:gallery-embed": "the cap-5 Research Gallery must embed on a FOREIGN ORIGIN (a stand-in host page on localhost, the built tree on 127.0.0.1) in BOTH modes, bare <script>+<doxa-maps-spa> and <iframe>, with the layout inside the host box and every curated tab painting a map. The summary line prints the tab count PER MODE, and a mode that checked zero tabs is a FAIL. One mode only: check:gallery-embed:js or check:gallery-embed:iframe. Starts its own servers on OS-assigned ports; needs only a built tree. Exit 2 = SKIP (no built tree / no playwright).",
|
||||
"check:gallery-embed:live-cors": "the Research Gallery on a foreign page under the LIVE static layer's CORS rule (Access-Control-Allow-Origin on files inside the tree's folders, NOT on the root manifest.json, measured on the live test host): it must still boot every tab, by reading the CORS-readable manifest copy generate-tree writes beside its bundle.",
|
||||
"check:gated-not-in-tree": "a GATED profile (the build config's gatedProfiles, today key-cities) never reaches the public tree: no public/<tree>/<name>/ folder, no manifest.json entry, no page referencing <name>/<name>.js. vite.config.js builds a gated bundle into .gated/<tree>/ (ignored by git). Data tables are out of scope on purpose: key-cities.json also feeds the production Cities with Internet map. Two-sided: three planted leaks in a temporary tree, each caught. Node only.",
|
||||
"check:gospel-all": "EVERYTHING gospel-resources owns, in one command. Its ten gates were all hand-run, which is how a dead bundle shipped: a duplicate module killed all five leaves and the one gate run that day was pointed at the map. check:no-orphan-gates was right that they were reachable — reachable is not run. Deliberately NOT in gates:pre/post: three need a browser, the shared wave holds browser gates under load, and a bundle owner should not be able to slow everyone else's build by adding to a list they do not own. Static gates run first and stop the run on failure, so a scarce browser slot is never spent confirming a table is inconsistent. Exit 75 = NOT MEASURED, never folded into a pass.",
|
||||
"check:gospel-internet": "the Gospel x Internet board, headless, against the REAL table and the REAL kit bus: the join table is what its builder makes from main (2,107 / 2,107, province placement agreeing with gospel-by-province), every facet count equals the rows the bus then keeps (the five-numbers-agree invariant), D1 OR-within-key, the country->province cascade, the 369 zero-access headline by band 220/45/45/59, URL state round-trip, CSV header-first/provenance-last with injection neutralised, no engagement column; then every catalogue key the bundle names exists in all 11 locales and is translated. Each half has a --poison arm that must fail. Plugin-local because check:i18n-keys-exist cannot see a useOptionalT-based alias.",
|
||||
"check:gospel-whiteboard": "the gospel set charts' geometry once drawn on a map: the bounded set's gap survives projection, the drawing stays inside the Mercator clamp, and three more properties, each with the arm that would catch its own defect. By the gospel owner. Headless.",
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -29,7 +29,7 @@ import { devSyncPlugin } from './internal/dev-sync-plugin.mjs'
|
|||
import { fileURLToPath } from 'node:url'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { readdirSync, statSync, existsSync } from 'node:fs'
|
||||
import { TREE_NAME } from './internal/_tree-name.mjs'
|
||||
import { TREE_NAME, GATED_PROFILES } from './internal/_tree-name.mjs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const profilesDir = resolve(__dirname, 'plugin-designs')
|
||||
|
|
@ -43,7 +43,12 @@ const MFE_NAME = TREE_NAME
|
|||
// Default output tree. generate-tree.mjs reads the same name + copies it onward.
|
||||
// DOXA_OUT_ROOT redirects a build's output WITHOUT touching public/: check:composite rebuilds each
|
||||
// composite into a temp dir and compares bytes with the committed bundle. Unset = unchanged behaviour.
|
||||
const OUT_ROOT = process.env.DOXA_OUT_ROOT ? resolve(process.env.DOXA_OUT_ROOT) : resolve(__dirname, 'public', MFE_NAME)
|
||||
// ★ A GATED bundle (gatedProfiles) never builds into public/: it goes to .gated/<tree>/, which git ignores.
|
||||
// Leaving it out of the manifest was not enough: its .js sat in public/<tree>/<bundle>/, so any host that
|
||||
// serves or copies the folder (the drop-in copies <tree>/ whole) shipped it anyway.
|
||||
const OUT_ROOT = process.env.DOXA_OUT_ROOT ? resolve(process.env.DOXA_OUT_ROOT)
|
||||
: GATED_PROFILES.includes(process.env.BUNDLE) ? resolve(__dirname, '.gated', MFE_NAME)
|
||||
: resolve(__dirname, 'public', MFE_NAME)
|
||||
|
||||
function discoverBundles() {
|
||||
if (!existsSync(profilesDir)) return {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue