kizlo 0.1.0 → 0.1.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.
@@ -0,0 +1,82 @@
1
+ import { createStack } from "../wp/docker.js";
2
+ import { ensureGitignored, groupDefault, withSpinner } from "../utils.js";
3
+ import { log } from "../daemon/logger.js";
4
+ import { resolveDevConfig } from "../daemon/config.js";
5
+ import { bootstrapDev } from "../wp/dev.js";
6
+ import { devStack } from "../wp/stack.js";
7
+ import { rmSync } from "node:fs";
8
+ import { defineCommand } from "citty";
9
+
10
+ //#region src/cli/commands/dev.ts
11
+ async function resolve(cwd) {
12
+ const cfg = await resolveDevConfig(cwd);
13
+ return {
14
+ cfg,
15
+ stack: createStack(devStack(cfg))
16
+ };
17
+ }
18
+ /** Print the connection summary. On a fresh install, show the one-time wp-admin password. */
19
+ function printSummary(info) {
20
+ log.success(`WordPress dev stack ready at ${info.url}`);
21
+ if (info.secrets) {
22
+ log.info(`wp-admin: ${info.url}/wp-admin (user: ${info.username} / pass: ${info.secrets.password})`);
23
+ log.info("Save the password now — it's shown only once and isn't stored on disk.");
24
+ } else if (info.imported) log.info(`wp-admin: ${info.url}/wp-admin — log in with your imported site's own credentials`);
25
+ else log.info(`wp-admin: ${info.url}/wp-admin (user: ${info.username}) — password unchanged from first install`);
26
+ log.info(`database: 127.0.0.1:${info.dbPort} (db: wordpress, user: wordpress, pass: wppass)`);
27
+ if (info.seeded > 0) log.info(`seeded: ${info.seeded} fixture${info.seeded === 1 ? "" : "s"}`);
28
+ }
29
+ /** Start (or resume) the dev stack and print the connection summary. Backs bare `kizlo dev`. */
30
+ async function bringUp() {
31
+ const { cfg } = await resolve(process.cwd());
32
+ if (!cfg.wordpressPath.startsWith("..")) ensureGitignored(cfg.configDir, cfg.wordpressPath);
33
+ printSummary(await withSpinner("Starting WordPress dev stack", () => bootstrapDev(cfg), "WordPress dev stack ready"));
34
+ }
35
+ const subCommands = {
36
+ stop: defineCommand({
37
+ meta: {
38
+ name: "stop",
39
+ description: "Stop the dev stack, keeping the database volume"
40
+ },
41
+ async run() {
42
+ const { stack } = await resolve(process.cwd());
43
+ await withSpinner("Stopping WordPress dev stack", () => stack.composeStop(), "Stack stopped (volumes kept)");
44
+ }
45
+ }),
46
+ down: defineCommand({
47
+ meta: {
48
+ name: "down",
49
+ description: "Stop and remove the dev stack containers (keeps the database and files)"
50
+ },
51
+ async run() {
52
+ const { stack } = await resolve(process.cwd());
53
+ await withSpinner("Removing WordPress dev stack", () => stack.composeDown(), "Stack removed (database and files kept)");
54
+ }
55
+ }),
56
+ reset: defineCommand({
57
+ meta: {
58
+ name: "reset",
59
+ description: "Wipe the database and the install folder, then rebuild fresh"
60
+ },
61
+ async run() {
62
+ const { cfg, stack } = await resolve(process.cwd());
63
+ await withSpinner("Wiping WordPress dev stack", () => stack.composeDown({ volumes: true }), "Stack wiped");
64
+ rmSync(cfg.wordpressDir, {
65
+ recursive: true,
66
+ force: true
67
+ });
68
+ printSummary(await withSpinner("Starting WordPress dev stack", () => bootstrapDev(cfg), "WordPress dev stack ready"));
69
+ }
70
+ })
71
+ };
72
+ const dev = defineCommand({
73
+ meta: {
74
+ name: "dev",
75
+ description: "Manage the local WordPress dev stack (stop | down | reset)"
76
+ },
77
+ subCommands,
78
+ run: groupDefault(Object.keys(subCommands), bringUp)
79
+ });
80
+
81
+ //#endregion
82
+ export { dev };
@@ -4,8 +4,8 @@ import { printBanner } from "../banner.js";
4
4
  import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
5
5
  import z from "zod/v4";
6
6
  import { randomBytes } from "node:crypto";
7
- import path from "node:path";
8
7
  import fs from "node:fs";
8
+ import path from "node:path";
9
9
  import { defineCommand } from "citty";
10
10
  import * as p from "@clack/prompts";
11
11
 
@@ -1,13 +1,34 @@
1
+ import { createStack } from "../wp/docker.js";
1
2
  import { isSeeded } from "../wp/utils.js";
2
- import { composeDown, composeUp } from "../wp/docker.js";
3
- import { withSpinner } from "../utils.js";
3
+ import { groupDefault, withSpinner } from "../utils.js";
4
4
  import { log } from "../daemon/logger.js";
5
5
  import { resolveTestConfig } from "../daemon/config.js";
