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.
Files changed (73) hide show
  1. package/README.md +1 -1
  2. package/dist/adapters/auth.d.ts +11 -2
  3. package/dist/adapters/auth.js +5 -1
  4. package/dist/adapters/captcha.d.ts +3 -1
  5. package/dist/adapters/captcha.js +5 -1
  6. package/dist/adapters/cookies.d.ts +7 -0
  7. package/dist/adapters/cookies.js +8 -0
  8. package/dist/adapters/geo.d.ts +13 -2
  9. package/dist/adapters/geo.js +5 -1
  10. package/dist/adapters/logger.d.ts +8 -1
  11. package/dist/adapters/logger.js +5 -1
  12. package/dist/cli/banner.js +12 -1
  13. package/dist/cli/commands/dev.js +203 -20
  14. package/dist/cli/commands/init.js +240 -47
  15. package/dist/cli/commands/test.js +56 -19
  16. package/dist/cli/commands/watch.js +5 -23
  17. package/dist/cli/daemon/config.js +11 -5
  18. package/dist/cli/daemon/logger.js +1 -0
  19. package/dist/cli/daemon/watch.js +35 -2
  20. package/dist/cli/presets/base.js +8 -1
  21. package/dist/cli/presets/nextjs.js +1 -1
  22. package/dist/cli/utils.js +74 -5
  23. package/dist/cli/wp/compose/docker-compose.yml +12 -0
  24. package/dist/cli/wp/dev.js +4 -2
  25. package/dist/cli/wp/docker.d.ts +22 -4
  26. package/dist/cli/wp/docker.js +27 -5
  27. package/dist/cli/wp/ports.js +72 -0
  28. package/dist/cli/wp/session.js +150 -0
  29. package/dist/cli/wp/settings.js +73 -0
  30. package/dist/cli/wp/types.d.ts +5 -3
  31. package/dist/cli/wp/utils.d.ts +7 -1
  32. package/dist/cli/wp/utils.js +17 -1
  33. package/dist/cli/wp/watchdog.d.ts +1 -0
  34. package/dist/cli/wp/watchdog.js +45 -0
  35. package/dist/client.d.ts +1 -1
  36. package/dist/client.js +2 -2
  37. package/dist/context.d.ts +12 -1
  38. package/dist/context.js +1 -1
  39. package/dist/index.d.ts +16 -12
  40. package/dist/index.js +9 -6
  41. package/dist/integrations/nextjs/kizlo.server.d.ts +1 -1
  42. package/dist/integrations/nextjs/kizlo.server.js +2 -2
  43. package/dist/integrations/nextjs/utils.js +2 -2
  44. package/dist/kizlo.d.ts +27 -6
  45. package/dist/kizlo.js +24 -11
  46. package/dist/package.js +1 -1
  47. package/dist/router.d.ts +7 -7
  48. package/dist/service.d.ts +2 -0
  49. package/dist/service.js +3 -0
  50. package/dist/settings/service.d.ts +22 -0
  51. package/dist/settings/service.interface.d.ts +134 -0
  52. package/dist/settings/service.js +43 -0
  53. package/dist/shared/error.d.ts +16 -5
  54. package/dist/shared/error.js +4 -0
  55. package/dist/shared/extension.d.ts +2 -0
  56. package/dist/shared/middleware.d.ts +13 -10
  57. package/dist/shared/middleware.js +7 -0
  58. package/dist/shared/procedure.d.ts +34 -28
  59. package/dist/shared/procedure.js +3 -6
  60. package/dist/shared/types.d.ts +19 -13
  61. package/dist/test/client.d.ts +5 -0
  62. package/dist/test/client.js +5 -0
  63. package/dist/test/server.d.ts +6 -0
  64. package/dist/test/server.js +6 -0
  65. package/dist/test.d.ts +4 -4
  66. package/dist/test.js +3 -3
  67. package/dist/webhook/index.d.ts +14 -13
  68. package/dist/webhook/index.js +5 -5
  69. package/dist/webhook/schema.d.ts +3 -3
  70. package/dist/webhook/schema.js +37 -37
  71. package/dist/webhook/utils.js +4 -4
  72. package/dist/wordpress/service.js +6 -0
  73. package/package.json +1 -1
@@ -3,7 +3,7 @@ const base = {
3
3
  id: "base",
4
4
  label: "Generic (no framework detected)",
5
5
  detect: () => 1,
6
- baseUrlEnvKey: "SERVER_BASE_URL",
6
+ baseUrlEnvKey: "KIZLO_BACKEND_URL",
7
7
  serverEntry() {
8
8
  return `import { createKizlo } from "kizlo"
9
9
 
@@ -11,6 +11,13 @@ export const { router, client, context, handler } = createKizlo()
11
11
  `;
12
12
  },
13
13
  clientEntry(ctx) {
14
+ if (ctx.clientUrl) return `import { createKizloClient } from "kizlo"
15
+ import { contract } from "./${ctx.serverDirName}/generated"
16
+
17
+ // Your Kizlo backend runs on a different origin than this app. Swap in your bundler's
18
+ // public env var (e.g. import.meta.env.VITE_KIZLO_BACKEND_URL) to set it per environment.
19
+ export const client = createKizloClient(contract, { url: "${ctx.clientUrl}" })
20
+ `;
14
21
  return `import { createKizloClient } from "kizlo"
15
22
  import { contract } from "./${ctx.serverDirName}/generated"
16
23
 
@@ -8,7 +8,7 @@ const nextjs = {
8
8
  ...ctx.pkg.devDependencies
9
9
  }.next ? 100 : 0;
10
10
  },
11
- baseUrlEnvKey: "NEXT_PUBLIC_SERVER_BASE_URL",
11
+ baseUrlEnvKey: "NEXT_PUBLIC_KIZLO_BACKEND_URL",
12
12
  apiPath: "/api/kizlo",
13
13
  serverEntry() {
14
14
  return `import { createKizlo } from "kizlo/nextjs/server"
package/dist/cli/utils.js CHANGED
@@ -1,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
- for (const [key, value] of Object.entries(values)) if (!present.has(key)) {
202
+ const remaining = new Map(Object.entries(values).filter(([key]) => !present.has(key)));
203
+ const append = (key) => {
144
204
  added.push(key);
145
- next.push(`${key}=${value}`);
205
+ next.push(`${key}=${remaining.get(key)}`);
206
+ remaining.delete(key);
207
+ };
208
+ for (const group of groups ?? []) {
209
+ const groupKeys = group.keys.filter((key) => remaining.has(key));
210
+ if (!groupKeys.length) continue;
211
+ if (next.length) next.push("");
212
+ next.push(`# ${group.comment}`);
213
+ for (const key of groupKeys) append(key);
146
214
  }
215
+ for (const key of remaining.keys()) append(key);
147
216
  return {
148
217
  content: `${next.join("\n")}\n`,
149
218
  added,
@@ -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:
@@ -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
  }
@@ -1,8 +1,14 @@
1
1
  //#region src/cli/wp/docker.d.ts
2
- /** Input piped to a command's stdin: an in-memory string, or a file streamed from disk. */
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(): Promise<void>;
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: () => Promise<void>;
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 };
@@ -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 };
@@ -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?: (ctx: SeedContext) => Promise<Record<string, JsonValue>>;
86
+ seed?: FixtureSeedFn;
85
87
  /** Revert per-test mutations (teardown); does NOT uninstall plugins or seeded data. */
86
- cleanup?: (ctx: SeedContext) => Promise<void>;
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 };
@@ -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 };
@@ -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 };