diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 9defb4ac3..54f9f8683 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -45,7 +45,7 @@ import { createHash } from 'node:crypto' // without a separate tierMap. Previously the manifest carried no `type`, so a manifest-fed // consumer (plugin-studio) mis-tiered every profile as cap-1 (single). Single source of truth. import { detectProfileType } from './plugin-gallery/discover.mjs' -import { profileMetaFields, bundleStage } from './profile-meta.mjs' +import { profileMetaFields, bundleStage, bundleVisibility, readProfileMeta, VISIBILITY } from './profile-meta.mjs' import { TREE_NAME as CONFIGURED_TREE_NAME } from './_tree-name.mjs' import { deriveComposedTiers } from './_composed-tiers.mjs' import { syncAppShells } from './sync-app-shells.mjs' @@ -1165,6 +1165,9 @@ function scanBundles() { } const PROTOTYPE_RE = /template|example|starter|sample|demo|clone/i const bundles = [] + // .meta.json `visibility` values this generator does not recognise. Collected across the + // whole scan and reported together below, so an author fixing three typos sees three, not one. + const visibilityErrors = [] for (const entry of readdirSync(APP_PROFILES).sort()) { const dir = join(APP_PROFILES, entry) if (!isBundleDir(entry) || !statSync(dir).isDirectory()) continue @@ -1304,9 +1307,13 @@ function scanBundles() { ? composesAMapLeaf(dir, profileName, file) : (metaIsMap(dir, profileName) ?? profileReachesAMap(dir, file)) + // An unrecognised `visibility` is collected, not coerced — see profile-meta.mjs VISIBILITY. + const badVisibility = readProfileMeta(dir, profileName).visibilityInvalid + if (badVisibility !== null) visibilityErrors.push(`${entry}/${profileName}: "${badVisibility}"`) + return { profile: profileName, - ...profileMetaFields(dir, profileName), // stage / heavy / widget, shared with the dev manifest + ...profileMetaFields(dir, profileName), // stage / visibility / heavy / widget, shared with the dev manifest file, description: description || fileTag || '', element: tag, @@ -1366,9 +1373,22 @@ function scanBundles() { elements: bundleElements, ...(composesUnique.length ? { composes: composesUnique } : {}), stage: bundleStage(profiles), + // Only when public — absent means team (profile-meta.mjs), so today's manifests are unchanged. + ...(bundleVisibility(profiles) === 'public' ? { visibility: 'public' } : {}), profiles, }) } + + // ★ REFUSE AN UNKNOWN VISIBILITY. This field decides whether another team's dashboard lists the + // plugin, and the safe default (team) is precisely why a typo must not be silent: the author has + // written their opt-in, the manifest says private, and the plugin simply never appears anywhere — + // a bug with no error to search for. Fail here, at the build that would have published it. + if (visibilityErrors.length) { + console.error('[tree] unknown `visibility` in a profile meta.json — expected ' + VISIBILITY.map((v) => `"${v}"`).join(' or ') + ':') + for (const e of visibilityErrors) console.error(` ${e}`) + console.error(' Absent means "team". The generator will not guess which you meant.') + process.exit(1) + } return bundles } diff --git a/internal/plugin-gallery/dev-plugin.mjs b/internal/plugin-gallery/dev-plugin.mjs index 3c407d1d9..89be358d4 100644 --- a/internal/plugin-gallery/dev-plugin.mjs +++ b/internal/plugin-gallery/dev-plugin.mjs @@ -16,7 +16,7 @@ import { readdirSync, statSync, existsSync, readFileSync } from 'node:fs' import { REPO_ROOT, discoverSpaRegistry } from './discover.mjs' import { readBuildConfig, renderGallery } from '../generate-gallery.mjs' import { TREE_NAME } from '../_tree-name.mjs' -import { profileMetaFields, bundleStage } from '../profile-meta.mjs' +import { profileMetaFields, bundleStage, bundleVisibility } from '../profile-meta.mjs' // ─── DEV plugin gallery ────────────────────────────────────────────────────── // `bun x vite` (dev) serves the plugin gallery at `/` — the same page `bun run build` @@ -137,7 +137,7 @@ export function galleryModel(reg) { // to drop them, so under the dev server — which is what the Driver's laptop runs — the studio's // Composed-of panel and composition trees were silently empty for every composite. const composes = [...new Set([...presets.keys()].flatMap((profile) => readComposes(b.bundle, profile)))] - return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], stage: bundleStage(profiles), ...(composes.length ? { composes } : {}), profiles } + return { bundle: b.bundle, scriptUrl, integrity: '', elements: [b.tag], stage: bundleStage(profiles), ...(bundleVisibility(profiles) === 'public' ? { visibility: 'public' } : {}), ...(composes.length ? { composes } : {}), profiles } }) return { schema: 'doxa-maps-manifest@1', generatedFrom: 'plugin-designs/*/*.vue (dev discovery)', embedBase: '.', diff --git a/internal/profile-meta.mjs b/internal/profile-meta.mjs index 3533d195c..5d3b3a02a 100644 --- a/internal/profile-meta.mjs +++ b/internal/profile-meta.mjs @@ -7,12 +7,34 @@ * stage "production" | "experimental" default experimental * heavy true when the element holds live WebGL contexts * widget { corner } for a corner widget the Plugin Studio mounts once, fixed + * visibility "team" | "public" default team — see VISIBILITY below */ import { readFileSync } from 'node:fs' import { join } from 'node:path' const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'] +/** + * ★ WHO ELSE MAY LIST THIS PLUGIN (dx-base, 2026-09-28). The platform dashboard reads each team's + * own /cdn/dx-maps/manifest.json instead of one shared index, so this field is what decides whether + * a plugin appears in ANOTHER team's library (loaded read-only from the owner's CDN). + * + * "team" listed only in the owning team's library + * "public" also listed in every other team's library + * ABSENT team — every manifest that exists today is private until a plugin opts in + * + * ★ DO NOT REACH FOR `stage` TO EXPRESS THIS. It sits right next to it in this file and reads like + * a fit: it is maturity, not audience. Using it would hide every experimental plugin from its OWN + * team and publish every production one to everybody — wrong in both directions at once. + * + * ★ AN UNKNOWN VALUE IS REFUSED, NOT DEFAULTED. Defaulting "Public" or "pubic" to team is the safe + * direction and that is exactly what makes it dangerous: the author reads their opt-in as done, the + * field says private, and nobody finds out until someone asks why the plugin never appeared. The + * generator exits on it (generate-tree, "visibility" block) so a typo is a build failure, loud, at + * the moment it is introduced. + */ +export const VISIBILITY = ['team', 'public'] + export function readProfileMeta(dir, profileName) { try { const meta = JSON.parse(readFileSync(join(dir, `${profileName}.meta.json`), 'utf8')) @@ -26,15 +48,31 @@ export function readProfileMeta(dir, profileName) { label: name ? name.trim() : '', // Where the plugin runs ("web", "desktop"…); absent = web. A desktop-only plugin is never a gallery tab. targets: Array.isArray(meta?.targets) ? meta.targets.filter((t) => typeof t === 'string') : null, + visibility: VISIBILITY.includes(meta?.visibility) ? meta.visibility : 'team', + // The declared-but-unrecognised value, kept so the generator can name it in the failure. + // null when the key is absent (the supported way to say "team") or already valid. + visibilityInvalid: meta?.visibility === undefined || VISIBILITY.includes(meta?.visibility) + ? null + : String(meta.visibility), } - } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null } } + } catch { return { stage: 'experimental', heavy: false, widget: null, label: '', targets: null, visibility: 'team', visibilityInvalid: null } } } /** The optional manifest keys for one profile (stage always; heavy / widget only when set). */ export function profileMetaFields(dir, profileName) { const m = readProfileMeta(dir, profileName) - return { stage: m.stage, ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}) } + // visibility is emitted ONLY when it is "public": absent means team, so every manifest in the + // tree today keeps the bytes it has and nothing becomes visible by upgrading the generator. + return { stage: m.stage, ...(m.visibility === 'public' ? { visibility: 'public' } : {}), ...(m.label ? { label: m.label } : {}), ...(m.targets ? { targets: m.targets } : {}), ...(m.heavy ? { heavy: true } : {}), ...(m.widget ? { widget: m.widget } : {}) } } /** A bundle is production if ANY of its profiles says so. */ export const bundleStage = (profiles) => (profiles.some((p) => p.stage === 'production') ? 'production' : 'experimental') + +/** + * A bundle is public if ANY of its profiles is — because a bundle is ONE IIFE. The profiles in it + * share a single .js, so publishing one profile publishes the file the others are compiled into; + * there is no shape of this field that makes half a script fetchable. The per-profile key is still + * emitted, so a reader can list only the profiles that opted in while loading the one bundle. + */ +export const bundleVisibility = (profiles) => (profiles.some((p) => p.visibility === 'public') ? 'public' : 'team')