6
+ import { testStack } from "../wp/stack.js";
6
7
  import { runSeeds } from "../wp/setup.js";
7
8
  import { spawn } from "node:child_process";
8
9
  import { defineCommand } from "citty";
9
10
 
10
11
  //#region src/cli/commands/test.ts
12
+ async function resolve(cwd) {
13
+ const cfg = await resolveTestConfig(cwd);
14
+ return {
15
+ cfg,
16
+ stack: createStack(testStack(cfg))
17
+ };
18
+ }
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;
25
+ }
26
+ await withSpinner("Seeding WordPress", () => runSeeds({
27
+ port: cfg.port,
28
+ fixtures: cfg.fixtures
29
+ }), "WordPress seeded");
30
+ log.success(`Stack ready on http://localhost:${cfg.port} — credentials at ${cfg.credentialsPath}`);
31
+ }
11
32
  /** `<pm> test` runs the project's own test script (bun needs `run` to skip its built-in runner). */
12
33
  function testCommand(pm) {
13
34
  return pm === "bun" ? [
@@ -23,45 +44,86 @@ function spawnTest(command, pm) {
23
44
  stdio: "inherit",
24
45
  shell: command !== void 0 || process.platform === "win32"
25
46
  });
26
- return new Promise((resolve, reject) => {
47
+ return new Promise((resolvePromise, reject) => {
27
48
  child.on("error", reject);
28
- child.on("close", (code) => resolve(code ?? 0));
49
+ child.on("close", (code) => resolvePromise(code ?? 0));
29
50
  });
30
51
  }
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"
52
+ const runArgs = {
53
+ teardown: {
54
+ type: "boolean",
55
+ description: "Tear down the test stack after tests finish (default: leave it running for fast reruns)"
35
56
  },
36
- args: {
37
- teardown: {
38
- type: "boolean",
39
- description: "Tear down the test stack after tests finish (default: leave it running for fast reruns)"
57
+ reset: {
58
+ type: "boolean",
59
+ description: "Wipe the database and reseed before running"
60
+ }
61
+ };
62
+ /** Boot (+ seed) the stack, run the project's test script, then leave it up (or tear down). */
63
+ 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");
71
+ log.info(cfg.command ? `Running: ${cfg.command}` : `Running: ${testCommand(cfg.packageManager).join(" ")}`);
72
+ let code = 1;
73
+ try {
74
+ code = await spawnTest(cfg.command, cfg.packageManager);
75
+ } finally {
76
+ if (args.teardown) await withSpinner("Tearing down WordPress test stack", () => stack.composeDown(), "WordPress test stack stopped");
77
+ else log.info("Stack left running for fast reruns — `kizlo test stop` to stop it, or rerun with --teardown.");
78
+ }
79
+ process.exit(code);
80
+ }
81
+ const subCommands = {
82
+ stop: defineCommand({
83
+ meta: {
84
+ name: "stop",
85
+ description: "Stop the WordPress test stack, keeping the database volume"
40
86
  },
41
- reset: {
87
+ async run() {
88
+ const { stack } = await resolve(process.cwd());
89
+ await withSpinner("Stopping WordPress test stack", () => stack.composeStop(), "Stack stopped (volumes kept)");
90
+ }
91
+ }),
92
+ down: defineCommand({
93
+ meta: {
94
+ name: "down",
95
+ description: "Stop and remove the test stack containers"
96
+ },
97
+ args: { volumes: {
42
98
  type: "boolean",
43
- description: "Wipe the database and reseed before running"
99
+ alias: "v",
100
+ description: "Also drop the database volume"
101
+ } },
102
+ async run({ args }) {
103
+ const { stack } = await resolve(process.cwd());
104
+ await withSpinner("Removing WordPress test stack", () => stack.composeDown({ volumes: args.volumes }), args.volumes ? "Stack removed (volumes dropped)" : "Stack removed (volumes kept)");
44
105
  }
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.");
106
+ }),
107
+ reset: defineCommand({
108
+ meta: {
109
+ name: "reset",
110
+ description: "Wipe the database and reseed a fresh WordPress test stack"
111
+ },
112
+ async run() {
113
+ const { cfg, stack } = await resolve(process.cwd());
114
+ await withSpinner("Wiping WordPress database", () => stack.composeDown({ volumes: true }), "Database wiped");
115
+ await bringUp(cfg, stack);
62
116
  }
63
- process.exit(code);
64
- }
117
+ })
118
+ };
119
+ const test = defineCommand({
120
+ meta: {
121
+ name: "test",
122
+ description: "Manage the WordPress test stack and run tests (stop | down | reset)"
123
+ },
124
+ args: runArgs,
125
+ subCommands,
126
+ run: groupDefault(Object.keys(subCommands), runSuite)
65
127
  });
66
128
 
67
129
  //#endregion
@@ -2,8 +2,8 @@ import { credentialsPath, findConfigDir } from "../wp/utils.js";
2
2
  import { detectPackageManager } from "../utils.js";
3
3
  import { log } from "./logger.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(),
@@ -66,22 +74,74 @@ async function resolveConfig(cwd, flags) {
66
74
  };
67
75
  }
68
76
  /**
77
+ * Sanitize an arbitrary package/dir name into a valid Docker compose project id:
78
+ * lowercase, `@scope/pkg` → `scope-pkg`, dropping anything outside `[a-z0-9_-]`.
79
+ */
80
+ function sanitizeProjectName(raw) {
81
+ return raw.toLowerCase().replace(/^@/, "").replace(/\//g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^[-_]+|[-_]+$/g, "") || "kizlo";
82
+ }
83
+ /**
84
+ * Base name for the local stacks: the config `name` if set, else the `package.json`
85
+ * `name` at `configDir`, else the config dir basename — sanitized to a Docker id.
86
+ */
87
+ function resolveStackName(configDir, configName) {
88
+ if (configName) return sanitizeProjectName(configName);
89
+ let pkgName;
90
+ try {
91
+ pkgName = JSON.parse(fs.readFileSync(path.join(configDir, "package.json"), "utf8")).name;
92
+ } catch {
93
+ pkgName = void 0;
94
+ }
95
+ return sanitizeProjectName(pkgName ?? path.basename(configDir));
96
+ }
97
+ /**
69
98
  * 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.
99
+ * `test` command, applying defaults (port 8889, auto-detected package manager)
100
+ * and anchoring the credentials artifact to the config directory.
72
101
  */
73
102
  async function resolveTestConfig(cwd) {
74
103
  const configDir = findConfigDir(cwd);
75
- const test = (await loadConfigFile(configDir))?.test ?? {};
104
+ const fileConfig = await loadConfigFile(configDir);
105
+ const test = fileConfig?.test ?? {};
76
106
  return {
77
107
  configDir,
108
+ project: `${resolveStackName(configDir, fileConfig?.name)}-test`,
78
109
  command: test.command,
79
- port: test.port ?? 8080,
110
+ port: test.port ?? 8889,
80
111
  fixtures: test.fixtures ?? [],
81
112
  credentialsPath: credentialsPath(cwd),
82
113
  packageManager: test.packageManager ?? detectPackageManager(configDir)
83
114
  };
84
115
  }
116
+ /**
117
+ * Resolve the `dev` block from `kizlo.config.*` into concrete values for the `dev`
118
+ * command, applying defaults (port 8080). `dev.path` is required — the whole
119
+ * WordPress install lives there, so we make the user choose a real folder rather
120
+ * than hide it under a default.
121
+ */
122
+ async function resolveDevConfig(cwd) {
123
+ const configDir = findConfigDir(cwd);
124
+ const fileConfig = await loadConfigFile(configDir);
125
+ const dev = fileConfig?.dev ?? {};
126
+ if (!dev.path) {
127
+ 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\" }.");
128
+ process.exit(1);
129
+ }
130
+ if (dev.byo && dev.fixtures?.length) {
131
+ log.error("`dev.byo` and `dev.fixtures` are mutually exclusive — byo imports an existing site, so it brings its own data.");
132
+ process.exit(1);
133
+ }
134
+ return {
135
+ configDir,
136
+ project: `${resolveStackName(configDir, fileConfig?.name)}-dev`,
137
+ port: dev.port ?? 8080,
138
+ dbPort: dev.dbPort ?? 3307,
139
+ byo: dev.byo ? path.resolve(configDir, dev.byo) : void 0,
140
+ fixtures: dev.fixtures ?? [],
141
+ wordpressPath: dev.path,
142
+ wordpressDir: path.resolve(configDir, dev.path)
143
+ };
144
+ }
85
145
 
86
146
  //#endregion
87
- export { resolveConfig, resolveTestConfig };
147
+ export { resolveConfig, resolveDevConfig, 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
  }));
