diff --git a/.changeset/plugin-template-scaffold-fixes.md b/.changeset/plugin-template-scaffold-fixes.md new file mode 100644 index 000000000..7cc133a6d --- /dev/null +++ b/.changeset/plugin-template-scaffold-fixes.md @@ -0,0 +1,30 @@ +--- +"@nextlyhq/adapter-drizzle": patch +"@nextlyhq/adapter-mysql": patch +"@nextlyhq/adapter-postgres": patch +"@nextlyhq/adapter-sqlite": patch +"@nextlyhq/admin": patch +"@nextlyhq/admin-css": patch +"@nextlyhq/blocks-engine": patch +"@nextlyhq/plugin-seo": patch +"create-nextly-app": patch +"@nextlyhq/eslint-config": patch +"nextly": patch +"@nextlyhq/plugin-form-builder": patch +"@nextlyhq/plugin-page-builder": patch +"@nextlyhq/plugin-sdk": patch +"@nextlyhq/prettier-config": patch +"@nextlyhq/storage-s3": patch +"@nextlyhq/storage-uploadthing": patch +"@nextlyhq/storage-vercel-blob": patch +"@nextlyhq/telemetry": patch +"@nextlyhq/tsconfig": patch +"@nextlyhq/ui": patch +--- + +The Plugin template now works end to end. `--template plugin` is accepted, the +scaffold no longer aborts with "App Router not detected", the dev playground's +env file is created automatically, `/admin` renders (QueryProvider) and lands +already logged in as the seeded dev user, the generated test suite matches the +current plugin-sdk API, and plugin scaffolds use the template bundled with the +CLI so it can no longer drift ahead of the published packages. diff --git a/packages/create-nextly-app/src/__tests__/scaffold-plugin.test.ts b/packages/create-nextly-app/src/__tests__/scaffold-plugin.test.ts index af3398674..9154b47f5 100644 --- a/packages/create-nextly-app/src/__tests__/scaffold-plugin.test.ts +++ b/packages/create-nextly-app/src/__tests__/scaffold-plugin.test.ts @@ -1,4 +1,13 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { + cp, + mkdir, + mkdtemp, + readFile, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -77,6 +86,23 @@ describe("scaffold --template plugin (D44/D45 smoke test)", () => { expect(pkg.files).toEqual(["dist"]); expect(pkg.keywords).toContain("nextly-plugin"); expect(pkg.scripts.dev).toContain("next dev dev"); + + // peerDependencies must never carry a dist-tag name (latest, alpha, …): + // pnpm 11 rejects non-semver peer specs + // (ERR_PNPM_INVALID_PEER_DEPENDENCY_SPECIFICATION) and then refuses to + // run any script in the scaffold. This test runs with the registry + // stubbed offline, so every version lookup exercises the fallback path — + // exactly where a dist-tag used to leak in. The generator only ever + // emits `^x.y.z(-prerelease)?` or the open `>=x.y.z` fallback, so the + // anchored pattern validates the complete spec — partial matches would + // wave through hybrids like `^latest` or `>=alpha`. + for (const [peer, spec] of Object.entries( + pkg.peerDependencies as Record + )) { + expect(spec, `peerDependencies.${peer}`).toMatch( + /^(?:\^|>=)\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/ + ); + } // The native-build allowlist lives in pnpm-workspace.yaml, NOT the package.json // `pnpm` field (pnpm 11 ignores that field). Without this, `pnpm install` aborts // on better-sqlite3 (the dev playground's native dep) with ERR_PNPM_IGNORED_BUILDS. @@ -88,6 +114,58 @@ describe("scaffold --template plugin (D44/D45 smoke test)", () => { ); expect(workspaceYaml).toContain("allowBuilds:"); expect(workspaceYaml).toContain("better-sqlite3"); + // Registry scaffolds resolve everything from npm — the yalc override + // block must never leak into them. + expect(workspaceYaml).not.toContain("overrides:"); + + // The dev playground must boot with zero manual steps: without dev/.env + // the dialect defaults to postgresql and `next dev` aborts in the + // instrumentation hook asking for DATABASE_URL. The scaffold + // materializes the committed example env into the real one. + expect(await exists(path.join(target, "dev/.env"))).toBe(true); + const devEnv = await readFile(path.join(target, "dev/.env"), "utf-8"); + expect(devEnv).toContain("DB_DIALECT=sqlite"); + + // The ignore rules must land — the materialized dev/.env (dev + // credentials) is only safe because .gitignore excludes it. + expect(await exists(path.join(target, ".gitignore"))).toBe(true); + + // /admin must render through QueryProvider — the admin's data hooks + // resolve their QueryClient from it, and mounting RootLayout without it + // crashes the page on first load ("No QueryClient set"). + const adminPage = await readFile( + path.join(target, "dev/src/app/admin/[[...params]]/page.tsx"), + "utf-8" + ); + expect(adminPage).toContain("QueryProvider"); + expect(adminPage).toContain("ErrorBoundary"); + + // The generated test must match the current harness + Direct API: the + // harness applies plugin schema contributions itself (passing them again + // via `collections` is a slug collision), and CRUD methods take a single + // args object (`create({ collection, data })`, `findByID({ ... })`). + const pluginTest = await readFile( + path.join(target, "src/plugin.test.ts"), + "utf-8" + ); + expect(pluginTest).not.toContain("contributes?.collections"); + expect(pluginTest).toContain("findByID({"); + expect(pluginTest).toContain("create({"); + + // The playground seeds the auto-login user at boot; without it the first + // /admin visit dead-ends on the setup wizard despite devAutoLogin. The + // seed must run twice: the runtime's background permission seeding races + // the first pass, and the second pass (role-exists path) tops up any + // permissions created in between so the role is complete either way. + const instrumentation = await readFile( + path.join(target, "dev/instrumentation.ts"), + "utf-8" + ); + expect(instrumentation).toContain("seedSuperAdmin"); + expect(instrumentation.match(/await seedDevUser\(\)/g)).toHaveLength(2); + // Credential seeding must be locked to `next dev` — a broader guard + // (e.g. !== "production") would also seed under NODE_ENV=test. + expect(instrumentation).toContain('process.env.NODE_ENV === "development"'); // Placeholders are replaced everywhere (no leftover {{ ... }} tokens). const pluginSrc = await readFile( @@ -104,4 +182,116 @@ describe("scaffold --template plugin (D44/D45 smoke test)", () => { expect(devConfig).toContain('"@acme/nextly-plugin-test"'); expect(devConfig).not.toMatch(/\{\{\s*\w+\s*\}\}/); }); + + it("pins yalc scaffolds' nested resolutions to the local store", async () => { + workdir = await mkdtemp(path.join(tmpdir(), "nextly-plugin-yalc-")); + target = path.join(workdir, "my-plugin"); + + await copyTemplate({ + projectName: "yalc-plugin", + projectType: "plugin", + targetDir: target, + database: { type: "sqlite" } as unknown as DatabaseConfig, + useYalc: true, + templateSource: { + basePath: path.join(templatesRoot, "base"), + templatePath: path.join(templatesRoot, "plugin"), + }, + }); + + // Yalc rewrites workspace:* ranges to *, which semver treats as + // stable-only — without an overrides pin, pnpm resolves the packages' + // nested copies of each other from the registry's last stable release + // and the mixed versions crash at runtime. + const workspaceYaml = await readFile( + path.join(target, "pnpm-workspace.yaml"), + "utf-8" + ); + expect(workspaceYaml).toContain("overrides:"); + expect(workspaceYaml).toContain('"nextly": "file:.yalc/nextly"'); + expect(workspaceYaml).toContain( + '"@nextlyhq/plugin-sdk": "file:.yalc/@nextlyhq/plugin-sdk"' + ); + expect(workspaceYaml).toContain( + '"@nextlyhq/adapter-drizzle": "file:.yalc/@nextlyhq/adapter-drizzle"' + ); + }); + + it("restores .gitignore from the publish-safe bundled name", async () => { + workdir = await mkdtemp(path.join(tmpdir(), "nextly-plugin-packed-")); + target = path.join(workdir, "my-plugin"); + + // Simulate the layout the PUBLISHED CLI carries: npm's packer strips + // dotted .gitignore files from tarballs, so the build stores them as + // `gitignore` (see tsup.config.ts) and the scaffolder renames them back. + const packedTemplate = path.join(workdir, "packed-template"); + await cp(path.join(templatesRoot, "plugin"), packedTemplate, { + recursive: true, + }); + await rename( + path.join(packedTemplate, ".gitignore"), + path.join(packedTemplate, "gitignore") + ); + + await copyTemplate({ + projectName: "packed-plugin", + projectType: "plugin", + targetDir: target, + database: { type: "sqlite" } as unknown as DatabaseConfig, + templateSource: { + basePath: path.join(templatesRoot, "base"), + templatePath: packedTemplate, + }, + }); + + // The dotted file is restored with its rules intact, and the transport + // name does not leak into the scaffold. + const gitignore = await readFile(path.join(target, ".gitignore"), "utf-8"); + expect(gitignore).toContain("node_modules"); + expect(gitignore).toContain(".env"); + expect(await exists(path.join(target, "gitignore"))).toBe(false); + }); + + it("merges into a pre-existing .gitignore instead of replacing it", async () => { + workdir = await mkdtemp(path.join(tmpdir(), "nextly-plugin-overlay-")); + target = path.join(workdir, "my-plugin"); + + const packedTemplate = path.join(workdir, "packed-template"); + await cp(path.join(templatesRoot, "plugin"), packedTemplate, { + recursive: true, + }); + await rename( + path.join(packedTemplate, ".gitignore"), + path.join(packedTemplate, "gitignore") + ); + + // Overlay scenario: the user chose "ignore" on a non-empty directory + // that already carries their own ignore rules. Restoration must keep + // them — a rename would silently un-ignore whatever they covered. + await mkdir(target, { recursive: true }); + await writeFile( + path.join(target, ".gitignore"), + "# user rules\nmy-secret.txt\n", + "utf-8" + ); + + await copyTemplate({ + projectName: "overlay-plugin", + projectType: "plugin", + targetDir: target, + database: { type: "sqlite" } as unknown as DatabaseConfig, + allowExistingTarget: true, + templateSource: { + basePath: path.join(templatesRoot, "base"), + templatePath: packedTemplate, + }, + }); + + const gitignore = await readFile(path.join(target, ".gitignore"), "utf-8"); + // User rules survive AND the template's required rules (dev/.env!) land. + expect(gitignore).toContain("my-secret.txt"); + expect(gitignore).toContain("node_modules"); + expect(gitignore).toContain(".env"); + expect(await exists(path.join(target, "gitignore"))).toBe(false); + }); }); diff --git a/packages/create-nextly-app/src/__tests__/template-channel.test.ts b/packages/create-nextly-app/src/__tests__/template-channel.test.ts index c676b5261..f2585e2c4 100644 --- a/packages/create-nextly-app/src/__tests__/template-channel.test.ts +++ b/packages/create-nextly-app/src/__tests__/template-channel.test.ts @@ -1,10 +1,12 @@ /** - * Tests for the content-template npm dist-tag channel. + * Tests for the per-template npm dist-tag channel. * - * Content templates (blog) render CMS content through `nextly/runtime` cache - * helpers, which ship on the active `alpha` channel; the conservative `latest` - * tag can lag behind it during the alpha. So a blog scaffold must pin nextly + - * @nextlyhq/* to `alpha`, while blank/plugin stay on `latest`. + * Train-coupled templates pin nextly + @nextlyhq/* to `alpha`: blog renders + * through `nextly/runtime` cache helpers that ship on the active alpha + * channel, and plugin ships bundled with the CLI so its generated test and + * dev playground exercise current plugin-sdk/admin APIs. The conservative + * `latest` tag can lag the train during the alpha, so only blank stays on + * `latest`. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -26,10 +28,18 @@ const LATEST = "0.0.2-alpha.37"; const ALPHA = "0.0.2-alpha.42"; describe("templateNextlyChannel", () => { - it("routes content templates to alpha and everything else to latest", () => { + it("routes content templates to alpha and blank to latest", () => { expect(templateNextlyChannel("blog")).toBe("alpha"); expect(templateNextlyChannel("blank")).toBe("latest"); - expect(templateNextlyChannel("plugin")).toBe("latest"); + }); + + it("keeps plugin on the same channel as the other train-coupled templates", () => { + // The plugin template ships bundled inside the CLI, so its scaffolds + // must install from the same release train the CLI was published on — + // whichever dist-tag that is. Asserted relationally (not against a + // literal tag) so this test survives channel renames when the alpha + // phase ends. + expect(templateNextlyChannel("plugin")).toBe(templateNextlyChannel("blog")); }); }); @@ -64,6 +74,42 @@ describe("content-template dist-tag pinning", () => { expect(pkg.dependencies["nextly"]).toBe(`^${LATEST}`); expect(pkg.dependencies["@nextlyhq/admin"]).toBe(`^${LATEST}`); }); + + it("pins the plugin scaffold's nextly family to its template channel", async () => { + // Expected version derived from the channel mapping (not a hardcoded + // tag), so the assertion keeps holding when the channel changes. + const tagVersions: Record = { + latest: LATEST, + alpha: ALPHA, + }; + const expected = `^${tagVersions[templateNextlyChannel("plugin")]}`; + + const pkg = JSON.parse( + await generatePackageJson("my-plugin", pgDatabase, false, "plugin") + ); + // The template ships with the CLI, so its devDeps and the declared peer + // compat range must come from the CLI's own release train. + expect(pkg.devDependencies["nextly"]).toBe(expected); + expect(pkg.devDependencies["@nextlyhq/plugin-sdk"]).toBe(expected); + expect(pkg.peerDependencies["nextly"]).toBe(expected); + }); + + it("omits the nextly family from plugin devDeps under --use-yalc", async () => { + const pkg = JSON.parse( + await generatePackageJson("my-plugin", pgDatabase, true, "plugin") + ); + // The installer yalc-adds these before the first install; registry specs + // here would race it into fetching published packages it is about to + // replace. Non-nextly tooling stays. + for (const dep of Object.keys(pkg.devDependencies)) { + expect(dep, "yalc plugin devDep").not.toMatch(/^nextly$|^@nextlyhq\//); + } + expect(pkg.devDependencies["tsup"]).toBeDefined(); + expect(pkg.devDependencies["vitest"]).toBeDefined(); + // Peers stay declared (publish metadata) but with a valid semver range, + // never a dist-tag name pnpm would reject. + expect(pkg.peerDependencies["nextly"]).toBe(">=0.0.0"); + }); }); // Reset modules per test so the per-channel version cache never masks the @@ -102,4 +148,44 @@ describe("alpha pin survives a registry lookup failure", () => { const pkg = JSON.parse(await gen("blog-app", pgDatabase, false, "blog")); expect(pkg.dependencies["nextly"]).toBe("alpha"); }); + + it("rejects a malformed registry version instead of wrapping it", async () => { + // The registry payload is untrusted: a dist-tag pointing at a + // non-semver value must fall back to the tag name (a valid npm spec), + // never become an invalid "^not-a-version" install spec. + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ latest: "not-a-version", alpha: "also bad" }), + })) + ); + const { generatePackageJson: gen, templateNextlyChannel: channelOf } = + await import("../utils/template"); + const pkg = JSON.parse(await gen("my-plugin", pgDatabase, false, "plugin")); + expect(pkg.devDependencies["nextly"]).toBe(channelOf("plugin")); + expect(pkg.peerDependencies["nextly"]).toBe(">=0.0.0"); + + const blogPkg = JSON.parse( + await gen("blog-app", pgDatabase, false, "blog") + ); + expect(blogPkg.dependencies["nextly"]).toBe(channelOf("blog")); + }); + + it("keeps plugin devDeps on its channel tag but peers on a semver range when the registry fails", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, json: async () => ({}) })) + ); + const { generatePackageJson: gen, templateNextlyChannel: channelOf } = + await import("../utils/template"); + const pkg = JSON.parse(await gen("my-plugin", pgDatabase, false, "plugin")); + // devDeps may carry the dist-tag name (a valid install spec) — asserted + // via the channel mapping, not a literal tag... + expect(pkg.devDependencies["nextly"]).toBe(channelOf("plugin")); + // ...but peerDependencies must stay a semver range: pnpm 11 rejects a + // dist-tag there and then refuses to run any script in the scaffold. + expect(pkg.peerDependencies["nextly"]).toBe(">=0.0.0"); + expect(pkg.peerDependencies["@nextlyhq/plugin-sdk"]).toBe(">=0.0.0"); + }); }); diff --git a/packages/create-nextly-app/src/__tests__/templates.test.ts b/packages/create-nextly-app/src/__tests__/templates.test.ts index e7875f8e0..0ad7aa704 100644 --- a/packages/create-nextly-app/src/__tests__/templates.test.ts +++ b/packages/create-nextly-app/src/__tests__/templates.test.ts @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { getAvailableTemplateNames, getTemplate, + shouldUseBundledTemplate, templateHasApproaches, templateHasDemoData, } from "../lib/templates"; @@ -80,3 +81,33 @@ describe("plugin package.json generation (D44/D45)", () => { expect(pkg.dependencies).toBeUndefined(); }); }); + +describe("shouldUseBundledTemplate", () => { + it("uses the bundled copy for blank and plugin by default", () => { + expect(shouldUseBundledTemplate("blank", {})).toBe(true); + expect(shouldUseBundledTemplate("plugin", {})).toBe(true); + // The CLI always passes commander's default branch; "main" must not + // force a download on its own. + expect(shouldUseBundledTemplate("blank", { branch: "main" })).toBe(true); + expect(shouldUseBundledTemplate("plugin", { branch: "main" })).toBe(true); + }); + + it("always resolves content templates live", () => { + expect(shouldUseBundledTemplate("blog", {})).toBe(false); + }); + + it("forces live resolution for every explicit source override", () => { + // A user passing --branch expects that branch's template, not the + // bundled copy silently substituted for it. + expect(shouldUseBundledTemplate("blank", { branch: "release" })).toBe( + false + ); + expect(shouldUseBundledTemplate("plugin", { branch: "release" })).toBe( + false + ); + expect( + shouldUseBundledTemplate("blank", { localTemplatePath: "/tmp/t" }) + ).toBe(false); + expect(shouldUseBundledTemplate("plugin", { useYalc: true })).toBe(false); + }); +}); diff --git a/packages/create-nextly-app/src/cli.ts b/packages/create-nextly-app/src/cli.ts index 5b174468d..dfc73af9a 100644 --- a/packages/create-nextly-app/src/cli.ts +++ b/packages/create-nextly-app/src/cli.ts @@ -5,6 +5,7 @@ import { Command } from "commander"; import { resolveProjectArg, validateProjectName } from "./cli-args"; import { createNextly } from "./create-nextly"; +import { getAvailableTemplateNames } from "./lib/templates"; import type { DatabaseType, ProjectApproach, ProjectType } from "./types"; // Static require for package.json. Works because tsup adds the createRequire @@ -33,11 +34,13 @@ program "-y, --yes", "Skip prompts and use defaults (blank template, SQLite, local storage)" ) - .option("-t, --template