kizlo 0.22.0 → 0.22.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.
@@ -41,17 +41,18 @@ function note(text) {
41
41
  process.stdout.write(` ${dim}${text}${reset}\n`);
42
42
  }
43
43
  /**
44
- * Print the connection summary as an aligned, Next.js-style block. Shows the wp-admin URL on
45
- * the loopback ("Local") and — when local WordPress is installed on a LAN address rather than
46
- * localhost — on the network, so it can be opened from other devices. On a fresh install, also
47
- * show the default wp-admin login. The MCP row is the address to paste into a harness's own
44
+ * Print the connection summary as an aligned, Next.js-style block. Shows the login URL on the
45
+ * loopback ("Local"), and on the network too when local WordPress is installed on a LAN address
46
+ * rather than localhost, so it can be opened from other devices. That path comes from the stack
47
+ * (`adminPath`), since Headless Mode's login rename moves it off `/wp-admin`. On a fresh install,
48
+ * also show the default wp-admin login. The MCP row is the address to paste into a harness's own
48
49
  * configuration: nothing writes one, because each harness reads a different file.
49
50
  */
50
51
  function printSummary(info, mcpUrl) {
51
52
  const { cyan, dim, reset } = palette();
52
53
  const { port, hostname } = new URL(info.url);
53
- const rows = [["WP Local", `http://localhost:${port}/wp-admin`]];
54
- if (hostname !== "localhost") rows.push(["WP Network", `${info.url}/wp-admin`]);
54
+ const rows = [["WP Local", `http://localhost:${port}${info.adminPath}`]];
55
+ if (hostname !== "localhost") rows.push(["WP Network", `${info.url}${info.adminPath}`]);
55
56
  rows.push(["Database Connection", `127.0.0.1:${info.dbPort}`]);
56
57
  if (mcpUrl) rows.push(["MCP", mcpUrl]);
57
58
  const width = Math.max(...rows.map(([label]) => label.length)) + 1;
@@ -7,6 +7,7 @@ interface Service {
7
7
  environment?: Record<string, string>
8
8
  ports?: string[]
9
9
  volumes?: string[]
10
+ healthcheck?: { test: string[] }
10
11
  }
11
12
 
12
13
  const wordpress = (): Service => parse(fs.readFileSync(COMPOSE_FILE, "utf8")).services.wordpress
@@ -41,6 +42,26 @@ describe("base compose wordpress service", () => {
41
42
  expect(wordpress().ports).toContain("${WP_PORT:-8080}:80")
42
43
  })
43
44
 
45
+ describe("healthcheck", () => {
46
+ const probe = (): string => wordpress().healthcheck?.test?.at(-1) ?? ""
47
+
48
+ test("requires the WordPress install wp-cli depends on", () => {
49
+ expect(probe()).toContain("test -f /var/www/html/wp-includes/version.php")
50
+ })
51
+
52
+ test("probes HTTP for liveness without asserting a successful status", () => {
53
+ expect(probe()).toContain("curl ")
54
+ expect(probe()).toContain("http://localhost/")
55
+ // `-f`/`--fail` turns any non-2xx into a probe failure, which is the bug being fixed:
56
+ // Headless Mode 404s pages by design, and a live server answering 404 is still live.
57
+ expect(probe().slice(probe().indexOf("curl "))).not.toMatch(/(?:^|\s)--?\S*f/)
58
+ })
59
+
60
+ test("references no WordPress application page, so a 404 from one can't fail it", () => {
61
+ expect(probe()).not.toMatch(/wp-login\.php|wp-admin|wp-json|feed|xmlrpc/)
62
+ })
63
+ })
64
+
44
65
  test("mounts both local-only config assets read-only", () => {
45
66
  const volumes = wordpress().volumes ?? []
46
67
  expect(volumes).toContain("./uploads.ini:/usr/local/etc/php/conf.d/zz-kizlo-uploads.ini:ro")
@@ -67,9 +67,14 @@ services:
67
67
  - ./uploads.ini:/usr/local/etc/php/conf.d/zz-kizlo-uploads.ini:ro
68
68
  - ./apache-listen.conf:/etc/apache2/conf-enabled/zz-kizlo-listen.conf:ro
69
69
  healthcheck:
70
- # wp-login.php always returns 200 unauthenticated; /wp-json can be gated to 401
71
- # by plugins, so it's not a reliable liveness probe.
72
- test: ["CMD-SHELL", "curl -sf http://localhost/wp-login.php > /dev/null || exit 1"]
70
+ # Liveness, not a status assertion: the install is in place (what wp-cli needs before
71
+ # it can run anything) and the server answers HTTP at all. Deliberately status-agnostic,
72
+ # because Kizlo's own Headless Mode legitimately 404s the front page, the feeds and the
73
+ # login path. Requiring a 2xx from any WordPress page (`curl -f`) makes an install that is
74
+ # working as configured look dead, and every service gated on this one never starts.
75
+ # curl's own limit is a second under `timeout` below, so a server that accepts the connection
76
+ # and then stalls is reported by curl rather than killed by docker with nothing to show.
77
+ test: ["CMD-SHELL", "test -f /var/www/html/wp-includes/version.php && curl -s -o /dev/null -m 4 http://localhost/"]
73
78
  interval: 5s
74
79
  timeout: 5s
75
80
  retries: 60
@@ -32,6 +32,37 @@ function devUrl(port) {
32
32
  return `http://${lanAddress() ?? "localhost"}:${port}`;
33
33
  }
34
34
  /**
35
+ * Where the login screen actually lives, read from the raw `kizlo_settings_headless` option. Mirrors
36
+ * the plugin's `HeadlessSettings::isLoginRenameActive()`: the master switch, the rename toggle and a
37
+ * non-empty slug must all hold, so a half-configured feature still points at `/wp-admin`. Anything
38
+ * unreadable (option unset, plugin absent, output not JSON) falls back the same way, which is what a
39
+ * project that never touches the setting gets.
40
+ */
41
+ function resolveAdminPath(option) {
42
+ if (!option) return "/wp-admin";
43
+ let settings;
44
+ try {
45
+ settings = JSON.parse(option);
46
+ } catch {
47
+ return "/wp-admin";
48
+ }
49
+ const slug = typeof settings?.login_slug === "string" ? settings.login_slug.trim() : "";
50
+ return settings?.enabled === true && settings.rename_login === true && slug !== "" ? `/${slug}` : "/wp-admin";
51
+ }
52
+ /** The Headless Mode option as wp-cli prints it; undefined when it's unset or wp-cli couldn't answer. */
53
+ async function readHeadlessOption() {
54
+ try {
55
+ return await wpCli([
56
+ "option",
57
+ "get",
58
+ "kizlo_settings_headless",
59
+ "--format=json"
60
+ ]);
61
+ } catch {
62
+ return;
63
+ }
64
+ }
65
+ /**
35
66
  * Seed `dev.fixtures` into freshly installed local WordPress, reusing the test seeding
36
67
  * primitives: seed the default subscriber so `ctx.userId` exists, then run each `seed`
37
68
  * over REST. Plugins are already active (`bootstrapDev` ensures them before seeding).
@@ -106,12 +137,14 @@ async function bootstrapDev(cfg) {
106
137
  });
107
138
  const seeded = !installed && cfg.fixtures.length ? await seedDevFixtures(cfg, url) : 0;
108
139
  const appPassword = !installed ? await createAdminAppPassword("kizlo-dev") : void 0;
140
+ const adminPath = resolveAdminPath(await readHeadlessOption());
109
141
  return {
110
142
  url,
111
143
  username: TEST_ADMIN.username,
112
144
  dbPort: cfg.dbPort,
113
145
  seeded,
114
146
  appPassword,
147
+ adminPath,
115
148
  adminPassword: !installed ? TEST_ADMIN.password : void 0
116
149
  };
117
150
  }
@@ -90,6 +90,50 @@ async function stackStatus(project) {
90
90
  return await dockerStatus() === "running" ? "unknown" : "stopped";
91
91
  }
92
92
  }
93
+ /**
94
+ * How long `composeUp` waits for every service to report healthy. The `wordpress` probe's own
95
+ * verdict lands anywhere from ~330s (a 30s start period, then 60 retries 5s apart) to ~630s when
96
+ * every attempt burns its full 5s timeout, so this cap sits inside that range on purpose: a stack
97
+ * that is still coming up is never cut off, while one that will never converge is reported at
98
+ * roughly six minutes rather than eleven. Past the cap the error names each service's state at that
99
+ * moment instead of the healthcheck's final verdict, which is the trade this bound accepts.
100
+ */
101
+ const WAIT_TIMEOUT_SECONDS = 360;
102
+ /** Cap on a healthcheck's reported output; docker keeps up to 4 KB per probe, far more than reads. */
103
+ const PROBE_OUTPUT_LIMIT = 500;
104
+ /**
105
+ * Compose's per-resource progress chatter, which it writes to stderr even when the command works:
106
+ * `Container x Created`, `Volume y Removed`. A failed `up --wait` buries its real message under a
107
+ * dozen of these, so they are dropped and the lines that say what went wrong survive: `Container x
108
+ * Error …`, `dependency failed to start: …`, a denied mount. An unrecognised verb just costs one
109
+ * noise line, and anything carrying trailing text is kept, so this can only under-filter.
110
+ */
111
+ const COMPOSE_PROGRESS_LINE = /^(?:Container|Network|Volume|Image)\s+\S+\s+(?:Creating|Created|Recreate|Recreated|Starting|Started|Stopping|Stopped|Removing|Removed|Waiting|Healthy|Running|Pulling|Pulled|Building|Built|Skipped)$/;
112
+ /** Docker's own message for a failed command, with its progress chatter removed. */
113
+ function dockerMessage(res) {
114
+ return (res.stderr || res.stdout).split("\n").map((line) => line.trim()).filter((line) => line !== "" && !COMPOSE_PROGRESS_LINE.test(line)).join("\n");
115
+ }
116
+ /**
117
+ * Whether compose reports a container's healthcheck as passing, or as having none to report. The
118
+ * field is empty for a service without a healthcheck and absent altogether on some compose builds,
119
+ * and neither is a failure, so both read as healthy here.
120
+ */
121
+ function isHealthy(health) {
122
+ return !health || health === "healthy";
123
+ }
124
+ /** Containers from `compose ps --format json`: one object per line, or a single array on older builds. */
125
+ function parseComposePs(stdout) {
126
+ const trimmed = stdout.trim();
127
+ if (!trimmed) return [];
128
+ return (trimmed.startsWith("[") ? [trimmed] : trimmed.split("\n")).flatMap((chunk) => {
129
+ try {
130
+ const parsed = JSON.parse(chunk);
131
+ return Array.isArray(parsed) ? parsed : [parsed];
132
+ } catch {
133
+ return [];
134
+ }
135
+ });
136
+ }
93
137
  function bind(stack) {
94
138
  const base = [
95
139
  "compose",
@@ -104,13 +148,68 @@ function bind(stack) {
104
148
  };
105
149
  const compose$1 = (args, opts) => run("docker", [...base, ...args], env, opts);
106
150
  const composePull$1 = (services = []) => compose$1(["pull", ...services]);
151
+ /**
152
+ * The last line a container's healthcheck printed, as `exit <code>: <output>`. Undefined when
153
+ * the container declares no healthcheck, has not run one yet, or docker could not answer. In all
154
+ * of those the caller has nothing to add beyond the service's state.
155
+ */
156
+ const lastProbe = async (container) => {
157
+ const res = await run("docker", [
158
+ "inspect",
159
+ "--format",
160
+ "{{json .State.Health}}",
161
+ container
162
+ ], env);
163
+ if (res.code !== 0) return void 0;
164
+ let health;
165
+ try {
166
+ health = JSON.parse(res.stdout.trim());
167
+ } catch {
168
+ return;
169
+ }
170
+ const last = health?.Log?.at(-1);
171
+ if (!last) return void 0;
172
+ const output = (last.Output ?? "").trim().replace(/\s*\n\s*/g, " ").slice(0, PROBE_OUTPUT_LIMIT);
173
+ return output ? `exit ${last.ExitCode}: ${output}` : `exit ${last.ExitCode}`;
174
+ };
175
+ const describe = async (container) => {
176
+ const state = container.Health ? `${container.State} (${container.Health})` : container.State;
177
+ const probe = await lastProbe(container.Name);
178
+ return ` ${container.Service}: ${state}${probe ? `, healthcheck ${probe}` : ""}`;
179
+ };
180
+ /**
181
+ * Why the stack did not come up, in terms the user can act on: every service that isn't running
182
+ * and healthy, with the last thing its healthcheck reported. `compose up --wait` only says that
183
+ * waiting failed, and a probe failing quietly (a 404, a refused connection) leaves no trace in
184
+ * its stderr, so the container's own health log is the only place that answer exists.
185
+ *
186
+ * Never throws. This runs while a failure is already being reported, so a second fault here
187
+ * (docker gone, a health log shaped differently) must cost the explanation, not replace it.
188
+ */
189
+ const diagnose = async () => {
190
+ try {
191
+ const failing = parseComposePs((await compose$1([
192
+ "ps",
193
+ "--all",
194
+ "--format",
195
+ "json"
196
+ ])).stdout).filter((container) => container.State !== "running" || !isHealthy(container.Health));
197
+ return (await Promise.all(failing.map(describe))).join("\n");
198
+ } catch {
199
+ return "";
200
+ }
201
+ };
107
202
  const composeUp$1 = async () => {
108
203
  const res = await compose$1([
109
204
  "up",
110
205
  "-d",
111
- "--wait"
206
+ "--wait",
207
+ "--wait-timeout",
208
+ String(WAIT_TIMEOUT_SECONDS)
112
209
  ]);
113
- if (res.code !== 0) throw new Error(`docker compose up failed:\n${res.stderr}`);
210
+ if (res.code === 0) return;
211
+ const detail = [dockerMessage(res), await diagnose()].filter(Boolean).join("\n");
212
+ throw new Error(`docker compose up failed (waited up to ${WAIT_TIMEOUT_SECONDS}s for healthy services):\n${detail}`);
114
213
  };
115
214
  const composeDown$1 = async (opts) => {
116
215
  const args = ["down"];
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.22.0";
2
+ var version = "0.22.1";
3
3
 
4
4
  //#endregion
5
5
  export { version };
@@ -44,7 +44,7 @@ declare const CORE_PROCEDURES: {
44
44
  include?: string | number | (string | number)[] | undefined;
45
45
  offset?: string | number | undefined;
46
46
  order?: "asc" | "desc" | undefined;
47
- orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | undefined;
47
+ orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | undefined;
48
48
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
49
49
  slug?: string | string[] | undefined;
50
50
  taxRelation?: "AND" | "OR" | undefined;
@@ -99,7 +99,7 @@ declare const CORE_PROCEDURES: {
99
99
  include?: string | number | (string | number)[] | undefined;
100
100
  offset?: string | number | undefined;
101
101
  order?: "asc" | "desc" | undefined;
102
- orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | "menu_order" | undefined;
102
+ orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "modified" | "relevance" | "include_slugs" | "menu_order" | undefined;
103
103
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
104
104
  slug?: string | string[] | undefined;
105
105
  parent?: string | number | (string | number)[] | undefined;
@@ -136,7 +136,7 @@ declare const CORE_PROCEDURES: {
136
136
  exclude?: string | number | (string | number)[] | undefined;
137
137
  include?: string | number | (string | number)[] | undefined;
138
138
  order?: "asc" | "desc" | undefined;
139
- orderBy?: "id" | "count" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
139
+ orderBy?: "count" | "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
140
140
  hideEmpty?: string | boolean | undefined;
141
141
  parent?: string | number | undefined;
142
142
  post?: string | number | undefined;
@@ -172,7 +172,7 @@ declare const CORE_PROCEDURES: {
172
172
  exclude?: string | number | (string | number)[] | undefined;
173
173
  include?: string | number | (string | number)[] | undefined;
174
174
  order?: "asc" | "desc" | undefined;
175
- orderBy?: "id" | "count" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
175
+ orderBy?: "count" | "id" | "slug" | "name" | "description" | "include" | "include_slugs" | "term_group" | undefined;
176
176
  hideEmpty?: string | boolean | undefined;
177
177
  post?: string | number | undefined;
178
178
  slug?: string | string[] | undefined;
@@ -292,7 +292,7 @@ declare const CORE_PROCEDURES: {
292
292
  include?: string | number | (string | number)[] | undefined;
293
293
  offset?: string | number | undefined;
294
294
  order?: "asc" | "desc" | undefined;
295
- orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
295
+ orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
296
296
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
297
297
  slug?: string | string[] | undefined;
298
298
  taxRelation?: "AND" | "OR" | undefined;
@@ -371,7 +371,7 @@ declare const CORE_PROCEDURES: {
371
371
  include?: string | number | (string | number)[] | undefined;
372
372
  offset?: string | number | undefined;
373
373
  order?: "asc" | "desc" | undefined;
374
- orderby?: "id" | "date" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
374
+ orderby?: "date" | "id" | "slug" | "parent" | "title" | "author" | "include" | "relevance" | "include_slugs" | "menu_order" | undefined;
375
375
  searchColumns?: ("post_title" | "post_content" | "post_excerpt")[] | undefined;
376
376
  slug?: string | string[] | undefined;
377
377
  taxRelation?: "AND" | "OR" | undefined;
@@ -507,7 +507,7 @@ declare const CORE_PROCEDURES: {
507
507
  include?: string | number | (string | number)[] | undefined;
508
508
  offset?: string | number | undefined;
509
509
  order?: "asc" | "desc" | undefined;
510
- orderby?: "id" | "date" | "parent" | "include" | "post" | undefined;
510
+ orderby?: "date" | "id" | "parent" | "include" | "post" | undefined;
511
511
  parent?: string | number | (string | number)[] | undefined;
512
512
  parentExclude?: string | number | (string | number)[] | undefined;
513
513
  post?: string | number | (string | number)[] | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kizlo",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "type": "module",
5
5
  "description": "A headless WordPress framework for TypeScript.",
6
6
  "keywords": [