package/dist/cli/utils.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { fileURLToPath } from "node:url";
4
- import fs from "node:fs";
5
5
  import * as p from "@clack/prompts";
6
6
 
7
7
  //#region src/cli/utils.ts
@@ -177,6 +177,21 @@ async function withSpinner(message, fn, done) {
177
177
  throw error;
178
178
  }
179
179
  }
180
+ /**
181
+ * Wrap a command group's default `run` so it fires only for the bare invocation
182
+ * (`kizlo test`), not when a subcommand was given (`kizlo test up`). citty runs a
183
+ * group's own `run` *in addition to* the matched subcommand, so without this guard
184
+ * the default would double-fire on every subcommand. Pass the group's subcommand
185
+ * names; the first non-flag arg is matched the same way citty picks the subcommand.
186
+ */
187
+ function groupDefault(subCommandNames, run) {
188
+ const names = new Set(subCommandNames);
189
+ return async (ctx) => {
190
+ const sub = ctx.rawArgs.find((arg) => !arg.startsWith("-"));
191
+ if (sub !== void 0 && names.has(sub)) return;
192
+ await run(ctx);
193
+ };
194
+ }
180
195
 
181
196
  //#endregion
182
- export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envKeysPresent, getVersion, loadEnvFiles, mergeEnv, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
197
+ export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envKeysPresent, getVersion, groupDefault, loadEnvFiles, mergeEnv, 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
@@ -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