kizlo 0.1.1 → 0.2.1

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 (73) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/auth.d.ts +11 -2
  3. package/dist/adapters/auth.js +5 -1
  4. package/dist/adapters/captcha.d.ts +3 -1
  5. package/dist/adapters/captcha.js +5 -1
  6. package/dist/adapters/cookies.d.ts +7 -0
  7. package/dist/adapters/cookies.js +8 -0
  8. package/dist/adapters/geo.d.ts +13 -2
  9. package/dist/adapters/geo.js +5 -1
  10. package/dist/adapters/logger.d.ts +8 -1
  11. package/dist/adapters/logger.js +5 -1
  12. package/dist/cli/banner.js +12 -1
  13. package/dist/cli/commands/dev.js +203 -20
  14. package/dist/cli/commands/init.js +240 -47
  15. package/dist/cli/commands/test.js +56 -19
  16. package/dist/cli/commands/watch.js +5 -23
  17. package/dist/cli/daemon/config.js +11 -5
  18. package/dist/cli/daemon/logger.js +1 -0
  19. package/dist/cli/daemon/watch.js +35 -2
  20. package/dist/cli/presets/base.js +8 -1
  21. package/dist/cli/presets/nextjs.js +1 -1
  22. package/dist/cli/utils.js +74 -5
  23. package/dist/cli/wp/compose/docker-compose.yml +12 -0
  24. package/dist/cli/wp/dev.js +4 -2
  25. package/dist/cli/wp/docker.d.ts +22 -4
  26. package/dist/cli/wp/docker.js +27 -5
  27. package/dist/cli/wp/ports.js +72 -0
  28. package/dist/cli/wp/session.js +150 -0
  29. package/dist/cli/wp/settings.js +73 -0
  30. package/dist/cli/wp/types.d.ts +5 -3
  31. package/dist/cli/wp/utils.d.ts +7 -1
  32. package/dist/cli/wp/utils.js +17 -1
  33. package/dist/cli/wp/watchdog.d.ts +1 -0
  34. package/dist/cli/wp/watchdog.js +45 -0
  35. package/dist/client.d.ts +1 -1
  36. package/dist/client.js +2 -2
  37. package/dist/context.d.ts +12 -1
  38. package/dist/context.js +1 -1
  39. package/dist/index.d.ts +16 -12
  40. package/dist/index.js +9 -6
  41. package/dist/integrations/nextjs/kizlo.server.d.ts +1 -1
  42. package/dist/integrations/nextjs/kizlo.server.js +2 -2
  43. package/dist/integrations/nextjs/utils.js +2 -2
  44. package/dist/kizlo.d.ts +27 -6
  45. package/dist/kizlo.js +24 -11
  46. package/dist/package.js +1 -1
  47. package/dist/router.d.ts +7 -7
  48. package/dist/service.d.ts +2 -0
  49. package/dist/service.js +3 -0
  50. package/dist/settings/service.d.ts +22 -0
  51. package/dist/settings/service.interface.d.ts +134 -0
  52. package/dist/settings/service.js +43 -0
  53. package/dist/shared/error.d.ts +16 -5
  54. package/dist/shared/error.js +4 -0
  55. package/dist/shared/extension.d.ts +2 -0
  56. package/dist/shared/middleware.d.ts +13 -10
  57. package/dist/shared/middleware.js +7 -0
  58. package/dist/shared/procedure.d.ts +34 -28
  59. package/dist/shared/procedure.js +3 -6
  60. package/dist/shared/types.d.ts +19 -13
  61. package/dist/test/client.d.ts +5 -0
  62. package/dist/test/client.js +5 -0
  63. package/dist/test/server.d.ts +6 -0
  64. package/dist/test/server.js +6 -0
  65. package/dist/test.d.ts +4 -4
  66. package/dist/test.js +3 -3
  67. package/dist/webhook/index.d.ts +14 -13
  68. package/dist/webhook/index.js +5 -5
  69. package/dist/webhook/schema.d.ts +3 -3
  70. package/dist/webhook/schema.js +37 -37
  71. package/dist/webhook/utils.js +4 -4
  72. package/dist/wordpress/service.js +6 -0
  73. package/package.json +1 -1
@@ -1,6 +1,13 @@
1
- import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envKeysPresent, getVersion, loadEnvFiles, mergeEnv, resolveModuleImport, runCommand, writeFileIfAbsent } from "../utils.js";
2
- import { CONTRACT_BARREL } from "../daemon/generate.js";
1
+ import { composeStop, createStack, dockerAvailable } from "../wp/docker.js";
2
+ import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envGroups, envKeysPresent, getVersion, loadEnvFiles, mergeEnv, pickStackPort, resolveModuleImport, runCommand, writeFileIfAbsent } from "../utils.js";
3
3
  import { printBanner } from "../banner.js";
