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,9 +1,9 @@
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
- import path from "node:path";
6
5
  import fs from "node:fs";
6
+ import path from "node:path";
7
7
  import { createJiti } from "jiti";
8
8
 
9
9
  //#region src/cli/daemon/config.ts
@@ -12,6 +12,14 @@ const fixtureSchema = z.custom((value) => typeof value === "object" && value !==
12
12
  const configSchema = z.object({
13
13
  dir: z.string().optional(),
14
14
  alias: z.string().optional(),
15
+ name: z.string().optional(),
16
+ dev: z.object({
17
+ path: z.string().optional(),
18
+ port: z.number().int().positive().optional(),
19
+ dbPort: z.number().int().positive().optional(),
20
+ byo: z.string().optional(),
21
+ fixtures: z.array(fixtureSchema).optional()
22
+ }).optional(),
15
23
  test: z.object({
16
24
  port: z.number().int().positive().optional(),
17
25
  fixtures: z.array(fixtureSchema).optional(),
@@ -29,6 +37,9 @@ const CONFIG_FILES = [
29
37
  "kizlo.config.js",
30
38
  "kizlo.config.mjs"
31
39
  ];
40
+ const DEFAULT_DEV_PORT = 8080;
41
+ const DEFAULT_DEV_DB_PORT = 3307;
42
+ const DEFAULT_TEST_PORT = 8889;
32
43
  function defaultDir(cwd) {
33
44
  return fs.existsSync(path.join(cwd, "src")) ? "src/lib/kizlo" : "lib/kizlo";
34
45
  }
@@ -66,22 +77,77 @@ async function resolveConfig(cwd, flags) {
66
77
  };
67
78
  }
68
79
  /**
80
+ * Sanitize an arbitrary package/dir name into a valid Docker compose project id:
81
+ * lowercase, `@scope/pkg` → `scope-pkg`, dropping anything outside `[a-z0-9_-]`.
82
+ */
83
+ function sanitizeProjectName(raw) {
84
+ return raw.toLowerCase().replace(/^@/, "").replace(/\//g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^[-_]+|[-_]+$/g, "") || "kizlo";
85
+ }
86
+ /**
87
+ * Base name for the local stacks: the config `name` if set, else the `package.json`
88
+ * `name` at `configDir`, else the config dir basename — sanitized to a Docker id.
89
+ */
90
+ function resolveStackName(configDir, configName) {
91
+ if (configName) return sanitizeProjectName(configName);
92
+ let pkgName;
93
+ try {
94
+ pkgName = JSON.parse(fs.readFileSync(path.join(configDir, "package.json"), "utf8")).name;
95
+ } catch {
96
+ pkgName = void 0;
97
+ }
98
+ return sanitizeProjectName(pkgName ?? path.basename(configDir));
99
+ }
100
+ /**
69
101
  * Resolve the `test` block from `kizlo.config.*` into concrete values for the
70
- * `wp`/`test` commands, applying defaults (port 8080, auto-detected package
71
- * manager) and anchoring the credentials artifact to the config directory.
102
+ * `test` command, applying defaults (port 8889, auto-detected package manager)
103
+ * and anchoring the credentials artifact to the config directory.
72
104
  */
73
105
  async function resolveTestConfig(cwd) {
74
106
  const configDir = findConfigDir(cwd);
75
- const test = (await loadConfigFile(configDir))?.test ?? {};
107
+ const fileConfig = await loadConfigFile(configDir);
108
+ const test = fileConfig?.test ?? {};
76
109
  return {
77
110
  configDir,
111
+ project: `${resolveStackName(configDir, fileConfig?.name)}-test`,
78
112
  command: test.command,
79
- port: test.port ?? 8080,
113
+ port: test.port ?? DEFAULT_TEST_PORT,
114
+ portExplicit: test.port !== void 0,
80
115
  fixtures: test.fixtures ?? [],
81
116
  credentialsPath: credentialsPath(cwd),
82
117
  packageManager: test.packageManager ?? detectPackageManager(configDir)
83
118
  };
84
119
  }
120
+ /**
121
+ * Resolve the `dev` block from `kizlo.config.*` into concrete values for the `dev`
122
+ * command, applying defaults (port 8080). `dev.path` is required — the whole
123
+ * WordPress install lives there, so we make the user choose a real folder rather
124
+ * than hide it under a default.
125
+ */
126
+ async function resolveDevConfig(cwd) {
127
+ const configDir = findConfigDir(cwd);
128
+ const fileConfig = await loadConfigFile(configDir);
129
+ const dev = fileConfig?.dev ?? {};
130
+ if (!dev.path) {
131
+ log.error("`dev.path` is required in kizlo.config — it's the folder your local WordPress lives in.\nPick a real, visible folder (it persists your site between runs), e.g. dev: { path: \"wordpress\" }.");
132
+ process.exit(1);
133
+ }
134
+ if (dev.byo && dev.fixtures?.length) {
135
+ log.error("`dev.byo` and `dev.fixtures` are mutually exclusive — byo imports an existing site, so it brings its own data.");
136
+ process.exit(1);
137
+ }
138
+ return {
139
+ configDir,
140
+ project: `${resolveStackName(configDir, fileConfig?.name)}-dev`,
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,
145
+ byo: dev.byo ? path.resolve(configDir, dev.byo) : void 0,
146
+ fixtures: dev.fixtures ?? [],
147
+ wordpressPath: dev.path,
148
+ wordpressDir: path.resolve(configDir, dev.path)
149
+ };
150
+ }
85
151
 
86
152
  //#endregion
87
- export { resolveConfig, resolveTestConfig };
153
+ export { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig };
@@ -1,7 +1,7 @@
1
1
  import { generateContract } from "../../shared/contract.js";
2
2
  import { loadEnvFiles } from "../utils.js";
3
- import path from "node:path";
4
3
  import fs from "node:fs";
4
+ import path from "node:path";
5
5
  import { createJiti } from "jiti";
6
6
 
7
7
  //#region src/cli/daemon/generate.ts
@@ -1,5 +1,5 @@
1
- import path from "node:path";
2
1
  import fs from "node:fs";
2
+ import path from "node:path";
3
3
 
4
4
  //#region src/cli/daemon/lock.ts
5
5
  function lockPath(cwd) {
package/dist/cli/index.js CHANGED
@@ -13,7 +13,7 @@ runMain(defineCommand({
13
13
  init: () => import("./commands/init.js").then((m) => m.init),
14
14
  watch: () => import("./commands/watch.js").then((m) => m.watch),
15
15
  generate: () => import("./commands/generate.js").then((m) => m.generate),
16
- wp: () => import("./commands/wp.js").then((m) => m.wp),
16
+ dev: () => import("./commands/dev.js").then((m) => m.dev),
17
17
  test: () => import("./commands/test.js").then((m) => m.test)
18
18
  }
19
19
  }));
@@ -3,7 +3,7 @@ const base = {
3
3
  id: "base",
4
4
  label: "Generic (no framework detected)",
5
5
  detect: () => 1,
6
- baseUrlEnvKey: "SERVER_BASE_URL",
6
+ baseUrlEnvKey: "KIZLO_BACKEND_URL",
7
7
  serverEntry() {
8
8
  return `import { createKizlo } from "kizlo"
9
9
 
@@ -11,6 +11,13 @@ export const { router, client, context, handler } = createKizlo()
11
11
  `;
12
12
  },
13
13
  clientEntry(ctx) {
14
+ if (ctx.clientUrl) return `import { createKizloClient } from "kizlo"
15
+ import { contract } from "./${ctx.serverDirName}/generated"
16
+
17
+ // Your Kizlo backend runs on a different origin than this app. Swap in your bundler's
18
+ // public env var (e.g. import.meta.env.VITE_KIZLO_BACKEND_URL) to set it per environment.
19
+ export const client = createKizloClient(contract, { url: "${ctx.clientUrl}" })
20
+ `;
14
21
  return `import { createKizloClient } from "kizlo"
15
22
  import { contract } from "./${ctx.serverDirName}/generated"
16
23
 
@@ -8,7 +8,7 @@ const nextjs = {
8
8
  ...ctx.pkg.devDependencies
9
9
  }.next ? 100 : 0;
10
10
  },
11
- baseUrlEnvKey: "NEXT_PUBLIC_SERVER_BASE_URL",
11
+ baseUrlEnvKey: "NEXT_PUBLIC_KIZLO_BACKEND_URL",
12
12
  apiPath: "/api/kizlo",
13
13
  serverEntry() {
14
14
  return `import { createKizlo } from "kizlo/nextjs/server"
package/dist/cli/utils.js CHANGED
@@ -1,10 +1,31 @@
1
+ import { log } from "./daemon/logger.js";
2
+ import { PortInUseError, resolveHostPort } from "./wp/ports.js";
1
3
  import { spawnSync } from "node:child_process";
4
+ import fs from "node:fs";
2
5
  import path from "node:path";
3
6
  import { fileURLToPath } from "node:url";
4
- import fs from "node:fs";
5
7
  import * as p from "@clack/prompts";
6
8
 
7
9
  //#region src/cli/utils.ts
10
+ /**
11
+ * Resolve a host port for a stack, exiting with a clear message when an *explicitly*
12
+ * configured port (`fixed`) is already taken. A default port auto-steps to the next free
13
+ * one (another project's stack or a stray server just shifts us over); an explicit
14
+ * `configKey` collision is the user's own to resolve, so we stop rather than silently
15
+ * serving on a port they didn't choose.
16
+ */
17
+ async function pickStackPort(preferred, { fixed, host, configKey }) {
18
+ try {
19
+ return await resolveHostPort(preferred, {
20
+ fixed,
21
+ host
22
+ });
23
+ } catch (error) {
24
+ if (!(error instanceof PortInUseError)) throw error;
25
+ log.error(`${configKey} ${preferred} is set in kizlo.config but is already in use.\nFree that port or change ${configKey} — an explicitly set port is never auto-reassigned.`);
26
+ process.exit(1);
27
+ }
28
+ }
8
29
  function stripJsonComments(source) {
9
30
  return source.replace(/\\"|"(?:\\"|[^"])*"|(\/\/.*|\/\*[\s\S]*?\*\/)/g, (match, comment) => comment ? "" : match);
10
31
  }
@@ -117,11 +138,49 @@ function envKeysPresent(contents, keys) {
117
138
  return keys.filter((key) => new RegExp(`^\\s*${key}\\s*=`, "m").test(contents));
118
139
  }
119
140
  /**
141
+ * The grouped `.env` layout shared by `init` and `dev`, so both write the same sectioned file:
142
+ * a target switch, the backend URL, then the production and dev connection blocks. `baseUrlEnvKey`
143
+ * varies per preset (e.g. `NEXT_PUBLIC_KIZLO_BACKEND_URL`), so it's threaded in. Only sections whose
144
+ * keys are actually being appended get a header, so a remote/prod run skips the dev block and vice versa.
145
+ */
146
+ function envGroups(baseUrlEnvKey) {
147
+ return [
148
+ {
149
+ comment: "Kizlo Target (dev | prod)",
150
+ keys: ["KIZLO_TARGET"]
151
+ },
152
+ {
153
+ comment: "Kizlo Backend URL",
154
+ keys: [baseUrlEnvKey]
155
+ },
156
+ {
157
+ comment: "Kizlo Production Env",
158
+ keys: [
159
+ "KIZLO_SITE_SECRET",
160
+ "KIZLO_WORDPRESS_URL",
161
+ "KIZLO_WORDPRESS_USERNAME",
162
+ "KIZLO_WORDPRESS_APPLICATION_PASSWORD"
163
+ ]
164
+ },
165
+ {
166
+ comment: "Kizlo Development Env",
167
+ keys: [
168
+ "KIZLO_DEV_SITE_SECRET",
169
+ "KIZLO_DEV_WORDPRESS_URL",
170
+ "KIZLO_DEV_WORDPRESS_USERNAME",
171
+ "KIZLO_DEV_WORDPRESS_APPLICATION_PASSWORD"
172
+ ]
173
+ }
174
+ ];
175
+ }
176
+ /**
120
177
  * Merges `values` into an existing .env body: other variables, comments and
121
178
  * blank lines are preserved. A managed key is rewritten only when listed in
122
- * `overwriteKeys`; otherwise its existing line is kept. Missing keys are appended.
179
+ * `overwriteKeys`; otherwise its existing line is kept. Missing keys are appended —
180
+ * grouped under their `groups` comment header (with a blank-line separator) when one
181
+ * is given, or as bare lines otherwise.
123
182
  */
124
- function mergeEnv(existing, values, overwriteKeys) {
183
+ function mergeEnv(existing, values, overwriteKeys, groups) {
125
184
  const body = existing.replace(/\s*$/, "");
126
185
  const lines = body.length ? body.split(/\r?\n/) : [];
127
186
  const added = [];
@@ -140,10 +199,20 @@ function mergeEnv(existing, values, overwriteKeys) {
140
199
  }
141
200
  return line;
142
201
  });
143
- for (const [key, value] of Object.entries(values)) if (!present.has(key)) {
202
+ const remaining = new Map(Object.entries(values).filter(([key]) => !present.has(key)));
203
+ const append = (key) => {
144
204
  added.push(key);
145
- next.push(`${key}=${value}`);
205
+ next.push(`${key}=${remaining.get(key)}`);
206
+ remaining.delete(key);
207
+ };
208
+ for (const group of groups ?? []) {
209
+ const groupKeys = group.keys.filter((key) => remaining.has(key));
210
+ if (!groupKeys.length) continue;
211
+ if (next.length) next.push("");
212
+ next.push(`# ${group.comment}`);
213
+ for (const key of groupKeys) append(key);
146
214
  }
215
+ for (const key of remaining.keys()) append(key);
147
216
  return {
148
217
  content: `${next.join("\n")}\n`,
149
218
  added,
@@ -177,6 +246,21 @@ async function withSpinner(message, fn, done) {
177
246
  throw error;
178
247
  }
179
248
  }
249
+ /**
250
+ * Wrap a command group's default `run` so it fires only for the bare invocation
251
+ * (`kizlo test`), not when a subcommand was given (`kizlo test up`). citty runs a
252
+ * group's own `run` *in addition to* the matched subcommand, so without this guard
253
+ * the default would double-fire on every subcommand. Pass the group's subcommand
254
+ * names; the first non-flag arg is matched the same way citty picks the subcommand.
255
+ */
256
+ function groupDefault(subCommandNames, run) {
257
+ const names = new Set(subCommandNames);
258
+ return async (ctx) => {
259
+ const sub = ctx.rawArgs.find((arg) => !arg.startsWith("-"));
260
+ if (sub !== void 0 && names.has(sub)) return;
261
+ await run(ctx);
262
+ };
263
+ }
180
264
 
181
265
  //#endregion
182
- export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envKeysPresent, getVersion, loadEnvFiles, mergeEnv, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
266
+ export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envGroups, envKeysPresent, getVersion, groupDefault, loadEnvFiles, mergeEnv, pickStackPort, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
@@ -1,6 +1,6 @@
1
- import { ensurePlugin, resolvePluginSource } from "./utils.js";
2
- import { DEFAULT_PLUGINS, DEFAULT_PORT, SEED_MARKER_OPTION, SEED_VERSION, TEST_ADMIN, TEST_USER } from "./constants.js";
3
1
  import { compose, composeUp, wpCli } from "./docker.js";
2
+ import { DEFAULT_PLUGINS, DEFAULT_PORT, SEED_MARKER_OPTION, SEED_VERSION, TEST_ADMIN, TEST_USER } from "./constants.js";
3
+ import { ensurePlugins } from "./utils.js";
4
4
 
5
5
  //#region src/cli/wp/bootstrap.ts
6
6
  async function seedUsers() {
@@ -22,7 +22,7 @@ async function seedUsers() {
22
22
  if (!id) throw new Error("seed/users.json must include a user with username \"user\"");
23
23
  return Number(id);
24
24
  }
25
- async function createAdminAppPassword() {
25
+ async function createAdminAppPassword(label = "harness") {
26
26
  await compose([
27
27
  "exec",
28
28
  "-T",
@@ -39,7 +39,7 @@ async function createAdminAppPassword() {
39
39
  "application-password",
40
40
  "create",
41
41
  TEST_ADMIN.username,
42
- "harness",
42
+ label,
43
43
  "--porcelain"
44
44
  ]);
45
45
  }
@@ -50,9 +50,7 @@ async function createAdminAppPassword() {
50
50
  * construct the REST client in Phase 2.
51
51
  */
52
52
  async function bootstrapWp(config) {
53
- const port = config.port ?? DEFAULT_PORT;
54
- const url = `http://localhost:${port}`;
55
- process.env.WP_PORT = String(port);
53
+ const url = `http://localhost:${config.port ?? DEFAULT_PORT}`;
56
54
  await composeUp();
57
55
  if ((await compose([
58
56
  "exec",
@@ -77,7 +75,7 @@ async function bootstrapWp(config) {
77
75
  "/%postname%/",
78
76
  "--hard"
79
77
  ]);
80
- for (const plugin of [...DEFAULT_PLUGINS, ...config.fixtures?.flatMap((a) => a.plugins ?? []) ?? []]) await ensurePlugin(...resolvePluginSource(plugin));
78
+ await ensurePlugins([...DEFAULT_PLUGINS, ...config.fixtures?.flatMap((f) => f.plugins ?? []) ?? []]);
81
79
  const adminId = Number(await wpCli([
82
80
  "user",
83
81
  "get",
@@ -88,7 +86,12 @@ async function bootstrapWp(config) {
88
86
  const appPassword = await createAdminAppPassword();
89
87
  await wpCli([
90
88
  "option",
91
- "update",
89
+ "delete",
90
+ SEED_MARKER_OPTION
91
+ ]).catch(() => {});
92
+ await wpCli([
93
+ "option",
94
+ "add",
92
95
  SEED_MARKER_OPTION,
93
96
  SEED_VERSION,
94
97
  "--autoload=no"
@@ -111,4 +114,4 @@ async function bootstrapWp(config) {
111
114
  }
112
115
 
113
116
  //#endregion
114
- export { bootstrapWp };
117
+ export { bootstrapWp, createAdminAppPassword, seedUsers };
@@ -0,0 +1,113 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { cpSync, createReadStream, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
3
+ import { join, resolve, sep } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+
6
+ //#region src/cli/wp/byo.ts
7
+ /** Path under the config's `.kizlo/` dir where the dump is staged for import, then deleted. */
8
+ const SQL_STAGE_REL = ".kizlo/byo-import.sql";
9
+ function tar(args) {
10
+ try {
11
+ return execFileSync("tar", args, {
12
+ encoding: "utf8",
13
+ maxBuffer: 64 * 1024 * 1024
14
+ });
15
+ } catch (error) {
16
+ const e = error;
17
+ throw new Error(`tar ${args.join(" ")} failed:\n${e.stderr || e.message}`);
18
+ }
19
+ }
20
+ /** Drop a leading `./` that some tar archives prefix entries with. */
21
+ function norm(entry) {
22
+ return entry.replace(/^\.\//, "");
23
+ }
24
+ /** Move `src` to `dest`, falling back to copy+remove across filesystems (rename's EXDEV). */
25
+ function moveInto(src, dest) {
26
+ try {
27
+ renameSync(src, dest);
28
+ } catch (error) {
29
+ if (error.code !== "EXDEV") throw error;
30
+ cpSync(src, dest, { recursive: true });
31
+ rmSync(src, {
32
+ recursive: true,
33
+ force: true
34
+ });
35
+ }
36
+ }
37
+ /**
38
+ * Validate a BYO archive and return the name of its single root-level `.sql` dump. The
39
+ * archive must be a `.tar.gz` holding a top-level `wordpress/` directory and exactly one
40
+ * root-level `*.sql`, and must live outside `wordpressDir` (else `reset`, which wipes
41
+ * that folder, would delete the archive before it could be re-read).
42
+ */
43
+ function validateByoArchive(archivePath, wordpressDir) {
44
+ if (!existsSync(archivePath)) throw new Error(`dev.byo archive not found: ${archivePath}`);
45
+ if (!/\.tar(\.(gz|xz|bz2|zst))?$|\.(tgz|txz|tbz2?|tzst)$/i.test(archivePath)) throw new Error(`dev.byo must be a tar archive (.tar.gz, .tar.xz, .tar.bz2, .tar.zst, .tar): ${archivePath}`);
46
+ if (resolve(archivePath).startsWith(resolve(wordpressDir) + sep)) throw new Error(`dev.byo archive must live outside dev.path — reset would delete it: ${archivePath}`);
47
+ const entries = tar(["-tf", archivePath]).split("\n").map(norm).filter(Boolean);
48
+ if (!entries.some((e) => e === "wordpress/" || e.startsWith("wordpress/"))) throw new Error("dev.byo archive must contain a top-level `wordpress/` directory");
49
+ const sqls = entries.filter((e) => /^[^/]+\.sql$/.test(e));
50
+ if (sqls.length !== 1) throw new Error(`dev.byo archive must contain exactly one root-level .sql file (found ${sqls.length})`);
51
+ return sqls[0];
52
+ }
53
+ /**
54
+ * Detect the dump's table prefix from its `{prefix}options` table — the one table every
55
+ * WordPress install has exactly one of. Streams the file and stops at the first match;
56
+ * falls back to `wp_` when none is found (so a standard dump still imports).
57
+ */
58
+ async function detectTablePrefix(sqlPath) {
59
+ const rl = createInterface({
60
+ input: createReadStream(sqlPath),
61
+ crlfDelay: Number.POSITIVE_INFINITY
62
+ });
63
+ try {
64
+ for await (const line of rl) {
65
+ const match = line.match(/(?:CREATE TABLE(?:\s+IF NOT EXISTS)?|INSERT INTO)\s+`?([0-9a-zA-Z$_]+?)options`?\b/i);
66
+ if (match) return match[1];
67
+ }
68
+ } finally {
69
+ rl.close();
70
+ }
71
+ return "wp_";
72
+ }
73
+ /**
74
+ * Lay a BYO install onto disk before the containers boot: extract the archive, move its
75
+ * `wordpress/` files into `wordpressDir` (dropping the bundled `wp-config.php` — the image
76
+ * regenerates one with the compose DB credentials), stage the dump under `.kizlo/`, and
77
+ * read its table prefix. Returns the prefix (to match the regenerated config) and the host
78
+ * path of the staged dump (the caller pipes it into the `mysql` container, then deletes it).
79
+ */
80
+ async function prepareByo(archivePath, wordpressDir, configDir) {
81
+ const sqlName = validateByoArchive(archivePath, wordpressDir);
82
+ const tmp = join(configDir, ".kizlo", "byo-extract");
83
+ rmSync(tmp, {
84
+ recursive: true,
85
+ force: true
86
+ });
87
+ mkdirSync(tmp, { recursive: true });
88
+ try {
89
+ tar([
90
+ "-xf",
91
+ archivePath,
92
+ "-C",
93
+ tmp
94
+ ]);
95
+ mkdirSync(wordpressDir, { recursive: true });
96
+ for (const entry of readdirSync(join(tmp, "wordpress"))) moveInto(join(tmp, "wordpress", entry), join(wordpressDir, entry));
97
+ rmSync(join(wordpressDir, "wp-config.php"), { force: true });
98
+ const sqlPath = join(configDir, SQL_STAGE_REL);
99
+ moveInto(join(tmp, sqlName), sqlPath);
100
+ return {
101
+ prefix: await detectTablePrefix(sqlPath),
102
+ sqlPath
103
+ };
104
+ } finally {
105
+ rmSync(tmp, {
106
+ recursive: true,
107
+ force: true
108
+ });
109
+ }
110
+ }
111
+
112
+ //#endregion
113
+ export { prepareByo };
@@ -1,5 +1,4 @@
1
- name: kizlo-wp-test
2
-
1
+ # Project name is supplied per-stack via `docker compose -p <project>`.
3
2
  services:
4
3
  mysql:
5
4
  image: mysql:8
@@ -36,8 +35,20 @@ services:
36
35
  WORDPRESS_DB_NAME: ${MYSQL_DATABASE:-wordpress}
37
36
  WORDPRESS_DB_USER: ${MYSQL_USER:-wordpress}
38
37
  WORDPRESS_DB_PASSWORD: ${MYSQL_PASSWORD:-wppass}
38
+ # The wordpress image eval()s WORDPRESS_CONFIG_EXTRA at runtime, and Compose
39
+ # interpolates `$` in this file first — so every PHP variable below is written
40
+ # `$$` to survive Compose and reach PHP as a literal `$`.
39
41
  WORDPRESS_CONFIG_EXTRA: |
40
42
  define('WP_ENVIRONMENT_TYPE', 'local');
43
+ // Dev only: serve under whatever host the request came in on (localhost or a LAN
44
+ // IP) instead of the stored siteurl, so the stack is reachable from other devices
45
+ // on the network, like `next dev`. CLI requests have no HTTP_HOST and fall back to
46
+ // the database value, so wp-cli/bootstrap stay on the canonical URL.
47
+ if (!empty($$_SERVER['HTTP_HOST'])) {
48
+ $$kizlo_scheme = (!empty($$_SERVER['HTTPS']) && $$_SERVER['HTTPS'] !== 'off') ? 'https://' : 'http://';
49
+ define('WP_HOME', $$kizlo_scheme . $$_SERVER['HTTP_HOST']);
50
+ define('WP_SITEURL', $$kizlo_scheme . $$_SERVER['HTTP_HOST']);
51
+ }
41
52
  volumes:
42
53
  - wp_data:/var/www/html
43
54
  healthcheck:
@@ -0,0 +1,16 @@
1
+ ; Kizlo dev — PHP OPcache tuned for local development.
2
+ ;
3
+ ; OPcache keeps compiled PHP bytecode in memory, so WordPress skips re-reading and
4
+ ; recompiling its hundreds of files on every request — the single biggest speedup
5
+ ; when the install is bind-mounted from the host.
6
+ ;
7
+ ; validate_timestamps=1 (with revalidate_freq=0) makes OPcache re-check each file's
8
+ ; mtime every request, so live edits from a bind mount show up immediately. Never
9
+ ; set this to 0 in dev or your changes will appear "stuck" behind a stale cache.
10
+ opcache.enable=1
11
+ opcache.enable_cli=0
12
+ opcache.memory_consumption=128
13
+ opcache.interned_strings_buffer=16
14
+ opcache.max_accelerated_files=10000
15
+ opcache.validate_timestamps=1
16
+ opcache.revalidate_freq=0
@@ -0,0 +1,104 @@
1
+ import { OPCACHE_INI, REMAP_ENTRYPOINT } from "../constants.js";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { basename, dirname, isAbsolute, resolve } from "node:path";
4
+
5
+ //#region src/cli/wp/compose/override.ts
6
+ /** Generated dev override location, relative to the config root. */
7
+ const OVERRIDE_REL = ".kizlo/docker-compose.dev.override.yml";
8
+ /** Generated test override location (plugin mounts only), relative to the config root. */
9
+ const TEST_OVERRIDE_REL = ".kizlo/docker-compose.test.override.yml";
10
+ const WP_ROOT = "/var/www/html";
11
+ const PLUGINS_DIR = `${WP_ROOT}/wp-content/plugins`;
12
+ /** Where the dev OPcache config is dropped so the container's PHP picks it up. */
13
+ const PHP_CONF_TARGET = "/usr/local/etc/php/conf.d/zz-kizlo-opcache.ini";
14
+ /** Where the Linux uid-remap entrypoint is mounted (run via `sh`, so no +x needed). */
15
+ const REMAP_TARGET = "/usr/local/bin/kizlo-remap-entrypoint.sh";
16
+ function renderService(name, block) {
17
+ const lines = [` ${name}:`];
18
+ if (block.user) lines.push(` user: "${block.user}"`);
19
+ if (block.entrypoint) lines.push(` entrypoint: ${JSON.stringify(block.entrypoint)}`);
20
+ if (block.env) {
21
+ lines.push(" environment:");
22
+ for (const [key, value] of Object.entries(block.env)) lines.push(` ${key}: "${value}"`);
23
+ }
24
+ if (block.ports?.length) lines.push(" ports:", ...block.ports.map((p) => ` - "${p}"`));
25
+ if (block.volumes?.length) lines.push(" volumes:", ...block.volumes);
26
+ return lines.join("\n");
27
+ }
28
+ /**
29
+ * A compose volume line (`- "host:container"`). `:ro` marks it read-only — the
30
+ * container can read it but never write back to the host file.
31
+ */
32
+ function bind(host, container, readOnly = false) {
33
+ return ` - "${host}:${container}${readOnly ? ":ro" : ""}"`;
34
+ }
35
+ /** Resolve each local-plugin dir to a volume bind under `wp-content/plugins`, layered by basename. */
36
+ function pluginBinds(configDir, mounts) {
37
+ return mounts.map((entry) => bind(isAbsolute(entry) ? entry : resolve(configDir, entry), `${PLUGINS_DIR}/${basename(entry)}`));
38
+ }
39
+ /**
40
+ * Generate the dev compose override. The whole install is bind-mounted from the
41
+ * host `wordpressDir`, so every file is live and editable; local plugin dirs layer
42
+ * on top by basename; and the dev OPcache config is dropped into PHP (a speed win
43
+ * with live-edit-safe settings). Binds apply to both `wordpress` (serves the site)
44
+ * and `wp-cli` (activates plugins). On Linux (`hostUser` set) the container also
45
+ * runs as the host user so generated files are owned by you, not uid 33. Returns
46
+ * the override path to append to the dev stack's compose files.
47
+ */
48
+ function writeDevOverride(configDir, opts) {
49
+ const path$1 = resolve(configDir, OVERRIDE_REL);
50
+ mkdirSync(dirname(path$1), { recursive: true });
51
+ mkdirSync(opts.wordpressDir, { recursive: true });
52
+ const mounts = pluginBinds(configDir, opts.mounts);
53
+ const rootBind = bind(opts.wordpressDir, WP_ROOT);
54
+ const remap = opts.hostUser ? {
55
+ wordpress: {
56
+ user: "0:0",
57
+ entrypoint: ["sh", REMAP_TARGET],
58
+ env: {
59
+ KIZLO_PUID: String(opts.hostUser.uid),
60
+ KIZLO_PGID: String(opts.hostUser.gid)
61
+ },
62
+ scriptBind: bind(REMAP_ENTRYPOINT, REMAP_TARGET, true)
63
+ },
64
+ wpCliUser: `${opts.hostUser.uid}:${opts.hostUser.gid}`
65
+ } : void 0;
66
+ const wordpress = {
67
+ user: remap?.wordpress.user,
68
+ entrypoint: remap?.wordpress.entrypoint,
69
+ env: remap?.wordpress.env,
70
+ volumes: [
71
+ ...remap ? [remap.wordpress.scriptBind] : [],
72
+ rootBind,
73
+ ...mounts,
74
+ bind(OPCACHE_INI, PHP_CONF_TARGET, true)
75
+ ]
76
+ };
77
+ const wpCli = {
78
+ user: remap?.wpCliUser,
79
+ volumes: [rootBind, ...mounts]
80
+ };
81
+ const services = [renderService("wordpress", wordpress), renderService("wp-cli", wpCli)];
82
+ if (opts.dbPort) services.push(renderService("mysql", { ports: [`127.0.0.1:${opts.dbPort}:3306`] }));
83
+ writeFileSync(path$1, `services:\n${services.join("\n")}\n`);
84
+ return path$1;
85
+ }
86
+ /**
87
+ * Generate the test compose override: bind-mount the fixtures' local plugin dirs over
88
+ * `wp-content/plugins` (by basename) on both `wordpress` and `wp-cli`, layered on top of
89
+ * the test stack's `wp_data` volume. Unlike the dev override it touches nothing else —
90
+ * the test stack keeps its volume-backed install, OPcache, and default entrypoints — so a
91
+ * fixture carrying a `{ path }` plugin runs against your live source with no build/zip
92
+ * step, exactly as in `kizlo dev`. Returns the override path to append to the test stack's
93
+ * compose files.
94
+ */
95
+ function writeTestOverride(configDir, mounts) {
96
+ const path$1 = resolve(configDir, TEST_OVERRIDE_REL);
97
+ mkdirSync(dirname(path$1), { recursive: true });
98
+ const binds = pluginBinds(configDir, mounts);
99
+ writeFileSync(path$1, `services:\n${[renderService("wordpress", { volumes: binds }), renderService("wp-cli", { volumes: binds })].join("\n")}\n`);
100
+ return path$1;
101
+ }
102
+
103
+ //#endregion
104
+ export { writeDevOverride, writeTestOverride };