Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1d3b74b
fix(create-nextly-app): validate --template against the template regi…
aqib-rx Jul 23, 2026
83fb16a
fix(create-nextly-app): skip app-shaped project detection for plugin …
aqib-rx Jul 23, 2026
9993cf4
fix(create-nextly-app): materialize dev/.env when scaffolding a plugin
aqib-rx Jul 23, 2026
1bf5c59
fix(create-nextly-app): wrap the plugin playground admin in QueryProv…
aqib-rx Jul 23, 2026
4a8587c
fix(create-nextly-app): update the plugin template test to the curren…
aqib-rx Jul 23, 2026
94a866a
feat(create-nextly-app): seed the dev auto-login user in the plugin p…
aqib-rx Jul 23, 2026
35585e0
fix(create-nextly-app): scaffold plugins from the bundled template
aqib-rx Jul 23, 2026
e376185
chore(release): add changeset for the plugin template fixes
aqib-rx Jul 23, 2026
4b3c8a0
fix(create-nextly-app): keep the latest dist-tag out of plugin peer d…
aqib-rx Jul 23, 2026
9e7f5dc
fix(create-nextly-app): close the super-admin seeding race in the plu…
aqib-rx Jul 23, 2026
b4c8313
fix(create-nextly-app): honor a non-main --branch for bundled templates
aqib-rx Jul 23, 2026
4913601
fix(create-nextly-app): restrict playground credential seeding to nex…
aqib-rx Jul 23, 2026
a042580
docs(create-nextly-app): explain the direct api usage in the plugin t…
aqib-rx Jul 23, 2026
ed4ccb6
fix(create-nextly-app): yalc-link plugin-sdk for plugin scaffolds
aqib-rx Jul 23, 2026
532c100
chore(release): add admin-css to the plugin template changeset
aqib-rx Jul 23, 2026
5869983
Merge remote-tracking branch 'origin/main' into fix/plugin-template-s…
muzzamil-rx Jul 27, 2026
fb9b7bb
Merge remote-tracking branch 'origin/main' into fix/plugin-template-s…
aqib-rx Jul 28, 2026
ebc7d2c
chore(release): add blocks-engine and plugin-seo to the plugin templa…
aqib-rx Jul 28, 2026
1cd8634
fix(create-nextly-app): harden plugin scaffolds against packing and c…
aqib-rx Jul 28, 2026
fe1320f
fix(create-nextly-app): pin yalc plugin scaffolds' nested resolutions…
aqib-rx Jul 28, 2026
ef7c789
fix(create-nextly-app): scaffold plugins from inside an existing next…
aqib-rx Jul 28, 2026
a369327
Merge remote-tracking branch 'origin/main' into fix/plugin-template-s…
aqib-rx Jul 29, 2026
0ec3970
fix(create-nextly-app): merge restored gitignores and validate regist…
aqib-rx Jul 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/plugin-template-scaffold-fixes.md
Original file line number Diff line number Diff line change
@@ -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
Comment thread
aqib-rx marked this conversation as resolved.
Comment thread
aqib-rx marked this conversation as resolved.
---

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.
99 changes: 98 additions & 1 deletion packages/create-nextly-app/src/__tests__/scaffold-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
import { cp, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -77,6 +77,19 @@ 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. Semver ranges start with a
// digit or range operator; dist-tag names start with a letter.
for (const [peer, spec] of Object.entries(
pkg.peerDependencies as Record<string, string>
)) {
expect(spec, `peerDependencies.${peer}`).toMatch(/^[\d^~><=*]/);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
// 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.
Expand All @@ -89,6 +102,55 @@ describe("scaffold --template plugin (D44/D45 smoke test)", () => {
expect(workspaceYaml).toContain("allowBuilds:");
expect(workspaceYaml).toContain("better-sqlite3");

// 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(
path.join(target, "src/plugin.ts"),
Expand All @@ -104,4 +166,39 @@ describe("scaffold --template plugin (D44/D45 smoke test)", () => {
expect(devConfig).toContain('"@acme/nextly-plugin-test"');
expect(devConfig).not.toMatch(/\{\{\s*\w+\s*\}\}/);
});

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);
});
});
77 changes: 70 additions & 7 deletions packages/create-nextly-app/src/__tests__/template-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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"));
});
});

Expand Down Expand Up @@ -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<string, string> = {
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
Expand Down Expand Up @@ -102,4 +148,21 @@ 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("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");
});
});
31 changes: 31 additions & 0 deletions packages/create-nextly-app/src/__tests__/templates.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
getAvailableTemplateNames,
getTemplate,
shouldUseBundledTemplate,
templateHasApproaches,
templateHasDemoData,
} from "../lib/templates";
Expand Down Expand Up @@ -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);
});
});
18 changes: 12 additions & 6 deletions packages/create-nextly-app/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -33,11 +34,13 @@ program
"-y, --yes",
"Skip prompts and use defaults (blank template, SQLite, local storage)"
)
.option("-t, --template <template>", "Project template (blank, blog)")
.option(
"-a, --approach <approach>",
"Schema approach (code-first, visual)"
"-t, --template <template>",
// Derive the list from the registry so --help stays in sync with what
// validation accepts as new templates are registered.
`Project template (${getAvailableTemplateNames().join(", ")})`
)
.option("-a, --approach <approach>", "Schema approach (code-first, visual)")
.option("-d, --database <db>", "Database type (sqlite, postgresql, mysql)")
.option("-b, --branch <branch>", "Git branch for template download", "main")
.option(
Expand Down Expand Up @@ -93,15 +96,18 @@ Examples:
if (projectName) {
const nameError = validateProjectName(projectName);
if (nameError) {
console.error(`Error: Invalid project name '${projectName}'. ${nameError}.`);
console.error(
`Error: Invalid project name '${projectName}'. ${nameError}.`
);
process.exit(1);
}
}

const projectType = options.template as ProjectType | undefined;

// Validate --template flag
const validTypes = ["blank", "blog"];
// Validate --template flag against the template registry so newly
// registered templates are accepted without touching this file.
const validTypes = getAvailableTemplateNames();
if (projectType && !validTypes.includes(projectType)) {
console.error(
`Error: Template '${projectType}' is not available. Use: ${validTypes.join(", ")}`
Expand Down
Loading
Loading