kizlo 0.8.1 → 0.8.3

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.
@@ -321,11 +321,11 @@ async function syncRemote(conn) {
321
321
  });
322
322
  if (!sync.ok) p.log.warn(`Could not sync the site settings to WordPress (${sync.error}) — make sure the kizlo plugin is active, then set them from the Kizlo settings.`);
323
323
  }
324
- /** The shared "Next steps" note, branched on whether a local dev stack was set up. */
325
- function nextStepsNote(mode, prefix = "") {
326
- p.note([
324
+ /** The "Next steps" lines, branched on whether a local dev stack was set up. */
325
+ function nextStepsLines(mode, prefix = "") {
326
+ return [
327
327
  ...mode === "local" ? [
328
- `Start your local WordPress dev stack (also watches your extensions):`,
328
+ `Start your local WordPress dev stack:`,
329
329
  ` ${prefix}npx kizlo dev`,
330
330
  ``
331
331
  ] : [
@@ -335,8 +335,12 @@ function nextStepsNote(mode, prefix = "") {
335
335
  ],
336
336
  `Generate the contract once for production builds:`,
337
337
  ` ${prefix}npx kizlo generate`
338
- ].join("\n"), "Next steps");
338
+ ];
339
+ }
340
+ /** The shared "Next steps" note, branched on whether a local dev stack was set up. */
341
+ function nextStepsNote(mode, prefix = "") {
342
+ p.note(nextStepsLines(mode, prefix).join("\n"), "Next steps");
339
343
  }
340
344
 
341
345
  //#endregion
342
- export { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv };
346
+ export { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsLines, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv };
@@ -0,0 +1,116 @@
1
+ import { resolveModuleImport, writeFileIfAbsent } from "../utils.js";
2
+ import { applyPatchToSource, patchChanged, renderPatchCode } from "../presets/patch.js";
3
+ import { findRootLayout, patchEntries, resolvePatch } from "../presets/template.js";
4
+ import { orCancel } from "./_setup.js";
5
+ import { CONTRACT_BARREL } from "../daemon/generate.js";
6
+ import path from "node:path";
7
+ import fs from "node:fs";
8
+ import * as p from "@clack/prompts";
9
+
10
+ //#region src/cli/commands/_wiring.ts
11
+ /** The kizlo.config.ts a scaffolded project gets: the Kizlo directory, optional import alias, and —
12
+ * for a local dev setup — the WordPress install folder so `kizlo dev` knows where it lives. */
13
+ function kizloConfigTemplate(dir, alias, devPath) {
14
+ return `import { defineConfig } from "kizlo/config"
15
+
16
+ export default defineConfig({
17
+ dir: "${dir}",${alias ? `\n\talias: "${alias}",` : ""}${devPath ? `\n\tdev: { path: "${devPath}" },` : ""}
18
+ })
19
+ `;
20
+ }
21
+ /**
22
+ * Build the {@link ScaffoldContext} that adapts a template's tokenized paths and imports to a real
23
+ * project. `dirRel` is the Kizlo home directory, `appDir` the App Router directory, and `alias` the
24
+ * import-alias prefix (empty for relative imports). The server-entry import is resolved per calling
25
+ * file so each scaffolded file references the server through the right specifier.
26
+ */
27
+ function buildScaffoldContext(cwd, { dirRel, appDir, alias, clientUrl }) {
28
+ const serverDirRel = path.join(dirRel, "server");
29
+ return {
30
+ kizloDir: dirRel,
31
+ serverDirName: path.basename(serverDirRel),
32
+ serverEntryPath: path.join(serverDirRel, "index.ts"),
33
+ clientPath: path.join(dirRel, "client.ts"),
34
+ appDir,
35
+ serverImport: (fromDir) => resolveModuleImport(cwd, serverDirRel, fromDir, alias),
36
+ clientUrl
37
+ };
38
+ }
39
+ /**
40
+ * The single overwrite policy for every scaffolded file: create it when absent, overwrite on
41
+ * `--force`, and otherwise ask before clobbering an existing file (keeping it when the user
42
+ * declines or when running non-interactively with `--yes`). Every scaffolded file routes through
43
+ * here rather than deciding for itself, so new files inherit the same behavior for free.
44
+ */
45
+ async function scaffoldFile(cwd, file, opts) {
46
+ const absPath = path.join(cwd, file.relPath);
47
+ const existed = fs.existsSync(absPath);
48
+ if (existed) {
49
+ let overwrite = opts.force;
50
+ if (!opts.force && !opts.yes) {
51
+ p.log.warn(`${file.label} already exists at ${file.relPath}`);
52
+ overwrite = orCancel(await p.confirm({
53
+ message: "Overwrite it?",
54
+ initialValue: true
55
+ }));
56
+ }
57
+ if (!overwrite) return "kept";
58
+ }
59
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
60
+ fs.writeFileSync(absPath, file.contents);
61
+ return existed ? "overwritten" : "created";
62
+ }
63
+ /** Report a {@link scaffoldFile} outcome in the setup commands' usual voice. */
64
+ function reportScaffold(file, result, yes) {
65
+ if (result === "kept") p.log.info(`Kept existing ${file.label} (${file.relPath})${yes ? " — pass --force to overwrite" : ""}`);
66
+ else p.log.success(`${result === "overwritten" ? "Overwrote" : "Created"} ${file.label} (${file.relPath})`);
67
+ }
68
+ /** Seed the generated-contract directory so imports resolve before the first `kizlo watch`/`generate`. */
69
+ function writeGeneratedContract(cwd, serverDirRel) {
70
+ const generatedDirRel = path.join(serverDirRel, "generated");
71
+ writeFileIfAbsent(path.join(cwd, generatedDirRel, "contract.json"), "{}\n");
72
+ writeFileIfAbsent(path.join(cwd, generatedDirRel, "index.ts"), CONTRACT_BARREL);
73
+ }
74
+ /**
75
+ * Merge Kizlo wiring into files the project already owns (the root layout's SEO exports). Never
76
+ * aborts: the target is resolved by identity (the layout that renders `<html>`), and on any doubt —
77
+ * not found, not parseable — the resolved payload is printed at the end with placement instructions
78
+ * rather than written to a guessed-at file. A confident apply is an idempotent upsert: it replaces
79
+ * our exports if present, adds them if not.
80
+ */
81
+ function applyLayoutPatches(cwd, manifest, scaffold) {
82
+ const manualSteps = [];
83
+ for (const entry of patchEntries(manifest)) {
84
+ const resolved = resolvePatch(entry, manifest.tokens, scaffold);
85
+ const target = findRootLayout(cwd, scaffold.appDir, resolved.relPath);
86
+ if (!target) {
87
+ manualSteps.push(resolved);
88
+ p.log.info(`Couldn't find your ${resolved.label} to wire Kizlo into — see the code to add below`);
89
+ continue;
90
+ }
91
+ const relTarget = path.relative(cwd, target);
92
+ const src = fs.readFileSync(target, "utf8");
93
+ let applied;
94
+ try {
95
+ applied = applyPatchToSource(src, resolved);
96
+ } catch {
97
+ manualSteps.push(resolved);
98
+ p.log.warn(`Couldn't parse your ${resolved.label} (${relTarget}) — see the code to add below`);
99
+ continue;
100
+ }
101
+ const { text, changes } = applied;
102
+ if (patchChanged(changes)) {
103
+ fs.writeFileSync(target, text);
104
+ const parts = [
105
+ ...changes.replacedExports.length ? [`replaced ${changes.replacedExports.join(", ")}`] : [],
106
+ ...changes.addedExports.length ? [`added ${changes.addedExports.join(", ")}`] : [],
107
+ ...changes.addedImports.length ? ["imports"] : []
108
+ ];
109
+ p.log.success(`Wired Kizlo into your ${resolved.label} (${relTarget}) — ${parts.join(", ")}`);
110
+ } else p.log.info(`Your ${resolved.label} is already wired (${relTarget})`);
111
+ }
112
+ for (const resolved of manualSteps) p.note(renderPatchCode(resolved), `Add these to your ${resolved.label} (the layout that renders <html>)`);
113
+ }
114
+
115
+ //#endregion
116
+ export { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract };
@@ -1,65 +1,90 @@
1
1
  import { dockerAvailable } from "../wp/docker.js";
2
- import { detectPackageManager, getVersion } from "../utils.js";
2
+ import { availablePackageManagers, detectInvokingPackageManager, ensureGitignored, frameworkCreateArgs, getVersion, installArgs, runCommandAsync, runCommandCaptured } from "../utils.js";
3
3
  import { printBanner } from "../banner.js";
4
4
  import { getPreset } from "../presets/index.js";
5
- import { collectConnectionInteractively, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
5
+ import { fetchTemplate } from "../presets/source.js";
6
+ import { adaptOwnFile, ownEntries, readManifest } from "../presets/template.js";
7
+ import { collectConnectionInteractively, nextStepsLines, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
8
+ import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
6
9
  import z from "zod/v4";
7
10
  import path from "node:path";
8
11
  import fs from "node:fs";
9
12
  import { defineCommand } from "citty";
10
13
  import * as p from "@clack/prompts";
11
- import { downloadTemplate } from "giget";
12
14
 
13
15
  //#region src/cli/commands/create.ts
14
- /** GitHub repo the full templates are fetched from, matching the release-tag scheme. */
15
- const TEMPLATE_REPO = "github:kizlo-io/kizlo";
16
16
  /**
17
17
  * The templates `create` can scaffold from. Each id is both the `templates/<id>` folder and the
18
- * preset that drives its WordPress setup.
18
+ * preset that drives its WordPress setup and framework bootstrap.
19
19
  */
20
20
  const TEMPLATES = ["nextjs"];
21
21
  function isTemplate(value) {
22
22
  return TEMPLATES.includes(value);
23
23
  }
24
24
  const projectName = validate(z.string().trim().min(1, "Required").refine((value) => !value.startsWith(".") && !value.includes("/") && !value.includes("\\"), "Enter a folder name (letters, numbers, dashes)"));
25
+ /** Package managers `create` can wire the getting-started steps for, in display order. */
26
+ const PACKAGE_MANAGERS = [
27
+ "pnpm",
28
+ "npm",
29
+ "yarn",
30
+ "bun"
31
+ ];
25
32
  /**
26
- * Resolve where the template is fetched from. Defaults to the GitHub repo pinned to the release
27
- * tag matching this CLI version (`kizlo-v<version>`), so the template and the `kizlo` dependency it
28
- * pulls come from the same release. `KIZLO_TEMPLATE_REF` overrides the ref (e.g. `main`) and
29
- * `KIZLO_TEMPLATE_LOCAL_DIR` points at a local `templates/` directory for pre-release development.
33
+ * Pin the `kizlo` dependency in the freshly bootstrapped `package.json`. The template is
34
+ * authoritative for the version (`kizloVersion` in its manifest, stamped at release), falling back to
35
+ * the running CLI's version in-repo before the first stamp. The dependency is only recorded, not
36
+ * installed — the getting-started note tells the user to run install.
30
37
  */
31
- function templateSource(template) {
32
- const localDir = process.env.KIZLO_TEMPLATE_LOCAL_DIR;
33
- if (localDir) return { local: path.resolve(localDir, template) };
34
- return { remote: `${TEMPLATE_REPO}/templates/${template}#${process.env.KIZLO_TEMPLATE_REF ?? `kizlo-v${getVersion()}`}` };
38
+ function recordKizloDependency(dir, manifest) {
39
+ const pkgPath = path.join(dir, "package.json");
40
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
41
+ pkg.dependencies ??= {};
42
+ pkg.dependencies.kizlo = manifest.kizloVersion ?? `^${getVersion()}`;
43
+ fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
44
+ }
45
+ /**
46
+ * The argv `create` runs to bootstrap a fresh base app with the framework's own official CLI, built
47
+ * from the template manifest's `bootstrap` (initializer + flags) — the template is the single source
48
+ * of truth for the framework. The CLI owns only the package-manager mechanics: {@link frameworkCreateArgs}
49
+ * assembles the `<pm> create …` invocation, and the `{{pm}}` token in the flags is substituted with the
50
+ * chosen manager (e.g. `--use-{{pm}}` → `--use-pnpm`).
51
+ */
52
+ function bootstrapArgs(manifest, pm, name) {
53
+ if (!manifest.bootstrap) return void 0;
54
+ const flags = manifest.bootstrap.flags.map((flag) => flag.replaceAll("{{pm}}", pm));
55
+ return frameworkCreateArgs(pm, manifest.bootstrap.initializer, name, flags);
35
56
  }
36
57
  /**
37
- * Copy the template into `dir`, then make it a standalone project: rewrite the `workspace:*` Kizlo
38
- * dependency to this CLI's version and name the package after the project folder. The template's
39
- * `kizlo.template.json` is an internal build input, so it's removed once the copy lands.
58
+ * Layer Kizlo's wiring onto a freshly bootstrapped app in `dir`, from an already-fetched template
59
+ * directory + manifest. The framework CLI has produced the base project (package.json, config,
60
+ * tsconfig, the root layout); this drives the manifest — the same engine `init` uses — to record the
61
+ * `kizlo` dependency, write `kizlo.config.ts`, scaffold the manifest's own files (wiring plus the demo
62
+ * starter pages), seed the generated contract, ignore `.env`, and patch the root layout's SEO exports.
63
+ * The target directory layout comes straight from the manifest tokens, which the bootstrap flags are
64
+ * chosen to match, so files land where the manifest expects. Fresh files are written with `force`,
65
+ * silently replacing the framework defaults.
40
66
  */
41
- async function scaffoldTemplate(template, dir, name) {
42
- const source = templateSource(template);
43
- if (source.local) {
44
- if (!fs.existsSync(source.local)) throw new Error(`Local template not found: ${source.local}`);
45
- fs.cpSync(source.local, dir, { recursive: true });
46
- } else await downloadTemplate(source.remote, {
47
- dir,
48
- forceClean: true
67
+ async function applyManifestWiring(dir, templateDir, manifest, opts) {
68
+ const { kizloDir, appDir, alias } = manifest.tokens;
69
+ const scaffold = buildScaffoldContext(dir, {
70
+ dirRel: kizloDir,
71
+ appDir,
72
+ alias,
73
+ clientUrl: void 0
49
74
  });
50
- fs.rmSync(path.join(dir, "kizlo.template.json"), { force: true });
51
- const pkgPath = path.join(dir, "package.json");
52
- if (!fs.existsSync(pkgPath)) {
53
- fs.rmSync(dir, {
54
- recursive: true,
55
- force: true
56
- });
57
- throw new Error(`Template "${template}" wasn't found at ${source.remote ?? source.local}. This ref may predate the templates — retry with KIZLO_TEMPLATE_REF=main.`);
58
- }
59
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
60
- pkg.name = name;
61
- if (pkg.dependencies?.kizlo === "workspace:*") pkg.dependencies.kizlo = `^${getVersion()}`;
62
- fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
75
+ recordKizloDependency(dir, manifest);
76
+ const files = [{
77
+ label: "Kizlo config",
78
+ relPath: "kizlo.config.ts",
79
+ contents: kizloConfigTemplate(kizloDir, alias, opts.devPath)
80
+ }, ...ownEntries(manifest, { includeStarter: true }).map((entry) => adaptOwnFile(templateDir, entry, manifest.tokens, scaffold))];
81
+ for (const file of files) reportScaffold(file, await scaffoldFile(dir, file, {
82
+ force: true,
83
+ yes: false
84
+ }), false);
85
+ writeGeneratedContract(dir, path.join(kizloDir, "server"));
86
+ ensureGitignored(dir, ".env");
87
+ applyLayoutPatches(dir, manifest, scaffold);
63
88
  }
64
89
  const create = defineCommand({
65
90
  meta: {
@@ -119,21 +144,60 @@ const create = defineCommand({
119
144
  p.cancel(`No preset for template "${template}".`);
120
145
  process.exit(1);
121
146
  }
147
+ const installed = availablePackageManagers(PACKAGE_MANAGERS);
148
+ const invokingPm = detectInvokingPackageManager();
149
+ const defaultPm = invokingPm && installed.includes(invokingPm) ? invokingPm : installed[0];
150
+ const pm = orCancel(await p.select({
151
+ message: "Package manager",
152
+ options: installed.map((id) => ({
153
+ value: id,
154
+ label: id
155
+ })),
156
+ initialValue: defaultPm
157
+ }));
122
158
  const conn = await collectConnectionInteractively(preset);
123
159
  if (preset.apiPath && conn.baseUrl) conn.baseUrl = withApiPath(conn.baseUrl, preset.apiPath);
124
160
  if (conn.mode === "local" && !await dockerAvailable()) {
125
161
  p.cancel("Docker isn't available — start Docker (or install it) and re-run, or choose “Use my own WordPress”.");
126
162
  process.exit(1);
127
163
  }
164
+ const fetched = await fetchTemplate(template);
165
+ const fail = (message) => {
166
+ fetched.cleanup();
167
+ p.cancel(message);
168
+ process.exit(1);
169
+ };
170
+ const manifest = readManifest(fetched.dir);
171
+ const bootstrap = bootstrapArgs(manifest, pm, name);
172
+ if (!bootstrap) {
173
+ fetched.cleanup();
174
+ p.cancel(`Template "${template}" can't be scaffolded — its manifest declares no framework bootstrap.`);
175
+ process.exit(1);
176
+ }
128
177
  const s = p.spinner();
129
- s.start(`Creating ${name} from the ${template} template`);
178
+ s.start(`Creating ${name} with the ${preset.label} CLI`);
179
+ const scaffold = runCommandCaptured(bootstrap, cwd);
180
+ s.stop(scaffold.ok ? `Created ${name} with the ${preset.label} CLI` : `${preset.label} setup failed`);
181
+ if (!scaffold.ok) {
182
+ if (scaffold.output) p.log.error(scaffold.output);
183
+ fail(`${preset.label} setup failed — see the output above and try again.`);
184
+ }
130
185
  try {
131
- await scaffoldTemplate(template, dir, name);
132
- s.stop(`Created ${name}`);
186
+ await applyManifestWiring(dir, fetched.dir, manifest, { devPath: conn.devPath });
133
187
  } catch (error) {
134
- s.stop("Could not create the project");
135
- p.cancel(error instanceof Error ? error.message : String(error));
136
- process.exit(1);
188
+ fail(error instanceof Error ? error.message : String(error));
189
+ }
190
+ fetched.cleanup();
191
+ let depsInstalled = false;
192
+ if (orCancel(await p.confirm({
193
+ message: "Install dependencies now?",
194
+ initialValue: true
195
+ }))) {
196
+ const is = p.spinner();
197
+ is.start(`Installing dependencies with ${pm}`);
198
+ depsInstalled = await runCommandAsync(installArgs(pm), dir, "ignore");
199
+ is.stop(depsInstalled ? "Installed dependencies" : "Could not install dependencies");
200
+ if (!depsInstalled) p.log.warn(`Install them yourself: cd ${name} && ${installArgs(pm).join(" ")}`);
137
201
  }
138
202
  await setupLocalWordPress(dir, conn);
139
203
  await writeEnv(dir, preset, conn, {
@@ -141,9 +205,12 @@ const create = defineCommand({
141
205
  yes: false
142
206
  });
143
207
  await syncRemote(conn);
144
- const pm = detectPackageManager(dir);
145
- p.note([`cd ${name}`, `${pm} install`].join("\n"), "Get started");
146
- nextStepsNote(conn.mode);
208
+ p.note([
209
+ `cd ${name}`,
210
+ ...depsInstalled ? [] : [`${pm} install`],
211
+ ``,
212
+ ...nextStepsLines(conn.mode)
213
+ ].join("\n"), "Next steps");
147
214
  p.outro("Kizlo is ready 🎉");
148
215
  }
149
216
  });
@@ -1,10 +1,11 @@
1
1
  import { dockerAvailable } from "../wp/docker.js";
2
- import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, getVersion, loadEnvFiles, resolveModuleImport, runCommand, writeFileIfAbsent } from "../utils.js";
2
+ import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, getVersion, loadEnvFiles, runCommand } from "../utils.js";
3
3
  import { printBanner } from "../banner.js";
4
4
  import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
5
+ import { fetchTemplate } from "../presets/source.js";
6
+ import { adaptOwnFile, ownEntries, readManifest } from "../presets/template.js";
5
7
  import { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
6
- import { CONTRACT_BARREL } from "../daemon/generate.js";
7
- import { applyPatchToSource, patchChanged, resolvePatch, resolvePatchTargetPath, templatePatches } from "../presets/patch.js";
8
+ import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
8
9
  import path from "node:path";
9
10
  import fs from "node:fs";
10
11
  import { defineCommand } from "citty";
@@ -18,14 +19,6 @@ function defaultDir(hasSrcDir) {
18
19
  function aliasWithSlash(alias) {
19
20
  return alias ? `${alias.replace(/\/+$/, "")}/` : "";
20
21
  }
21
- function kizloConfigTemplate(dir, alias, devPath) {
22
- return `import { defineConfig } from "kizlo/config"
23
-
24
- export default defineConfig({
25
- dir: "${dir}",${alias ? `\n\talias: "${alias}",` : ""}${devPath ? `\n\tdev: { path: "${devPath}" },` : ""}
26
- })
27
- `;
28
- }
29
22
  /** Whether two URLs share an origin (scheme + host + port); false when either can't be parsed. */
30
23
  function sameOrigin(a, b) {
31
24
  try {
@@ -39,35 +32,6 @@ function detectAppDir(cwd, hasSrcDir) {
39
32
  if (fs.existsSync(path.join(cwd, "app"))) return "app";
40
33
  return hasSrcDir ? "src/app" : "app";
41
34
  }
42
- /**
43
- * The single overwrite policy for every scaffolded file: create it when absent, overwrite on
44
- * `--force`, and otherwise ask before clobbering an existing file (keeping it when the user
45
- * declines or when running non-interactively with `--yes`). Every scaffolded file routes through
46
- * here rather than deciding for itself, so new files inherit the same behavior for free.
47
- */
48
- async function scaffoldFile(cwd, file, opts) {
49
- const absPath = path.join(cwd, file.relPath);
50
- const existed = fs.existsSync(absPath);
51
- if (existed) {
52
- let overwrite = opts.force;
53
- if (!opts.force && !opts.yes) {
54
- p.log.warn(`${file.label} already exists at ${file.relPath}`);
55
- overwrite = orCancel(await p.confirm({
56
- message: "Overwrite it?",
57
- initialValue: true
58
- }));
59
- }
60
- if (!overwrite) return "kept";
61
- }
62
- fs.mkdirSync(path.dirname(absPath), { recursive: true });
63
- fs.writeFileSync(absPath, file.contents);
64
- return existed ? "overwritten" : "created";
65
- }
66
- /** Report a {@link scaffoldFile} outcome in init's usual voice. */
67
- function reportScaffold(file, result, yes) {
68
- if (result === "kept") p.log.info(`Kept existing ${file.label} (${file.relPath})${yes ? " — pass --force to overwrite" : ""}`);
69
- else p.log.success(`${result === "overwritten" ? "Overwrote" : "Created"} ${file.label} (${file.relPath})`);
70
- }
71
35
  async function collectInteractively(ctx) {
72
36
  const conn = await collectConnectionInteractively(ctx.preset);
73
37
  const dir = orCancel(await p.text({
@@ -108,6 +72,41 @@ function collectFromEnv(ctx) {
108
72
  function readPkg(pkgPath) {
109
73
  return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
110
74
  }
75
+ function kizloDep(pkg) {
76
+ return pkg.dependencies?.kizlo ?? pkg.devDependencies?.kizlo;
77
+ }
78
+ /** A bare `x.y.z` from a version spec (range prefix and pre-release/build metadata dropped); undefined if none. */
79
+ function coerceVersion(spec) {
80
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
81
+ return match ? [
82
+ Number(match[1]),
83
+ Number(match[2]),
84
+ Number(match[3])
85
+ ] : void 0;
86
+ }
87
+ /** Whether `have` is a strictly older release than `want`; false when either can't be compared. */
88
+ function isOlder(have, want) {
89
+ const a = coerceVersion(have);
90
+ const b = coerceVersion(want);
91
+ if (!a || !b) return false;
92
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
93
+ return false;
94
+ }
95
+ /**
96
+ * The template is authoritative for the kizlo version: it declares `kizloVersion` in its manifest
97
+ * (stamped at release), falling back to the running CLI's version in-repo before the first stamp.
98
+ * When the project's kizlo is older, upgrade the project up to it and say so — a deliberate pin is
99
+ * never changed silently. Never downgrades.
100
+ */
101
+ function alignKizloVersion(cwd, pm, pkg, manifest) {
102
+ const want = manifest.kizloVersion ?? `^${getVersion()}`;
103
+ const have = kizloDep(pkg);
104
+ if (!have || !isOlder(have, want)) return;
105
+ const s = p.spinner();
106
+ s.start(`Upgrading kizlo from ${have} to ${want}`);
107
+ const ok = runCommand(addDependencyArgs(pm, `kizlo@${want}`), cwd, "ignore");
108
+ s.stop(ok ? `Upgraded kizlo to ${want}` : `Could not upgrade kizlo automatically — install kizlo@${want} yourself`);
109
+ }
111
110
  const init = defineCommand({
112
111
  meta: {
113
112
  name: "init",
@@ -168,6 +167,10 @@ const init = defineCommand({
168
167
  preset = detectPreset(initCtx);
169
168
  if (preset.id !== "base") p.log.success(`${preset.label} detected`);
170
169
  }
170
+ if (preset.template === "nextjs" && !fs.existsSync(path.join(cwd, "app")) && !fs.existsSync(path.join(cwd, "src/app"))) {
171
+ p.cancel("Kizlo needs the Next.js App Router — no `app` or `src/app` directory found. The Pages Router isn't supported.");
172
+ process.exit(1);
173
+ }
171
174
  if (yes) loadEnvFiles(cwd);
172
175
  const setup = yes ? collectFromEnv({
173
176
  cwd,
@@ -185,11 +188,12 @@ const init = defineCommand({
185
188
  process.exit(1);
186
189
  }
187
190
  if (!hasKizlo) {
191
+ const spec = `kizlo@^${getVersion()}`;
188
192
  const s = p.spinner();
189
193
  s.start(`Installing kizlo with ${pm}`);
190
- const ok = runCommand(addDependencyArgs(pm, "kizlo@latest"), cwd, "ignore");
194
+ const ok = runCommand(addDependencyArgs(pm, spec), cwd, "ignore");
191
195
  s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
192
- if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, "kizlo@latest").join(" ")}`);
196
+ if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
193
197
  }
194
198
  if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
195
199
  await setupLocalWordPress(cwd, setup);
@@ -201,20 +205,28 @@ const init = defineCommand({
201
205
  const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
202
206
  const serverDirRel = path.join(dirRel, "server");
203
207
  const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
204
- const scaffold = {
205
- kizloDir: dirRel,
206
- serverDirName: path.basename(serverDirRel),
207
- serverEntryPath: path.join(serverDirRel, "index.ts"),
208
- clientPath: path.join(dirRel, "client.ts"),
208
+ const scaffold = buildScaffoldContext(cwd, {
209
+ dirRel,
209
210
  appDir: detectAppDir(cwd, hasSrcDir),
210
- serverImport: (fromDir) => resolveModuleImport(cwd, serverDirRel, fromDir, setup.alias),
211
+ alias: setup.alias,
211
212
  clientUrl
212
- };
213
+ });
213
214
  const files = [{
214
215
  label: "Kizlo config",
215
216
  relPath: "kizlo.config.ts",
216
217
  contents: kizloConfigTemplate(dirRel, setup.alias, setup.devPath)
217
- }, ...preset.scaffolds(scaffold)];
218
+ }];
219
+ let manifest;
220
+ if (preset.template) {
221
+ const fetched = await fetchTemplate(preset.template);
222
+ try {
223
+ manifest = readManifest(fetched.dir);
224
+ alignKizloVersion(cwd, pm, pkg, manifest);
225
+ for (const entry of ownEntries(manifest)) files.push(adaptOwnFile(fetched.dir, entry, manifest.tokens, scaffold));
226
+ } finally {
227
+ fetched.cleanup();
228
+ }
229
+ } else if (preset.scaffolds) files.push(...preset.scaffolds(scaffold));
218
230
  const scaffolded = [];
219
231
  for (const file of files) scaffolded.push({
220
232
  file,
@@ -223,47 +235,10 @@ const init = defineCommand({
223
235
  yes
224
236
  })
225
237
  });
226
- const generatedDirRel = path.join(serverDirRel, "generated");
227
- writeFileIfAbsent(path.join(cwd, generatedDirRel, "contract.json"), "{}\n");
228
- writeFileIfAbsent(path.join(cwd, generatedDirRel, "index.ts"), CONTRACT_BARREL);
238
+ writeGeneratedContract(cwd, serverDirRel);
229
239
  const gitignore = ensureGitignored(cwd, ".env");
230
240
  for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
231
- for (const patch of templatePatches(preset.id)) {
232
- const resolved = resolvePatch(patch, scaffold);
233
- const abs = resolvePatchTargetPath(cwd, resolved.relPath);
234
- if (!abs) {
235
- p.log.info(`Skipped ${resolved.label} wiring — ${resolved.relPath} not found (add it yourself)`);
236
- continue;
237
- }
238
- const relTarget = path.relative(cwd, abs);
239
- const src = fs.readFileSync(abs, "utf8");
240
- let text;
241
- let changes;
242
- try {
243
- ({text, changes} = applyPatchToSource(src, resolved, { replaceConflicts: force }));
244
- } catch (err) {
245
- p.log.error(`Could not parse ${resolved.label} (${relTarget}) to wire Kizlo into it: ${err instanceof Error ? err.message : String(err)}`);
246
- throw err;
247
- }
248
- if (!force && !yes && changes.keptExports.length) {
249
- p.log.warn(`${resolved.label} (${relTarget}) already defines ${changes.keptExports.join(", ")}`);
250
- if (orCancel(await p.confirm({
251
- message: "Replace with Kizlo's version?",
252
- initialValue: true
253
- }))) ({text, changes} = applyPatchToSource(src, resolved, { replaceConflicts: true }));
254
- }
255
- if (patchChanged(changes)) {
256
- fs.writeFileSync(abs, text);
257
- const parts = [
258
- ...changes.replacedExports.length ? [`replaced ${changes.replacedExports.join(", ")}`] : [],
259
- ...changes.addedExports.length ? [`added ${changes.addedExports.join(", ")}`] : [],
260
- ...changes.addedImports.length ? ["imports"] : []
261
- ];
262
- p.log.success(`Wired Kizlo into your ${resolved.label} (${relTarget}) — ${parts.join(", ")}`);
263
- }
264
- if (changes.keptExports.length) p.log.info(`Kept your existing ${changes.keptExports.join(", ")} in ${resolved.label} — pass --force to replace`);
265
- else if (!patchChanged(changes)) p.log.info(`Your ${resolved.label} is already wired (${relTarget})`);
266
- }
241
+ if (manifest) applyLayoutPatches(cwd, manifest, scaffold);
267
242
  if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
268
243
  nextStepsNote(setup.mode);
269
244
  p.outro("Kizlo is ready 🎉");
@@ -1,5 +1,3 @@
1
- import { materializeTemplate } from "./template.js";
2
-
3
1
  //#region src/cli/presets/nextjs.ts
4
2
  const nextjs = {
5
3
  id: "nextjs",
@@ -12,9 +10,7 @@ const nextjs = {
12
10
  },
13
11
  baseUrlEnvKey: "NEXT_PUBLIC_KIZLO_BACKEND_URL",
14
12
  apiPath: "/api/kizlo",
15
- scaffolds(ctx) {
16
- return materializeTemplate("nextjs", ctx);
17
- }
13
+ template: "nextjs"
18
14
  };
19
15
 
20
16
  //#endregion
@@ -1,4 +1,3 @@
1
- import { TEMPLATE_PATCHES } from "./generated/templates.js";
2
1
  import path from "node:path";
3
2
  import fs from "node:fs";
4
3
  import { builders, detectCodeFormat, generateCode, parseModule } from "magicast";
@@ -28,26 +27,7 @@ function resolvePatchTargetPath(cwd, relPath) {
28
27
  if (fs.existsSync(candidate)) return candidate;
29
28
  }
30
29
  }
31
- /** The Kizlo-wiring patches for a framework (files init merges into rather than writes). */
32
- function templatePatches(framework) {
33
- return TEMPLATE_PATCHES[framework] ?? [];
34
- }
35
- /** Substitute the path/import tokens for a patch against the project's real directories. */
36
- function resolvePatch(patch, ctx) {
37
- const relPath = patch.tokenizedFile.replaceAll("{{kizloDir}}", ctx.kizloDir).replaceAll("{{appDir}}", ctx.appDir);
38
- const fromDir = path.posix.dirname(relPath);
39
- const imports = patch.imports.map((imp) => ({
40
- module: imp.module.replaceAll("{{serverImport}}", ctx.serverImport(fromDir)),
41
- names: imp.names
42
- }));
43
- return {
44
- label: patch.label,
45
- relPath,
46
- imports,
47
- exports: patch.exports
48
- };
49
- }
50
- /** Whether a patch actually modified the file (vs. a no-op / kept outcome). */
30
+ /** Whether a patch actually modified the file (vs. a no-op / already-wired outcome). */
51
31
  function patchChanged(changes) {
52
32
  return Boolean(changes.addedImports.length || changes.addedExports.length || changes.replacedExports.length);
53
33
  }
@@ -69,15 +49,18 @@ function sameExpr(a, b) {
69
49
  }
70
50
  /**
71
51
  * Merge a resolved patch into `source` by parsing it with magicast (a real TS/JS/JSX parser via Babel,
72
- * so declarations are bounded exactly — never by a text heuristic). Imports and absent exports are
73
- * added; an export the file already defines is replaced surgically when `replaceConflicts` is set, else
74
- * kept. A static Next counterpart (`metadata` for `generateMetadata`) is dropped as part of a replace.
75
- * Idempotent: an export whose current value already equals ours is left untouched.
52
+ * so declarations are bounded exactly — never by a text heuristic). Missing imports are added; each
53
+ * export is upserted — set with `builders`, which replaces it if present and adds it if not — and a
54
+ * static Next counterpart (`metadata` for `generateMetadata`) is dropped as part of a replace. The
55
+ * upsert is deliberate replace-always: we cannot distinguish a user's own `generateMetadata` from a
56
+ * stale copy of ours, and those values come from WordPress (edited in wp-admin, not the file), so the
57
+ * export body isn't meant to be hand-customized. Idempotent: an export already equal to ours is left
58
+ * untouched, so re-running never churns the file.
76
59
  *
77
- * Throws if the target cannot be parsed or regenerated. Callers must NOT swallow that — a file we can't
78
- * parse is one we must not guess at, so wiring stops rather than writing a corrupted file.
60
+ * Throws if the target cannot be parsed or regenerated. Callers must NOT swallow that — a file we
61
+ * can't parse is one we must not guess at, so wiring falls back to a printed instruction instead.
79
62
  */
80
- function applyPatchToSource(source, patch, opts) {
63
+ function applyPatchToSource(source, patch) {
81
64
  const format = detectCodeFormat(source);
82
65
  const mod = parseModule(source, {
83
66
  sourceFileName: "layout.tsx",
@@ -86,8 +69,7 @@ function applyPatchToSource(source, patch, opts) {
86
69
  const changes = {
87
70
  addedImports: [],
88
71
  addedExports: [],
89
- replacedExports: [],
90
- keptExports: []
72
+ replacedExports: []
91
73
  };
92
74
  for (const imp of patch.imports) for (const name of imp.names) {
93
75
  if (mod.imports.$items.some((item) => item.from === imp.module && item.imported === name)) continue;
@@ -101,20 +83,12 @@ function applyPatchToSource(source, patch, opts) {
101
83
  const exportNames = Object.keys(mod.exports);
102
84
  if (exportNames.includes(exp.name)) {
103
85
  if (sameExpr(generateCode(mod.exports[exp.name]).code, exp.value)) continue;
104
- if (!opts.replaceConflicts) {
105
- changes.keptExports.push(exp.name);
106
- continue;
107
- }
108
86
  mod.exports[exp.name] = builders.raw(exp.value);
109
87
  changes.replacedExports.push(exp.name);
110
88
  continue;
111
89
  }
112
90
  const counterpart = staticCounterpart(exp.name);
113
91
  if (counterpart && exportNames.includes(counterpart)) {
114
- if (!opts.replaceConflicts) {
115
- changes.keptExports.push(exp.name);
116
- continue;
117
- }
118
92
  delete mod.exports[counterpart];
119
93
  mod.exports[exp.name] = builders.raw(exp.value);
120
94
  changes.replacedExports.push(exp.name);
@@ -128,6 +102,20 @@ function applyPatchToSource(source, patch, opts) {
128
102
  changes
129
103
  };
130
104
  }
105
+ /**
106
+ * Render a resolved patch as source the user can paste in, for the printed fallback when the target
107
+ * can't be resolved or parsed. The same declared payload the confident apply upserts, so what we tell
108
+ * the user to add never drifts from what we would have written.
109
+ */
110
+ function renderPatchCode(patch) {
111
+ const imports = patch.imports.map((imp) => `import { ${imp.names.join(", ")} } from "${imp.module}"`);
112
+ const exports = patch.exports.map((exp) => `export const ${exp.name} = ${exp.value}`);
113
+ return `${[
114
+ ...imports,
115
+ "",
116
+ ...exports
117
+ ].join("\n")}\n`;
118
+ }
131
119
 
132
120
  //#endregion
133
- export { applyPatchToSource, patchChanged, resolvePatch, resolvePatchTargetPath, templatePatches };
121
+ export { applyPatchToSource, patchChanged, renderPatchCode, resolvePatchTargetPath };
@@ -0,0 +1,50 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import { downloadTemplate } from "giget";
5
+
6
+ //#region src/cli/presets/source.ts
7
+ /** GitHub repo the templates are fetched from. */
8
+ const TEMPLATE_REPO = "github:kizlo-io/kizlo";
9
+ /**
10
+ * Resolve where a template is fetched from. Defaults to the templates on the repo's default branch:
11
+ * the template is decoupled from the CLI version — it declares its own `kizlo` dependency — so there
12
+ * is no tag to match and no release to cut. `KIZLO_TEMPLATE_LOCAL_DIR` points at a local `templates/`
13
+ * directory so development (and `create`/`init` tests) never touch the network.
14
+ */
15
+ function templateSource(template) {
16
+ const localDir = process.env.KIZLO_TEMPLATE_LOCAL_DIR;
17
+ if (localDir) return { local: path.resolve(localDir, template) };
18
+ return { remote: `${TEMPLATE_REPO}/templates/${template}` };
19
+ }
20
+ /**
21
+ * Get the template as a readable directory both `create` and `init` can copy from or read. With
22
+ * `KIZLO_TEMPLATE_LOCAL_DIR` set it's the local folder (returned as-is, cleanup is a no-op). Otherwise
23
+ * it's downloaded once into a fresh temp directory the caller cleans up. The single fetch path shared
24
+ * by both commands, so they can never source different bytes.
25
+ */
26
+ async function fetchTemplate(template) {
27
+ const source = templateSource(template);
28
+ if (source.local) {
29
+ if (!fs.existsSync(source.local)) throw new Error(`Local template not found: ${source.local}`);
30
+ return {
31
+ dir: source.local,
32
+ cleanup: () => {}
33
+ };
34
+ }
35
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kizlo-template-"));
36
+ await downloadTemplate(source.remote, {
37
+ dir,
38
+ forceClean: true
39
+ });
40
+ return {
41
+ dir,
42
+ cleanup: () => fs.rmSync(dir, {
43
+ recursive: true,
44
+ force: true
45
+ })
46
+ };
47
+ }
48
+
49
+ //#endregion
50
+ export { fetchTemplate };
@@ -1,8 +1,59 @@
1
- import { TEMPLATE_SUBSETS } from "./generated/templates.js";
1
+ import { resolvePatchTargetPath } from "./patch.js";
2
+ import z from "zod/v4";
2
3
  import path from "node:path";
4
+ import fs from "node:fs";
3
5
 
4
6
  //#region src/cli/presets/template.ts
5
- /** Role → the human label init uses in prompts and logs. Mirrors the old hardcoded labels. */
7
+ /**
8
+ * The template's `template.json`. It is the single declaration of which files Kizlo lays onto a
9
+ * project and how each is applied — the same engine drives both `create` (onto a fresh app the
10
+ * framework CLI just bootstrapped) and `init` (onto the user's existing project). Every entry is one
11
+ * of a small closed set — deliberately no "patch an arbitrary source file" kind, so a dangerous
12
+ * strategy is unrepresentable:
13
+ *
14
+ * - `own` — a whole file Kizlo writes, adapted to the project's directories and written through the
15
+ * overwrite policy. `wiring`-scope files (the default) are laid down by both commands; `starter`
16
+ * demo files are laid down only by `create` (see {@link ownEntries}).
17
+ * - `patch` — a partial injection into a file the framework owns (the root layout's SEO exports).
18
+ * Both commands try a confident apply, otherwise print the payload with placement instructions.
19
+ */
20
+ const tokensSchema = z.object({
21
+ kizloDir: z.string(),
22
+ appDir: z.string(),
23
+ alias: z.string()
24
+ });
25
+ const ownSchema = z.object({
26
+ kind: z.literal("own"),
27
+ role: z.string(),
28
+ path: z.string(),
29
+ scope: z.enum(["wiring", "starter"]).optional()
30
+ });
31
+ const patchSchema = z.object({
32
+ kind: z.literal("patch"),
33
+ role: z.string(),
34
+ label: z.string(),
35
+ path: z.string(),
36
+ imports: z.array(z.object({
37
+ module: z.string(),
38
+ names: z.array(z.string())
39
+ })),
40
+ exports: z.array(z.object({
41
+ name: z.string(),
42
+ value: z.string()
43
+ }))
44
+ });
45
+ const bootstrapSchema = z.object({
46
+ initializer: z.string(),
47
+ flags: z.array(z.string()).default([])
48
+ });
49
+ const manifestSchema = z.object({
50
+ framework: z.string(),
51
+ kizloVersion: z.string().optional(),
52
+ bootstrap: bootstrapSchema.optional(),
53
+ tokens: tokensSchema,
54
+ files: z.array(z.discriminatedUnion("kind", [ownSchema, patchSchema]))
55
+ });
56
+ /** Role → the human label init uses in prompts and logs. */
6
57
  const ROLE_LABELS = {
7
58
  "server-entry": "Kizlo server instance",
8
59
  client: "Browser client",
@@ -10,27 +61,112 @@ const ROLE_LABELS = {
10
61
  robots: "robots.txt route",
11
62
  sitemap: "sitemap route",
12
63
  "sitemap-redirect": "sitemap.xml redirect route",
13
- manifest: "web manifest route"
64
+ manifest: "web manifest route",
65
+ "home-page": "home page",
66
+ "blog-post": "blog post page",
67
+ styles: "global styles"
14
68
  };
69
+ /** Read and validate the template's manifest from a fetched template directory. */
70
+ function readManifest(templateDir) {
71
+ const manifestPath = path.join(templateDir, "template.json");
72
+ if (!fs.existsSync(manifestPath)) throw new Error(`Template manifest not found at ${manifestPath}`);
73
+ return manifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
74
+ }
15
75
  /**
16
- * Turn a framework's extracted template subset into the {@link ScaffoldFile}s init writes, by
17
- * substituting the tokens the extractor left behind: the path prefixes (`{{kizloDir}}`, `{{appDir}}`)
18
- * with the project's real directories, and `{{serverImport}}` with the import specifier resolved for
19
- * each file's location (tsconfig alias or relative). The single source of these bodies is the
20
- * template folder, so init and `create` can never drift.
76
+ * The `own` files to scaffold. By default only `wiring` files (the Kizlo plumbing) — this is what
77
+ * `init` lays onto an existing project, so it never touches the user's own pages. With
78
+ * `includeStarter`, demo `starter` files are added too, which is what `create` lays onto a fresh app.
21
79
  */
22
- function materializeTemplate(framework, ctx) {
23
- return (TEMPLATE_SUBSETS[framework] ?? []).map((file) => {
24
- const relPath = file.tokenizedPath.replaceAll("{{kizloDir}}", ctx.kizloDir).replaceAll("{{appDir}}", ctx.appDir);
25
- const fromDir = path.posix.dirname(relPath);
26
- const contents = file.body.replaceAll("{{serverImport}}", ctx.serverImport(fromDir));
27
- return {
28
- label: ROLE_LABELS[file.role] ?? file.role,
29
- relPath,
30
- contents
31
- };
32
- });
80
+ function ownEntries(manifest, opts = {}) {
81
+ return manifest.files.filter((file) => file.kind === "own" && (opts.includeStarter || file.scope !== "starter"));
82
+ }
83
+ function patchEntries(manifest) {
84
+ return manifest.files.filter((file) => file.kind === "patch");
85
+ }
86
+ /** The specifier the template's files use to reach the server entry, e.g. `@/lib/kizlo/server`. */
87
+ function serverImportSpecifier(tokens) {
88
+ const withoutSrc = tokens.kizloDir.replace(/^src\//, "");
89
+ return `${tokens.alias.replace(/\/+$/, "")}/${withoutSrc}/server`;
90
+ }
91
+ /**
92
+ * Adapt a template path prefix (`src/app/...`, `src/lib/kizlo/...`) to the project's real directories.
93
+ * Only the leading token dir is swapped; the rest of the path is preserved verbatim.
94
+ */
95
+ function adaptTemplatePath(relPath, tokens, ctx) {
96
+ const normalized = relPath.split(path.sep).join("/");
97
+ if (normalized === tokens.appDir || normalized.startsWith(`${tokens.appDir}/`)) return `${ctx.appDir}${normalized.slice(tokens.appDir.length)}`;
98
+ if (normalized === tokens.kizloDir || normalized.startsWith(`${tokens.kizloDir}/`)) return `${ctx.kizloDir}${normalized.slice(tokens.kizloDir.length)}`;
99
+ return normalized;
100
+ }
101
+ /**
102
+ * Turn an `own` manifest entry into the {@link ScaffoldFile} init writes: read the template's real
103
+ * file, rewrite its path prefix to the project's directories, and swap the server-entry import
104
+ * specifier for the one resolved at the file's new location (tsconfig alias or relative). The swap is
105
+ * surgical — it replaces the exact specifier the template uses, not a loose directory substring — so
106
+ * an unrelated occurrence can't be corrupted. This is the job the old build-time extractor did; it
107
+ * moves to runtime and stays as precise.
108
+ */
109
+ function adaptOwnFile(templateDir, entry, tokens, ctx) {
110
+ const abs = path.join(templateDir, entry.path);
111
+ if (!fs.existsSync(abs)) throw new Error(`Template file "${entry.path}" is listed in the manifest but does not exist in the template.`);
112
+ const body = fs.readFileSync(abs, "utf8");
113
+ const relPath = adaptTemplatePath(entry.path, tokens, ctx);
114
+ const fromDir = path.posix.dirname(relPath);
115
+ const contents = body.split(serverImportSpecifier(tokens)).join(ctx.serverImport(fromDir));
116
+ return {
117
+ label: ROLE_LABELS[entry.role] ?? entry.role,
118
+ relPath,
119
+ contents
120
+ };
121
+ }
122
+ /** Substitute the path/import tokens for a patch against the project's real directories. */
123
+ function resolvePatch(entry, tokens, ctx) {
124
+ const relPath = adaptTemplatePath(entry.path, tokens, ctx);
125
+ const fromDir = path.posix.dirname(relPath);
126
+ const imports = entry.imports.map((imp) => ({
127
+ module: imp.module.replaceAll("{{serverImport}}", ctx.serverImport(fromDir)),
128
+ names: imp.names
129
+ }));
130
+ return {
131
+ label: entry.label,
132
+ relPath,
133
+ imports,
134
+ exports: entry.exports
135
+ };
136
+ }
137
+ const LAYOUT_FILE = /^layout\.(tsx|jsx|ts|js|mjs|cjs)$/;
138
+ /** A root layout is the one that renders `<html>` — its identity, not its path. */
139
+ const RENDERS_HTML = /<html[\s/>]/;
140
+ /** Every file under `dir`, recursively (files only). Missing dir yields nothing. */
141
+ function walk(dir) {
142
+ if (!fs.existsSync(dir)) return [];
143
+ const out = [];
144
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
145
+ const full = path.join(dir, entry.name);
146
+ if (entry.isDirectory()) out.push(...walk(full));
147
+ else out.push(full);
148
+ }
149
+ return out;
150
+ }
151
+ /**
152
+ * Resolve the root layout by identity, not path. Try the manifest hint first; if it exists and
153
+ * renders `<html>`, use it. Otherwise scan the App Router directory for a layout that renders `<html>`
154
+ * and use it only when there is exactly one. On zero, many, or an unreadable file, return undefined so
155
+ * the caller prints the wiring for the user to place. Never guesses.
156
+ */
157
+ function findRootLayout(cwd, appDir, hintRelPath) {
158
+ const hint = resolvePatchTargetPath(cwd, hintRelPath);
159
+ if (hint && rendersHtml(hint)) return hint;
160
+ const matches = walk(path.join(cwd, appDir)).filter((file) => LAYOUT_FILE.test(path.basename(file)) && rendersHtml(file));
161
+ return matches.length === 1 ? matches[0] : void 0;
162
+ }
163
+ function rendersHtml(file) {
164
+ try {
165
+ return RENDERS_HTML.test(fs.readFileSync(file, "utf8"));
166
+ } catch {
167
+ return false;
168
+ }
33
169
  }
34
170
 
35
171
  //#endregion
36
- export { materializeTemplate };
172
+ export { adaptOwnFile, findRootLayout, ownEntries, patchEntries, readManifest, resolvePatch };
package/dist/cli/utils.js CHANGED
@@ -2,7 +2,7 @@ import { log } from "./daemon/logger.js";
2
2
  import { PortInUseError, resolveHostPort } from "./wp/ports.js";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import fs from "node:fs";
7
7
  import * as p from "@clack/prompts";
8
8
 
@@ -107,6 +107,31 @@ function detectPackageManager(cwd) {
107
107
  if (fs.existsSync(path.join(cwd, "bun.lockb")) || fs.existsSync(path.join(cwd, "bun.lock"))) return "bun";
108
108
  return "npm";
109
109
  }
110
+ /**
111
+ * The package manager that invoked the current process, read from `npm_config_user_agent`
112
+ * (e.g. `pnpm/9.0.0 npm/? node/v20`). Used to pre-select the most likely choice when a fresh
113
+ * project has no lockfile to detect from. Returns undefined when the agent is absent or unknown.
114
+ */
115
+ function detectInvokingPackageManager() {
116
+ const name = process.env.npm_config_user_agent?.split("/")[0];
117
+ return name === "pnpm" || name === "yarn" || name === "bun" || name === "npm" ? name : void 0;
118
+ }
119
+ /**
120
+ * Whether `command` resolves to a runnable executable, probed with `--version`. Used to hide or
121
+ * disable package-manager choices the user can't actually run. A non-zero exit or a spawn error
122
+ * (the binary isn't on PATH) both count as unavailable.
123
+ */
124
+ function isCommandAvailable(command) {
125
+ const result = spawnSync(command, ["--version"], {
126
+ stdio: "ignore",
127
+ shell: process.platform === "win32"
128
+ });
129
+ return !result.error && result.status === 0;
130
+ }
131
+ /** The package managers currently installed on the host, in the given display order. */
132
+ function availablePackageManagers(order) {
133
+ return order.filter((pm) => isCommandAvailable(pm));
134
+ }
110
135
  function addDependencyArgs(pm, pkg) {
111
136
  if (pm === "npm") return [
112
137
  "npm",
@@ -119,6 +144,42 @@ function addDependencyArgs(pm, pkg) {
119
144
  pkg
120
145
  ];
121
146
  }
147
+ /** The argv to install a project's dependencies with `pm` — every supported manager spells it `<pm> install`. */
148
+ function installArgs(pm) {
149
+ return [pm, "install"];
150
+ }
151
+ /**
152
+ * The argv to run a framework's `create-*` initializer through a package manager, e.g.
153
+ * `pnpm create next-app@latest my-app --ts …`. The mechanics differ per manager: npm forwards
154
+ * flags to the initializer only after a `--` separator, and `yarn create` resolves the latest
155
+ * version itself so the `@latest` tag is dropped. `flags` are the initializer's own options.
156
+ */
157
+ function frameworkCreateArgs(pm, initializer, name, flags) {
158
+ switch (pm) {
159
+ case "npm": return [
160
+ "npm",
161
+ "create",
162
+ initializer,
163
+ name,
164
+ "--",
165
+ ...flags
166
+ ];
167
+ case "yarn": return [
168
+ "yarn",
169
+ "create",
170
+ initializer.replace(/@latest$/, ""),
171
+ name,
172
+ ...flags
173
+ ];
174
+ default: return [
175
+ pm,
176
+ "create",
177
+ initializer,
178
+ name,
179
+ ...flags
180
+ ];
181
+ }
182
+ }
122
183
  function runCommand(args, cwd, stdio = "inherit") {
123
184
  const [command, ...rest] = args;
124
185
  if (!command) return false;
@@ -128,6 +189,45 @@ function runCommand(args, cwd, stdio = "inherit") {
128
189
  shell: process.platform === "win32"
129
190
  }).status === 0;
130
191
  }
192
+ /**
193
+ * Async sibling of {@link runCommand}: runs a command without blocking the event loop, so a clack
194
+ * spinner can keep animating while it works (a synchronous `spawnSync` would freeze it on the first
195
+ * frame). Resolves whether it exited cleanly; a spawn error (binary off PATH) resolves false.
196
+ */
197
+ function runCommandAsync(args, cwd, stdio = "inherit") {
198
+ const [command, ...rest] = args;
199
+ if (!command) return Promise.resolve(false);
200
+ return new Promise((resolve$1) => {
201
+ const child = spawn(command, rest, {
202
+ cwd,
203
+ stdio,
204
+ shell: process.platform === "win32"
205
+ });
206
+ child.on("error", () => resolve$1(false));
207
+ child.on("close", (code) => resolve$1(code === 0));
208
+ });
209
+ }
210
+ /**
211
+ * Run a command capturing its combined output instead of streaming it, for a step that should stay
212
+ * quiet on success but whose logs are worth surfacing when it fails (e.g. the framework's scaffolder
213
+ * behind a spinner). Returns whether it succeeded and the trimmed stdout+stderr.
214
+ */
215
+ function runCommandCaptured(args, cwd) {
216
+ const [command, ...rest] = args;
217
+ if (!command) return {
218
+ ok: false,
219
+ output: ""
220
+ };
221
+ const result = spawnSync(command, rest, {
222
+ cwd,
223
+ encoding: "utf8",
224
+ shell: process.platform === "win32"
225
+ });
226
+ return {
227
+ ok: result.status === 0,
228
+ output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
229
+ };
230
+ }
131
231
  function writeFileIfAbsent(filePath, contents) {
132
232
  if (fs.existsSync(filePath)) return false;
133
233
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
@@ -263,4 +363,4 @@ function groupDefault(subCommandNames, run) {
263
363
  }
264
364
 
265
365
  //#endregion
266
- export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envGroups, envKeysPresent, getVersion, groupDefault, loadEnvFiles, mergeEnv, pickStackPort, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
366
+ export { addDependencyArgs, availablePackageManagers, detectImportAlias, detectInvokingPackageManager, detectPackageManager, ensureGitignored, envGroups, envKeysPresent, frameworkCreateArgs, getVersion, groupDefault, installArgs, loadEnvFiles, mergeEnv, pickStackPort, resolveModuleImport, runCommand, runCommandAsync, runCommandCaptured, withSpinner, writeFileIfAbsent };
@@ -6,8 +6,8 @@ import { createAdminAppPassword, seedUsers } from "./bootstrap.js";
6
6
  import { prepareByo } from "./byo.js";
7
7
  import { join } from "node:path";
8
8
  import { existsSync, rmSync } from "node:fs";
9
- import { randomBytes } from "node:crypto";
10
9
  import { networkInterfaces } from "node:os";
10
+ import { randomBytes } from "node:crypto";
11
11
 
12
12
  //#region src/cli/wp/dev.ts
13
13
  /** A strong random admin password (144 bits, URL/JSON/CLI-safe characters). */
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.8.1";
2
+ var version = "0.8.3";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/router.d.ts CHANGED
@@ -764,7 +764,7 @@ declare const ROUTER_MAP: {
764
764
  exclude?: string | number | (string | number)[] | undefined;
765
765
  include?: string | number | (string | number)[] | undefined;
766
766
  order?: "asc" | "desc" | undefined;
767
- orderBy?: "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | "count" | undefined;
767
+ orderBy?: "count" | "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
768
768
  hideEmpty?: string | boolean | undefined;
769
769
  parent?: string | number | undefined;
770
770
  post?: string | number | undefined;
@@ -976,7 +976,7 @@ declare const ROUTER_MAP: {
976
976
  exclude?: string | number | (string | number)[] | undefined;
977
977
  include?: string | number | (string | number)[] | undefined;
978
978
  order?: "asc" | "desc" | undefined;
979
- orderBy?: "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | "count" | undefined;
979
+ orderBy?: "count" | "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
980
980
  hideEmpty?: string | boolean | undefined;
981
981
  post?: string | number | undefined;
982
982
  slug?: string | string[] | undefined;
@@ -1135,7 +1135,7 @@ declare const ROUTER_MAP: {
1135
1135
  sitemaps: {
1136
1136
  list: Procedure<"internal", {}, {
1137
1137
  key: string;
1138
- type: "author" | "post_type" | "taxonomy";
1138
+ type: "post_type" | "taxonomy" | "author";
1139
1139
  pages: number;
1140
1140
  lastmod: string;
1141
1141
  }[], DefinedErrorMapLike>;
@@ -1143,7 +1143,7 @@ declare const ROUTER_MAP: {
1143
1143
  origin: string;
1144
1144
  sitemaps: {
1145
1145
  key: string;
1146
- type: "author" | "post_type" | "taxonomy";
1146
+ type: "post_type" | "taxonomy" | "author";
1147
1147
  pages: number;
1148
1148
  lastmod: string;
1149
1149
  }[];
@@ -108,17 +108,17 @@ declare const Seo: z.ZodObject<{
108
108
  type Seo = z.infer<typeof Seo>;
109
109
  declare const CONTENT_TYPES: readonly ["post_type", "taxonomy", "author"];
110
110
  declare const ContentType: z.ZodEnum<{
111
- author: "author";
112
111
  post_type: "post_type";
113
112
  taxonomy: "taxonomy";
113
+ author: "author";
114
114
  }>;
115
115
  type ContentType = z.infer<typeof ContentType>;
116
116
  declare const Sitemap: z.ZodObject<{
117
117
  key: z.ZodString;
118
118
  type: z.ZodEnum<{
119
- author: "author";
120
119
  post_type: "post_type";
121
120
  taxonomy: "taxonomy";
121
+ author: "author";
122
122
  }>;
123
123
  pages: z.ZodNumber;
124
124
  lastmod: z.ZodString;
@@ -127,9 +127,9 @@ type Sitemap = z.infer<typeof Sitemap>;
127
127
  declare const SitemapList: z.ZodArray<z.ZodObject<{
128
128
  key: z.ZodString;
129
129
  type: z.ZodEnum<{
130
- author: "author";
131
130
  post_type: "post_type";
132
131
  taxonomy: "taxonomy";
132
+ author: "author";
133
133
  }>;
134
134
  pages: z.ZodNumber;
135
135
  lastmod: z.ZodString;
@@ -140,9 +140,9 @@ declare const SitemapIndex: z.ZodObject<{
140
140
  sitemaps: z.ZodArray<z.ZodObject<{
141
141
  key: z.ZodString;
142
142
  type: z.ZodEnum<{
143
- author: "author";
144
143
  post_type: "post_type";
145
144
  taxonomy: "taxonomy";
145
+ author: "author";
146
146
  }>;
147
147
  pages: z.ZodNumber;
148
148
  lastmod: z.ZodString;
@@ -174,9 +174,9 @@ declare const SitemapUrlList: z.ZodArray<z.ZodObject<{
174
174
  type SitemapUrlList = z.infer<typeof SitemapUrlList>;
175
175
  declare const ListSitemapUrlInputBase: z.ZodObject<{
176
176
  type: z.ZodEnum<{
177
- author: "author";
178
177
  post_type: "post_type";
179
178
  taxonomy: "taxonomy";
179
+ author: "author";
180
180
  }>;
181
181
  page: z.ZodOptional<z.ZodPipe<z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>, z.ZodCoercedNumber<string | number>>>;
182
182
  }, z.core.$strip>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kizlo",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "description": "A headless WordPress framework for TypeScript.",
6
6
  "keywords": [
@@ -1,59 +0,0 @@
1
- //#region src/cli/presets/generated/templates.ts
2
- const TEMPLATE_SUBSETS = { "nextjs": [
3
- {
4
- "role": "server-entry",
5
- "tokenizedPath": "{{kizloDir}}/server/index.ts",
6
- "body": "import { createKizlo } from \"kizlo/nextjs/server\"\n\nexport const { router, client, context, handler } = createKizlo()\n"
7
- },
8
- {
9
- "role": "client",
10
- "tokenizedPath": "{{kizloDir}}/client.ts",
11
- "body": "import { createKizloClient } from \"kizlo/nextjs\"\nimport { contract } from \"./server/generated\"\n\nexport const client = createKizloClient(contract)\n"
12
- },
13
- {
14
- "role": "api-route",
15
- "tokenizedPath": "{{appDir}}/api/kizlo/[[...rest]]/route.ts",
16
- "body": "import { handler } from \"{{serverImport}}\"\n\nexport { handler as GET, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, handler as OPTIONS }\n"
17
- },
18
- {
19
- "role": "robots",
20
- "tokenizedPath": "{{appDir}}/robots.txt/route.ts",
21
- "body": "import { createRobotsRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Vercel treats special metadata files like robots.txt and sitemap.xml as completely static\n// files at build time, bypassing normal Incremental Static Regeneration (ISR) and on-demand\n// revalidatePath rules.\n//\n// While your revalidation logic executes perfectly in a local Node.js server, Vercel uploads\n// these routes directly to its Edge CDN as immutable assets. Subsequent on-demand revalidation\n// triggers will successfully clear the Data Cache but fail to purge the CDN-level Edge cache\n// for that file.\n//\n// force-dynamic opts out of that static treatment so CMS edits show up; the WordPress call is\n// cached, so requests stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createRobotsRoute(client)\n"
22
- },
23
- {
24
- "role": "sitemap",
25
- "tokenizedPath": "{{appDir}}/sitemaps/[sitemap]/route.ts",
26
- "body": "import { createSitemapRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Like robots.txt, Vercel bakes sitemap routes into immutable Edge CDN assets at build time,\n// bypassing ISR and on-demand revalidatePath. The index gets frozen with whatever WordPress\n// returned at build (often nothing, since the self-referential backend isn't reachable yet),\n// and revalidation can't purge that CDN copy.\n//\n// force-dynamic opts out of that static treatment. createSitemapRoute caches the WordPress calls\n// per sitemap and refreshes them on content changes (via revalidateTag), so requests stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createSitemapRoute(client)\n"
27
- },
28
- {
29
- "role": "sitemap-redirect",
30
- "tokenizedPath": "{{appDir}}/sitemap.xml/route.ts",
31
- "body": "import { createSitemapRedirectRoute } from \"kizlo/nextjs/server\"\n\n// Many crawlers ignore robots.txt and probe the well-known /sitemap.xml directly. This route\n// permanently redirects (308) to the generated index at /sitemaps/index.xml. The response is a\n// fixed constant with no WordPress call or request input, so Next statically generates it at\n// build time (no runtime/edge function invoked per request).\nexport const GET = createSitemapRedirectRoute()\n"
32
- },
33
- {
34
- "role": "manifest",
35
- "tokenizedPath": "{{appDir}}/site.webmanifest/route.ts",
36
- "body": "import { createManifestRoute } from \"kizlo/nextjs/server\"\nimport { client } from \"{{serverImport}}\"\n\n// Run on the edge: cheaper and faster than Node for a response this small.\nexport const runtime = \"edge\"\n\n// Like robots.txt, Vercel bakes this route into an immutable Edge CDN asset at build time,\n// bypassing ISR and on-demand revalidatePath. force-dynamic opts out of that static treatment\n// so brand-settings edits show up; createManifestRoute caches the WordPress call, so requests\n// stay cheap.\nexport const dynamic = \"force-dynamic\"\n\nexport const GET = createManifestRoute(client)\n"
37
- }
38
- ] };
39
- const TEMPLATE_PATCHES = { "nextjs": [{
40
- "label": "root layout",
41
- "tokenizedFile": "{{appDir}}/layout.tsx",
42
- "imports": [{
43
- "module": "kizlo/nextjs/server",
44
- "names": ["createRootMetadata", "createRootViewport"]
45
- }, {
46
- "module": "{{serverImport}}",
47
- "names": ["client"]
48
- }],
49
- "exports": [{
50
- "name": "generateMetadata",
51
- "value": "createRootMetadata(client)"
52
- }, {
53
- "name": "generateViewport",
54
- "value": "createRootViewport(client)"
55
- }]
56
- }] };
57
-
58
- //#endregion
59
- export { TEMPLATE_PATCHES, TEMPLATE_SUBSETS };