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.
- package/dist/cli/commands/dev.js +7 -6
- package/dist/cli/wp/compose/docker-compose.test.ts +21 -0
- package/dist/cli/wp/compose/docker-compose.yml +8 -3
- package/dist/cli/wp/dev.js +33 -0
- package/dist/cli/wp/docker.js +101 -2
- package/dist/package.js +1 -1
- package/dist/procedures.d.ts +7 -7
- package/package.json +1 -1
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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}
|
|
54
|
-
if (hostname !== "localhost") rows.push(["WP Network", `${info.url}
|
|
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
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
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
|
package/dist/cli/wp/dev.js
CHANGED
|
@@ -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
|
}
|
package/dist/cli/wp/docker.js
CHANGED
|
@@ -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
|
|
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
package/dist/procedures.d.ts
CHANGED
|
@@ -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?: "
|
|
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?: "
|
|
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?: "
|
|
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?: "
|
|
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?: "
|
|
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?: "
|
|
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?: "
|
|
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;
|