diff --git a/.changeset/major-boxes-juggle.md b/.changeset/major-boxes-juggle.md new file mode 100644 index 0000000..9d5bb49 --- /dev/null +++ b/.changeset/major-boxes-juggle.md @@ -0,0 +1,8 @@ +--- +"emdash": patch +"@emdash-cms/plugin-webhook-notifier": patch +"@emdash-cms/plugin-atproto": patch +"@emdash-cms/plugin-audit-log": patch +--- + +Fixes sandboxed plugin entries failing when package exports point to unbuilt TypeScript source. Adds build-time and bundle-time validation to catch misconfigured plugin exports early. diff --git a/.oxlintrc.json b/.oxlintrc.json index ec77927..ba5f558 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -55,7 +55,7 @@ "typescript/no-unsafe-type-assertion": "off", "typescript/no-unnecessary-type-assertion": "off", "unicorn/consistent-function-scoping": "off", - "e18e(prefer-static-regex)": "off" + "e18e/prefer-static-regex": "off" } }, { diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 8b67635..5a99937 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -14,6 +14,8 @@ import type { MediaProviderDescriptor } from "../../media/types.js"; import { defaultSeed } from "../../seed/default.js"; import type { PluginDescriptor } from "./runtime.js"; +const TS_SOURCE_EXT_RE = /^\.(ts|tsx|mts|cts|jsx)$/; + /** Pattern to remove scoped package prefix from plugin ID */ const SCOPED_PREFIX_PATTERN = /^@[^/]+\/plugin-/; @@ -435,7 +437,17 @@ export const sandboxedPlugins = []; // Resolve the bundle to a file path using project's require context const filePath = resolveModulePathFromProject(bundleSpecifier, projectRoot); - // Read the source code + + const ext = filePath.slice(filePath.lastIndexOf(".")); + if (TS_SOURCE_EXT_RE.test(ext)) { + throw new Error( + `Sandboxed plugin "${descriptor.id}" entrypoint "${bundleSpecifier}" resolves to ` + + `unbuilt source (${filePath}). Sandbox entries must be pre-built JavaScript. ` + + `Ensure the plugin's package.json exports point to built files (e.g. dist/*.mjs) ` + + `and run the plugin's build step before building the site.`, + ); + } + const code = readFileSync(filePath, "utf-8"); // Create the plugin entry with embedded code and sandbox config diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index e2a2674..f9553bf 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -213,6 +213,32 @@ export async function resolveSourceEntry( return undefined; } +// ── Export validation ─────────────────────────────────────────────────────── + +const TS_SOURCE_EXPORT_RE = /\.(?:ts|tsx|mts|cts|jsx)$/; + +/** + * Find package.json exports that point to source files instead of built output. + * Returns an array of `{ exportPath, resolvedPath }` for each offending export. + */ +export function findSourceExports( + exports: Record, +): Array<{ exportPath: string; resolvedPath: string }> { + const issues: Array<{ exportPath: string; resolvedPath: string }> = []; + for (const [exportPath, exportValue] of Object.entries(exports)) { + const resolved = + typeof exportValue === "string" + ? exportValue + : exportValue && typeof exportValue === "object" && "import" in exportValue + ? (exportValue as { import: string }).import + : null; + if (resolved && TS_SOURCE_EXPORT_RE.test(resolved)) { + issues.push({ exportPath, resolvedPath: resolved }); + } + } + return issues; +} + // ── Directory helpers ──────────────────────────────────────────────────────── /** diff --git a/packages/core/src/cli/commands/bundle.ts b/packages/core/src/cli/commands/bundle.ts index 2f10a8a..d7dea7f 100644 --- a/packages/core/src/cli/commands/bundle.ts +++ b/packages/core/src/cli/commands/bundle.ts @@ -27,6 +27,7 @@ import { extractManifest, findNodeBuiltinImports, findBuildOutput, + findSourceExports, resolveSourceEntry, calculateDirectorySize, createTarball, @@ -495,6 +496,20 @@ export const bundleCommand = defineCommand({ consola.start("Validating bundle..."); let hasErrors = false; + // Check that package.json exports point to built files, not source. + // Plugins published to npm with source exports will break site builds + // because the sandbox module generator embeds the resolved file as-is. + if (pkg.exports) { + for (const issue of findSourceExports(pkg.exports as Record)) { + consola.error( + `Export "${issue.exportPath}" points to source (${issue.resolvedPath}). ` + + `Package exports must point to built files (e.g. dist/*.mjs). ` + + `Add a build step and update the exports map.`, + ); + hasErrors = true; + } + } + // Check for Node.js builtins in backend.js const backendPath = join(bundleDir, "backend.js"); if (await fileExists(backendPath)) { diff --git a/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts b/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts index 6d0ec10..217c896 100644 --- a/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts +++ b/packages/core/tests/integration/smoke/site-matrix-smoke.test.ts @@ -181,39 +181,37 @@ async function fetchWithRetry(url: string, retries = 10, delayMs = 1500): Promis } // --------------------------------------------------------------------------- -// Build verification — runs `astro build` for every site to catch adapter -// and bundling errors that dev mode doesn't surface. +// Build verification — runs a single recursive `pnpm build` across all demos +// and templates in parallel, then verifies each site produced output. // --------------------------------------------------------------------------- -describe.sequential("Site build verification", () => { - const BUILD_TIMEOUT = 120_000; +describe("Site build verification", () => { + it("all demos and templates build successfully", { timeout: 300_000 }, async () => { + await ensureBuilt(); - for (const site of SITE_MATRIX) { - if (site.mode === "typecheck") continue; - - it(`${site.name} builds successfully`, { timeout: BUILD_TIMEOUT + 30_000 }, async () => { - await ensureBuilt(); - - try { - await execAsync("pnpm", ["exec", "astro", "build"], { - cwd: site.dir, - timeout: BUILD_TIMEOUT, + try { + await execAsync( + "pnpm", + ["run", "--recursive", "--filter", "{./demos/*}", "--filter", "{./templates/*}", "build"], + { + cwd: WORKSPACE_ROOT, + timeout: 240_000, env: { ...process.env, CI: "true", }, - }); - } catch (error) { - const stderr = - error instanceof Error && "stderr" in error ? (error as { stderr: string }).stderr : ""; - const stdout = - error instanceof Error && "stdout" in error ? (error as { stdout: string }).stdout : ""; - throw new Error(`${site.name} build failed:\n\n${stderr || stdout}`.slice(0, 5000), { - cause: error, - }); - } - }); - } + }, + ); + } catch (error) { + const stderr = + error instanceof Error && "stderr" in error ? (error as { stderr: string }).stderr : ""; + const stdout = + error instanceof Error && "stdout" in error ? (error as { stdout: string }).stdout : ""; + throw new Error(`Site builds failed:\n\n${stderr || stdout}`.slice(0, 5000), { + cause: error, + }); + } + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/tests/unit/astro/virtual-modules-sandbox.test.ts b/packages/core/tests/unit/astro/virtual-modules-sandbox.test.ts new file mode 100644 index 0000000..3097eed --- /dev/null +++ b/packages/core/tests/unit/astro/virtual-modules-sandbox.test.ts @@ -0,0 +1,113 @@ +import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import type { PluginDescriptor } from "../../../src/astro/integration/runtime.js"; +import { generateSandboxedPluginsModule } from "../../../src/astro/integration/virtual-modules.js"; + +function descriptor(overrides: Partial = {}): PluginDescriptor { + return { + id: "test-plugin", + version: "1.0.0", + entrypoint: "@test/plugin/sandbox", + format: "standard", + capabilities: [], + allowedHosts: [], + storage: {}, + adminPages: [], + adminWidgets: [], + ...overrides, + }; +} + +describe("generateSandboxedPluginsModule", () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), "emdash-vm-test-")); + }); + + afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); + }); + + async function setupFakeProject(exportPath: string, content: string) { + // Create a fake project root with package.json + await writeFile(join(tmpDir, "package.json"), JSON.stringify({ name: "test-project" })); + + // Create the plugin package inside node_modules + const pluginDir = join(tmpDir, "node_modules", "@test", "plugin"); + await mkdir(pluginDir, { recursive: true }); + + // Determine the directory for the export file + const fileParts = exportPath.split("/"); + if (fileParts.length > 1) { + const dir = join(pluginDir, ...fileParts.slice(0, -1)); + await mkdir(dir, { recursive: true }); + } + + await writeFile(join(pluginDir, exportPath), content); + await writeFile( + join(pluginDir, "package.json"), + JSON.stringify({ + name: "@test/plugin", + exports: { "./sandbox": `./${exportPath}` }, + }), + ); + } + + it("returns empty module when no plugins configured", () => { + const result = generateSandboxedPluginsModule([], tmpDir); + expect(result).toContain("export const sandboxedPlugins = []"); + }); + + it("embeds pre-built JavaScript successfully", async () => { + await setupFakeProject("dist/sandbox-entry.mjs", "export default { hooks: {} };"); + + const result = generateSandboxedPluginsModule( + [descriptor({ entrypoint: "@test/plugin/sandbox" })], + tmpDir, + ); + + expect(result).toContain("sandboxedPlugins"); + expect(result).toContain("test-plugin"); + expect(result).toContain("export default { hooks: {} };"); + }); + + it("throws for .ts source files", async () => { + await setupFakeProject("src/sandbox-entry.ts", "export default {};"); + + expect(() => + generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir), + ).toThrow(/unbuilt source/); + }); + + it("throws for .tsx source files", async () => { + await setupFakeProject("src/sandbox-entry.tsx", "export default {};"); + + expect(() => + generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir), + ).toThrow(/unbuilt source/); + }); + + it("throws for .mts source files", async () => { + await setupFakeProject("src/sandbox-entry.mts", "export default {};"); + + expect(() => + generateSandboxedPluginsModule([descriptor({ entrypoint: "@test/plugin/sandbox" })], tmpDir), + ).toThrow(/unbuilt source/); + }); + + it("includes plugin id in error message", async () => { + await setupFakeProject("src/sandbox-entry.ts", "export default {};"); + + expect(() => + generateSandboxedPluginsModule( + [descriptor({ id: "my-broken-plugin", entrypoint: "@test/plugin/sandbox" })], + tmpDir, + ), + ).toThrow(/my-broken-plugin/); + }); +}); diff --git a/packages/core/tests/unit/cli/bundle-utils.test.ts b/packages/core/tests/unit/cli/bundle-utils.test.ts index 2b8e9b5..3f50ca5 100644 --- a/packages/core/tests/unit/cli/bundle-utils.test.ts +++ b/packages/core/tests/unit/cli/bundle-utils.test.ts @@ -21,6 +21,7 @@ import { resolveSourceEntry, findNodeBuiltinImports, findBuildOutput, + findSourceExports, } from "../../../src/cli/commands/bundle-utils.js"; import type { ResolvedPlugin } from "../../../src/plugins/types.js"; @@ -298,3 +299,62 @@ describe("findNodeBuiltinImports", () => { expect(findNodeBuiltinImports(code)).toEqual(["fs"]); }); }); + +describe("findSourceExports", () => { + it("flags .ts exports", () => { + const issues = findSourceExports({ ".": "./src/index.ts" }); + expect(issues).toEqual([{ exportPath: ".", resolvedPath: "./src/index.ts" }]); + }); + + it("flags .tsx exports", () => { + const issues = findSourceExports({ "./admin": "./src/admin.tsx" }); + expect(issues).toEqual([{ exportPath: "./admin", resolvedPath: "./src/admin.tsx" }]); + }); + + it("flags .mts exports", () => { + const issues = findSourceExports({ ".": "./src/index.mts" }); + expect(issues).toHaveLength(1); + }); + + it("flags .cts exports", () => { + const issues = findSourceExports({ ".": "./src/index.cts" }); + expect(issues).toHaveLength(1); + }); + + it("flags .jsx exports", () => { + const issues = findSourceExports({ ".": "./src/index.jsx" }); + expect(issues).toHaveLength(1); + }); + + it("accepts .mjs exports", () => { + const issues = findSourceExports({ ".": "./dist/index.mjs" }); + expect(issues).toEqual([]); + }); + + it("accepts .js exports", () => { + const issues = findSourceExports({ ".": "./dist/index.js" }); + expect(issues).toEqual([]); + }); + + it("handles conditional exports with import field", () => { + const issues = findSourceExports({ + ".": { import: "./src/index.ts", types: "./dist/index.d.mts" }, + }); + expect(issues).toEqual([{ exportPath: ".", resolvedPath: "./src/index.ts" }]); + }); + + it("accepts conditional exports pointing to built files", () => { + const issues = findSourceExports({ + ".": { import: "./dist/index.mjs", types: "./dist/index.d.mts" }, + }); + expect(issues).toEqual([]); + }); + + it("flags multiple bad exports", () => { + const issues = findSourceExports({ + ".": "./src/index.ts", + "./sandbox": "./src/sandbox-entry.ts", + }); + expect(issues).toHaveLength(2); + }); +}); diff --git a/packages/plugins/atproto/package.json b/packages/plugins/atproto/package.json index f60e79c..6ffba68 100644 --- a/packages/plugins/atproto/package.json +++ b/packages/plugins/atproto/package.json @@ -3,13 +3,16 @@ "version": "0.1.0", "description": "AT Protocol / standard.site syndication plugin for EmDash CMS", "type": "module", - "main": "src/index.ts", + "main": "dist/index.mjs", "exports": { - ".": "./src/index.ts", - "./sandbox": "./src/sandbox-entry.ts" + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ - "src" + "dist" ], "keywords": [ "emdash", @@ -23,13 +26,17 @@ ], "author": "Matt Kane", "license": "MIT", - "peerDependencies": { + "dependencies": { "emdash": "workspace:*" }, "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:", "vitest": "catalog:" }, "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", + "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "test": "vitest run", "typecheck": "tsgo --noEmit" }, diff --git a/packages/plugins/audit-log/package.json b/packages/plugins/audit-log/package.json index fe3c0cd..7f47893 100644 --- a/packages/plugins/audit-log/package.json +++ b/packages/plugins/audit-log/package.json @@ -3,13 +3,16 @@ "version": "0.1.0", "description": "Audit logging plugin for EmDash CMS - tracks content changes", "type": "module", - "main": "src/index.ts", + "main": "dist/index.mjs", "exports": { - ".": "./src/index.ts", - "./sandbox": "./src/sandbox-entry.ts" + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ - "src" + "dist" ], "keywords": [ "emdash", @@ -21,12 +24,16 @@ ], "author": "Matt Kane", "license": "MIT", - "dependencies": {}, - "peerDependencies": { + "dependencies": { "emdash": "workspace:*" }, - "devDependencies": {}, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", + "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "typecheck": "tsgo --noEmit" }, "optionalDependencies": {}, diff --git a/packages/plugins/webhook-notifier/package.json b/packages/plugins/webhook-notifier/package.json index 0a991c0..8efcc5c 100644 --- a/packages/plugins/webhook-notifier/package.json +++ b/packages/plugins/webhook-notifier/package.json @@ -3,13 +3,16 @@ "version": "0.1.0", "description": "Webhook notification plugin for EmDash CMS - posts to external URLs on content changes", "type": "module", - "main": "src/index.ts", + "main": "dist/index.mjs", "exports": { - ".": "./src/index.ts", - "./sandbox": "./src/sandbox-entry.ts" + ".": { + "import": "./dist/index.mjs", + "types": "./dist/index.d.mts" + }, + "./sandbox": "./dist/sandbox-entry.mjs" }, "files": [ - "src" + "dist" ], "keywords": [ "emdash", @@ -21,14 +24,18 @@ ], "author": "Matt Kane", "license": "MIT", - "peerDependencies": { + "dependencies": { "emdash": "workspace:*" }, - "devDependencies": {}, + "devDependencies": { + "tsdown": "catalog:", + "typescript": "catalog:" + }, "scripts": { + "build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean", + "dev": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --watch", "typecheck": "tsgo --noEmit" }, - "dependencies": {}, "optionalDependencies": {}, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee3a8a5..65158c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1109,6 +1109,12 @@ importers: specifier: workspace:* version: link:../../core devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 vitest: specifier: 'catalog:' version: 4.0.18(@types/node@24.10.13)(@vitest/browser-playwright@4.0.18)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2) @@ -1118,6 +1124,13 @@ importers: emdash: specifier: workspace:* version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 packages/plugins/color: dependencies: @@ -1199,6 +1212,13 @@ importers: emdash: specifier: workspace:* version: link:../../core + devDependencies: + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 packages/x402: dependencies: @@ -15756,7 +15776,7 @@ snapshots: rolldown: 1.0.0-rc.3 rolldown-plugin-dts: 0.22.2(@typescript/native-preview@7.0.0-dev.20260213.1)(oxc-resolver@11.16.4)(rolldown@1.0.0-rc.3)(typescript@5.9.3) semver: 7.7.4 - tinyexec: 1.0.2 + tinyexec: 1.0.4 tinyglobby: 0.2.15 tree-kill: 1.2.2 unconfig-core: 7.4.2