kizlo 0.9.0 → 0.10.0

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.
@@ -1,9 +1,8 @@
1
- import { dockerAvailable } from "../wp/docker.js";
2
1
  import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, getVersion, loadEnvFiles, runCommandAsync } from "../utils.js";
3
2
  import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
4
3
  import { fetchTemplate } from "../presets/source.js";
5
- import { adaptFile, changesFor, fileEntries, patchEntries, readManifest } from "../presets/template.js";
6
- import { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
4
+ import { adaptFile, changesFor, fileEntries, isOlderVersion, minCliError, patchEntries, readManifest, resolveDependencies } from "../presets/template.js";
5
+ import { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, readEnvExample, resolveEnvKeys, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
7
6
  import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
8
7
  import path from "node:path";
9
8
  import fs from "node:fs";
@@ -32,7 +31,6 @@ function detectAppDir(cwd, hasSrcDir) {
32
31
  return hasSrcDir ? "src/app" : "app";
33
32
  }
34
33
  async function collectInteractively(ctx) {
35
- const conn = await collectConnectionInteractively(ctx.preset);
36
34
  const dir = orCancel(await p.text({
37
35
  message: "Kizlo directory",
38
36
  initialValue: defaultDir(ctx.hasSrcDir),
@@ -49,7 +47,7 @@ async function collectInteractively(ctx) {
49
47
  })).trim();
50
48
  }
51
49
  return {
52
- ...conn,
50
+ ...await collectConnectionInteractively(ctx.preset),
53
51
  dir,
54
52
  alias
55
53
  };
@@ -60,7 +58,7 @@ async function collectInteractively(ctx) {
60
58
  * later. Never fails — `--yes` always scaffolds a fillable project.
61
59
  */
62
60
  function collectFromEnv(ctx) {
63
- const conn = collectConnectionFromEnv(ctx.preset);
61
+ const conn = collectConnectionFromEnv(ctx.envKeys);
64
62
  const dir = defaultDir(ctx.hasSrcDir);
65
63
  return {
66
64
  ...conn,
@@ -71,40 +69,23 @@ function collectFromEnv(ctx) {
71
69
  function readPkg(pkgPath) {
72
70
  return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
73
71
  }
74
- function kizloDep(pkg) {
75
- return pkg.dependencies?.kizlo ?? pkg.devDependencies?.kizlo;
76
- }
77
- /** A bare `x.y.z` from a version spec (range prefix and pre-release/build metadata dropped); undefined if none. */
78
- function coerceVersion(spec) {
79
- const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
80
- return match ? [
81
- Number(match[1]),
82
- Number(match[2]),
83
- Number(match[3])
84
- ] : void 0;
85
- }
86
- /** Whether `have` is a strictly older release than `want`; false when either can't be compared. */
87
- function isOlder(have, want) {
88
- const a = coerceVersion(have);
89
- const b = coerceVersion(want);
90
- if (!a || !b) return false;
91
- for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
92
- return false;
93
- }
94
72
  /**
95
- * The template is authoritative for the kizlo version: it declares `kizloVersion` in its manifest
96
- * (stamped at release), falling back to the running CLI's version in-repo before the first stamp.
97
- * When the project's kizlo is older, upgrade the project up to it and say so — a deliberate pin is
98
- * never changed silently. Never downgrades.
73
+ * The template is authoritative for the pinned versions: it declares `dependencies`/`devDependencies`
74
+ * in its manifest (stamped at release), falling back to the running CLI's version for `kizlo` in-repo
75
+ * before the first stamp. For each declared package the project already has at an older release, upgrade
76
+ * it up to the template's pin and say so — a deliberate pin is never changed silently. Never downgrades,
77
+ * and never adds a package the project doesn't already have (a missing `kizlo` is installed separately).
99
78
  */
100
- async function alignKizloVersion(cwd, pm, pkg, manifest) {
101
- const want = manifest.kizloVersion ?? `^${getVersion()}`;
102
- const have = kizloDep(pkg);
103
- if (!have || !isOlder(have, want)) return;
104
- const s = p.spinner();
105
- s.start(`Upgrading kizlo from ${have} to ${want}`);
106
- const ok = await runCommandAsync(addDependencyArgs(pm, `kizlo@${want}`), cwd, "ignore");
107
- s.stop(ok ? `Upgraded kizlo to ${want}` : `Could not upgrade kizlo automatically — install kizlo@${want} yourself`);
79
+ async function alignDependencies(cwd, pm, pkg, manifest) {
80
+ const { dependencies, devDependencies } = resolveDependencies(manifest);
81
+ for (const [name, want] of [...Object.entries(dependencies), ...Object.entries(devDependencies)]) {
82
+ const have = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
83
+ if (!have || !isOlderVersion(have, want)) continue;
84
+ const s = p.spinner();
85
+ s.start(`Upgrading ${name} from ${have} to ${want}`);
86
+ const ok = await runCommandAsync(addDependencyArgs(pm, `${name}@${want}`), cwd, "ignore");
87
+ s.stop(ok ? `Upgraded ${name} to ${want}` : `Could not upgrade ${name} automatically — install ${name}@${want} yourself`);
88
+ }
108
89
  }
109
90
  const init = defineCommand({
110
91
  meta: {
@@ -178,76 +159,85 @@ const init = defineCommand({
178
159
  process.exit(1);
179
160
  }
180
161
  if (yes) loadEnvFiles(cwd);
181
- const setup = yes ? collectFromEnv({
182
- cwd,
183
- hasSrcDir,
184
- preset
185
- }) : await collectInteractively({
186
- cwd,
187
- hasSrcDir,
188
- preset
189
- });
190
- if (args.alias !== void 0) setup.alias = String(args.alias).trim();
191
- setup.alias = aliasWithSlash(setup.alias);
192
- if (setup.mode === "local" && !await dockerAvailable()) {
193
- p.cancel("Docker isn't available — start Docker (or install it) and re-run, or choose “Use my own WordPress”.");
194
- process.exit(1);
195
- }
196
- if (!hasKizlo) {
197
- const spec = `kizlo@^${getVersion()}`;
198
- const s = p.spinner();
199
- s.start(`Installing kizlo with ${pm}`);
200
- const ok = await runCommandAsync(addDependencyArgs(pm, spec), cwd, "ignore");
201
- s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
202
- if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
203
- }
204
- if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
205
- await setupLocalWordPress(cwd, setup);
206
- await writeEnv(cwd, preset, setup, {
207
- force,
208
- yes
209
- });
210
- await syncRemote(setup);
211
- const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
212
- const serverDirRel = path.join(dirRel, "server");
213
- const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
214
- const scaffold = buildScaffoldContext(cwd, {
215
- dirRel,
216
- appDir: detectAppDir(cwd, hasSrcDir),
217
- alias: setup.alias,
218
- clientUrl
219
- });
220
- const files = [{
221
- label: "Kizlo config",
222
- relPath: "kizlo.config.ts",
223
- contents: kizloConfigTemplate(dirRel, setup.alias, setup.devPath)
224
- }];
225
162
  let manifest;
163
+ let fetched;
226
164
  if (preset.template) {
227
- const fetched = await fetchTemplate(preset.template);
228
- try {
229
- manifest = readManifest(fetched.dir);
230
- await alignKizloVersion(cwd, pm, pkg, manifest);
231
- for (const entry of fileEntries(changesFor(manifest, "init"))) files.push(adaptFile(fetched.dir, entry, manifest.conventions, scaffold));
232
- } finally {
165
+ fetched = await fetchTemplate(preset.template);
166
+ manifest = readManifest(fetched.dir);
167
+ const minErr = minCliError(manifest);
168
+ if (minErr) {
233
169
  fetched.cleanup();
170
+ p.cancel(minErr);
171
+ process.exit(1);
234
172
  }
235
- } else if (preset.scaffolds) files.push(...preset.scaffolds(scaffold));
236
- const scaffolded = [];
237
- for (const file of files) scaffolded.push({
238
- file,
239
- result: await scaffoldFile(cwd, file, {
173
+ }
174
+ const envKeys = resolveEnvKeys(preset, manifest);
175
+ try {
176
+ const setup = yes ? collectFromEnv({
177
+ cwd,
178
+ hasSrcDir,
179
+ preset,
180
+ envKeys
181
+ }) : await collectInteractively({
182
+ cwd,
183
+ hasSrcDir,
184
+ preset
185
+ });
186
+ if (args.alias !== void 0) setup.alias = String(args.alias).trim();
187
+ setup.alias = aliasWithSlash(setup.alias);
188
+ if (!hasKizlo) {
189
+ const spec = `kizlo@${manifest?.dependencies?.kizlo ?? `^${getVersion()}`}`;
190
+ const s = p.spinner();
191
+ s.start(`Installing kizlo with ${pm}`);
192
+ const ok = await runCommandAsync(addDependencyArgs(pm, spec), cwd, "ignore");
193
+ s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
194
+ if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
195
+ }
196
+ if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
197
+ await setupLocalWordPress(cwd, setup);
198
+ await writeEnv(cwd, envKeys, setup, {
240
199
  force,
241
- yes
242
- })
243
- });
244
- writeGeneratedContract(cwd, serverDirRel);
245
- const gitignore = ensureGitignored(cwd, ".env");
246
- for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
247
- if (manifest) applyLayoutPatches(cwd, patchEntries(changesFor(manifest, "init")), manifest.conventions, scaffold);
248
- if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
249
- nextStepsNote(setup);
250
- p.outro("Kizlo is ready 🎉");
200
+ yes,
201
+ exampleTemplate: fetched ? readEnvExample(fetched.dir) : void 0
202
+ });
203
+ await syncRemote(setup);
204
+ const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
205
+ const serverDirRel = path.join(dirRel, "server");
206
+ const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
207
+ const scaffold = buildScaffoldContext(cwd, {
208
+ dirRel,
209
+ appDir: detectAppDir(cwd, hasSrcDir),
210
+ alias: setup.alias,
211
+ clientUrl
212
+ });
213
+ const files = [{
214
+ label: "Kizlo config",
215
+ relPath: "kizlo.config.ts",
216
+ contents: kizloConfigTemplate(dirRel, setup.alias, setup.mode === "local")
217
+ }];
218
+ if (manifest && fetched) {
219
+ await alignDependencies(cwd, pm, pkg, manifest);
220
+ for (const entry of fileEntries(changesFor(manifest, "init"))) files.push(adaptFile(fetched.dir, entry, manifest.conventions, scaffold));
221
+ } else if (preset.scaffolds) files.push(...preset.scaffolds(scaffold));
222
+ const scaffolded = [];
223
+ for (const file of files) scaffolded.push({
224
+ file,
225
+ result: await scaffoldFile(cwd, file, {
226
+ force,
227
+ yes
228
+ })
229
+ });
230
+ writeGeneratedContract(cwd, serverDirRel);
231
+ const gitignore = ensureGitignored(cwd, ".env");
232
+ ensureGitignored(cwd, ".kizlo/");
233
+ for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
234
+ if (manifest) applyLayoutPatches(cwd, patchEntries(changesFor(manifest, "init")), manifest.conventions, scaffold);
235
+ if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
236
+ nextStepsNote(setup);
237
+ p.outro("Kizlo is ready 🎉");
238
+ } finally {
239
+ fetched?.cleanup();
240
+ }
251
241
  }
252
242
  });
253
243
 
@@ -1,4 +1,4 @@
1
- import { createStack } from "../wp/docker.js";
1
+ import { createStack, dockerHint, dockerStatus } from "../wp/docker.js";
2
2
  import { isSeeded, recordedPort } from "../wp/utils.js";
3
3
  import { log } from "../daemon/logger.js";
4
4
  import { isFree } from "../wp/ports.js";
@@ -19,8 +19,8 @@ async function resolve(cwd) {
19
19
  };
20
20
  }
21
21
  /**
22
- * The host port to bring the test stack up on. Unlike the dev stack (rebuilt every run), the
23
- * test stack is reused and its tests connect via the URL recorded in the credentials artifact —
22
+ * The host port to bring the test WordPress up on. Unlike local dev WordPress (rebuilt every run), the
23
+ * test WordPress is reused and its tests connect via the URL recorded in the credentials artifact —
24
24
  * so the port can't just be re-chosen each time:
25
25
  *
26
26
  * - A **running** stack keeps its live published port (fast reruns stay on the same URL).
@@ -40,7 +40,7 @@ async function resolveTestPort(cfg, stack, fresh) {
40
40
  const recorded = recordedPort();
41
41
  if (recorded !== void 0) {
42
42
  if (await isFree(recorded)) return recorded;
43
- log.error(`The test stack's port ${recorded} (from a previous run) is now held by something else.\nFree that port, or run \`kizlo test reset\` to rebuild on a fresh one.`);
43
+ log.error(`The test WordPress port ${recorded} (from a previous run) is now held by something else.\nFree that port, or run \`kizlo test reset\` to rebuild on a fresh one.`);
44
44
  process.exit(1);
45
45
  }
46
46
  }
@@ -50,7 +50,7 @@ async function resolveTestPort(cfg, stack, fresh) {
50
50
  });
51
51
  }
52
52
  /**
53
- * Boot the test stack on a resolved host port and seed it once (idempotent — skips seeding when
53
+ * Boot the test WordPress on a resolved host port and seed it once (idempotent — skips seeding when
54
54
  * already seeded). Returns the port-bound stack and the port, so callers report the real URL.
55
55
  */
56
56
  async function bringUp(cfg, stack, fresh = false) {
@@ -59,8 +59,8 @@ async function bringUp(cfg, stack, fresh = false) {
59
59
  ...cfg,
60
60
  port
61
61
  }));
62
- await withSpinner("Starting WordPress test stack", () => bound.composeUp(), "WordPress test stack ready");
63
- if (await isSeeded()) log.info("Stack already seeded — skipping seed.");
62
+ await withSpinner("Starting test WordPress", () => bound.composeUp(), "Test WordPress ready");
63
+ if (await isSeeded()) log.info("Test WordPress already seeded — skipping seed.");
64
64
  else await withSpinner("Seeding WordPress", () => runSeeds({
65
65
  port,
66
66
  fixtures: cfg.fixtures
@@ -90,30 +90,45 @@ function spawnTest(command, pm) {
90
90
  child.on("close", (code) => resolvePromise(code ?? 0));
91
91
  });
92
92
  }
93
+ /** Log which test command is about to run, then spawn it and resolve with its exit code. */
94
+ function runProjectTests(cfg) {
95
+ log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
96
+ return spawnTest(cfg.command, cfg.packageManager);
97
+ }
93
98
  const runArgs = {
94
99
  teardown: {
95
100
  type: "boolean",
96
- description: "Tear down the test stack after tests finish (default: leave it running for fast reruns)"
101
+ description: "Tear down the test WordPress after tests finish (default: leave it running for fast reruns)"
97
102
  },
98
103
  reset: {
99
104
  type: "boolean",
100
105
  description: "Wipe the database and reseed before running"
101
106
  }
102
107
  };
103
- /** Boot (+ seed) the stack, run the project's test script, then leave it up (or tear down). */
108
+ /**
109
+ * Run the project's tests. When `test.local` is set, boot (+ seed) local WordPress first, run the
110
+ * suite (JS then the plugin's PHPUnit) against it, then leave it up (or tear down). Without
111
+ * `test.local`, there's no Docker WordPress to manage — just run the project's own test script.
112
+ */
104
113
  async function runSuite({ args }) {
105
- const { cfg, stack: probe } = await resolve(process.cwd());
114
+ const cfg = await resolveTestConfig(process.cwd());
115
+ if (!cfg.local) process.exit(await runProjectTests(cfg));
116
+ const status = await dockerStatus();
117
+ if (status !== "running") {
118
+ log.error(dockerHint(status));
119
+ process.exit(1);
120
+ }
121
+ const probe = createStack(testStack(cfg));
106
122
  if (args.reset) await withSpinner("Wiping WordPress database", () => probe.composeDown({ volumes: true }), "Database wiped");
107
123
  const { stack } = await bringUp(cfg, probe, Boolean(args.reset));
108
- log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
109
124
  let code = 1;
110
125
  try {
111
- const jsCode = await spawnTest(cfg.command, cfg.packageManager);
126
+ const jsCode = await runProjectTests(cfg);
112
127
  const phpCode = await runPluginPhpunit(cfg);
113
128
  code = jsCode !== 0 ? jsCode : phpCode;
114
129
  } finally {
115
- if (args.teardown) await withSpinner("Tearing down WordPress test stack", () => stack.composeDown(), "WordPress test stack stopped");
116
- else log.info("Stack left running for fast reruns — `kizlo test stop` to stop it, or rerun with --teardown.");
130
+ if (args.teardown) await withSpinner("Tearing down test WordPress", () => stack.composeDown(), "Test WordPress stopped");
131
+ else log.info("Test WordPress left running for fast reruns — `kizlo test stop` to stop it, or rerun with --teardown.");
117
132
  }
118
133
  process.exit(code);
119
134
  }
@@ -121,45 +136,30 @@ const subCommands = {
121
136
  stop: defineCommand({
122
137
  meta: {
123
138
  name: "stop",
124
- description: "Stop the WordPress test stack, keeping the database volume"
139
+ description: "Stop the test WordPress, keeping the database volume"
125
140
  },
126
141
  async run() {
127
142
  const { stack } = await resolve(process.cwd());
128
- await withSpinner("Stopping WordPress test stack", () => stack.composeStop(), "Stack stopped (volumes kept)");
129
- }
130
- }),
131
- down: defineCommand({
132
- meta: {
133
- name: "down",
134
- description: "Stop and remove the test stack containers"
135
- },
136
- args: { volumes: {
137
- type: "boolean",
138
- alias: "v",
139
- description: "Also drop the database volume"
140
- } },
141
- async run({ args }) {
142
- const { stack } = await resolve(process.cwd());
143
- await withSpinner("Removing WordPress test stack", () => stack.composeDown({ volumes: args.volumes }), args.volumes ? "Stack removed (volumes dropped)" : "Stack removed (volumes kept)");
143
+ await withSpinner("Stopping test WordPress", () => stack.composeStop(), "Test WordPress stopped (volumes kept)");
144
144
  }
145
145
  }),
146
146
  reset: defineCommand({
147
147
  meta: {
148
148
  name: "reset",
149
- description: "Wipe the database and reseed a fresh WordPress test stack"
149
+ description: "Wipe the database and reseed fresh test WordPress"
150
150
  },
151
151
  async run() {
152
152
  const { cfg, stack } = await resolve(process.cwd());
153
153
  await withSpinner("Wiping WordPress database", () => stack.composeDown({ volumes: true }), "Database wiped");
154
154
  const { port } = await bringUp(cfg, stack, true);
155
- log.success(`Stack ready on http://localhost:${port} — credentials at ${cfg.credentialsPath}`);
155
+ log.success(`Test WordPress ready on http://localhost:${port} — credentials at ${cfg.credentialsPath}`);
156
156
  }
157
157
  })
158
158
  };
159
159
  const test = defineCommand({
160
160
  meta: {
161
161
  name: "test",
162
- description: "Manage the WordPress test stack and run tests (stop | down | reset)"
162
+ description: "Run tests against local WordPress (stop | reset)"
163
163
  },
164
164
  args: runArgs,
165
165
  subCommands,
@@ -1,3 +1,4 @@
1
+ import { LOCAL_DIR_REL } from "../wp/constants.js";
1
2
  import { credentialsPath, findConfigDir } from "../wp/utils.js";
2
3
  import { log } from "./logger.js";
3
4
  import { detectPackageManager } from "../utils.js";
@@ -14,13 +15,13 @@ const configSchema = z.object({
14
15
  alias: z.string().optional(),
15
16
  name: z.string().optional(),
16
17
  dev: z.object({
17
- path: z.string().optional(),
18
+ local: z.boolean().optional(),
18
19
  port: z.number().int().positive().optional(),
19
20
  dbPort: z.number().int().positive().optional(),
20
- byo: z.string().optional(),
21
21
  fixtures: z.array(fixtureSchema).optional()
22
22
  }).optional(),
23
23
  test: z.object({
24
+ local: z.boolean().optional(),
24
25
  port: z.number().int().positive().optional(),
25
26
  fixtures: z.array(fixtureSchema).optional(),
26
27
  packageManager: z.enum([
@@ -108,6 +109,7 @@ async function resolveTestConfig(cwd) {
108
109
  const test = fileConfig?.test ?? {};
109
110
  return {
110
111
  configDir,
112
+ local: Boolean(test.local),
111
113
  project: `${resolveStackName(configDir, fileConfig?.name)}-test`,
112
114
  command: test.command,
113
115
  port: test.port ?? DEFAULT_TEST_PORT,
@@ -118,33 +120,26 @@ async function resolveTestConfig(cwd) {
118
120
  };
119
121
  }
120
122
  /**
121
- * Whether a local WordPress dev stack is configured (`dev.path` is set). When false, `kizlo dev`
122
- * has no stack to boot and runs the contract watcher alone — the path a bring-your-own-WordPress
123
- * project takes. Loads `kizlo.config.*` from the config dir; a missing config counts as no stack.
123
+ * Whether this project runs local WordPress under `kizlo dev` — `dev.local` is `true` in
124
+ * `kizlo.config.*`. When false, `kizlo dev` has nothing to boot and runs the contract watcher alone,
125
+ * the path a project pointing at its own WordPress takes. The flag is written by `create`/`init` when
126
+ * local WordPress is chosen, and lives next to the rest of the `dev` config, so it's committed and
127
+ * survives `kizlo dev reset`.
124
128
  */
125
- async function hasDevStack(cwd) {
129
+ async function usesLocalWordPress(cwd) {
126
130
  const fileConfig = await loadConfigFile(findConfigDir(cwd));
127
- return Boolean(fileConfig?.dev?.path);
131
+ return Boolean(fileConfig?.dev?.local);
128
132
  }
129
133
  /**
130
- * Resolve the `dev` block from `kizlo.config.*` into concrete values for the `dev`
131
- * command, applying defaults (port 8080). `dev.path` is required — the whole
132
- * WordPress install lives there, so we make the user choose a real folder rather
133
- * than hide it under a default. Callers gate on {@link hasDevStack} first, since a
134
- * project without a local stack runs the watcher alone rather than reaching here.
134
+ * Resolve the `dev` block from `kizlo.config.*` into concrete values for the `dev` command, applying
135
+ * defaults (port 8080). The install folder is fixed at `.kizlo/local` — no longer a config choice — so
136
+ * there's nothing required here. Callers gate on {@link usesLocalWordPress} first, since a project
137
+ * without local WordPress runs the watcher alone rather than reaching here.
135
138
  */
136
139
  async function resolveDevConfig(cwd) {
137
140
  const configDir = findConfigDir(cwd);
138
141
  const fileConfig = await loadConfigFile(configDir);
139
142
  const dev = fileConfig?.dev ?? {};
140
- if (!dev.path) {
141
- log.error("`dev.path` is required in kizlo.config — it's the folder your local WordPress lives in.\nPick a real, visible folder (it persists your site between runs), e.g. dev: { path: \"wordpress\" }.");
142
- process.exit(1);
143
- }
144
- if (dev.byo && dev.fixtures?.length) {
145
- log.error("`dev.byo` and `dev.fixtures` are mutually exclusive — byo imports an existing site, so it brings its own data.");
146
- process.exit(1);
147
- }
148
143
  return {
149
144
  configDir,
150
145
  project: `${resolveStackName(configDir, fileConfig?.name)}-dev`,
@@ -152,12 +147,11 @@ async function resolveDevConfig(cwd) {
152
147
  portExplicit: dev.port !== void 0,
153
148
  dbPort: dev.dbPort ?? DEFAULT_DEV_DB_PORT,
154
149
  dbPortExplicit: dev.dbPort !== void 0,
155
- byo: dev.byo ? path.resolve(configDir, dev.byo) : void 0,
156
150
  fixtures: dev.fixtures ?? [],
157
- wordpressPath: dev.path,
158
- wordpressDir: path.resolve(configDir, dev.path)
151
+ wordpressPath: LOCAL_DIR_REL,
152
+ wordpressDir: path.resolve(configDir, LOCAL_DIR_REL)
159
153
  };
160
154
  }
161
155
 
162
156
  //#endregion
163
- export { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, hasDevStack, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig };
157
+ export { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, usesLocalWordPress };
@@ -1,9 +1,11 @@
1
+ import { DEFAULT_ENV_KEYS } from "../utils.js";
2
+
1
3
  //#region src/cli/presets/base.ts
2
4
  const base = {
3
5
  id: "base",
4
6
  label: "Generic (no framework detected)",
5
7
  detect: () => 1,
6
- baseUrlEnvKey: "KIZLO_BACKEND_URL",
8
+ envKeys: DEFAULT_ENV_KEYS,
7
9
  scaffolds(ctx) {
8
10
  return [{
9
11
  label: "Kizlo server instance",
@@ -24,7 +26,7 @@ function clientEntry(ctx) {
24
26
  import { contract } from "./${ctx.serverDirName}/generated"
25
27
 
26
28
  // Your Kizlo backend runs on a different origin than this app. Swap in your bundler's
27
- // public env var (e.g. import.meta.env.VITE_KIZLO_BACKEND_URL) to set it per environment.
29
+ // public env var (e.g. import.meta.env.VITE_KIZLO_API_URL) to set it per environment.
28
30
  export const client = createKizloClient(contract, { url: "${ctx.clientUrl}" })
29
31
  `;
30
32
  return `import { createKizloClient } from "kizlo"
@@ -8,7 +8,6 @@ const nextjs = {
8
8
  ...ctx.pkg.devDependencies
9
9
  }.next ? 100 : 0;
10
10
  },
11
- baseUrlEnvKey: "NEXT_PUBLIC_KIZLO_BACKEND_URL",
12
11
  apiPath: "/api/kizlo",
13
12
  template: "nextjs"
14
13
  };
@@ -1,3 +1,4 @@
1
+ import { getVersion } from "../utils.js";
1
2
  import { resolvePatchTargetPath } from "./patch.js";
2
3
  import z from "zod/v4";
3
4
  import path from "node:path";
@@ -58,9 +59,36 @@ const bootstrapSchema = z.object({
58
59
  flags: z.array(z.string()).default([])
59
60
  });
60
61
  const changeSchema = z.discriminatedUnion("kind", [fileSchema, patchSchema]);
62
+ /**
63
+ * The `.env` key names the scaffold writes and the pinned runtime later reads — the one part of the
64
+ * wiring contract that can drift when the scaffold-time CLI and the pinned runtime disagree. Declared
65
+ * here as data so the scaffold writes whatever names the pinned runtime expects. Semantic slots only;
66
+ * the CLI still fills each value (generate a secret, provision Docker, prompt for a remote URL).
67
+ */
68
+ const envSchema = z.object({
69
+ baseUrl: z.string(),
70
+ remote: z.object({
71
+ siteSecret: z.string(),
72
+ wpUrl: z.string(),
73
+ wpUsername: z.string(),
74
+ wpPassword: z.string()
75
+ }),
76
+ local: z.object({
77
+ connect: z.string(),
78
+ siteSecret: z.string(),
79
+ wpUrl: z.string(),
80
+ wpUsername: z.string(),
81
+ wpPassword: z.string()
82
+ })
83
+ });
84
+ /** A package name → version-range map, package.json's own vocabulary. */
85
+ const depsSchema = z.record(z.string(), z.string());
61
86
  const manifestSchema = z.object({
62
87
  framework: z.string(),
63
- kizloVersion: z.string().optional(),
88
+ env: envSchema.optional(),
89
+ dependencies: depsSchema.optional(),
90
+ devDependencies: depsSchema.optional(),
91
+ minCli: z.string().optional(),
64
92
  bootstrap: bootstrapSchema.optional(),
65
93
  conventions: conventionsSchema,
66
94
  base: z.array(changeSchema).default([]),
@@ -88,6 +116,52 @@ function readManifest(templateDir) {
88
116
  return manifestSchema.parse(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
89
117
  }
90
118
  /**
119
+ * The dependencies a scaffolded project should pin, from the manifest. `kizlo` is always present —
120
+ * every project needs it — so it falls back to the running CLI's version in-repo before the first
121
+ * stamped release; any other declared package is taken verbatim (a template only lists packages it has
122
+ * a version for). Framework packages release in lockstep with `kizlo`, so a stamped manifest carries
123
+ * them all at the same released version.
124
+ */
125
+ function resolveDependencies(manifest) {
126
+ return {
127
+ dependencies: {
128
+ kizlo: `^${getVersion()}`,
129
+ ...manifest.dependencies
130
+ },
131
+ devDependencies: { ...manifest.devDependencies }
132
+ };
133
+ }
134
+ /** A bare `x.y.z` from a version spec (range prefix and pre-release/build metadata dropped); undefined if none. */
135
+ function coerceVersion(spec) {
136
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(spec);
137
+ return match ? [
138
+ Number(match[1]),
139
+ Number(match[2]),
140
+ Number(match[3])
141
+ ] : void 0;
142
+ }
143
+ /** Whether `have` is a strictly older release than `want`; false when either can't be compared. */
144
+ function isOlderVersion(have, want) {
145
+ const a = coerceVersion(have);
146
+ const b = coerceVersion(want);
147
+ if (!a || !b) return false;
148
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] < b[i];
149
+ return false;
150
+ }
151
+ /**
152
+ * The loud-failure backstop: an error message when the running CLI is too old to apply this manifest,
153
+ * or `undefined` when it's fine. Dormant by design — a manifest with no `minCli` never fails, so the
154
+ * check is inert until a structural change bumps the floor. The first CLI-incompatible manifest change
155
+ * sets `minCli`; from then on older CLIs (that already ship this check) stop with a clear message
156
+ * instead of silently under-wiring the project.
157
+ */
158
+ function minCliError(manifest) {
159
+ if (!manifest.minCli) return void 0;
160
+ const running = getVersion();
161
+ if (!isOlderVersion(running, manifest.minCli)) return void 0;
162
+ return `This template needs the kizlo CLI >= ${manifest.minCli} (you have ${running}). Run \`npx kizlo@latest\` and try again.`;
163
+ }
164
+ /**
91
165
  * The changes a command applies: the shared `base` set plus the command's own section. `create` gets
92
166
  * `base + create` (plumbing plus the demo pages); `init` gets `base + init` (plumbing plus any
93
167
  * init-only surgical patches). `base` is applied first so a later section can override it.
@@ -193,4 +267,4 @@ function rendersHtml(file) {
193
267
  }
194
268
 
195
269
  //#endregion
196
- export { adaptFile, changesFor, fileEntries, findRootLayout, isExample, patchEntries, readManifest, resolvePatch };
270
+ export { adaptFile, changesFor, fileEntries, findRootLayout, isExample, isOlderVersion, minCliError, patchEntries, readManifest, resolveDependencies, resolvePatch };