diff --git a/.changeset/fix-json-import-attribute-stripped.md b/.changeset/fix-json-import-attribute-stripped.md new file mode 100644 index 00000000..a4ca1b6f --- /dev/null +++ b/.changeset/fix-json-import-attribute-stripped.md @@ -0,0 +1,19 @@ +--- +"@create-node-app/core": patch +--- + +Fix dependencies declared in a template's or extension's `package.json` being +silently dropped during scaffolding. + +`importIfExists` loaded `.json` files via a dynamic `import()` with a +`with { type: "json" }` attribute. Although valid at the source level, +tsup/esbuild strips the attribute when bundling, so the published `dist` +contained a bare `import("…json")`. On Node >= 20.10 that throws +`ERR_IMPORT_ATTRIBUTE_MISSING`, and the surrounding `try/catch` swallowed it — +so every dependency coming from a static `package.json` (i.e. almost every +extension) vanished from the generated project, breaking type-check/build. + +JSON manifests are now read with `readFileSync` + `JSON.parse`, which is immune +to bundler transforms and works across Node versions. Added a regression test +that asserts dependencies from both `package.json` and `package/index.js` are +merged into the installable result. diff --git a/packages/create-node-app-core/package.ts b/packages/create-node-app-core/package.ts index 0c7a5953..f78c1110 100644 --- a/packages/create-node-app-core/package.ts +++ b/packages/create-node-app-core/package.ts @@ -1,7 +1,6 @@ // Removed unused eslint-disable (global-require) after migration to flat config -import { existsSync } from "fs"; +import { existsSync, readFileSync } from "fs"; import { createRequire } from "node:module"; -import { pathToFileURL } from "node:url"; import merge from "lodash.merge"; import type { TemplateOrExtension } from "./loaders.js"; import { getPackagePath } from "./paths.js"; @@ -37,20 +36,22 @@ const localRequire = createRequire(import.meta.url); /** * Load a module from disk if it exists. Throws if missing. * - * For `.json` files we use dynamic `import()` with the `with { type: "json" }` - * assertion (Node 22+). For `.js`/`.cjs` files we use `createRequire`. This - * avoids the legacy `require()` pattern that only works because of tsup's - * `--shims` build flag. + * For `.json` files we read the file and `JSON.parse` it. We deliberately avoid + * a dynamic `import()` with an import attribute (`with { type: "json" }`): + * although valid at the source level, tsup/esbuild strips the attribute when + * bundling, producing a bare `import("...json")` in the published `dist`. On + * Node >= 20.10 that throws `ERR_IMPORT_ATTRIBUTE_MISSING`, which the callers + * swallow — silently dropping every dependency declared in a template's or + * extension's `package.json`. Reading + parsing is bundler- and Node-agnostic. + * + * For `.js`/`.cjs` files (e.g. `package/index.js`) we use `createRequire`. */ const importIfExists = async (filePath: string): Promise => { if (!existsSync(filePath)) { throw new Error(`File ${filePath} does not exist`); } if (filePath.endsWith(".json")) { - const mod = (await import(pathToFileURL(filePath).href, { - with: { type: "json" }, - })) as { default?: unknown }; - return mod.default ?? mod; + return JSON.parse(readFileSync(filePath, "utf8")); } // Fallback to createRequire for JS/CJS template modules. return localRequire(filePath); diff --git a/packages/create-node-app-core/tests/package.test.mts b/packages/create-node-app-core/tests/package.test.mts new file mode 100644 index 00000000..20cfad50 --- /dev/null +++ b/packages/create-node-app-core/tests/package.test.mts @@ -0,0 +1,88 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { pathToFileURL } from "node:url"; +import { tmpdir } from "node:os"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { loadPackages } from "../package.js"; + +const safeRm = (d: string) => { + try { + rmSync(d, { recursive: true, force: true }); + } catch { + // ignore + } +}; + +/** + * Regression test for the bundler-stripped JSON import attribute. + * + * `importIfExists` used to load `package.json` via a dynamic `import()` with a + * `with { type: "json" }` attribute. tsup/esbuild stripped the attribute in the + * published `dist`, so on Node >= 20.10 the import threw and the surrounding + * `try/catch` swallowed it — silently dropping every dependency declared in a + * template's or extension's `package.json`. + * + * This test asserts that dependencies coming from a static `package.json` + * (extension) and from a `package/index.js` module (template) are both present + * in the merged, installable result. + */ +test("loadPackages merges dependencies from package.json and package/index.js", async () => { + const root = mkdtempSync(path.join(tmpdir(), "cna-loadpkg-")); + try { + // Template exposes its manifest through package/index.js (resolved via require). + const templateDir = path.join(root, "template"); + mkdirSync(path.join(templateDir, "package"), { recursive: true }); + writeFileSync( + path.join(templateDir, "package", "index.js"), + `module.exports = function resolvePackage(_setup, { appName }) { + return { + name: appName, + scripts: { build: "tsc" }, + dependencies: { react: "^19.0.0" }, + devDependencies: { typescript: "^5.0.0" }, + }; + };\n`, + ); + + // Extension declares its dependencies through a static package.json. + const extensionDir = path.join(root, "extension"); + mkdirSync(extensionDir, { recursive: true }); + writeFileSync( + path.join(extensionDir, "package.json"), + JSON.stringify({ + name: "example-extension", + dependencies: { "@tanstack/react-query": "^5.0.0" }, + devDependencies: { vitest: "^3.0.0" }, + }), + ); + + const result = (await loadPackages({ + appName: "my-app", + templatesOrExtensions: [ + { url: pathToFileURL(templateDir).toString() }, + { url: pathToFileURL(extensionDir).toString() }, + ], + })) as { dependencies: string[]; devDependencies: string[] }; + + assert.ok( + result.dependencies.includes("react@^19.0.0"), + "template dependency should be present", + ); + assert.ok( + result.dependencies.includes("@tanstack/react-query@^5.0.0"), + "extension dependency from package.json should be merged (regression)", + ); + assert.ok( + result.devDependencies.includes("typescript@^5.0.0"), + "template devDependency should be present", + ); + assert.ok( + result.devDependencies.includes("vitest@^3.0.0"), + "extension devDependency from package.json should be merged (regression)", + ); + } finally { + safeRm(root); + } +});