kizlo 0.10.0 → 0.12.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.
Files changed (58) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/server.d.ts +9 -0
  3. package/dist/astro/server.js +10 -0
  4. package/dist/astro.d.ts +2 -0
  5. package/dist/astro.js +3 -0
  6. package/dist/cli/commands/_setup.js +19 -10
  7. package/dist/cli/commands/create.js +102 -72
  8. package/dist/cli/commands/dev.js +113 -20
  9. package/dist/cli/commands/generate.js +4 -0
  10. package/dist/cli/commands/init.js +111 -57
  11. package/dist/cli/daemon/config.js +13 -7
  12. package/dist/cli/daemon/generate.js +2 -3
  13. package/dist/cli/daemon/jiti.js +101 -0
  14. package/dist/cli/daemon/logger.js +1 -1
  15. package/dist/cli/daemon/reload.js +66 -0
  16. package/dist/cli/daemon/watch.js +7 -4
  17. package/dist/cli/presets/source.js +122 -24
  18. package/dist/cli/presets/template.js +75 -17
  19. package/dist/cli/wp/settings.js +8 -2
  20. package/dist/comment/schema.js +2 -2
  21. package/dist/context.d.ts +9 -0
  22. package/dist/context.js +20 -2
  23. package/dist/index.d.ts +4 -4
  24. package/dist/index.js +3 -3
  25. package/dist/integrations/astro/kizlo.client.d.ts +15 -0
  26. package/dist/integrations/astro/kizlo.client.js +13 -0
  27. package/dist/integrations/astro/kizlo.server.d.ts +10 -0
  28. package/dist/integrations/astro/kizlo.server.js +12 -0
  29. package/dist/integrations/astro/manifest.d.ts +9 -0
  30. package/dist/integrations/astro/manifest.js +11 -0
  31. package/dist/integrations/astro/metadata.d.ts +64 -0
  32. package/dist/integrations/astro/metadata.js +71 -0
  33. package/dist/integrations/astro/robots.d.ts +8 -0
  34. package/dist/integrations/astro/robots.js +11 -0
  35. package/dist/integrations/astro/sitemap.d.ts +16 -0
  36. package/dist/integrations/astro/sitemap.js +20 -0
  37. package/dist/integrations/astro/utils.d.ts +19 -0
  38. package/dist/integrations/astro/utils.js +17 -0
  39. package/dist/integrations/nextjs/metadata.d.ts +1 -0
  40. package/dist/integrations/nextjs/metadata.js +1 -0
  41. package/dist/kizlo.d.ts +8 -1
  42. package/dist/kizlo.js +16 -13
  43. package/dist/nextjs/server.d.ts +2 -1
  44. package/dist/nextjs/server.js +2 -1
  45. package/dist/package.js +1 -1
  46. package/dist/page/schema.js +2 -2
  47. package/dist/post/schema.js +2 -2
  48. package/dist/seo/utils.d.ts +8 -2
  49. package/dist/seo/utils.js +9 -1
  50. package/dist/shared/constants.js +9 -1
  51. package/dist/shared/schema.d.ts +1 -2
  52. package/dist/shared/schema.js +2 -2
  53. package/dist/wordpress/service.d.ts +4 -0
  54. package/dist/wordpress/service.js +5 -0
  55. package/package.json +18 -2
  56. package/dist/cli/presets/base.js +0 -40
  57. package/dist/cli/presets/index.js +0 -16
  58. package/dist/cli/presets/nextjs.js +0 -16
package/README.md CHANGED
@@ -23,16 +23,16 @@ Kizlo is an open-source framework for building content-driven applications with
23
23
 
24
24
  ## Quick start
25
25
 
26
- Set up Kizlo in your project:
26
+ Scaffold a new project with Kizlo already wired:
27
27
 
28
28
  ```bash
29
- npx kizlo@latest init
29
+ npx kizlo@latest create
30
30
  ```
31
31
 
32
- Start the local WordPress stack:
32
+ Or add Kizlo to an existing project:
33
33
 
34
34
  ```bash
35
- npx kizlo dev
35
+ npx kizlo@latest init
36
36
  ```
37
37
 
