@mhosaic/feedback-cli 0.28.0 → 0.29.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/dist/bin.js
CHANGED
|
@@ -91,6 +91,14 @@ function scanCsp(cwd) {
|
|
|
91
91
|
};
|
|
92
92
|
return walk("");
|
|
93
93
|
}
|
|
94
|
+
function isBundledByDesign(cwd) {
|
|
95
|
+
try {
|
|
96
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
97
|
+
return pkg?.mhosaicFeedback?.delivery === "bundled";
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
94
102
|
async function runDoctor(argv) {
|
|
95
103
|
const cwd = argv.includes("--cwd") ? argv[argv.indexOf("--cwd") + 1] ?? process.cwd() : process.cwd();
|
|
96
104
|
const checks = [];
|
|
@@ -111,23 +119,30 @@ async function runDoctor(argv) {
|
|
|
111
119
|
}
|
|
112
120
|
}
|
|
113
121
|
checks.push({ name: "<FeedbackProvider> wired in entry", ok: wrapOk, hint: "run `mhosaic-feedback init`" });
|
|
114
|
-
|
|
115
|
-
checks.push({
|
|
116
|
-
name: "loader delivery path (version pins reach this app)",
|
|
117
|
-
ok: bundledAt === null,
|
|
118
|
-
...bundledAt ? {
|
|
119
|
-
hint: `bundled import in ${bundledAt} \u2014 change '@mhosaic/feedback' to '@mhosaic/feedback/loader' (same API) so pins land without a redeploy`
|
|
120
|
-
} : {}
|
|
121
|
-
});
|
|
122
|
-
if (bundledAt === null) {
|
|
123
|
-
const cspBlockedAt = scanCsp(cwd);
|
|
122
|
+
if (isBundledByDesign(cwd)) {
|
|
124
123
|
checks.push({
|
|
125
|
-
name:
|
|
126
|
-
ok:
|
|
127
|
-
|
|
128
|
-
|
|
124
|
+
name: 'delivery: bundled on purpose (mhosaicFeedback.delivery="bundled") \u2014 update the version manually',
|
|
125
|
+
ok: true
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
const bundledAt = scanDeliveryPath(cwd, framework.entry ?? void 0);
|
|
129
|
+
checks.push({
|
|
130
|
+
name: "loader delivery path (version pins reach this app)",
|
|
131
|
+
ok: bundledAt === null,
|
|
132
|
+
...bundledAt ? {
|
|
133
|
+
hint: `bundled import in ${bundledAt} \u2014 change '@mhosaic/feedback' to '@mhosaic/feedback/loader' (same API) so pins land without a redeploy. If a strict CSP forbids the CDN, keep it bundled and set package.json "mhosaicFeedback": { "delivery": "bundled" }`
|
|
129
134
|
} : {}
|
|
130
135
|
});
|
|
136
|
+
if (bundledAt === null) {
|
|
137
|
+
const cspBlockedAt = scanCsp(cwd);
|
|
138
|
+
checks.push({
|
|
139
|
+
name: `CSP allows the widget CDN (${CDN_HOST})`,
|
|
140
|
+
ok: cspBlockedAt === null,
|
|
141
|
+
...cspBlockedAt ? {
|
|
142
|
+
hint: `CSP in ${cspBlockedAt} omits the CDN \u2014 add 'https://${CDN_HOST}' to script-src (and the platform origin to connect-src), or if the CSP can't allow an external CDN keep the widget bundled and set package.json "mhosaicFeedback": { "delivery": "bundled" }`
|
|
143
|
+
} : {}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
131
146
|
}
|
|
132
147
|
let hasFailure = false;
|
|
133
148
|
for (const c of checks) {
|
|
@@ -139,9 +154,10 @@ async function runDoctor(argv) {
|
|
|
139
154
|
if (hasFailure) process.exitCode = 1;
|
|
140
155
|
}
|
|
141
156
|
export {
|
|
157
|
+
isBundledByDesign,
|
|
142
158
|
runDoctor,
|
|
143
159
|
scanCsp,
|
|
144
160
|
scanDeliveryPath,
|
|
145
161
|
scriptSrcOf
|
|
146
162
|
};
|
|
147
|
-
//# sourceMappingURL=doctor-
|
|
163
|
+
//# sourceMappingURL=doctor-24Z2JARJ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/commands/doctor.ts"],"sourcesContent":["import { existsSync, readFileSync, readdirSync } from 'node:fs'\nimport type { Dirent } from 'node:fs'\nimport { join } from 'node:path'\n\nimport kleur from 'kleur'\n\nimport { detectFramework } from '../detect'\n\n// A bare `@mhosaic/feedback` import (no subpath) pulls the WHOLE widget into\n// the host's build — freezing its version to package.json. That's the\n// \"bundled\" delivery path: Mhosaic version pins/promotions never reach the\n// app without a dependency bump + redeploy. The loader subpaths\n// (`@mhosaic/feedback/loader`, `.../loader/react`) instead resolve the\n// version at runtime from the manifest, so a pin reaches every client with\n// zero per-repo work. This matcher flags the bundled path; see\n// scanDeliveryPath below. (2026-07-07: a client was found silently bundled\n// and stuck four releases behind — doctor missed it because it only checked\n// the React provider, not vanilla `createFeedback` installs.)\nconst BUNDLED_IMPORT = /from\\s+['\"]@mhosaic\\/feedback['\"]/\nconst CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.vue', '.svelte', '.astro'])\nconst SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.output', '.svelte-kit'])\n\n/** Shallow-bounded walk of `src/` (and the entry) for a bare bundled import.\n * Returns the first offending repo-relative path, or null if none. Capped so\n * doctor stays fast on large repos. */\nexport function scanDeliveryPath(cwd: string, entry?: string): string | null {\n const seen = new Set<string>()\n const check = (rel: string): boolean => {\n const abs = join(cwd, rel)\n if (seen.has(abs) || !existsSync(abs)) return false\n seen.add(abs)\n try {\n return BUNDLED_IMPORT.test(readFileSync(abs, 'utf8'))\n } catch {\n return false\n }\n }\n if (entry && check(entry)) return entry\n\n let budget = 2000 // files; a backstop, real hosts are far smaller\n const walk = (dir: string): string | null => {\n let entries: Dirent[]\n try {\n entries = readdirSync(join(cwd, dir), { withFileTypes: true })\n } catch {\n return null\n }\n for (const e of entries) {\n if (budget-- <= 0) return null\n const rel = dir ? `${dir}/${e.name}` : e.name\n if (e.isDirectory()) {\n if (SKIP_DIRS.has(e.name)) continue\n const hit = walk(rel)\n if (hit) return hit\n } else if (CODE_EXT.has(e.name.slice(e.name.lastIndexOf('.')))) {\n if (check(rel)) return rel\n }\n }\n return null\n }\n return existsSync(join(cwd, 'src')) ? walk('src') : null\n}\n\n// The loader injects `<script src=\"https://cdn.jsdelivr.net/...widget.min.js\">`\n// at runtime, so a strict CSP must allow that host in `script-src` or the\n// widget silently never loads — the \"Refused to load … not in the script-src\n// directive\" failure that took a client's widget down on 2026-07-08 right\n// after it migrated to the loader. This is the companion requirement to the\n// import swap; catching it here means at migration time, not in production.\nconst CDN_HOST = 'cdn.jsdelivr.net'\n// CSP config lives in many shapes (index.html meta, _headers, nginx confs,\n// *.toml/json framework config). We only need the text.\nconst CSP_EXT = new Set(['.html', '.htm', '.conf', '.toml', '.json', '.js', '.cjs', '.mjs', '.ts'])\nconst CSP_EXTRA_NAMES = new Set(['_headers'])\n// A source token that already permits any cross-origin script host — no CDN\n// entry needed. `'unsafe-inline'`/`'unsafe-eval'` do NOT help a cross-origin\n// src, so they don't count here.\nconst PERMISSIVE_SRC = /(?:^|\\s)(?:\\*|https:)(?=\\s|;|\"|'|$)/\n\n/** The effective script-src value from one CSP string (falls back to\n * default-src), whitespace-flattened so a multi-line meta tag parses. Null\n * when neither directive is present. */\nexport function scriptSrcOf(csp: string): string | null {\n const flat = csp.replace(/\\s+/g, ' ')\n const m = /script-src([^;]*)/i.exec(flat) ?? /default-src([^;]*)/i.exec(flat)\n return m ? (m[1] ?? null) : null\n}\n\n/** Bounded repo walk for a CSP whose (effective) script-src is restrictive\n * AND omits the widget CDN — i.e. would block the loader. Returns the first\n * offending repo-relative file, or null. Permissive policies (`*`, `https:`)\n * and policies that already allow the CDN pass. */\nexport function scanCsp(cwd: string): string | null {\n let budget = 3000\n const walk = (dir: string): string | null => {\n let entries: Dirent[]\n try {\n entries = readdirSync(join(cwd, dir), { withFileTypes: true })\n } catch {\n return null\n }\n for (const e of entries) {\n if (budget-- <= 0) return null\n const rel = dir ? `${dir}/${e.name}` : e.name\n if (e.isDirectory()) {\n if (SKIP_DIRS.has(e.name)) continue\n const hit = walk(rel)\n if (hit) return hit\n continue\n }\n const ext = e.name.slice(e.name.lastIndexOf('.'))\n if (!CSP_EXT.has(ext) && !CSP_EXTRA_NAMES.has(e.name)) continue\n let text: string\n try {\n text = readFileSync(join(cwd, rel), 'utf8')\n } catch {\n continue\n }\n if (!text.includes('script-src') && !text.includes('Content-Security-Policy')) continue\n const scriptSrc = scriptSrcOf(text)\n if (scriptSrc === null) continue // CSP present but no script/default-src → not restricting scripts\n if (scriptSrc.includes(CDN_HOST) || PERMISSIVE_SRC.test(scriptSrc)) continue // already allowed\n return rel // restrictive script-src that omits the CDN → would block the loader\n }\n return null\n }\n return walk('')\n}\n\n/** A host can declare it is intentionally bundled — e.g. a strict CSP that\n * forbids external CDN scripts, so the loader physically cannot run (its\n * jsDelivr <script> is refused). It opts out via package.json\n * `{\"mhosaicFeedback\": {\"delivery\": \"bundled\"}}`. This is the ONE escape\n * hatch; every host without it still gets the hard \"switch to the loader\"\n * failure, so the default stays strong. Expected to be rare (one known case:\n * a CSP-locked client whose widget updates are done manually). */\nexport function isBundledByDesign(cwd: string): boolean {\n try {\n const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'))\n return pkg?.mhosaicFeedback?.delivery === 'bundled'\n } catch {\n return false\n }\n}\n\nexport async function runDoctor(argv: string[]): Promise<void> {\n const cwd = argv.includes('--cwd') ? argv[argv.indexOf('--cwd') + 1] ?? process.cwd() : process.cwd()\n\n const checks: Array<{ name: string; ok: boolean; hint?: string }> = []\n\n const framework = await detectFramework(cwd)\n checks.push({ name: `framework detected: ${framework.kind}`, ok: framework.kind !== 'unknown' && framework.kind !== 'plain' })\n\n const envPath = join(cwd, '.env.local')\n const envOk = existsSync(envPath) && readFileSync(envPath, 'utf8').includes('VITE_FEEDBACK_API_KEY=')\n checks.push({ name: '.env.local has VITE_FEEDBACK_API_KEY', ok: envOk, hint: 'run `mhosaic-feedback init`' })\n\n const giPath = join(cwd, '.gitignore')\n const giOk = existsSync(giPath) && readFileSync(giPath, 'utf8').includes('.env.local')\n checks.push({ name: '.gitignore ignores .env.local', ok: giOk, hint: 'add `.env.local` to .gitignore' })\n\n let wrapOk = false\n if (framework.entry) {\n const entryPath = join(cwd, framework.entry)\n if (existsSync(entryPath)) {\n const src = readFileSync(entryPath, 'utf8')\n // Accept both the legacy path (`@mhosaic/feedback/react`) and the\n // new loader path (`@mhosaic/feedback/loader/react`) so doctor\n // returns green on already-integrated hosts pre- and post-v0.16.\n wrapOk =\n src.includes('<FeedbackProvider') &&\n (src.includes(\"@mhosaic/feedback/loader/react\") ||\n src.includes(\"@mhosaic/feedback/react\"))\n }\n }\n checks.push({ name: '<FeedbackProvider> wired in entry', ok: wrapOk, hint: 'run `mhosaic-feedback init`' })\n\n // Delivery path: the loader resolves the widget version at runtime, so\n // Mhosaic version pins reach this app automatically. A bare\n // `@mhosaic/feedback` import bundles a frozen version instead. This is the\n // one check that keeps \"every client is loader\" true over time — unless the\n // host has explicitly opted into bundling (isBundledByDesign), the one\n // sanctioned exception for a CSP that can't allow the CDN.\n if (isBundledByDesign(cwd)) {\n checks.push({\n name: 'delivery: bundled on purpose (mhosaicFeedback.delivery=\"bundled\") — update the version manually',\n ok: true,\n })\n // CSP check deliberately skipped: a bundled widget is served same-origin,\n // so there is no CDN host to allowlist.\n } else {\n const bundledAt = scanDeliveryPath(cwd, framework.entry ?? undefined)\n checks.push({\n name: 'loader delivery path (version pins reach this app)',\n ok: bundledAt === null,\n ...(bundledAt\n ? {\n hint: `bundled import in ${bundledAt} — change '@mhosaic/feedback' to '@mhosaic/feedback/loader' (same API) so pins land without a redeploy. If a strict CSP forbids the CDN, keep it bundled and set package.json \"mhosaicFeedback\": { \"delivery\": \"bundled\" }`,\n }\n : {}),\n })\n\n // CSP: the loader fetches the widget from the CDN, so a strict script-src\n // must allow it. Only meaningful on the loader path — a bundled app serves\n // the widget same-origin and needs no CDN entry (the check above already\n // tells it to switch). Gating on `!bundledAt` avoids a confusing double\n // warning on apps that haven't migrated yet.\n if (bundledAt === null) {\n const cspBlockedAt = scanCsp(cwd)\n checks.push({\n name: `CSP allows the widget CDN (${CDN_HOST})`,\n ok: cspBlockedAt === null,\n ...(cspBlockedAt\n ? {\n hint: `CSP in ${cspBlockedAt} omits the CDN — add 'https://${CDN_HOST}' to script-src (and the platform origin to connect-src), or if the CSP can't allow an external CDN keep the widget bundled and set package.json \"mhosaicFeedback\": { \"delivery\": \"bundled\" }`,\n }\n : {}),\n })\n }\n }\n\n let hasFailure = false\n for (const c of checks) {\n const icon = c.ok ? kleur.green('✓') : kleur.red('✗')\n process.stdout.write(`${icon} ${c.name}${!c.ok && c.hint ? kleur.gray(' — ' + c.hint) : ''}\\n`)\n if (!c.ok) hasFailure = true\n }\n if (hasFailure) process.exitCode = 1\n}\n"],"mappings":"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","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mhosaic/feedback-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"description": "CLI to install @mhosaic/feedback into a host app, verify the integration, and drop a guided Claude Code skill (/integrate-feedback) into ~/.claude/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/commands/doctor.ts"],"sourcesContent":["import { existsSync, readFileSync, readdirSync } from 'node:fs'\nimport type { Dirent } from 'node:fs'\nimport { join } from 'node:path'\n\nimport kleur from 'kleur'\n\nimport { detectFramework } from '../detect'\n\n// A bare `@mhosaic/feedback` import (no subpath) pulls the WHOLE widget into\n// the host's build — freezing its version to package.json. That's the\n// \"bundled\" delivery path: Mhosaic version pins/promotions never reach the\n// app without a dependency bump + redeploy. The loader subpaths\n// (`@mhosaic/feedback/loader`, `.../loader/react`) instead resolve the\n// version at runtime from the manifest, so a pin reaches every client with\n// zero per-repo work. This matcher flags the bundled path; see\n// scanDeliveryPath below. (2026-07-07: a client was found silently bundled\n// and stuck four releases behind — doctor missed it because it only checked\n// the React provider, not vanilla `createFeedback` installs.)\nconst BUNDLED_IMPORT = /from\\s+['\"]@mhosaic\\/feedback['\"]/\nconst CODE_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.vue', '.svelte', '.astro'])\nconst SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.output', '.svelte-kit'])\n\n/** Shallow-bounded walk of `src/` (and the entry) for a bare bundled import.\n * Returns the first offending repo-relative path, or null if none. Capped so\n * doctor stays fast on large repos. */\nexport function scanDeliveryPath(cwd: string, entry?: string): string | null {\n const seen = new Set<string>()\n const check = (rel: string): boolean => {\n const abs = join(cwd, rel)\n if (seen.has(abs) || !existsSync(abs)) return false\n seen.add(abs)\n try {\n return BUNDLED_IMPORT.test(readFileSync(abs, 'utf8'))\n } catch {\n return false\n }\n }\n if (entry && check(entry)) return entry\n\n let budget = 2000 // files; a backstop, real hosts are far smaller\n const walk = (dir: string): string | null => {\n let entries: Dirent[]\n try {\n entries = readdirSync(join(cwd, dir), { withFileTypes: true })\n } catch {\n return null\n }\n for (const e of entries) {\n if (budget-- <= 0) return null\n const rel = dir ? `${dir}/${e.name}` : e.name\n if (e.isDirectory()) {\n if (SKIP_DIRS.has(e.name)) continue\n const hit = walk(rel)\n if (hit) return hit\n } else if (CODE_EXT.has(e.name.slice(e.name.lastIndexOf('.')))) {\n if (check(rel)) return rel\n }\n }\n return null\n }\n return existsSync(join(cwd, 'src')) ? walk('src') : null\n}\n\n// The loader injects `<script src=\"https://cdn.jsdelivr.net/...widget.min.js\">`\n// at runtime, so a strict CSP must allow that host in `script-src` or the\n// widget silently never loads — the \"Refused to load … not in the script-src\n// directive\" failure that took a client's widget down on 2026-07-08 right\n// after it migrated to the loader. This is the companion requirement to the\n// import swap; catching it here means at migration time, not in production.\nconst CDN_HOST = 'cdn.jsdelivr.net'\n// CSP config lives in many shapes (index.html meta, _headers, nginx confs,\n// *.toml/json framework config). We only need the text.\nconst CSP_EXT = new Set(['.html', '.htm', '.conf', '.toml', '.json', '.js', '.cjs', '.mjs', '.ts'])\nconst CSP_EXTRA_NAMES = new Set(['_headers'])\n// A source token that already permits any cross-origin script host — no CDN\n// entry needed. `'unsafe-inline'`/`'unsafe-eval'` do NOT help a cross-origin\n// src, so they don't count here.\nconst PERMISSIVE_SRC = /(?:^|\\s)(?:\\*|https:)(?=\\s|;|\"|'|$)/\n\n/** The effective script-src value from one CSP string (falls back to\n * default-src), whitespace-flattened so a multi-line meta tag parses. Null\n * when neither directive is present. */\nexport function scriptSrcOf(csp: string): string | null {\n const flat = csp.replace(/\\s+/g, ' ')\n const m = /script-src([^;]*)/i.exec(flat) ?? /default-src([^;]*)/i.exec(flat)\n return m ? (m[1] ?? null) : null\n}\n\n/** Bounded repo walk for a CSP whose (effective) script-src is restrictive\n * AND omits the widget CDN — i.e. would block the loader. Returns the first\n * offending repo-relative file, or null. Permissive policies (`*`, `https:`)\n * and policies that already allow the CDN pass. */\nexport function scanCsp(cwd: string): string | null {\n let budget = 3000\n const walk = (dir: string): string | null => {\n let entries: Dirent[]\n try {\n entries = readdirSync(join(cwd, dir), { withFileTypes: true })\n } catch {\n return null\n }\n for (const e of entries) {\n if (budget-- <= 0) return null\n const rel = dir ? `${dir}/${e.name}` : e.name\n if (e.isDirectory()) {\n if (SKIP_DIRS.has(e.name)) continue\n const hit = walk(rel)\n if (hit) return hit\n continue\n }\n const ext = e.name.slice(e.name.lastIndexOf('.'))\n if (!CSP_EXT.has(ext) && !CSP_EXTRA_NAMES.has(e.name)) continue\n let text: string\n try {\n text = readFileSync(join(cwd, rel), 'utf8')\n } catch {\n continue\n }\n if (!text.includes('script-src') && !text.includes('Content-Security-Policy')) continue\n const scriptSrc = scriptSrcOf(text)\n if (scriptSrc === null) continue // CSP present but no script/default-src → not restricting scripts\n if (scriptSrc.includes(CDN_HOST) || PERMISSIVE_SRC.test(scriptSrc)) continue // already allowed\n return rel // restrictive script-src that omits the CDN → would block the loader\n }\n return null\n }\n return walk('')\n}\n\nexport async function runDoctor(argv: string[]): Promise<void> {\n const cwd = argv.includes('--cwd') ? argv[argv.indexOf('--cwd') + 1] ?? process.cwd() : process.cwd()\n\n const checks: Array<{ name: string; ok: boolean; hint?: string }> = []\n\n const framework = await detectFramework(cwd)\n checks.push({ name: `framework detected: ${framework.kind}`, ok: framework.kind !== 'unknown' && framework.kind !== 'plain' })\n\n const envPath = join(cwd, '.env.local')\n const envOk = existsSync(envPath) && readFileSync(envPath, 'utf8').includes('VITE_FEEDBACK_API_KEY=')\n checks.push({ name: '.env.local has VITE_FEEDBACK_API_KEY', ok: envOk, hint: 'run `mhosaic-feedback init`' })\n\n const giPath = join(cwd, '.gitignore')\n const giOk = existsSync(giPath) && readFileSync(giPath, 'utf8').includes('.env.local')\n checks.push({ name: '.gitignore ignores .env.local', ok: giOk, hint: 'add `.env.local` to .gitignore' })\n\n let wrapOk = false\n if (framework.entry) {\n const entryPath = join(cwd, framework.entry)\n if (existsSync(entryPath)) {\n const src = readFileSync(entryPath, 'utf8')\n // Accept both the legacy path (`@mhosaic/feedback/react`) and the\n // new loader path (`@mhosaic/feedback/loader/react`) so doctor\n // returns green on already-integrated hosts pre- and post-v0.16.\n wrapOk =\n src.includes('<FeedbackProvider') &&\n (src.includes(\"@mhosaic/feedback/loader/react\") ||\n src.includes(\"@mhosaic/feedback/react\"))\n }\n }\n checks.push({ name: '<FeedbackProvider> wired in entry', ok: wrapOk, hint: 'run `mhosaic-feedback init`' })\n\n // Delivery path: the loader resolves the widget version at runtime, so\n // Mhosaic version pins reach this app automatically. A bare\n // `@mhosaic/feedback` import bundles a frozen version instead. This is the\n // one check that keeps \"every client is loader\" true over time.\n const bundledAt = scanDeliveryPath(cwd, framework.entry ?? undefined)\n checks.push({\n name: 'loader delivery path (version pins reach this app)',\n ok: bundledAt === null,\n ...(bundledAt\n ? {\n hint: `bundled import in ${bundledAt} — change '@mhosaic/feedback' to '@mhosaic/feedback/loader' (same API) so pins land without a redeploy`,\n }\n : {}),\n })\n\n // CSP: the loader fetches the widget from the CDN, so a strict script-src\n // must allow it. Only meaningful on the loader path — a bundled app serves\n // the widget same-origin and needs no CDN entry (the check above already\n // tells it to switch). Gating on `!bundledAt` avoids a confusing double\n // warning on apps that haven't migrated yet.\n if (bundledAt === null) {\n const cspBlockedAt = scanCsp(cwd)\n checks.push({\n name: `CSP allows the widget CDN (${CDN_HOST})`,\n ok: cspBlockedAt === null,\n ...(cspBlockedAt\n ? {\n hint: `CSP in ${cspBlockedAt} omits the CDN — add 'https://${CDN_HOST}' to script-src (and the platform origin to connect-src) or the widget silently won't load`,\n }\n : {}),\n })\n }\n\n let hasFailure = false\n for (const c of checks) {\n const icon = c.ok ? kleur.green('✓') : kleur.red('✗')\n process.stdout.write(`${icon} ${c.name}${!c.ok && c.hint ? kleur.gray(' — ' + c.hint) : ''}\\n`)\n if (!c.ok) hasFailure = true\n }\n if (hasFailure) process.exitCode = 1\n}\n"],"mappings":";;;;;;AAAA,SAAS,YAAY,cAAc,mBAAmB;AAEtD,SAAS,YAAY;AAErB,OAAO,WAAW;AAclB,IAAM,iBAAiB;AACvB,IAAM,WAAW,oBAAI,IAAI,CAAC,OAAO,QAAQ,OAAO,QAAQ,QAAQ,QAAQ,WAAW,QAAQ,CAAC;AAC5F,IAAM,YAAY,oBAAI,IAAI,CAAC,gBAAgB,QAAQ,QAAQ,SAAS,SAAS,WAAW,aAAa,CAAC;AAK/F,SAAS,iBAAiB,KAAa,OAA+B;AAC3E,QAAM,OAAO,oBAAI,IAAY;AAC7B,QAAM,QAAQ,CAAC,QAAyB;AACtC,UAAM,MAAM,KAAK,KAAK,GAAG;AACzB,QAAI,KAAK,IAAI,GAAG,KAAK,CAAC,WAAW,GAAG,EAAG,QAAO;AAC9C,SAAK,IAAI,GAAG;AACZ,QAAI;AACF,aAAO,eAAe,KAAK,aAAa,KAAK,MAAM,CAAC;AAAA,IACtD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,SAAS,MAAM,KAAK,EAAG,QAAO;AAElC,MAAI,SAAS;AACb,QAAM,OAAO,CAAC,QAA+B;AAC3C,QAAI;AACJ,QAAI;AACF,gBAAU,YAAY,KAAK,KAAK,GAAG,GAAG,EAAE,eAAe,KAAK,CAAC;AAAA,IAC/D,QAAQ;AACN,aAAO;AAAA,IACT;AACA,eAAW,KAAK,SAAS;AACvB,UAAI,YAAY,EAAG,QAAO;AAC1B,YAAM,MAAM,MAAM,GAAG,GAAG,IAAI,EAAE,IAAI,KAAK,EAAE;AACzC,UAAI,EAAE,YAAY,GAAG;AACnB,YAAI,UAAU,IAAI,EAAE,IAAI,EAAG;AAC3B,cAAM,MAAM,KAAK,GAAG;AACpB,YAAI,IAAK,QAAO;AAAA,MAClB,WAAW,SAAS,IAAI,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,GAAG,CAAC,CAAC,GAAG;AAC9D,YAAI,MAAM,GAAG,EAAG,QAAO;AAAA,MACzB;AAAA,IACF;AACA,WAAO;AAAA,EACT;AACA,SAAO,WAAW,KAAK,KAAK,KAAK,CAAC,IAAI,KAAK,KAAK,IAAI;AACtD;AAQA,IAAM,WAAW;AAGjB,IAAM,UAAU,oBAAI,IAAI,CAAC,SAAS,QAAQ,SAAS,SAAS,SAAS,OAAO,QAAQ,QAAQ,KAAK,CAAC;AAClG,IAAM,kBAAkB,oBAAI,IAAI,CAAC,UAAU,CAAC;AAI5C,IAAM,iBAAiB;AAKhB,SAAS,YAAY,KAA4B;AACtD,QAAM,OAAO,IAAI,QAAQ,QAAQ,GAAG;AACpC,QAAM,IAAI,qBAAqB,KAAK,IAAI,KAAK,sBAAsB,KAAK,IAAI;AAC5E,SAAO,IAAK,EAAE,CAAC,KAAK,OAAQ;AAC9B;AAMO,SAAS,QAAQ,KAA4B;AAClD,MAAI,SAAS;AACb,QAAM,OAAO,CAAC,QAA+B;AAC3C,QAAI;AACJ,QAAI;AACF,gBAAU,YAAY,KAAK,KAAK,GAAG,GAAG,EAAE,eAAe,KAAK,CAAC;AAAA,IAC/D,QAAQ;AACN,aAAO;AAAA,IACT;AACA,eAAW,KAAK,SAAS;AACvB,UAAI,YAAY,EAAG,QAAO;AAC1B,YAAM,MAAM,MAAM,GAAG,GAAG,IAAI,EAAE,IAAI,KAAK,EAAE;AACzC,UAAI,EAAE,YAAY,GAAG;AACnB,YAAI,UAAU,IAAI,EAAE,IAAI,EAAG;AAC3B,cAAM,MAAM,KAAK,GAAG;AACpB,YAAI,IAAK,QAAO;AAChB;AAAA,MACF;AACA,YAAM,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,YAAY,GAAG,CAAC;AAChD,UAAI,CAAC,QAAQ,IAAI,GAAG,KAAK,CAAC,gBAAgB,IAAI,EAAE,IAAI,EAAG;AACvD,UAAI;AACJ,UAAI;AACF,eAAO,aAAa,KAAK,KAAK,GAAG,GAAG,MAAM;AAAA,MAC5C,QAAQ;AACN;AAAA,MACF;AACA,UAAI,CAAC,KAAK,SAAS,YAAY,KAAK,CAAC,KAAK,SAAS,yBAAyB,EAAG;AAC/E,YAAM,YAAY,YAAY,IAAI;AAClC,UAAI,cAAc,KAAM;AACxB,UAAI,UAAU,SAAS,QAAQ,KAAK,eAAe,KAAK,SAAS,EAAG;AACpE,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AACA,SAAO,KAAK,EAAE;AAChB;AAEA,eAAsB,UAAU,MAA+B;AAC7D,QAAM,MAAM,KAAK,SAAS,OAAO,IAAI,KAAK,KAAK,QAAQ,OAAO,IAAI,CAAC,KAAK,QAAQ,IAAI,IAAI,QAAQ,IAAI;AAEpG,QAAM,SAA8D,CAAC;AAErE,QAAM,YAAY,MAAM,gBAAgB,GAAG;AAC3C,SAAO,KAAK,EAAE,MAAM,uBAAuB,UAAU,IAAI,IAAI,IAAI,UAAU,SAAS,aAAa,UAAU,SAAS,QAAQ,CAAC;AAE7H,QAAM,UAAU,KAAK,KAAK,YAAY;AACtC,QAAM,QAAQ,WAAW,OAAO,KAAK,aAAa,SAAS,MAAM,EAAE,SAAS,wBAAwB;AACpG,SAAO,KAAK,EAAE,MAAM,wCAAwC,IAAI,OAAO,MAAM,8BAA8B,CAAC;AAE5G,QAAM,SAAS,KAAK,KAAK,YAAY;AACrC,QAAM,OAAO,WAAW,MAAM,KAAK,aAAa,QAAQ,MAAM,EAAE,SAAS,YAAY;AACrF,SAAO,KAAK,EAAE,MAAM,iCAAiC,IAAI,MAAM,MAAM,iCAAiC,CAAC;AAEvG,MAAI,SAAS;AACb,MAAI,UAAU,OAAO;AACnB,UAAM,YAAY,KAAK,KAAK,UAAU,KAAK;AAC3C,QAAI,WAAW,SAAS,GAAG;AACzB,YAAM,MAAM,aAAa,WAAW,MAAM;AAI1C,eACE,IAAI,SAAS,mBAAmB,MAC/B,IAAI,SAAS,gCAAgC,KAC5C,IAAI,SAAS,yBAAyB;AAAA,IAC5C;AAAA,EACF;AACA,SAAO,KAAK,EAAE,MAAM,qCAAqC,IAAI,QAAQ,MAAM,8BAA8B,CAAC;AAM1G,QAAM,YAAY,iBAAiB,KAAK,UAAU,SAAS,MAAS;AACpE,SAAO,KAAK;AAAA,IACV,MAAM;AAAA,IACN,IAAI,cAAc;AAAA,IAClB,GAAI,YACA;AAAA,MACE,MAAM,qBAAqB,SAAS;AAAA,IACtC,IACA,CAAC;AAAA,EACP,CAAC;AAOD,MAAI,cAAc,MAAM;AACtB,UAAM,eAAe,QAAQ,GAAG;AAChC,WAAO,KAAK;AAAA,MACV,MAAM,8BAA8B,QAAQ;AAAA,MAC5C,IAAI,iBAAiB;AAAA,MACrB,GAAI,eACA;AAAA,QACE,MAAM,UAAU,YAAY,sCAAiC,QAAQ;AAAA,MACvE,IACA,CAAC;AAAA,IACP,CAAC;AAAA,EACH;AAEA,MAAI,aAAa;AACjB,aAAW,KAAK,QAAQ;AACtB,UAAM,OAAO,EAAE,KAAK,MAAM,MAAM,QAAG,IAAI,MAAM,IAAI,QAAG;AACpD,YAAQ,OAAO,MAAM,GAAG,IAAI,IAAI,EAAE,IAAI,GAAG,CAAC,EAAE,MAAM,EAAE,OAAO,MAAM,KAAK,aAAQ,EAAE,IAAI,IAAI,EAAE;AAAA,CAAI;AAC9F,QAAI,CAAC,EAAE,GAAI,cAAa;AAAA,EAC1B;AACA,MAAI,WAAY,SAAQ,WAAW;AACrC;","names":[]}
|