kizlo 0.1.1 → 0.2.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/README.md +1 -1
- package/dist/adapters/auth.d.ts +11 -2
- package/dist/adapters/auth.js +5 -1
- package/dist/adapters/captcha.d.ts +3 -1
- package/dist/adapters/captcha.js +5 -1
- package/dist/adapters/cookies.d.ts +7 -0
- package/dist/adapters/cookies.js +8 -0
- package/dist/adapters/geo.d.ts +13 -2
- package/dist/adapters/geo.js +5 -1
- package/dist/adapters/logger.d.ts +8 -1
- package/dist/adapters/logger.js +5 -1
- package/dist/cli/banner.js +12 -1
- package/dist/cli/commands/dev.js +203 -20
- package/dist/cli/commands/init.js +240 -47
- package/dist/cli/commands/test.js +56 -19
- package/dist/cli/commands/watch.js +5 -23
- package/dist/cli/daemon/config.js +11 -5
- package/dist/cli/daemon/logger.js +1 -0
- package/dist/cli/daemon/watch.js +35 -2
- package/dist/cli/presets/base.js +8 -1
- package/dist/cli/presets/nextjs.js +1 -1
- package/dist/cli/utils.js +74 -5
- package/dist/cli/wp/compose/docker-compose.yml +12 -0
- package/dist/cli/wp/dev.js +4 -2
- package/dist/cli/wp/docker.d.ts +22 -4
- package/dist/cli/wp/docker.js +27 -5
- package/dist/cli/wp/ports.js +72 -0
- package/dist/cli/wp/session.js +150 -0
- package/dist/cli/wp/settings.js +73 -0
- package/dist/cli/wp/types.d.ts +5 -3
- package/dist/cli/wp/utils.d.ts +7 -1
- package/dist/cli/wp/utils.js +17 -1
- package/dist/cli/wp/watchdog.d.ts +1 -0
- package/dist/cli/wp/watchdog.js +45 -0
- package/dist/client.d.ts +1 -1
- package/dist/client.js +2 -2
- package/dist/context.d.ts +12 -1
- package/dist/context.js +1 -1
- package/dist/index.d.ts +16 -12
- package/dist/index.js +9 -6
- package/dist/integrations/nextjs/kizlo.server.d.ts +1 -1
- package/dist/integrations/nextjs/kizlo.server.js +2 -2
- package/dist/integrations/nextjs/utils.js +2 -2
- package/dist/kizlo.d.ts +27 -6
- package/dist/kizlo.js +24 -11
- package/dist/package.js +1 -1
- package/dist/router.d.ts +7 -7
- package/dist/service.d.ts +2 -0
- package/dist/service.js +3 -0
- package/dist/settings/service.d.ts +22 -0
- package/dist/settings/service.interface.d.ts +134 -0
- package/dist/settings/service.js +43 -0
- package/dist/shared/error.d.ts +16 -5
- package/dist/shared/error.js +4 -0
- package/dist/shared/extension.d.ts +2 -0
- package/dist/shared/middleware.d.ts +13 -10
- package/dist/shared/middleware.js +7 -0
- package/dist/shared/procedure.d.ts +34 -28
- package/dist/shared/procedure.js +3 -6
- package/dist/shared/types.d.ts +19 -13
- package/dist/test/client.d.ts +5 -0
- package/dist/test/client.js +5 -0
- package/dist/test/server.d.ts +6 -0
- package/dist/test/server.js +6 -0
- package/dist/test.d.ts +4 -4
- package/dist/test.js +3 -3
- package/dist/webhook/index.d.ts +14 -13
- package/dist/webhook/index.js +5 -5
- package/dist/webhook/schema.d.ts +3 -3
- package/dist/webhook/schema.js +37 -37
- package/dist/webhook/utils.js +4 -4
- package/dist/wordpress/service.js +6 -0
- package/package.json +1 -1
package/dist/cli/presets/base.js
CHANGED
|
@@ -3,7 +3,7 @@ const base = {
|
|
|
3
3
|
id: "base",
|
|
4
4
|
label: "Generic (no framework detected)",
|
|
5
5
|
detect: () => 1,
|
|
6
|
-
baseUrlEnvKey: "
|
|
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: "
|
|
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,3 +1,5 @@
|
|
|
1
|
+
import { log } from "./daemon/logger.js";
|
|
2
|
+
import { PortInUseError, resolveHostPort } from "./wp/ports.js";
|
|
1
3
|
import { spawnSync } from "node:child_process";
|
|
2
4
|
import fs from "node:fs";
|
|
3
5
|
import path from "node:path";
|
|
@@ -5,6 +7,25 @@ import { fileURLToPath } from "node:url";
|
|
|
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
|
-
|
|
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}=${
|
|
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,
|
|
@@ -194,4 +263,4 @@ function groupDefault(subCommandNames, run) {
|
|
|
194
263
|
}
|
|
195
264
|
|
|
196
265
|
//#endregion
|
|
197
|
-
export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envKeysPresent, getVersion, groupDefault, loadEnvFiles, mergeEnv, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
|
|
266
|
+
export { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, envGroups, envKeysPresent, getVersion, groupDefault, loadEnvFiles, mergeEnv, pickStackPort, resolveModuleImport, runCommand, withSpinner, writeFileIfAbsent };
|
|
@@ -35,8 +35,20 @@ services:
|
|
|
35
35
|
WORDPRESS_DB_NAME: ${MYSQL_DATABASE:-wordpress}
|
|
36
36
|
WORDPRESS_DB_USER: ${MYSQL_USER:-wordpress}
|
|
37
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 `$`.
|
|
38
41
|
WORDPRESS_CONFIG_EXTRA: |
|
|
39
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
|
+
}
|
|
40
52
|
volumes:
|
|
41
53
|
- wp_data:/var/www/html
|
|
42
54
|
healthcheck:
|
package/dist/cli/wp/dev.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { WordPressService } from "../../wordpress/service.js";
|
|
2
2
|
import { compose, composeUp, wpCli } from "./docker.js";
|
|
3
|
-
import { TEST_ADMIN } from "./constants.js";
|
|
3
|
+
import { DEFAULT_PLUGINS, TEST_ADMIN } from "./constants.js";
|
|
4
4
|
import { ensurePlugins } from "./utils.js";
|
|
5
5
|
import { createAdminAppPassword, seedUsers } from "./bootstrap.js";
|
|
6
6
|
import { prepareByo } from "./byo.js";
|
|
@@ -128,14 +128,16 @@ async function bootstrapDev(cfg) {
|
|
|
128
128
|
"--hard"
|
|
129
129
|
]);
|
|
130
130
|
}
|
|
131
|
-
await ensurePlugins(cfg.fixtures.flatMap((fixture) => fixture.plugins ?? []));
|
|
131
|
+
await ensurePlugins([...DEFAULT_PLUGINS, ...cfg.fixtures.flatMap((fixture) => fixture.plugins ?? [])]);
|
|
132
132
|
const seeded = !installed && !byo && cfg.fixtures.length ? await seedDevFixtures(cfg, url) : 0;
|
|
133
|
+
const appPassword = password ? await createAdminAppPassword("kizlo-dev") : void 0;
|
|
133
134
|
return {
|
|
134
135
|
url,
|
|
135
136
|
username: TEST_ADMIN.username,
|
|
136
137
|
dbPort: cfg.dbPort,
|
|
137
138
|
imported,
|
|
138
139
|
seeded,
|
|
140
|
+
appPassword,
|
|
139
141
|
secrets: password ? { password } : void 0
|
|
140
142
|
};
|
|
141
143
|
}
|
package/dist/cli/wp/docker.d.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
//#region src/cli/wp/docker.d.ts
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* Options for a spawned docker command.
|
|
4
|
+
* - `input`/`inputFile`: data piped to stdin (an in-memory string, or a file streamed from disk).
|
|
5
|
+
* - `detached`: run in its own process group so a terminal Ctrl+C (delivered to the whole
|
|
6
|
+
* foreground group) can't kill it mid-flight — used for teardown's `stop`, which must finish.
|
|
7
|
+
*/
|
|
3
8
|
type RunInput = {
|
|
4
9
|
input?: string;
|
|
5
10
|
inputFile?: string;
|
|
11
|
+
detached?: boolean;
|
|
6
12
|
};
|
|
7
13
|
/** A docker-compose stack: project id (`-p`), published port, and compose files (`-f`). */
|
|
8
14
|
interface Stack {
|
|
@@ -17,10 +23,14 @@ interface Stack {
|
|
|
17
23
|
interface DockerStack {
|
|
18
24
|
compose(args: string[], opts?: RunInput): Promise<RunResult>;
|
|
19
25
|
composeUp(): Promise<void>;
|
|
20
|
-
composeStop(
|
|
26
|
+
composeStop(opts?: {
|
|
27
|
+
detached?: boolean;
|
|
28
|
+
}): Promise<void>;
|
|
21
29
|
composeDown(opts?: {
|
|
22
30
|
volumes?: boolean;
|
|
23
31
|
}): Promise<void>;
|
|
32
|
+
/** The host port this stack's WordPress is currently published on, or `undefined` if it isn't running. */
|
|
33
|
+
publishedPort(): Promise<number | undefined>;
|
|
24
34
|
wpCli(args: string[]): Promise<string>;
|
|
25
35
|
}
|
|
26
36
|
interface RunResult {
|
|
@@ -28,6 +38,12 @@ interface RunResult {
|
|
|
28
38
|
stdout: string;
|
|
29
39
|
stderr: string;
|
|
30
40
|
}
|
|
41
|
+
/**
|
|
42
|
+
* Whether the Docker daemon is reachable. `docker version` contacts the server (not just
|
|
43
|
+
* the client), so a non-zero exit means the daemon is down or Docker isn't installed —
|
|
44
|
+
* the early check before a command that needs a stack (e.g. `init`'s local setup).
|
|
45
|
+
*/
|
|
46
|
+
declare function dockerAvailable(): Promise<boolean>;
|
|
31
47
|
/** Build a stack-bound docker helper and mark it active for the bare helpers below. */
|
|
32
48
|
declare function createStack(stack: Stack): DockerStack;
|
|
33
49
|
/** `docker compose <args>` against the active stack. */
|
|
@@ -39,8 +55,10 @@ declare const composeDown: (opts?: {
|
|
|
39
55
|
volumes?: boolean;
|
|
40
56
|
}) => Promise<void>;
|
|
41
57
|
/** `docker compose stop` — halt the active stack's containers but keep volumes. */
|
|
42
|
-
declare const composeStop: (
|
|
58
|
+
declare const composeStop: (opts?: {
|
|
59
|
+
detached?: boolean;
|
|
60
|
+
}) => Promise<void>;
|
|
43
61
|
/** Run a wp-cli command against the active stack's warm `wp-cli` container. */
|
|
44
62
|
declare const wpCli: (args: string[]) => Promise<string>;
|
|
45
63
|
//#endregion
|
|
46
|
-
export { DockerStack, RunResult, Stack, compose, composeDown, composeStop, composeUp, createStack, wpCli };
|
|
64
|
+
export { DockerStack, RunResult, Stack, compose, composeDown, composeStop, composeUp, createStack, dockerAvailable, wpCli };
|
package/dist/cli/wp/docker.js
CHANGED
|
@@ -10,7 +10,8 @@ function run(cmd, args, env, opts) {
|
|
|
10
10
|
"pipe",
|
|
11
11
|
"pipe"
|
|
12
12
|
],
|
|
13
|
-
env
|
|
13
|
+
env,
|
|
14
|
+
detached: opts?.detached
|
|
14
15
|
});
|
|
15
16
|
let stdout = "";
|
|
16
17
|
let stderr = "";
|
|
@@ -31,6 +32,18 @@ function run(cmd, args, env, opts) {
|
|
|
31
32
|
else child.stdin.end();
|
|
32
33
|
});
|
|
33
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether the Docker daemon is reachable. `docker version` contacts the server (not just
|
|
37
|
+
* the client), so a non-zero exit means the daemon is down or Docker isn't installed —
|
|
38
|
+
* the early check before a command that needs a stack (e.g. `init`'s local setup).
|
|
39
|
+
*/
|
|
40
|
+
async function dockerAvailable() {
|
|
41
|
+
try {
|
|
42
|
+
return (await run("docker", ["version"], process.env)).code === 0;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
34
47
|
function bind(stack) {
|
|
35
48
|
const base = [
|
|
36
49
|
"compose",
|
|
@@ -56,8 +69,16 @@ function bind(stack) {
|
|
|
56
69
|
if (opts?.volumes) args.push("-v");
|
|
57
70
|
await compose$1(args);
|
|
58
71
|
};
|
|
59
|
-
const composeStop$1 = async () => {
|
|
60
|
-
await compose$1(["stop"]);
|
|
72
|
+
const composeStop$1 = async (opts) => {
|
|
73
|
+
await compose$1(["stop"], opts);
|
|
74
|
+
};
|
|
75
|
+
const publishedPort = async () => {
|
|
76
|
+
const match = (await compose$1([
|
|
77
|
+
"port",
|
|
78
|
+
"wordpress",
|
|
79
|
+
"80"
|
|
80
|
+
])).stdout.match(/:(\d+)\s*$/);
|
|
81
|
+
return match ? Number(match[1]) : void 0;
|
|
61
82
|
};
|
|
62
83
|
const wpCli$1 = async (args) => {
|
|
63
84
|
const res = await compose$1([
|
|
@@ -75,6 +96,7 @@ function bind(stack) {
|
|
|
75
96
|
composeUp: composeUp$1,
|
|
76
97
|
composeStop: composeStop$1,
|
|
77
98
|
composeDown: composeDown$1,
|
|
99
|
+
publishedPort,
|
|
78
100
|
wpCli: wpCli$1
|
|
79
101
|
};
|
|
80
102
|
}
|
|
@@ -100,9 +122,9 @@ const composeUp = () => activeStack().composeUp();
|
|
|
100
122
|
/** Tear the active stack down, optionally removing volumes. */
|
|
101
123
|
const composeDown = (opts) => activeStack().composeDown(opts);
|
|
102
124
|
/** `docker compose stop` — halt the active stack's containers but keep volumes. */
|
|
103
|
-
const composeStop = () => activeStack().composeStop();
|
|
125
|
+
const composeStop = (opts) => activeStack().composeStop(opts);
|
|
104
126
|
/** Run a wp-cli command against the active stack's warm `wp-cli` container. */
|
|
105
127
|
const wpCli = (args) => activeStack().wpCli(args);
|
|
106
128
|
|
|
107
129
|
//#endregion
|
|
108
|
-
export { compose, composeDown, composeStop, composeUp, createStack, wpCli };
|
|
130
|
+
export { compose, composeDown, composeStop, composeUp, createStack, dockerAvailable, wpCli };
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
//#region src/cli/wp/ports.ts
|
|
4
|
+
/** Thrown when an explicitly-configured port is already taken — a collision the user owns. */
|
|
5
|
+
var PortInUseError = class extends Error {
|
|
6
|
+
port;
|
|
7
|
+
host;
|
|
8
|
+
constructor(port, host) {
|
|
9
|
+
super(`Port ${host}:${port} is already in use`);
|
|
10
|
+
this.name = "PortInUseError";
|
|
11
|
+
this.port = port;
|
|
12
|
+
this.host = host;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
/** True if `host:port` can be bound right now (i.e. nothing else is listening on it). */
|
|
16
|
+
function isFree(port, host = "0.0.0.0") {
|
|
17
|
+
return new Promise((resolvePromise) => {
|
|
18
|
+
const server = createServer();
|
|
19
|
+
server.once("error", () => resolvePromise(false));
|
|
20
|
+
server.listen({
|
|
21
|
+
port,
|
|
22
|
+
host
|
|
23
|
+
}, () => server.close(() => resolvePromise(true)));
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** An OS-assigned ephemeral port on `host` — the fallback when the whole scan range is taken. */
|
|
27
|
+
function ephemeralPort(host) {
|
|
28
|
+
return new Promise((resolvePromise, reject) => {
|
|
29
|
+
const server = createServer();
|
|
30
|
+
server.once("error", reject);
|
|
31
|
+
server.listen({
|
|
32
|
+
port: 0,
|
|
33
|
+
host
|
|
34
|
+
}, () => {
|
|
35
|
+
const address = server.address();
|
|
36
|
+
const port = typeof address === "object" && address ? address.port : 0;
|
|
37
|
+
server.close(() => resolvePromise(port));
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Resolve `preferred` to a host port that's actually free, scanning upward from it.
|
|
43
|
+
* The configured/default value is only a *starting point*: if it (or the next few) are
|
|
44
|
+
* already taken — by a stray container, another stack, or any unrelated server — we step
|
|
45
|
+
* to the next free port instead of letting `docker compose up` fail with a port clash.
|
|
46
|
+
* Falls back to an OS-assigned ephemeral port if the whole scan range is busy.
|
|
47
|
+
*
|
|
48
|
+
* `host` must match how the port is published: `0.0.0.0` for the all-interfaces WP port,
|
|
49
|
+
* `127.0.0.1` for the loopback-only MySQL port — a port can be free on one and not the other.
|
|
50
|
+
*/
|
|
51
|
+
async function findFreePort(preferred, host = "0.0.0.0") {
|
|
52
|
+
for (let port = preferred; port < preferred + 100; port++) if (await isFree(port, host)) return port;
|
|
53
|
+
return ephemeralPort(host);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a host port, branching on whether the value was *chosen by the user*:
|
|
57
|
+
*
|
|
58
|
+
* - A **default** port (`fixed: false`) is only a starting point — if it's taken (by another
|
|
59
|
+
* project's stack or any unrelated server) we step to the next free one via {@link findFreePort},
|
|
60
|
+
* so the stack comes up instead of failing on a clash.
|
|
61
|
+
* - An **explicit** port (`fixed: true`, set in `kizlo.config`) is the user taking control: a
|
|
62
|
+
* collision there is theirs to resolve, so we throw {@link PortInUseError} rather than silently
|
|
63
|
+
* serving somewhere they didn't ask for.
|
|
64
|
+
*/
|
|
65
|
+
async function resolveHostPort(preferred, { fixed, host = "0.0.0.0" }) {
|
|
66
|
+
if (!fixed) return findFreePort(preferred, host);
|
|
67
|
+
if (await isFree(preferred, host)) return preferred;
|
|
68
|
+
throw new PortInUseError(preferred, host);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
//#endregion
|
|
72
|
+
export { PortInUseError, isFree, resolveHostPort };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { execFile, spawn } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
|
|
8
|
+
//#region src/cli/wp/session.ts
|
|
9
|
+
const exec = promisify(execFile);
|
|
10
|
+
/**
|
|
11
|
+
* Where foreground `kizlo dev` sessions are recorded, keyed by compose project.
|
|
12
|
+
* Global (not per-repo) on purpose: the whole point is to reap a *different*
|
|
13
|
+
* project's stack that was orphaned by a crashed session you never came back to.
|
|
14
|
+
*/
|
|
15
|
+
function registryPath() {
|
|
16
|
+
return join(homedir(), ".cache", "kizlo", "dev-sessions.json");
|
|
17
|
+
}
|
|
18
|
+
function readRegistry() {
|
|
19
|
+
const file = registryPath();
|
|
20
|
+
if (!existsSync(file)) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function writeRegistry(registry) {
|
|
28
|
+
const file = registryPath();
|
|
29
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
30
|
+
writeFileSync(file, JSON.stringify(registry, null, 2));
|
|
31
|
+
}
|
|
32
|
+
/** True when a process with this PID is still alive (signal 0 probes without killing). */
|
|
33
|
+
function pidAlive(pid) {
|
|
34
|
+
try {
|
|
35
|
+
process.kill(pid, 0);
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Stop a compose project's containers by label, without needing its compose files.
|
|
43
|
+
* Used for orphans owned by a dead PID — we can't assume we're in that project's
|
|
44
|
+
* directory, so we target the running containers directly rather than via compose.
|
|
45
|
+
*/
|
|
46
|
+
async function stopByProject(project) {
|
|
47
|
+
const { stdout } = await exec("docker", [
|
|
48
|
+
"ps",
|
|
49
|
+
"-q",
|
|
50
|
+
"--filter",
|
|
51
|
+
`label=com.docker.compose.project=${project}`
|
|
52
|
+
]);
|
|
53
|
+
const ids = stdout.split("\n").filter(Boolean);
|
|
54
|
+
if (ids.length) await exec("docker", ["stop", ...ids]);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Stop and remove *every* container for `project` — running or stopped — keeping its
|
|
58
|
+
* volumes (the DB and bind-mounted install survive). Run right before bringing a dev
|
|
59
|
+
* stack up so `compose up` creates brand-new containers instead of reconciling a stale
|
|
60
|
+
* one left by a crashed session or a second invocation: reconciling an existing — often
|
|
61
|
+
* unhealthy — mysql in place is what intermittently fails the dependency wait with
|
|
62
|
+
* "exited (137)". Returns true if it removed anything. Best effort: a docker-down error
|
|
63
|
+
* surfaces later on `up`, where the message is actionable.
|
|
64
|
+
*/
|
|
65
|
+
async function removeProjectContainers(project) {
|
|
66
|
+
try {
|
|
67
|
+
const { stdout } = await exec("docker", [
|
|
68
|
+
"ps",
|
|
69
|
+
"-aq",
|
|
70
|
+
"--filter",
|
|
71
|
+
`label=com.docker.compose.project=${project}`
|
|
72
|
+
]);
|
|
73
|
+
const ids = stdout.split("\n").filter(Boolean);
|
|
74
|
+
if (!ids.length) return false;
|
|
75
|
+
await exec("docker", ["stop", ...ids]).catch(() => {});
|
|
76
|
+
await exec("docker", [
|
|
77
|
+
"rm",
|
|
78
|
+
"-f",
|
|
79
|
+
...ids
|
|
80
|
+
]);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Spawn the detached watchdog that stops `project`'s stack when this process dies by
|
|
88
|
+
* any means the in-process handlers can't catch (terminal closed, SIGKILL, crash).
|
|
89
|
+
* Returns its PID so the graceful teardown path can kill it (the stack is already
|
|
90
|
+
* being stopped, so the watchdog would have nothing to do). Best effort: a failure
|
|
91
|
+
* here just means we fall back to signal handlers + the startup reaper.
|
|
92
|
+
*/
|
|
93
|
+
function spawnWatchdog(project) {
|
|
94
|
+
try {
|
|
95
|
+
const watchdog = fileURLToPath(new URL("./watchdog.js", import.meta.url));
|
|
96
|
+
const child = spawn(process.execPath, [
|
|
97
|
+
watchdog,
|
|
98
|
+
String(process.pid),
|
|
99
|
+
project
|
|
100
|
+
], {
|
|
101
|
+
detached: true,
|
|
102
|
+
stdio: "ignore"
|
|
103
|
+
});
|
|
104
|
+
child.unref();
|
|
105
|
+
return child.pid;
|
|
106
|
+
} catch {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Record this process as the owner of `project`'s foreground stack. */
|
|
111
|
+
function registerSession(project) {
|
|
112
|
+
const registry = readRegistry();
|
|
113
|
+
registry[project] = { pid: process.pid };
|
|
114
|
+
writeRegistry(registry);
|
|
115
|
+
}
|
|
116
|
+
/** Drop `project` from the registry (graceful teardown). Best effort. */
|
|
117
|
+
function unregisterSession(project) {
|
|
118
|
+
try {
|
|
119
|
+
const registry = readRegistry();
|
|
120
|
+
if (registry[project]) {
|
|
121
|
+
delete registry[project];
|
|
122
|
+
writeRegistry(registry);
|
|
123
|
+
}
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Stop any foreground stacks whose owning session has died (SIGKILL, crash, power
|
|
128
|
+
* loss) — the cases a signal handler can never catch. Runs at the start of every
|
|
129
|
+
* `kizlo dev`, so leaked stacks from *any* project self-heal the next time you
|
|
130
|
+
* bring one up. Returns the projects that were reaped, for logging.
|
|
131
|
+
*/
|
|
132
|
+
async function reapOrphans() {
|
|
133
|
+
const registry = readRegistry();
|
|
134
|
+
const reaped = [];
|
|
135
|
+
let changed = false;
|
|
136
|
+
for (const [project, session] of Object.entries(registry)) {
|
|
137
|
+
if (pidAlive(session.pid)) continue;
|
|
138
|
+
try {
|
|
139
|
+
await stopByProject(project);
|
|
140
|
+
reaped.push(project);
|
|
141
|
+
} catch {}
|
|
142
|
+
delete registry[project];
|
|
143
|
+
changed = true;
|
|
144
|
+
}
|
|
145
|
+
if (changed) writeRegistry(registry);
|
|
146
|
+
return reaped;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
//#endregion
|
|
150
|
+
export { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { WP_KIZLO_BASE } from "../../wordpress/constants.js";
|
|
2
|
+
import { WordPressService } from "../../wordpress/service.js";
|
|
3
|
+
|
|
4
|
+
//#region src/cli/wp/settings.ts
|
|
5
|
+
/** Loopback hosts that, from inside the dev Docker stack, must be rewritten to reach the host. */
|
|
6
|
+
const LOOPBACK_HOSTS = new Set([
|
|
7
|
+
"localhost",
|
|
8
|
+
"127.0.0.1",
|
|
9
|
+
"0.0.0.0",
|
|
10
|
+
"::1"
|
|
11
|
+
]);
|
|
12
|
+
/** The origin of a URL (scheme + host + port), or undefined when it can't be parsed. */
|
|
13
|
+
function originOf(url) {
|
|
14
|
+
try {
|
|
15
|
+
return new URL(url).origin;
|
|
16
|
+
} catch {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Rewrite a loopback host to `host.docker.internal` so the dockerized WordPress dev stack can reach a
|
|
22
|
+
* Kizlo server on the host (the container's own `localhost` is not the host). Non-loopback hosts (real
|
|
23
|
+
* deployments) and unparseable URLs are returned unchanged.
|
|
24
|
+
*/
|
|
25
|
+
function toContainerHostUrl(url) {
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(url);
|
|
28
|
+
if (!LOOPBACK_HOSTS.has(parsed.hostname)) return url;
|
|
29
|
+
parsed.hostname = "host.docker.internal";
|
|
30
|
+
return parsed.toString();
|
|
31
|
+
} catch {
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Push site-level settings into the WordPress plugin's `kizlo_settings_site` option: the shared
|
|
37
|
+
* `secret` (so webhook signing/verification share a key) plus the `url`/`backend_url` the plugin
|
|
38
|
+
* needs to reach the Kizlo server when delivering events. `backend_url` is the backend URL verbatim;
|
|
39
|
+
* `url` is an explicit `siteUrl` when given (split deployments), otherwise the backend's origin (the
|
|
40
|
+
* single-origin default used by dev + local provision). `PUT /kizlo/v1/settings/site` merges, so
|
|
41
|
+
* sending only the keys we have is a safe partial update that leaves other site settings intact;
|
|
42
|
+
* empty fields are omitted. Best-effort: the plugin may not be active yet (remote `init`), so
|
|
43
|
+
* failures are returned, never thrown.
|
|
44
|
+
*/
|
|
45
|
+
async function syncSiteSettings(credentials, settings) {
|
|
46
|
+
const body = {};
|
|
47
|
+
if (settings.secret) body.secret = settings.secret;
|
|
48
|
+
const url = settings.siteUrl ?? (settings.backendUrl ? originOf(settings.backendUrl) : void 0);
|
|
49
|
+
if (settings.backendUrl) body.backend_url = settings.containerized ? toContainerHostUrl(settings.backendUrl) : settings.backendUrl;
|
|
50
|
+
if (url) body.url = url;
|
|
51
|
+
const response = await new WordPressService({ credentials }).put("settings/site", {
|
|
52
|
+
base: WP_KIZLO_BASE,
|
|
53
|
+
body
|
|
54
|
+
});
|
|
55
|
+
if (response.error) return {
|
|
56
|
+
ok: false,
|
|
57
|
+
error: describeError(response.status, response.error)
|
|
58
|
+
};
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A short, log-friendly summary of a failed request. A transport failure has no HTTP status (`0`), so
|
|
63
|
+
* its message (e.g. `fetch failed`) is the only signal; otherwise the status code is shown — the raw
|
|
64
|
+
* `error.message` can be a full HTML page (when the URL serves the frontend instead of WordPress) and
|
|
65
|
+
* would flood the warning.
|
|
66
|
+
*/
|
|
67
|
+
function describeError(status, error) {
|
|
68
|
+
if (status === 0) return error.message;
|
|
69
|
+
return `HTTP ${status}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
//#endregion
|
|
73
|
+
export { syncSiteSettings };
|
package/dist/cli/wp/types.d.ts
CHANGED
|
@@ -68,6 +68,8 @@ type LocalPlugin = {
|
|
|
68
68
|
type DevPluginSource = PluginSource | LocalPlugin;
|
|
69
69
|
/** True when a dev plugin entry is a local directory to bind-mount, not something to install. */
|
|
70
70
|
declare function isLocalPlugin(plugin: DevPluginSource): plugin is LocalPlugin;
|
|
71
|
+
type FixtureSeedFn = (ctx: SeedContext) => Promise<Record<string, JsonValue>>;
|
|
72
|
+
type FixtureCleanupFn = (ctx: SeedContext) => Promise<void>;
|
|
71
73
|
/** The serializable test layer an extension ships alongside its live extension. */
|
|
72
74
|
interface Fixture {
|
|
73
75
|
/** Namespace key under `TestCredentials.fixtures`. */
|
|
@@ -81,11 +83,11 @@ interface Fixture {
|
|
|
81
83
|
*/
|
|
82
84
|
plugins?: DevPluginSource[];
|
|
83
85
|
/** Build this extension's world once during seeding; return created handles. */
|
|
84
|
-
seed?:
|
|
86
|
+
seed?: FixtureSeedFn;
|
|
85
87
|
/** Revert per-test mutations (teardown); does NOT uninstall plugins or seeded data. */
|
|
86
|
-
cleanup?:
|
|
88
|
+
cleanup?: FixtureCleanupFn;
|
|
87
89
|
}
|
|
88
90
|
/** Identity helper for authoring a `Fixture` with full inference and autocomplete. */
|
|
89
91
|
declare function defineFixture(fixture: Fixture): Fixture;
|
|
90
92
|
//#endregion
|
|
91
|
-
export { DevPluginSource, Fixture, JsonValue, LocalPlugin, PluginSource, SeedContext, TestAdminUser, TestCredentials, TestUser, defineFixture, isLocalPlugin };
|
|
93
|
+
export { DevPluginSource, Fixture, FixtureCleanupFn, FixtureSeedFn, JsonValue, LocalPlugin, PluginSource, SeedContext, TestAdminUser, TestCredentials, TestUser, defineFixture, isLocalPlugin };
|
package/dist/cli/wp/utils.d.ts
CHANGED
|
@@ -56,5 +56,11 @@ declare function findConfigDir(cwd?: string): string;
|
|
|
56
56
|
/** Fixed credentials artifact path, anchored to the config root. */
|
|
57
57
|
declare function credentialsPath(cwd?: string): string;
|
|
58
58
|
declare function getTestCredentials(): TestCredentials;
|
|
59
|
+
/**
|
|
60
|
+
* The host port the credentials artifact advertises, if it exists — the port a warm test
|
|
61
|
+
* stack is pinned to. Tests connect via this URL, so a reused stack must come back up on
|
|
62
|
+
* the same port; `undefined` means no stack has ever been seeded here (a cold boot).
|
|
63
|
+
*/
|
|
64
|
+
declare function recordedPort(): number | undefined;
|
|
59
65
|
//#endregion
|
|
60
|
-
export { activateLocalPlugin, credentialsPath, ensurePlugin, ensurePlugins, findConfigDir, getTestCredentials, githubRelease, isSeeded, kizloRelease, resolvePluginSource };
|
|
66
|
+
export { activateLocalPlugin, credentialsPath, ensurePlugin, ensurePlugins, findConfigDir, getTestCredentials, githubRelease, isSeeded, kizloRelease, recordedPort, resolvePluginSource };
|
package/dist/cli/wp/utils.js
CHANGED
|
@@ -142,6 +142,22 @@ function getTestCredentials() {
|
|
|
142
142
|
cached = JSON.parse(readFileSync(credentialsPath(), "utf-8"));
|
|
143
143
|
return cached;
|
|
144
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* The host port the credentials artifact advertises, if it exists — the port a warm test
|
|
147
|
+
* stack is pinned to. Tests connect via this URL, so a reused stack must come back up on
|
|
148
|
+
* the same port; `undefined` means no stack has ever been seeded here (a cold boot).
|
|
149
|
+
*/
|
|
150
|
+
function recordedPort() {
|
|
151
|
+
const path$1 = credentialsPath();
|
|
152
|
+
if (!existsSync(path$1)) return void 0;
|
|
153
|
+
try {
|
|
154
|
+
const { url } = JSON.parse(readFileSync(path$1, "utf-8"));
|
|
155
|
+
const port = new URL(url).port;
|
|
156
|
+
return port ? Number(port) : void 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
145
161
|
|
|
146
162
|
//#endregion
|
|
147
|
-
export { activateLocalPlugin, credentialsPath, ensurePlugin, ensurePlugins, findConfigDir, getTestCredentials, githubRelease, isSeeded, kizloRelease, resolvePluginSource };
|
|
163
|
+
export { activateLocalPlugin, credentialsPath, ensurePlugin, ensurePlugins, findConfigDir, getTestCredentials, githubRelease, isSeeded, kizloRelease, recordedPort, resolvePluginSource };
|