38
38
  See the [docs](https://kizlo.io/docs) for the full guide.
@@ -0,0 +1,9 @@
1
+ import { WEB_MANIFEST_ROUTE } from "../brand/manifest.js";
2
+ import { renderJsonLd } from "../seo/utils.js";
3
+ import { KizloServerOptions, createKizlo } from "../integrations/astro/kizlo.server.js";
4
+ import { AstroRoute, AstroRouteContext, getServerBaseUrl } from "../integrations/astro/utils.js";
5
+ import { createManifestRoute } from "../integrations/astro/manifest.js";
6
+ import { PageHead, RootHead, ThemeColor, resolvePageHead, resolveRootHead } from "../integrations/astro/metadata.js";
7
+ import { createRobotsRoute } from "../integrations/astro/robots.js";
8
+ import { createSitemapRedirectRoute, createSitemapRoute } from "../integrations/astro/sitemap.js";
9
+ export { AstroRoute, AstroRouteContext, KizloServerOptions, PageHead, RootHead, ThemeColor, WEB_MANIFEST_ROUTE, createKizlo, createManifestRoute, createRobotsRoute, createSitemapRedirectRoute, createSitemapRoute, getServerBaseUrl, renderJsonLd, resolvePageHead, resolveRootHead };
@@ -0,0 +1,10 @@
1
+ import { WEB_MANIFEST_ROUTE } from "../brand/manifest.js";
2
+ import { renderJsonLd } from "../seo/utils.js";
3
+ import { getServerBaseUrl } from "../integrations/astro/utils.js";
4
+ import { createKizlo } from "../integrations/astro/kizlo.server.js";
5
+ import { createManifestRoute } from "../integrations/astro/manifest.js";
6
+ import { resolvePageHead, resolveRootHead } from "../integrations/astro/metadata.js";
7
+ import { createRobotsRoute } from "../integrations/astro/robots.js";
8
+ import { createSitemapRedirectRoute, createSitemapRoute } from "../integrations/astro/sitemap.js";
9
+
10
+ export { WEB_MANIFEST_ROUTE, createKizlo, createManifestRoute, createRobotsRoute, createSitemapRedirectRoute, createSitemapRoute, getServerBaseUrl, renderJsonLd, resolvePageHead, resolveRootHead };
@@ -0,0 +1,2 @@
1
+ import { KizloClientOptions, createKizloClient } from "./integrations/astro/kizlo.client.js";
2
+ export { KizloClientOptions, createKizloClient };
package/dist/astro.js ADDED
@@ -0,0 +1,3 @@
1
+ import { createKizloClient } from "./integrations/astro/kizlo.client.js";
2
+
3
+ export { createKizloClient };
@@ -7,6 +7,7 @@ import { bootstrapDev } from "../wp/dev.js";
7
7
  import { removeProjectContainers } from "../wp/session.js";
8
8
  import { syncSiteSettings } from "../wp/settings.js";
9
9
  import { devStack } from "../wp/stack.js";
10
+ import { isPluginVersionSupported, pluginUpdateMessage } from "@kizlo/shared";
10
11
  import z from "zod/v4";
11
12
  import path from "node:path";
12
13
  import fs from "node:fs";
@@ -16,13 +17,13 @@ import getPort, { portNumbers } from "get-port";
16
17
 
17
18
  //#region src/cli/commands/_setup.ts
18
19
  /**
19
- * The resolved `.env` key names the scaffold writes: the template's declared `env` when it has one,
20
- * else the preset's built-in `envKeys` (the generic `base` preset), else the {@link DEFAULT_ENV_KEYS}
21
- * fallback for an in-repo template before the first stamped release. Threaded into `managedEnv`,
22
- * `writeEnv`, and `collectConnectionFromEnv` so the scaffold writes the names the pinned runtime reads.
20
+ * The resolved `.env` key names the scaffold writes: the template's declared `env` when it has one, else
21
+ * the {@link DEFAULT_ENV_KEYS} fallback — for an in-repo template before the first stamped release, and
22
+ * for the base no-template fallback (which has no manifest). Threaded into `managedEnv`, `writeEnv`, and
23
+ * `collectConnectionFromEnv` so the scaffold writes the names the pinned runtime reads.
23
24
  */
24
- function resolveEnvKeys(preset, manifest) {
25
- return manifest?.env ?? preset.envKeys ?? DEFAULT_ENV_KEYS;
25
+ function resolveEnvKeys(manifest) {
26
+ return manifest?.env ?? DEFAULT_ENV_KEYS;
26
27
  }
27
28
  /** Remote connection keys — what a real deploy needs, and what `.env.example` always lists. */
28
29
  function remoteKeys(envKeys) {
@@ -80,7 +81,11 @@ function managedEnv(envKeys, conn) {
80
81
  };
81
82
  }
82
83
  const requiredString = z.string().trim().min(1, "Required");
83
- const urlString = requiredString.pipe(z.url("Must be a valid URL (e.g. https://example.com)"));
84
+ const urlString = requiredString.pipe(z.url({
85
+ protocol: /^https?$/,
86
+ hostname: /^(localhost|(\d{1,3}\.){3}\d{1,3}|([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})$/,
87
+ error: "Must be a valid http(s) URL with a real host (e.g. https://example.com)"
88
+ }));
84
89
  const dirPath = requiredString.refine((value) => !value.endsWith(".ts"), "Enter a directory, not a file");
85
90
  function validate(schema) {
86
91
  return (value) => {
@@ -130,11 +135,11 @@ function pickAppPort() {
130
135
  * `create` scaffolds a brand-new app that has no public URL yet, so it defaults to the local dev
131
136
  * origin and lets the user fill in production later.
132
137
  */
133
- async function collectConnectionInteractively(preset, opts = {}) {
138
+ async function collectConnectionInteractively(apiPath, opts = {}) {
134
139
  let siteUrl;
135
140
  let baseUrl;
136
141
  if (opts.baseUrl !== void 0) baseUrl = opts.baseUrl;
137
- else if (preset.apiPath) baseUrl = orCancel(await p.text({
142
+ else if (apiPath) baseUrl = orCancel(await p.text({
138
143
  message: "Public app URL",
139
144
  placeholder: "https://your-app.com",
140
145
  validate: validate(urlString)
@@ -382,7 +387,11 @@ async function syncRemote(conn) {
382
387
  siteUrl: conn.siteUrl,
383
388
  backendUrl: conn.baseUrl
384
389
  });
385
- 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.`);
390
+ if (!sync.ok) {
391
+ 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.`);
392
+ return;
393
+ }
394
+ if (!isPluginVersionSupported(sync.pluginVersion)) p.log.warn(pluginUpdateMessage(sync.pluginVersion));
386
395
  }
387
396
  /** The "Next steps" lines. `kizlo dev` is the single entry point for development. */
388
397
  function nextStepsLines(conn, prefix = "") {
@@ -1,7 +1,6 @@
1
1
  import { availablePackageManagers, detectInvokingPackageManager, ensureGitignored, frameworkCreateArgs, installArgs, runCommandAsync, runCommandCaptured } from "../utils.js";
2
- import { getPreset } from "../presets/index.js";
3
- import { fetchTemplate } from "../presets/source.js";
4
- import { adaptFile, changesFor, fileEntries, isExample, minCliError, patchEntries, readManifest, resolveDependencies } from "../presets/template.js";
2
+ import { fetchRegistry, listTemplates, resolveRegistry } from "../presets/source.js";
3
+ import { adaptFile, changesFor, fileEntries, minCliError, patchEntries, readManifest, resolveDependencies } from "../presets/template.js";
5
4
  import { collectConnectionInteractively, nextStepsLines, orCancel, pickAppPort, readEnvExample, resolveEnvKeys, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
6
5
  import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
7
6
  import z from "zod/v4";
@@ -11,15 +10,30 @@ import { defineCommand } from "citty";
11
10
  import * as p from "@clack/prompts";
12
11
 
13
12
  //#region src/cli/commands/create.ts
13
+ /** The final path segment of a project name — the app folder itself; everything before it is where it lands. */
14
+ function folderSegment(value) {
15
+ const segments = value.replace(/\\/g, "/").split("/").filter(Boolean);
16
+ return segments[segments.length - 1] ?? "";
17
+ }
14
18
  /**
15
- * The templates `create` can scaffold from. Each id is both the `templates/<id>` folder and the
16
- * preset that drives its WordPress setup and framework bootstrap.
19
+ * Tidy the entered name/path for both the target dir and the display: trim, drop a leading `./`, and drop a
20
+ * trailing separator, so `cd`, the spinner, and the warning lines read as `templates/my-app`, not
21
+ * `./templates/my-app/`. Preserves `../` and absolute paths (only a leading `./` is redundant).
17
22
  */
18
- const TEMPLATES = ["nextjs"];
19
- function isTemplate(value) {
20
- return TEMPLATES.includes(value);
23
+ function normalizeProjectName(value) {
24
+ return value.trim().replace(/^\.\/+/, "").replace(/[/\\]+$/, "");
21
25
  }
22
- const projectName = validate(z.string().trim().min(1, "Required").refine((value) => !value.startsWith(".") && !value.includes("/") && !value.includes("\\"), "Enter a folder name (letters, numbers, dashes)"));
26
+ /**
27
+ * A project name may be a bare folder (`my-app`) or a path to scaffold somewhere other than the current
28
+ * dir — relative (`apps/my-app`, `../my-app`) or absolute (`/srv/my-app`). Only the last segment is the
29
+ * app folder, so that's what's validated as a name; the parent dirs (which may not exist yet) are created
30
+ * by the framework's own CLI. `.`/`..` as the final segment, or a name with no real segment, is rejected.
31
+ */
32
+ const projectNameSchema = z.string().trim().min(1, "Required").refine((value) => {
33
+ const base = folderSegment(value);
34
+ return base.length > 0 && base !== "." && base !== ".." && /^[A-Za-z0-9._-]+$/.test(base);
35
+ }, "The app folder (last path segment) can only contain letters, numbers, dots, and dashes");
36
+ const projectName = validate(projectNameSchema);
23
37
  /** Package managers `create` can wire the getting-started steps for, in display order. */
24
38
  const PACKAGE_MANAGERS = [
25
39
  "pnpm",
@@ -37,17 +51,25 @@ function recordDependencies(dir, manifest) {
37
51
  const pkgPath = path.join(dir, "package.json");
38
52
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
39
53
  const { dependencies, devDependencies } = resolveDependencies(manifest);
40
- pkg.dependencies = {
54
+ pkg.dependencies = sortDependencies({
41
55
  ...pkg.dependencies,
42
56
  ...dependencies
43
- };
44
- if (Object.keys(devDependencies).length) pkg.devDependencies = {
57
+ });
58
+ if (Object.keys(devDependencies).length) pkg.devDependencies = sortDependencies({
45
59
  ...pkg.devDependencies,
46
60
  ...devDependencies
47
- };
61
+ });
48
62
  fs.writeFileSync(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
49
63
  }
50
64
  /**
65
+ * Alphabetize a dependency map by package name. The framework CLI writes its deps sorted, but merging
66
+ * Kizlo's pins on top appends them out of order — sherif flags unsorted dependency keys, so re-sort the
67
+ * whole map before writing.
68
+ */
69
+ function sortDependencies(deps) {
70
+ return Object.fromEntries(Object.entries(deps).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0));
71
+ }
72
+ /**
51
73
  * The argv `create` runs to bootstrap a fresh base app with the framework's own official CLI, built
52
74
  * from the template manifest's `bootstrap` (initializer + flags) — the template is the single source
53
75
  * of truth for the framework. The CLI owns only the package-manager mechanics: {@link frameworkCreateArgs}
@@ -63,8 +85,8 @@ function bootstrapArgs(manifest, pm, name) {
63
85
  * Layer Kizlo's wiring onto a freshly bootstrapped app in `dir`, from an already-fetched template
64
86
  * directory + manifest. The framework CLI has produced the base project (package.json, config,
65
87
  * tsconfig, the root layout); this drives the manifest — the same engine `init` uses — to record the
66
- * `kizlo` dependency, write `kizlo.config.ts`, scaffold the manifest's files (the `base` plumbing and
67
- * the `create` layout/styles always, the `example`-flagged demo pages only when `includeExamples`),
88
+ * `kizlo` dependency, write `kizlo.config.ts`, scaffold the manifest's files (core wiring from `base`
89
+ * and `create` always, the `example`-flagged demo pages only when `includeExamples`),
68
90
  * seed the generated contract, and ignore `.env` and the `.kizlo/` working dir (which holds the local WordPress install). On a fresh app Kizlo owns the layout, so `create`
69
91
  * writes it whole (already SEO-wired) rather than patching — `applyLayoutPatches` runs over `create`'s
70
92
  * patches, which are none today. The target directory layout comes straight from the manifest's
@@ -80,7 +102,7 @@ async function applyManifestWiring(dir, templateDir, manifest, opts) {
80
102
  clientUrl: void 0
81
103
  });
82
104
  recordDependencies(dir, manifest);
83
- const changes = changesFor(manifest, "create").filter((change) => opts.includeExamples || !isExample(change));
105
+ const changes = changesFor(manifest, "create", { includeExamples: opts.includeExamples });
84
106
  const files = [{
85
107
  label: "Kizlo config",
86
108
  relPath: "kizlo.config.ts",
@@ -104,54 +126,75 @@ const create = defineCommand({
104
126
  template: {
105
127
  type: "positional",
106
128
  required: false,
107
- description: `Template to scaffold from (${TEMPLATES.join(", ")})`
129
+ description: "Template to scaffold from (discovered from the registry)"
108
130
  },
109
131
  name: {
110
132
  type: "positional",
111
133
  required: false,
112
- description: "Project folder to create"
134
+ description: "Project folder to create — a bare name or a path (e.g. apps/my-app, ../my-app, /srv/my-app)"
135
+ },
136
+ source: {
137
+ type: "string",
138
+ description: "Where to scaffold from — a local dir or giget source, either a registry of templates or a single template (default: Kizlo's GitHub templates)"
113
139
  }
114
140
  },
115
141
  async run({ args }) {
116
142
  const cwd = process.cwd();
117
143
  p.intro(`Let's create a new Kizlo application`);
144
+ const registry = resolveRegistry(args.source ? String(args.source) : void 0);
145
+ let fetchedRegistry;
146
+ try {
147
+ fetchedRegistry = await fetchRegistry(registry);
148
+ } catch (error) {
149
+ p.cancel(error instanceof Error ? error.message : String(error));
150
+ process.exit(1);
151
+ }
152
+ const templates = listTemplates(fetchedRegistry.dir);
153
+ if (templates.length === 0) {
154
+ fetchedRegistry.cleanup();
155
+ p.cancel(`No templates found in the registry (${registry.source}). Each template needs a template.json.`);
156
+ process.exit(1);
157
+ }
158
+ const cancelFrom = (message) => {
159
+ fetchedRegistry.cleanup();
160
+ p.cancel(message);
161
+ process.exit(1);
162
+ };
118
163
  const requested = args.template;
119
- let template;
164
+ let selected;
120
165
  if (requested) {
121
- if (!isTemplate(requested)) {
122
- p.cancel(`Unknown template "${requested}". Available: ${TEMPLATES.join(", ")}`);
123
- process.exit(1);
124
- }
125
- template = requested;
126
- } else template = orCancel(await p.select({
127
- message: "Template",
128
- options: TEMPLATES.map((id) => ({
129
- value: id,
130
- label: id
131
- }))
132
- }));
133
- const name = args.name ? String(args.name).trim() : orCancel(await p.text({
134
- message: "Project name",
166
+ selected = templates.find((entry) => entry.id === requested);
167
+ if (!selected) return cancelFrom(`Unknown template "${requested}". Available: ${templates.map((entry) => entry.id).join(", ")}`);
168
+ } else {
169
+ const id = orCancel(await p.select({
170
+ message: "Template",
171
+ options: templates.map((entry) => ({
172
+ value: entry.id,
173
+ label: entry.label
174
+ }))
175
+ }));
176
+ selected = templates.find((entry) => entry.id === id);
177
+ }
178
+ if (!selected) return cancelFrom("No template selected.");
179
+ const template = selected.id;
180
+ const templateDir = selected.dir;
181
+ const label = selected.label;
182
+ const fail = cancelFrom;
183
+ const manifest = readManifest(templateDir);
184
+ const minErr = minCliError(manifest);
185
+ if (minErr) return fail(minErr);
186
+ const name = normalizeProjectName(args.name ? String(args.name) : orCancel(await p.text({
187
+ message: "What is your project named?",
135
188
  placeholder: "my-app",
136
189
  validate: projectName
137
- }));
138
- if (!args.name) {
139
- const check = projectName(name);
140
- if (check) {
141
- p.cancel(check);
142
- process.exit(1);
143
- }
144
- }
145
- const dir = path.resolve(cwd, name);
146
- if (fs.existsSync(dir)) {
147
- p.cancel(`${name} already exists — pick a different name or remove it.`);
148
- process.exit(1);
149
- }
150
- const preset = getPreset(template);
151
- if (!preset) {
152
- p.cancel(`No preset for template "${template}".`);
190
+ })));
191
+ const invalid = projectName(name);
192
+ if (invalid) {
193
+ p.cancel(invalid);
153
194
  process.exit(1);
154
195
  }
196
+ const dir = path.resolve(cwd, name);
197
+ if (fs.existsSync(dir)) return cancelFrom(`${name} already exists — pick a different name or remove it.`);
155
198
  const installed = availablePackageManagers(PACKAGE_MANAGERS);
156
199
  const invokingPm = detectInvokingPackageManager();
157
200
  const defaultPm = invokingPm && installed.includes(invokingPm) ? invokingPm : installed[0];
@@ -163,45 +206,32 @@ const create = defineCommand({
163
206
  })),
164
207
  initialValue: defaultPm
165
208
  }));
166
- const conn = await collectConnectionInteractively(preset, { baseUrl: `http://localhost:${await pickAppPort()}` });
167
- if (preset.apiPath && conn.baseUrl) conn.baseUrl = withApiPath(conn.baseUrl, preset.apiPath);
209
+ const conn = await collectConnectionInteractively(manifest.apiPath, { baseUrl: `http://localhost:${await pickAppPort()}` });
210
+ if (manifest.apiPath && conn.baseUrl) conn.baseUrl = withApiPath(conn.baseUrl, manifest.apiPath);
168
211
  const includeExamples = orCancel(await p.confirm({
169
212
  message: "Add examples?",
170
213
  initialValue: true
171
214
  }));
172
- const fetched = await fetchTemplate(template);
173
- const fail = (message) => {
174
- fetched.cleanup();
175
- p.cancel(message);
176
- process.exit(1);
177
- };
178
- const manifest = readManifest(fetched.dir);
179
- const minErr = minCliError(manifest);
180
- if (minErr) fail(minErr);
181
215
  const bootstrap = bootstrapArgs(manifest, pm, name);
182
- if (!bootstrap) {
183
- fetched.cleanup();
184
- p.cancel(`Template "${template}" can't be scaffolded — its manifest declares no framework bootstrap.`);
185
- process.exit(1);
186
- }
216
+ if (!bootstrap) return fail(`Template "${template}" can't be scaffolded — its manifest declares no framework bootstrap.`);
187
217
  const s = p.spinner();
188
- s.start(`Creating ${name} with the ${preset.label} CLI`);
218
+ s.start(`Creating ${name} with the ${label} CLI`);
189
219
  const scaffold = runCommandCaptured(bootstrap, cwd);
190
- s.stop(scaffold.ok ? `Created ${name} with the ${preset.label} CLI` : `${preset.label} setup failed`);
220
+ s.stop(scaffold.ok ? `Created ${name} with the ${label} CLI` : `${label} setup failed`);
191
221
  if (!scaffold.ok) {
192
222
  if (scaffold.output) p.log.error(scaffold.output);
193
- fail(`${preset.label} setup failed — see the output above and try again.`);
223
+ fail(`${label} setup failed — see the output above and try again.`);
194
224
  }
195
- const exampleTemplate = readEnvExample(fetched.dir);
225
+ const exampleTemplate = readEnvExample(templateDir);
196
226
  try {
197
- await applyManifestWiring(dir, fetched.dir, manifest, {
227
+ await applyManifestWiring(dir, templateDir, manifest, {
198
228
  includeExamples,
199
229
  localDev: conn.mode === "local"
200
230
  });
201
231
  } catch (error) {
202
232
  fail(error instanceof Error ? error.message : String(error));
203
233
  }
204
- fetched.cleanup();
234
+ fetchedRegistry.cleanup();
205
235
  let depsInstalled = false;
206
236
  if (orCancel(await p.confirm({
207
237
  message: "Install dependencies now?",
@@ -214,7 +244,7 @@ const create = defineCommand({
214
244
  if (!depsInstalled) p.log.warn(`Install them yourself: cd ${name} && ${installArgs(pm).join(" ")}`);
215
245
  }
216
246
  await setupLocalWordPress(dir, conn);
217
- await writeEnv(dir, resolveEnvKeys(preset, manifest), conn, {
247
+ await writeEnv(dir, resolveEnvKeys(manifest), conn, {
218
248
  force: true,
219
249
  yes: false,
220
250
  exampleTemplate
@@ -1,4 +1,6 @@
1
1
  import { createStack, dockerHint, dockerStatus } from "../wp/docker.js";
2
+ import { findConfigDir } from "../wp/utils.js";
3
+ import { log } from "../daemon/logger.js";
2
4
  import { DEFAULT_ENV_KEYS, ensureGitignored, envGroups, groupDefault, mergeEnv, pickStackPort, withSpinner } from "../utils.js";
3
5
  import { resolveDevConfig, usesLocalWordPress } from "../daemon/config.js";
4
6
  import { bootstrapDev } from "../wp/dev.js";
@@ -6,6 +8,7 @@ import { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, u
6
8
  import { syncSiteSettings } from "../wp/settings.js";
7
9
  import { devStack } from "../wp/stack.js";
8
10
  import { palette } from "../banner.js";
11
+ import { watchReload } from "../daemon/reload.js";
9
12
  import { startWatcher } from "../daemon/watch.js";
10
13
  import { join } from "node:path";
11
14
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
@@ -166,13 +169,13 @@ function armForegroundTeardown(cfg) {
166
169
  }
167
170
  }
168
171
  /**
169
- * Boot local WordPress and run in the foreground until exit — the path behind bare
170
- * `kizlo dev`. Arms teardown first (so a mid-startup cancel still
171
- * stops partial containers), shows a timed spinner, prints the summary + stop hint, then
172
- * parks. A referenced timer holds the event loop open — an unresolved promise alone would
173
- * not, so without it Node would empty its loop and exit straight after printing.
172
+ * Bring the dev stack's containers up for `cfg`: clear any stale containers, resolve the published
173
+ * ports (stepping off a collision unless the port is pinned in config), write the compose files, and
174
+ * note any port that moved. Returns `cfg` with the resolved ports — the shape the rest of the boot
175
+ * and the teardown work against. Shared by the first boot and every reload reboot, so a changed
176
+ * `dev.port` in `kizlo.config.ts` is re-resolved the same way each time.
174
177
  */
175
- async function startForeground(cfg) {
178
+ async function prepareStack(cfg) {
176
179
  await removeProjectContainers(cfg.project);
177
180
  const port = await pickStackPort(cfg.port, {
178
181
  fixed: cfg.portExplicit,
@@ -191,11 +194,17 @@ async function startForeground(cfg) {
191
194
  createStack(devStack(ready));
192
195
  if (port !== cfg.port) note(`Port ${cfg.port} is in use — serving on ${port} instead.`);
193
196
  if (dbPort !== cfg.dbPort) note(`Database port ${cfg.dbPort} is in use — using ${dbPort} instead.`);
194
- armForegroundTeardown(ready);
195
- const start = Date.now();
196
- const creds = await withSpinner("Starting local WordPress", () => bootstrapDev(ready));
197
+ return ready;
198
+ }
199
+ /**
200
+ * After a boot, sync the fresh credentials (or the current LAN URL) into `.env` and the plugin, print
201
+ * the connection summary, and print the timed ready line. `reloaded` swaps the wording and drops the
202
+ * one-time Ctrl+C hint. `start` is when the boot began, so the timing spans the whole reboot including
203
+ * the spinner. The `.env` writes here are why the caller pauses the reload watcher around a reboot.
204
+ */
205
+ async function report(cfg, creds, reloaded, start) {
197
206
  if (creds.appPassword) {
198
- const env = updateWpEnv(ready, creds);
207
+ const env = updateWpEnv(cfg, creds);
199
208
  note("Updated .env with the new WordPress credentials");
200
209
  if (env) {
201
210
  const sync = await syncSiteSettings({
@@ -209,34 +218,118 @@ async function startForeground(cfg) {
209
218
  });
210
219
  if (!sync.ok) note(`Could not sync the site settings to WordPress (${sync.error}) — make sure the kizlo plugin is active, then set them from the Kizlo settings.`);
211
220
  }
212
- } else if (syncLocalUrl(ready, creds.url)) note("Updated .env WordPress URL to the current network address — restart your app to pick it up");
221
+ } else if (syncLocalUrl(cfg, creds.url)) note("Updated .env WordPress URL to the current network address — restart your app to pick it up");
213
222
  printSummary(creds);
214
223
  const { green, dim, bold, reset } = palette();
215
224
  const ms = Date.now() - start;
216
225
  const took = ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
217
- process.stdout.write(`\n ${dim}Press ${reset}${bold}Ctrl+C${reset}${dim} (or close the terminal) to stop local WordPress${reset}\n`);
218
- process.stdout.write(`\n ${green}✓${reset} Ready in ${took}\n\n`);
219
- const stopWatcher = await startWatcher(ready.configDir);
220
- if (stopWatcher) process.on("exit", stopWatcher);
226
+ if (!reloaded) process.stdout.write(`\n ${dim}Press ${reset}${bold}Ctrl+C${reset}${dim} (or close the terminal) to stop local WordPress${reset}\n`);
227
+ process.stdout.write(`\n ${green}✓${reset} ${reloaded ? "Reloaded" : "Ready"} in ${took}\n\n`);
228
+ }
229
+ /**
230
+ * Boot local WordPress for an already-prepared `cfg` (ports resolved, teardown armed) behind a spinner,
231
+ * then report. The first-boot path only — reboots go through {@link rebootStack}, which also folds the
232
+ * prepare step under the spinner.
233
+ */
234
+ async function bootAndReport(cfg) {
235
+ const start = Date.now();
236
+ await report(cfg, await withSpinner("Starting local WordPress", () => bootstrapDev(cfg)), false, start);
237
+ }
238
+ /**
239
+ * Reboot the whole stack in place after a config/env change: re-resolve the config, recreate the
240
+ * containers, and boot WordPress — all behind a single spinner labelled with the file that changed, so
241
+ * there's no loaderless gap between detecting the change and the boot. Teardown is already armed from
242
+ * the first boot, so this never re-arms. Returns the freshly resolved config (ports may have moved) so
243
+ * the caller can re-target its watchers.
244
+ */
245
+ async function rebootStack(cwd, label) {
246
+ const start = Date.now();
247
+ const { ready, creds } = await withSpinner(label, async () => {
248
+ const ready$1 = await prepareStack(await resolveDevConfig(cwd));
249
+ return {
250
+ ready: ready$1,
251
+ creds: await bootstrapDev(ready$1)
252
+ };
253
+ });
254
+ await report(ready, creds, true, start);
255
+ return ready;
256
+ }
257
+ /**
258
+ * Boot local WordPress and run in the foreground until exit — the path behind bare `kizlo dev`. Arms
259
+ * teardown first (so a mid-startup cancel still stops partial containers), boots and prints the
260
+ * summary, then parks. While parked it watches `.env` and `kizlo.config.*`: a change reboots the whole
261
+ * stack in place so a new port, connection, or fixture takes effect without rerunning the command. The
262
+ * reboot runs in the same process — the watchdog and session keep watching this PID throughout — and is
263
+ * guarded so the credential/URL writes it makes to `.env` don't trigger another reboot. A referenced
264
+ * timer holds the event loop open — an unresolved promise alone would not, so without it Node would
265
+ * empty its loop and exit straight after printing.
266
+ */
267
+ async function startForeground(cfg) {
268
+ const cwd = process.cwd();
269
+ let ready = await prepareStack(cfg);
270
+ armForegroundTeardown(ready);
271
+ await bootAndReport(ready);
272
+ let stopContract = await startWatcher(ready.configDir);
273
+ let reloading = false;
274
+ const reload = async (changed) => {
275
+ if (reloading) return;
276
+ reloading = true;
277
+ try {
278
+ stopContract?.();
279
+ ready = await rebootStack(cwd, `${changed} changed — restarting the session`);
280
+ stopContract = await startWatcher(ready.configDir);
281
+ } catch (error) {
282
+ log.error("Failed to reload the dev session:", error);
283
+ } finally {
284
+ setTimeout(() => {
285
+ reloading = false;
286
+ }, 500);
287
+ }
288
+ };
289
+ const stopReload = watchReload(ready.configDir, (changed) => void reload(changed));
290
+ process.on("exit", () => {
291
+ stopContract?.();
292
+ stopReload();
293
+ });
221
294
  setInterval(() => {}, 1 << 30);
222
295
  await new Promise(() => {});
223
296
  }
224
297
  /**
225
298
  * Run the contract watcher in the foreground until exit — the path bare `kizlo dev` takes when local
226
299
  * WordPress isn't configured (a project pointing at its own WordPress). Generates once, then watches
227
- * the server files and regenerates on save. Returns immediately when another watcher already holds
228
- * the lock; otherwise the persistent watcher keeps the process alive on its own.
300
+ * the server files and regenerates on save. It also watches `.env` and `kizlo.config.*` and restarts
301
+ * the watcher on a change, so a new server dir or env value is picked up the same way the local path
302
+ * reboots its stack. Returns immediately when another watcher already holds the lock; otherwise the
303
+ * persistent watcher keeps the process alive on its own.
229
304
  */
230
305
  async function watchOnly(cwd) {
231
- const stop = await startWatcher(cwd);
306
+ let stop = await startWatcher(cwd);
232
307
  if (!stop) return;
233
- process.on("exit", stop);
308
+ let reloading = false;
309
+ const reload = async () => {
310
+ if (reloading) return;
311
+ reloading = true;
312
+ try {
313
+ stop?.();
314
+ stop = await startWatcher(cwd);
315
+ } finally {
316
+ setTimeout(() => {
317
+ reloading = false;
318
+ }, 300);
319
+ }
320
+ };
321
+ const stopReload = watchReload(findConfigDir(cwd), () => void reload());
322
+ const shutdown = () => {
323
+ stop?.();
324
+ stopReload();
325
+ };
326
+ process.on("exit", shutdown);
234
327
  for (const signal of [
235
328
  "SIGINT",
236
329
  "SIGTERM",
237
330
  "SIGHUP"
238
331
  ]) process.on(signal, () => {
239
- stop();
332
+ shutdown();
240
333
  process.exit(0);
241
334
  });
242
335
  }
@@ -15,6 +15,10 @@ const generate = defineCommand({
15
15
  } },
16
16
  async run({ args }) {
17
17
  const cfg = await resolveConfig(process.cwd(), { dir: args.dir });
18
+ if (!cfg) {
19
+ log.info("No Kizlo server directory configured, nothing to generate. Set `dir` in kizlo.config.ts or pass --dir.");
20
+ return;
21
+ }
18
22
  let ok;
19
23
  try {
20
24
  ok = await generateOnce(cfg);