kizlo 0.1.0 → 0.2.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 (87) hide show
  1. package/dist/adapters/auth.d.ts +11 -2
  2. package/dist/adapters/auth.js +5 -1
  3. package/dist/adapters/captcha.d.ts +3 -1
  4. package/dist/adapters/captcha.js +5 -1
  5. package/dist/adapters/cookies.d.ts +7 -0
  6. package/dist/adapters/cookies.js +8 -0
  7. package/dist/adapters/geo.d.ts +13 -2
  8. package/dist/adapters/geo.js +5 -1
  9. package/dist/adapters/logger.d.ts +8 -1
  10. package/dist/adapters/logger.js +5 -1
  11. package/dist/cli/banner.js +12 -1
  12. package/dist/cli/commands/dev.js +262 -0
  13. package/dist/cli/commands/init.js +235 -43
  14. package/dist/cli/commands/test.js +133 -34
  15. package/dist/cli/commands/watch.js +2 -2
  16. package/dist/cli/daemon/config.js +73 -7
  17. package/dist/cli/daemon/generate.js +1 -1
  18. package/dist/cli/daemon/lock.js +1 -1
  19. package/dist/cli/index.js +1 -1
  20. package/dist/cli/presets/base.js +8 -1
  21. package/dist/cli/presets/nextjs.js +1 -1
  22. package/dist/cli/utils.js +90 -6
  23. package/dist/cli/wp/bootstrap.js +13 -10
  24. package/dist/cli/wp/byo.js +113 -0
  25. package/dist/cli/wp/compose/docker-compose.yml +13 -2
  26. package/dist/cli/wp/compose/opcache.ini +16 -0
  27. package/dist/cli/wp/compose/override.js +104 -0
  28. package/dist/cli/wp/compose/override.test.ts +115 -0
  29. package/dist/cli/wp/compose/override.ts +134 -0
  30. package/dist/cli/wp/compose/remap-entrypoint.sh +17 -0
  31. package/dist/cli/wp/constants.js +7 -8
  32. package/dist/cli/wp/dev.js +146 -0
  33. package/dist/cli/wp/docker.d.ts +55 -15
  34. package/dist/cli/wp/docker.js +102 -41
  35. package/dist/cli/wp/ports.js +72 -0
  36. package/dist/cli/wp/session.js +150 -0
  37. package/dist/cli/wp/settings.js +73 -0
  38. package/dist/cli/wp/setup.js +1 -1
  39. package/dist/cli/wp/stack.js +58 -0
  40. package/dist/cli/wp/types.d.ts +26 -5
  41. package/dist/cli/wp/types.js +5 -1
  42. package/dist/cli/wp/utils.d.ts +29 -2
  43. package/dist/cli/wp/utils.js +61 -3
  44. package/dist/cli/wp/watchdog.d.ts +1 -0
  45. package/dist/cli/wp/watchdog.js +45 -0
  46. package/dist/client.d.ts +1 -1
  47. package/dist/client.js +2 -2
  48. package/dist/config.d.ts +49 -3
  49. package/dist/context.d.ts +12 -1
  50. package/dist/context.js +1 -1
  51. package/dist/index.d.ts +16 -11
  52. package/dist/index.js +10 -6
  53. package/dist/integrations/nextjs/kizlo.server.d.ts +1 -1
  54. package/dist/integrations/nextjs/kizlo.server.js +2 -2
  55. package/dist/integrations/nextjs/utils.js +2 -2
  56. package/dist/kizlo.d.ts +27 -6
  57. package/dist/kizlo.js +24 -11
  58. package/dist/menu/fixture.d.ts +1 -1
  59. package/dist/package.js +1 -1
  60. package/dist/post/fixture.d.ts +1 -1
  61. package/dist/service.d.ts +2 -0
  62. package/dist/service.js +3 -0
  63. package/dist/settings/service.d.ts +22 -0
  64. package/dist/settings/service.interface.d.ts +134 -0
  65. package/dist/settings/service.js +43 -0
  66. package/dist/shared/error.d.ts +16 -5
  67. package/dist/shared/error.js +4 -0
  68. package/dist/shared/extension.d.ts +2 -0
  69. package/dist/shared/middleware.d.ts +13 -10
  70. package/dist/shared/middleware.js +7 -0
  71. package/dist/shared/procedure.d.ts +34 -28
  72. package/dist/shared/procedure.js +3 -6
  73. package/dist/shared/types.d.ts +19 -13
  74. package/dist/test/client.d.ts +5 -0
  75. package/dist/test/client.js +5 -0
  76. package/dist/test/server.d.ts +6 -0
  77. package/dist/test/server.js +6 -0
  78. package/dist/test.d.ts +4 -4
  79. package/dist/test.js +4 -4
  80. package/dist/webhook/index.d.ts +14 -13
  81. package/dist/webhook/index.js +5 -5
  82. package/dist/webhook/schema.d.ts +3 -3
  83. package/dist/webhook/schema.js +37 -37
  84. package/dist/webhook/utils.js +4 -4
  85. package/dist/wordpress/service.js +6 -0
  86. package/package.json +2 -2
  87. package/dist/cli/commands/wp.js +0 -66