4
+ import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveStackName } from "../daemon/config.js";
5
+ import { CONTRACT_BARREL } from "../daemon/generate.js";
6
+ import { createAdminAppPassword } from "../wp/bootstrap.js";
7
+ import { bootstrapDev } from "../wp/dev.js";
8
+ import { removeProjectContainers } from "../wp/session.js";
9
+ import { syncSiteSettings } from "../wp/settings.js";
10
+ import { devStack } from "../wp/stack.js";
4
11
  import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
5
12
  import z from "zod/v4";
6
13
  import { randomBytes } from "node:crypto";
@@ -10,14 +17,48 @@ import { defineCommand } from "citty";
10
17
  import * as p from "@clack/prompts";
11
18
 
12
19
  //#region src/cli/commands/init.ts
13
- const WP_ENV_KEYS = [
14
- "SITE_SECRET",
15
- "WORDPRESS_URL",
16
- "WORDPRESS_USERNAME",
17
- "WORDPRESS_APPLICATION_PASSWORD"
20
+ /** Production connection keys — what a real deploy needs, and what `.env.example` always lists. */
21
+ const PROD_WP_ENV_KEYS = [
22
+ "KIZLO_SITE_SECRET",
23
+ "KIZLO_WORDPRESS_URL",
24
+ "KIZLO_WORDPRESS_USERNAME",
25
+ "KIZLO_WORDPRESS_APPLICATION_PASSWORD"
18
26
  ];
19
- function envKeys(preset) {
20
- return [preset.baseUrlEnvKey, ...WP_ENV_KEYS];
27
+ /**
28
+ * The `.env` keys init manages and the values to write, branched on the connection mode. A local
29
+ * dev stack writes the `KIZLO_DEV_WORDPRESS_*` / `KIZLO_DEV_SITE_SECRET` set plus `KIZLO_TARGET=dev`, so it
30
+ * never touches the production keys (a user can point those at a real site). A remote site writes the
31
+ * bare production keys exactly as before — no `KIZLO_TARGET`, since `"production"` is the default target.
32
+ */
33
+ function managedEnv(preset, setup) {
34
+ if (setup.mode === "local") return {
35
+ keys: [
36
+ preset.baseUrlEnvKey,
37
+ "KIZLO_TARGET",
38
+ "KIZLO_DEV_SITE_SECRET",
39
+ "KIZLO_DEV_WORDPRESS_URL",
40
+ "KIZLO_DEV_WORDPRESS_USERNAME",
41
+ "KIZLO_DEV_WORDPRESS_APPLICATION_PASSWORD"
42
+ ],
43
+ values: {
44
+ [preset.baseUrlEnvKey]: setup.baseUrl,
45
+ KIZLO_TARGET: "dev",
46
+ KIZLO_DEV_SITE_SECRET: setup.siteSecret,
47
+ KIZLO_DEV_WORDPRESS_URL: setup.wpUrl,
48
+ KIZLO_DEV_WORDPRESS_USERNAME: setup.wpUsername,
49
+ KIZLO_DEV_WORDPRESS_APPLICATION_PASSWORD: setup.wpPassword
50
+ }
51
+ };
52
+ return {
53
+ keys: [preset.baseUrlEnvKey, ...PROD_WP_ENV_KEYS],
54
+ values: {
55
+ [preset.baseUrlEnvKey]: setup.baseUrl,
56
+ KIZLO_SITE_SECRET: setup.siteSecret,
57
+ KIZLO_WORDPRESS_URL: setup.wpUrl,
58
+ KIZLO_WORDPRESS_USERNAME: setup.wpUsername,
59
+ KIZLO_WORDPRESS_APPLICATION_PASSWORD: setup.wpPassword
60
+ }
61
+ };
21
62
  }
22
63
  function defaultDir(hasSrcDir) {
23
64
  return hasSrcDir ? "src/lib/kizlo" : "lib/kizlo";
@@ -26,11 +67,11 @@ function defaultDir(hasSrcDir) {
26
67
  function aliasWithSlash(alias) {
27
68
  return alias ? `${alias.replace(/\/+$/, "")}/` : "";
28
69
  }
29
- function kizloConfigTemplate(dir, alias) {
70
+ function kizloConfigTemplate(dir, alias, devPath) {
30
71
  return `import { defineConfig } from "kizlo/config"
31
72
 
32
73
  export default defineConfig({
33
- dir: "${dir}",${alias ? `\n\talias: "${alias}",` : ""}
74
+ dir: "${dir}",${alias ? `\n\talias: "${alias}",` : ""}${devPath ? `\n\tdev: { path: "${devPath}" },` : ""}
34
75
  })
35
76
  `;
36
77
  }
@@ -46,6 +87,14 @@ function withApiPath(baseUrl, apiPath) {
46
87
  return baseUrl;
47
88
  }
48
89
  }
90
+ /** Whether two URLs share an origin (scheme + host + port); false when either can't be parsed. */
91
+ function sameOrigin(a, b) {
92
+ try {
93
+ return new URL(a).origin === new URL(b).origin;
94
+ } catch {
95
+ return false;
96
+ }
97
+ }
49
98
  function detectAppDir(cwd, hasSrcDir) {
50
99
  if (fs.existsSync(path.join(cwd, "src", "app"))) return "src/app";
51
100
  if (fs.existsSync(path.join(cwd, "app"))) return "app";
@@ -68,13 +117,27 @@ function orCancel(value) {
68
117
  return value;
69
118
  }
70
119
  async function collectInteractively(ctx) {
71
- const baseUrl = orCancel(await p.text({
72
- message: "Public server base URL",
120
+ let siteUrl;
121
+ let baseUrl;
122
+ if (ctx.preset.apiPath) baseUrl = orCancel(await p.text({
123
+ message: "Public app URL",
73
124
  placeholder: "https://your-app.com",
74
125
  validate: validate(urlString)
75
126
  }));
127
+ else {
128
+ siteUrl = orCancel(await p.text({
129
+ message: "Public site URL",
130
+ placeholder: "https://your-app.com",
131
+ validate: validate(urlString)
132
+ }));
133
+ baseUrl = orCancel(await p.text({
134
+ message: "Kizlo backend URL (where the handler is mounted)",
135
+ placeholder: "https://your-app.com/kizlo",
136
+ validate: validate(urlString)
137
+ }));
138
+ }
76
139
  const siteSecret = orCancel(await p.select({
77
- message: "SITE_SECRET (signing key)",
140
+ message: "Site secret (webhook signing key)",
78
141
  initialValue: "generate",
79
142
  options: [{
80
143
  value: "generate",
@@ -85,21 +148,44 @@ async function collectInteractively(ctx) {
85
148
  label: "Enter my own"
86
149
  }]
87
150
  })) === "enter" ? orCancel(await p.password({
88
- message: "Enter SITE_SECRET",
151
+ message: "Enter the site secret",
89
152
  validate: validate(requiredString)
90
153
  })) : randomBytes(32).toString("hex");
91
- const wpUrl = orCancel(await p.text({
92
- message: "WordPress URL",
93
- placeholder: "https://wp.your-app.com",
94
- validate: validate(urlString)
95
- }));
96
- const wpUsername = orCancel(await p.text({
97
- message: "WordPress username",
98
- validate: validate(requiredString)
154
+ const mode = orCancel(await p.select({
155
+ message: "WordPress connection",
156
+ initialValue: "local",
157
+ options: [{
158
+ value: "local",
159
+ label: "Set up a local dev environment",
160
+ hint: "runs WordPress in Docker"
161
+ }, {
162
+ value: "remote",
163
+ label: "Use my own WordPress",
164
+ hint: "connect to an existing site"
165
+ }]
99
166
  }));
100
- const wpPassword = orCancel(await p.password({
101
- message: "WordPress application password",
102
- validate: validate(requiredString)
167
+ let wpUrl = "";
168
+ let wpUsername = "";
169
+ let wpPassword = "";
170
+ let devPath;
171
+ if (mode === "remote") {
172
+ wpUrl = orCancel(await p.text({
173
+ message: "WordPress URL",
174
+ placeholder: "https://wp.your-app.com",
175
+ validate: validate(urlString)
176
+ }));
177
+ wpUsername = orCancel(await p.text({
178
+ message: "WordPress username",
179
+ validate: validate(requiredString)
180
+ }));
181
+ wpPassword = orCancel(await p.password({
182
+ message: "WordPress application password",
183
+ validate: validate(requiredString)
184
+ }));
185
+ } else devPath = orCancel(await p.text({
186
+ message: "Local WordPress folder",
187
+ initialValue: "wordpress",
188
+ validate: validate(dirPath)
103
189
  }));
104
190
  const dir = orCancel(await p.text({
105
191
  message: "Kizlo directory",
@@ -117,11 +203,14 @@ async function collectInteractively(ctx) {
117
203
  })).trim();
118
204
  }
119
205
  return {
206
+ mode,
120
207
  baseUrl,
208
+ siteUrl,
121
209
  siteSecret,
122
210
  wpUrl,
123
211
  wpUsername,
124
212
  wpPassword,
213
+ devPath,
125
214
  dir,
126
215
  alias
127
216
  };
@@ -134,11 +223,12 @@ async function collectInteractively(ctx) {
134
223
  function collectFromEnv(ctx) {
135
224
  const dir = defaultDir(ctx.hasSrcDir);
136
225
  return {
226
+ mode: "remote",
137
227
  baseUrl: process.env[ctx.preset.baseUrlEnvKey]?.trim() ?? "",
138
- siteSecret: process.env.SITE_SECRET?.trim() || randomBytes(32).toString("hex"),
139
- wpUrl: process.env.WORDPRESS_URL?.trim() ?? "",
140
- wpUsername: process.env.WORDPRESS_USERNAME?.trim() ?? "",
141
- wpPassword: process.env.WORDPRESS_APPLICATION_PASSWORD?.trim() ?? "",
228
+ siteSecret: process.env.KIZLO_SITE_SECRET?.trim() || randomBytes(32).toString("hex"),
229
+ wpUrl: process.env.KIZLO_WORDPRESS_URL?.trim() ?? "",
230
+ wpUsername: process.env.KIZLO_WORDPRESS_USERNAME?.trim() ?? "",
231
+ wpPassword: process.env.KIZLO_WORDPRESS_APPLICATION_PASSWORD?.trim() ?? "",
142
232
  dir,
143
233
  alias: ctx.preset.routeHandler ? aliasWithSlash(detectImportAlias(ctx.cwd, path.join(dir, "server"))?.prefix) : ""
144
234
  };
@@ -146,6 +236,66 @@ function collectFromEnv(ctx) {
146
236
  function readPkg(pkgPath) {
147
237
  return JSON.parse(fs.readFileSync(pkgPath, "utf8"));
148
238
  }
239
+ /** Build a {@link ResolvedDevConfig} from the chosen install folder, matching `resolveDevConfig`'s
240
+ * defaults — built directly so init never has to round-trip through the config file it's writing. */
241
+ function devConfigFor(cwd, devPath) {
242
+ return {
243
+ configDir: cwd,
244
+ project: `${resolveStackName(cwd)}-dev`,
245
+ port: DEFAULT_DEV_PORT,
246
+ portExplicit: false,
247
+ dbPort: DEFAULT_DEV_DB_PORT,
248
+ dbPortExplicit: false,
249
+ fixtures: [],
250
+ wordpressPath: devPath,
251
+ wordpressDir: path.resolve(cwd, devPath)
252
+ };
253
+ }
254
+ /**
255
+ * Boot the local dev stack once to produce working credentials, then stop it (volumes
256
+ * persist, so a later `kizlo dev` resumes instantly). The dev stack mints no application
257
+ * password — that's a test-stack concern — so we create one here for REST auth in `.env`.
258
+ * While the stack is still up, push the dev site settings (`siteSecret` plus the Kizlo server's
259
+ * `url`/`backend_url`, derived from `baseUrl`) into the plugin so webhook signing and event delivery
260
+ * work.
261
+ */
262
+ async function provisionLocalStack(cfg, siteSecret, baseUrl) {
263
+ await removeProjectContainers(cfg.project);
264
+ const port = await pickStackPort(cfg.port, {
265
+ fixed: cfg.portExplicit,
266
+ configKey: "dev.port"
267
+ });
268
+ const dbPort = await pickStackPort(cfg.dbPort, {
269
+ fixed: cfg.dbPortExplicit,
270
+ host: "127.0.0.1",
271
+ configKey: "dev.dbPort"
272
+ });
273
+ const ready = {
274
+ ...cfg,
275
+ port,
276
+ dbPort
277
+ };
278
+ createStack(devStack(ready));
279
+ const info = await bootstrapDev(ready);
280
+ const appPassword = info.appPassword ?? await createAdminAppPassword("kizlo");
281
+ const sync = await syncSiteSettings({
282
+ url: info.url,
283
+ username: info.username,
284
+ password: appPassword
285
+ }, {
286
+ secret: siteSecret,
287
+ backendUrl: baseUrl,
288
+ containerized: true
289
+ });
290
+ await composeStop();
291
+ return {
292
+ url: info.url,
293
+ username: info.username,
294
+ appPassword,
295
+ adminPassword: info.secrets?.password,
296
+ secretSyncError: sync.ok ? void 0 : sync.error
297
+ };
298
+ }
149
299
  const init = defineCommand({
150
300
  meta: {
151
301
  name: "init",
@@ -218,22 +368,38 @@ const init = defineCommand({
218
368
  });
219
369
  if (args.alias !== void 0) setup.alias = String(args.alias).trim();
220
370
  setup.alias = aliasWithSlash(setup.alias);
371
+ if (setup.mode === "local" && !await dockerAvailable()) {
372
+ p.cancel("Docker isn't available — start Docker (or install it) and re-run, or choose “Use my own WordPress”.");
373
+ process.exit(1);
374
+ }
221
375
  if (!hasKizlo) {
222
376
  const s = p.spinner();
223
377
  s.start(`Installing kizlo with ${pm}`);
224
- const ok = runCommand(addDependencyArgs(pm, "kizlo"), cwd, "ignore");
378
+ const ok = runCommand(addDependencyArgs(pm, "kizlo@latest"), cwd, "ignore");
225
379
  s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
226
- if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, "kizlo").join(" ")}`);
380
+ if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, "kizlo@latest").join(" ")}`);
227
381
  }
228
382
  if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
229
- const keys = envKeys(preset);
230
- const envValues = {
231
- [preset.baseUrlEnvKey]: setup.baseUrl,
232
- SITE_SECRET: setup.siteSecret,
233
- WORDPRESS_URL: setup.wpUrl,
234
- WORDPRESS_USERNAME: setup.wpUsername,
235
- WORDPRESS_APPLICATION_PASSWORD: setup.wpPassword
236
- };
383
+ let adminPassword;
384
+ if (setup.mode === "local" && setup.devPath) {
385
+ ensureGitignored(cwd, setup.devPath);
386
+ const s = p.spinner();
387
+ s.start("Setting up local WordPress (first run downloads images, this can take a while)");
388
+ try {
389
+ const local = await provisionLocalStack(devConfigFor(cwd, setup.devPath), setup.siteSecret, setup.baseUrl);
390
+ setup.wpUrl = local.url;
391
+ setup.wpUsername = local.username;
392
+ setup.wpPassword = local.appPassword;
393
+ adminPassword = local.adminPassword;
394
+ s.stop("Local WordPress ready");
395
+ if (local.secretSyncError) p.log.warn(`Could not sync KIZLO_DEV_SITE_SECRET to the local plugin (${local.secretSyncError})`);
396
+ } catch (error) {
397
+ s.stop("Local WordPress setup failed");
398
+ p.cancel(error instanceof Error ? error.message : String(error));
399
+ process.exit(1);
400
+ }
401
+ }
402
+ const { keys, values: envValues } = managedEnv(preset, setup);
237
403
  const envPath = path.join(cwd, ".env");
238
404
  const envExisted = fs.existsSync(envPath);
239
405
  const existingEnv = envExisted ? fs.readFileSync(envPath, "utf8") : "";
@@ -249,11 +415,26 @@ const init = defineCommand({
249
415
  initialValue: true
250
416
  }))) overwriteKeys = /* @__PURE__ */ new Set();
251
417
  }
252
- const merge = mergeEnv(existingEnv, envValues, overwriteKeys);
418
+ const merge = mergeEnv(existingEnv, envValues, overwriteKeys, envGroups(preset.baseUrlEnvKey));
253
419
  fs.writeFileSync(envPath, merge.content);
254
- const exampleCreated = writeFileIfAbsent(path.join(cwd, ".env.example"), `${keys.map((key) => `${key}=`).join("\n")}\n`);
420
+ if (setup.mode === "remote" && setup.wpUrl && setup.wpUsername && setup.wpPassword) {
421
+ const sync = await syncSiteSettings({
422
+ url: setup.wpUrl,
423
+ username: setup.wpUsername,
424
+ password: setup.wpPassword
425
+ }, {
426
+ secret: setup.siteSecret,
427
+ siteUrl: setup.siteUrl,
428
+ backendUrl: setup.baseUrl
429
+ });
430
+ 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.`);
431
+ }
432
+ const exampleKeys = [preset.baseUrlEnvKey, ...PROD_WP_ENV_KEYS];
433
+ const { content: exampleEnv } = mergeEnv("", Object.fromEntries(exampleKeys.map((key) => [key, ""])), new Set(exampleKeys), envGroups(preset.baseUrlEnvKey));
434
+ const exampleBody = `${exampleEnv}\n# Point the app at a local dev stack (managed by \`kizlo dev\`) instead of the keys above:\n# KIZLO_TARGET=dev\n`;
435
+ const exampleCreated = writeFileIfAbsent(path.join(cwd, ".env.example"), exampleBody);
255
436
  const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
256
- const configCreated = writeFileIfAbsent(path.join(cwd, "kizlo.config.ts"), kizloConfigTemplate(dirRel, setup.alias));
437
+ const configCreated = writeFileIfAbsent(path.join(cwd, "kizlo.config.ts"), kizloConfigTemplate(dirRel, setup.alias, setup.devPath));
257
438
  const serverDirRel = path.join(dirRel, "server");
258
439
  const serverEntryRel = path.join(serverDirRel, "index.ts");
259
440
  const serverPath = path.join(cwd, serverEntryRel);
@@ -273,10 +454,12 @@ const init = defineCommand({
273
454
  const generatedDirRel = path.join(serverDirRel, "generated");
274
455
  writeFileIfAbsent(path.join(cwd, generatedDirRel, "contract.json"), "{}\n");
275
456
  writeFileIfAbsent(path.join(cwd, generatedDirRel, "index.ts"), CONTRACT_BARREL);
457
+ const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
276
458
  const scaffold = {
277
459
  serverDirName: path.basename(serverDirRel),
278
460
  appDir: detectAppDir(cwd, hasSrcDir),
279
- serverImport: (fromDir) => resolveModuleImport(cwd, serverDirRel, fromDir, setup.alias)
461
+ serverImport: (fromDir) => resolveModuleImport(cwd, serverDirRel, fromDir, setup.alias),
462
+ clientUrl
280
463
  };
281
464
  const clientRel = path.join(dirRel, "client.ts");
282
465
  const clientCreated = writeFileIfAbsent(path.join(cwd, clientRel), preset.clientEntry(scaffold));
@@ -298,10 +481,20 @@ const init = defineCommand({
298
481
  p.log.success(clientCreated ? `Created browser client (${clientRel})` : "Skipped browser client (exists)");
299
482
  if (routeRel) p.log.success(routeCreated ? `Created API route (${routeRel})` : "Skipped API route (exists)");
300
483
  if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
484
+ if (setup.mode === "local") {
485
+ p.log.success(`Local WordPress configured (${setup.devPath}) — .env points at ${setup.wpUrl}`);
486
+ if (adminPassword) p.log.warn(`wp-admin login: ${setup.wpUsername} / ${adminPassword} — save it, it's shown only once`);
487
+ }
301
488
  p.note([
302
- `Watch your extensions and regenerate the contract during development:`,
303
- ` npx kizlo watch`,
304
- ``,
489
+ ...setup.mode === "local" ? [
490
+ `Start your local WordPress dev stack (also watches your extensions):`,
491
+ ` npx kizlo dev`,
492
+ ``
493
+ ] : [
494
+ `Watch your extensions and regenerate the contract during development:`,
495
+ ` npx kizlo watch`,
496
+ ``
497
+ ],
305
498
  `Generate the contract once for production builds:`,
306
499
  ` npx kizlo generate`
307
500
  ].join("\n"), "Next steps");
@@ -1,7 +1,8 @@
1
1
  import { createStack } from "../wp/docker.js";
2
- import { isSeeded } from "../wp/utils.js";
3
- import { groupDefault, withSpinner } from "../utils.js";
2
+ import { isSeeded, recordedPort } from "../wp/utils.js";
4
3
  import { log } from "../daemon/logger.js";
4
+ import { isFree } from "../wp/ports.js";
5
+ import { groupDefault, pickStackPort, withSpinner } from "../utils.js";
5
6
  import { resolveTestConfig } from "../daemon/config.js";
6
7
  import { testStack } from "../wp/stack.js";
7
8
  import { runSeeds } from "../wp/setup.js";
@@ -16,18 +17,57 @@ async function resolve(cwd) {
16
17
  stack: createStack(testStack(cfg))
17
18
  };
18
19
  }
19
- /** Boot the stack and seed it once (idempotent — skips seeding if already seeded). */
20
- async function bringUp(cfg, stack) {
21
- await withSpinner("Starting WordPress test stack", () => stack.composeUp(), "WordPress test stack ready");
22
- if (await isSeeded()) {
23
- log.info("Stack already seeded — skipping seed.");
24
- return;
20
+ /**
21
+ * The host port to bring the test stack up on. Unlike the dev stack (rebuilt every run), the
22
+ * test stack is reused and its tests connect via the URL recorded in the credentials artifact —
23
+ * so the port can't just be re-chosen each time:
24
+ *
25
+ * - A **running** stack keeps its live published port (fast reruns stay on the same URL).
26
+ * - A **warm but stopped** stack reuses the port recorded in the credentials — that URL is what
27
+ * tests read, so it must not move; if something else stole it meanwhile we stop and say so,
28
+ * rather than silently relocate.
29
+ * - Only a **cold** stack (never seeded, or wiped by `reset` / `--reset`) picks a fresh free port
30
+ * from the configured default — auto-stepping past a collision (e.g. another project's stack)
31
+ * unless `test.port` was set explicitly, in which case the user owns the clash.
32
+ *
33
+ * `fresh` forces the cold path so a `reset` re-picks — that's how a poisoned recorded port heals.
34
+ */
35
+ async function resolveTestPort(cfg, stack, fresh) {
36
+ if (!fresh) {
37
+ const live = await stack.publishedPort();
38
+ if (live !== void 0) return live;
39
+ const recorded = recordedPort();
40
+ if (recorded !== void 0) {
41
+ if (await isFree(recorded)) return recorded;
42
+ 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
+ process.exit(1);
44
+ }
25
45
  }
26
- await withSpinner("Seeding WordPress", () => runSeeds({
27
- port: cfg.port,
46
+ return pickStackPort(cfg.port, {
47
+ fixed: cfg.portExplicit,
48
+ configKey: "test.port"
49
+ });
50
+ }
51
+ /**
52
+ * Boot the test stack on a resolved host port and seed it once (idempotent — skips seeding when
53
+ * already seeded). Returns the port-bound stack and the port, so callers report the real URL.
54
+ */
55
+ async function bringUp(cfg, stack, fresh = false) {
56
+ const port = await resolveTestPort(cfg, stack, fresh);
57
+ const bound = port === cfg.port ? stack : createStack(testStack({
58
+ ...cfg,
59
+ port
60
+ }));
61
+ await withSpinner("Starting WordPress test stack", () => bound.composeUp(), "WordPress test stack ready");
62
+ if (await isSeeded()) log.info("Stack already seeded — skipping seed.");
63
+ else await withSpinner("Seeding WordPress", () => runSeeds({
64
+ port,
28
65
  fixtures: cfg.fixtures
29
66
  }), "WordPress seeded");
30
- log.success(`Stack ready on http://localhost:${cfg.port} — credentials at ${cfg.credentialsPath}`);
67
+ return {
68
+ stack: bound,
69
+ port
70
+ };
31
71
  }
32
72
  /** `<pm> test` runs the project's own test script (bun needs `run` to skip its built-in runner). */
33
73
  function testCommand(pm) {
@@ -61,13 +101,9 @@ const runArgs = {
61
101
  };
62
102
  /** Boot (+ seed) the stack, run the project's test script, then leave it up (or tear down). */
63
103
  async function runSuite({ args }) {
64
- const { cfg, stack } = await resolve(process.cwd());
65
- if (args.reset) await withSpinner("Wiping WordPress database", () => stack.composeDown({ volumes: true }), "Database wiped");
66
- await withSpinner("Starting WordPress test stack", () => stack.composeUp(), "WordPress test stack ready");
67
- if (!await isSeeded()) await withSpinner("Seeding WordPress", () => runSeeds({
68
- port: cfg.port,
69
- fixtures: cfg.fixtures
70
- }), "WordPress seeded");
104
+ const { cfg, stack: probe } = await resolve(process.cwd());
105
+ if (args.reset) await withSpinner("Wiping WordPress database", () => probe.composeDown({ volumes: true }), "Database wiped");
106
+ const { stack } = await bringUp(cfg, probe, Boolean(args.reset));
71
107
  log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
72
108
  let code = 1;
73
109
  try {
@@ -112,7 +148,8 @@ const subCommands = {
112
148
  async run() {
113
149
  const { cfg, stack } = await resolve(process.cwd());
114
150
  await withSpinner("Wiping WordPress database", () => stack.composeDown({ volumes: true }), "Database wiped");
115
- await bringUp(cfg, stack);
151
+ const { port } = await bringUp(cfg, stack, true);
152
+ log.success(`Stack ready on http://localhost:${port} — credentials at ${cfg.credentialsPath}`);
116
153
  }
117
154
  })
118
155
  };
@@ -1,10 +1,6 @@
1
1
  import { getVersion } from "../utils.js";
2
- import { log } from "../daemon/logger.js";
3
- import { resolveConfig } from "../daemon/config.js";
4
- import { generateOnce } from "../daemon/generate.js";
5
2
  import { printBanner } from "../banner.js";
6
- import { acquire, isLocked, lockPath, release } from "../daemon/lock.js";
7
- import { watch as watch$1 } from "../daemon/watch.js";
3
+ import { startWatcher } from "../daemon/watch.js";
8
4
  import { defineCommand } from "citty";
9
5
 
10
6
  //#region src/cli/commands/watch.ts
@@ -13,32 +9,18 @@ const args = { dir: {
13
9
  description: "Override the Kizlo directory (defaults to kizlo.config.ts)"
14
10
  } };
15
11
  async function run({ args: args$1 }) {
16
- const cwd = process.cwd();
17
- const lock = lockPath(cwd);
18
- if (isLocked(lock)) {
19
- log.info("Watcher already running — nothing to do.");
20
- return;
21
- }
22
12
  printBanner(getVersion());
23
- const cfg = await resolveConfig(cwd, { dir: args$1.dir });
24
- acquire(lock);
25
- const cleanup = () => release(lock);
26
- process.on("exit", cleanup);
13
+ const stop = await startWatcher(process.cwd(), { dir: args$1.dir });
14
+ if (!stop) return;
15
+ process.on("exit", stop);
27
16
  for (const signal of [
28
17
  "SIGINT",
29
18
  "SIGTERM",
30
19
  "SIGHUP"
31
20
  ]) process.on(signal, () => {
32
- cleanup();
21
+ stop();
33
22
  process.exit(0);
34
23
  });
35
- try {
36
- if (await generateOnce(cfg)) log.success("Contract generated");
37
- else log.warn(`No Kizlo server found in ${cfg.serverEntry}`);
38
- } catch (error) {
39
- log.error("Failed to generate the Kizlo contract:", error);
40
- }
41
- await watch$1(cfg);
42
24
  }
43
25
  const watch = defineCommand({
44
26
  meta: {
@@ -1,6 +1,6 @@
1
1
  import { credentialsPath, findConfigDir } from "../wp/utils.js";
2
- import { detectPackageManager } from "../utils.js";
3
2
  import { log } from "./logger.js";
3
+ import { detectPackageManager } from "../utils.js";
4
4
  import z from "zod/v4";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
@@ -37,6 +37,9 @@ const CONFIG_FILES = [
37
37
  "kizlo.config.js",
38
38
  "kizlo.config.mjs"
39
39
  ];
40
+ const DEFAULT_DEV_PORT = 8080;
41
+ const DEFAULT_DEV_DB_PORT = 3307;
42
+ const DEFAULT_TEST_PORT = 8889;
40
43
  function defaultDir(cwd) {
41
44
  return fs.existsSync(path.join(cwd, "src")) ? "src/lib/kizlo" : "lib/kizlo";
42
45
  }
@@ -107,7 +110,8 @@ async function resolveTestConfig(cwd) {
107
110
  configDir,
108
111
  project: `${resolveStackName(configDir, fileConfig?.name)}-test`,
109
112
  command: test.command,
110
- port: test.port ?? 8889,
113
+ port: test.port ?? DEFAULT_TEST_PORT,
114
+ portExplicit: test.port !== void 0,
111
115
  fixtures: test.fixtures ?? [],
112
116
  credentialsPath: credentialsPath(cwd),
113
117
  packageManager: test.packageManager ?? detectPackageManager(configDir)
@@ -134,8 +138,10 @@ async function resolveDevConfig(cwd) {
134
138
  return {
135
139
  configDir,
136
140
  project: `${resolveStackName(configDir, fileConfig?.name)}-dev`,
137
- port: dev.port ?? 8080,
138
- dbPort: dev.dbPort ?? 3307,
141
+ port: dev.port ?? DEFAULT_DEV_PORT,
142
+ portExplicit: dev.port !== void 0,
143
+ dbPort: dev.dbPort ?? DEFAULT_DEV_DB_PORT,
144
+ dbPortExplicit: dev.dbPort !== void 0,
139
145
  byo: dev.byo ? path.resolve(configDir, dev.byo) : void 0,
140
146
  fixtures: dev.fixtures ?? [],
141
147
  wordpressPath: dev.path,
@@ -144,4 +150,4 @@ async function resolveDevConfig(cwd) {
144
150
  }
145
151
 
146
152
  //#endregion
147
- export { resolveConfig, resolveDevConfig, resolveTestConfig };
153
+ export { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig };
@@ -3,6 +3,7 @@ import { consola } from "consola";
3
3
  //#region src/cli/daemon/logger.ts
4
4
  /** Tagged logger for the daemon — every line is prefixed with `kizlo`. */
5
5
  const log = consola.withTag("kizlo");
6
+ log.options.formatOptions.date = false;
6
7
 
7
8
  //#endregion
8
9
  export { log };
@@ -1,5 +1,7 @@
1
1
  import { log } from "./logger.js";
2
+ import { resolveConfig } from "./config.js";
2
3
  import { generateOnce } from "./generate.js";
4
+ import { acquire, isLocked, lockPath, release } from "./lock.js";
3
5
  import path from "node:path";
4
6
  import { FSWatcher } from "chokidar";
5
7
 
@@ -28,10 +30,41 @@ async function watch(cfg) {
28
30
  });
29
31
  const onChange = debounce(() => regenerate(cfg), 300);
30
32
  watcher.add(path.resolve(cfg.cwd, cfg.serverDir));
31
- watcher.on("ready", () => log.start("Watching for changes..."));
33
+ watcher.on("ready", () => log.start("Watching for contract changes..."));
32
34
  watcher.on("all", () => void onChange());
33
35
  return watcher;
34
36
  }
37
+ /**
38
+ * Acquire the single-instance lock, generate the contract once, and start the file
39
+ * watcher. Returns a synchronous `stop()` that closes the watcher and releases the
40
+ * lock — or `undefined` when another watcher already holds the lock (a standalone
41
+ * `kizlo watch`, or a framework dev script), in which case the caller carries on
42
+ * without watching. Shared by the standalone `kizlo watch` command and the
43
+ * folded-in watcher of `kizlo dev`, so a single terminal covers both.
44
+ */
45
+ async function startWatcher(cwd, opts) {
46
+ const lock = lockPath(cwd);
47
+ if (isLocked(lock)) {
48
+ log.info("Watcher already running — skipping the contract watcher.");
49
+ return;
50
+ }
51
+ const cfg = await resolveConfig(cwd, { dir: opts?.dir });
52
+ acquire(lock);
53
+ try {
54
+ if (await generateOnce(cfg)) log.success("Contract generated");
55
+ else log.warn(`No Kizlo server found in ${cfg.serverEntry}`);
56
+ } catch (error) {
57
+ log.error("Failed to generate the Kizlo contract:", error);
58
+ }
59
+ const watcher = await watch(cfg);
60
+ let stopped = false;
61
+ return () => {
62
+ if (stopped) return;
63
+ stopped = true;
64
+ release(lock);
65
+ watcher.close();
66
+ };
67
+ }
35
68
 
36
69
  //#endregion
37
- export { watch };
70
+ export { startWatcher };