@@ -1,23 +1,64 @@
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 { createAdminAppPassword } from "../wp/bootstrap.js";
6
+ import { bootstrapDev } from "../wp/dev.js";
7
+ import { removeProjectContainers } from "../wp/session.js";
8
+ import { syncSiteSettings } from "../wp/settings.js";
9
+ import { devStack } from "../wp/stack.js";
10
+ import { CONTRACT_BARREL } from "../daemon/generate.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";
7
- import path from "node:path";
8
14
  import fs from "node:fs";
15
+ import path from "node:path";
9
16
  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,6 +368,10 @@ 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}`);
@@ -226,14 +380,26 @@ const init = defineCommand({
226
380
  if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, "kizlo").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,7 +481,16 @@ 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([
489
+ ...setup.mode === "local" ? [
490
+ `Start your local WordPress dev stack:`,
491
+ ` npx kizlo dev`,
492
+ ``
493
+ ] : [],
302
494
  `Watch your extensions and regenerate the contract during development:`,
303
495
  ` npx kizlo watch`,
304
496
  ``,
@@ -1,13 +1,74 @@
1
- import { isSeeded } from "../wp/utils.js";
2
- import { composeDown, composeUp } from "../wp/docker.js";
3
- import { withSpinner } from "../utils.js";
1
+ import { createStack } from "../wp/docker.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";
7
+ import { testStack } from "../wp/stack.js";
6
8
  import { runSeeds } from "../wp/setup.js";
7
9
  import { spawn } from "node:child_process";
8
10
  import { defineCommand } from "citty";
9
11
 
10
12
  //#region src/cli/commands/test.ts
13
+ async function resolve(cwd) {
14
+ const cfg = await resolveTestConfig(cwd);
15
+ return {
16
+ cfg,
17
+ stack: createStack(testStack(cfg))
18
+ };
19
+ }
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
+ }
45
+ }
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,
65
+ fixtures: cfg.fixtures
66
+ }), "WordPress seeded");
67
+ return {
68
+ stack: bound,
69
+ port
70
+ };
71
+ }
11
72
  /** `<pm> test` runs the project's own test script (bun needs `run` to skip its built-in runner). */
12
73
  function testCommand(pm) {
13
74
  return pm === "bun" ? [
@@ -23,45 +84,83 @@ function spawnTest(command, pm) {
23
84
  stdio: "inherit",
24
85
  shell: command !== void 0 || process.platform === "win32"
25
86
  });
26
- return new Promise((resolve, reject) => {
87
+ return new Promise((resolvePromise, reject) => {
27
88
  child.on("error", reject);
28
- child.on("close", (code) => resolve(code ?? 0));
89
+ child.on("close", (code) => resolvePromise(code ?? 0));
29
90
  });
30
91
  }
31
- const test = defineCommand({
32
- meta: {
33
- name: "test",
34
- description: "Boot the WordPress test stack (if needed), seed it, and run your tests against it"
92
+ const runArgs = {
93
+ teardown: {
94
+ type: "boolean",
95
+ description: "Tear down the test stack after tests finish (default: leave it running for fast reruns)"
35
96
  },
36
- args: {
37
- teardown: {
38
- type: "boolean",
39
- description: "Tear down the test stack after tests finish (default: leave it running for fast reruns)"
97
+ reset: {
98
+ type: "boolean",
99
+ description: "Wipe the database and reseed before running"
100
+ }
101
+ };
102
+ /** Boot (+ seed) the stack, run the project's test script, then leave it up (or tear down). */
103
+ async function runSuite({ args }) {
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));
107
+ log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
108
+ let code = 1;
109
+ try {
110
+ code = await spawnTest(cfg.command, cfg.packageManager);
111
+ } finally {
112
+ if (args.teardown) await withSpinner("Tearing down WordPress test stack", () => stack.composeDown(), "WordPress test stack stopped");
113
+ else log.info("Stack left running for fast reruns — `kizlo test stop` to stop it, or rerun with --teardown.");
114
+ }
115
+ process.exit(code);
116
+ }
117
+ const subCommands = {
118
+ stop: defineCommand({
119
+ meta: {
120
+ name: "stop",
121
+ description: "Stop the WordPress test stack, keeping the database volume"
122
+ },
123
+ async run() {
124
+ const { stack } = await resolve(process.cwd());
125
+ await withSpinner("Stopping WordPress test stack", () => stack.composeStop(), "Stack stopped (volumes kept)");
126
+ }
127
+ }),
128
+ down: defineCommand({
129
+ meta: {
130
+ name: "down",
131
+ description: "Stop and remove the test stack containers"
40
132
  },
41
- reset: {
133
+ args: { volumes: {
42
134
  type: "boolean",
43
- description: "Wipe the database and reseed before running"
135
+ alias: "v",
136
+ description: "Also drop the database volume"
137
+ } },
138
+ async run({ args }) {
139
+ const { stack } = await resolve(process.cwd());
140
+ await withSpinner("Removing WordPress test stack", () => stack.composeDown({ volumes: args.volumes }), args.volumes ? "Stack removed (volumes dropped)" : "Stack removed (volumes kept)");
44
141
  }
45
- },
46
- async run({ args }) {
47
- const cfg = await resolveTestConfig(process.cwd());
48
- process.env.WP_PORT = String(cfg.port);
49
- if (args.reset) await withSpinner("Wiping WordPress database", () => composeDown({ volumes: true }), "Database wiped");
50
- await withSpinner("Starting WordPress test stack", composeUp, "WordPress test stack ready");
51
- if (!await isSeeded()) await withSpinner("Seeding WordPress", () => runSeeds({
52
- port: cfg.port,
53
- fixtures: cfg.fixtures
54
- }), "WordPress seeded");
55
- log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
56
- let code = 1;
57
- try {
58
- code = await spawnTest(cfg.command, cfg.packageManager);
59
- } finally {
60
- if (args.teardown) await withSpinner("Tearing down WordPress test stack", () => composeDown(), "WordPress test stack stopped");
61
- else log.info("Stack left running for fast reruns — `kizlo wp stop` to stop it, or rerun with --teardown.");
142
+ }),
143
+ reset: defineCommand({
144
+ meta: {
145
+ name: "reset",
146
+ description: "Wipe the database and reseed a fresh WordPress test stack"
147
+ },
148
+ async run() {
149
+ const { cfg, stack } = await resolve(process.cwd());
150
+ await withSpinner("Wiping WordPress database", () => stack.composeDown({ volumes: true }), "Database wiped");
151
+ const { port } = await bringUp(cfg, stack, true);
152
+ log.success(`Stack ready on http://localhost:${port} — credentials at ${cfg.credentialsPath}`);
62
153
  }
63
- process.exit(code);
64
- }
154
+ })
155
+ };
156
+ const test = defineCommand({
157
+ meta: {
158
+ name: "test",
159
+ description: "Manage the WordPress test stack and run tests (stop | down | reset)"
160
+ },
161
+ args: runArgs,
162
+ subCommands,
163
+ run: groupDefault(Object.keys(subCommands), runSuite)
65
164
  });
66
165
 
67
166
  //#endregion
@@ -1,8 +1,8 @@
1
- import { getVersion } from "../utils.js";
2
1
  import { log } from "../daemon/logger.js";
2
+ import { getVersion } from "../utils.js";
3
+ import { printBanner } from "../banner.js";
3
4
  import { resolveConfig } from "../daemon/config.js";
4
5
  import { generateOnce } from "../daemon/generate.js";
5
- import { printBanner } from "../banner.js";
6
6
  import { acquire, isLocked, lockPath, release } from "../daemon/lock.js";
7
7
  import { watch as watch$1 } from "../daemon/watch.js";
8
8
  import { defineCommand } from "citty";