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
package/README.md CHANGED
@@ -41,7 +41,7 @@ Kizlo is a framework-agnostic toolkit for headless WordPress, built for a stack
41
41
  Set up Kizlo in your project:
42
42
 
43
43
  ```bash
44
- npx kizlo init
44
+ npx kizlo@latest init
45
45
  ```
46
46
 
47
47
  Watch your extensions and regenerate the contract during development:
@@ -2,17 +2,26 @@ import { Promisify } from "@kizlo/shared";
2
2
 
3
3
  //#region src/adapters/auth.d.ts
4
4
  interface AuthUser {
5
+ /** The WordPress user id this caller maps to. */
5
6
  id: number;
7
+ /** The user's email, if known. */
6
8
  email?: string;
9
+ /** The user's phone number, if known. */
7
10
  phone?: string;
11
+ /** The user's first name. */
8
12
  firstName: string;
13
+ /** The user's last name. */
9
14
  lastName: string;
10
15
  }
16
+ type AuthGetUser = (request: Request | null) => Promisify<AuthUser | null>;
11
17
  interface AuthAdapter {
12
- getUser(request: Request | null): Promisify<AuthUser | null>;
18
+ /** Resolve the authenticated user from the request, or `null` when there is no session. Receives `null` for server-side (non-HTTP) invocations. */
19
+ getUser: AuthGetUser;
13
20
  }
21
+ /** Author a custom auth adapter, typed against the {@link AuthAdapter} contract. */
22
+ declare function createAuthAdapter(adapter: AuthAdapter): AuthAdapter;
14
23
  declare function authMock(options?: {
15
24
  mockUserId?: number;
16
25
  }): AuthAdapter;
17
26
  //#endregion
18
- export { AuthAdapter, AuthUser, authMock };
27
+ export { AuthAdapter, AuthGetUser, AuthUser, authMock, createAuthAdapter };
@@ -1,4 +1,8 @@
1
1
  //#region src/adapters/auth.ts
2
+ /** Author a custom auth adapter, typed against the {@link AuthAdapter} contract. */
3
+ function createAuthAdapter(adapter) {
4
+ return adapter;
5
+ }
2
6
  function authMock(options) {
3
7
  return { getUser() {
4
8
  return {
@@ -12,4 +16,4 @@ function authMock(options) {
12
16
  }
13
17
 
14
18
  //#endregion
15
- export { authMock };
19
+ export { authMock, createAuthAdapter };
@@ -3,6 +3,8 @@ type CaptchaAdapter = (input: {
3
3
  token: string;
4
4
  ip: string;
5
5
  }) => Promise<boolean>;
6
+ /** Author a custom captcha adapter — types your verify function against the {@link CaptchaAdapter} contract. */
7
+ declare function createCaptchaAdapter(adapter: CaptchaAdapter): CaptchaAdapter;
6
8
  declare function recaptcha(opts: {
7
9
  secret: string;
8
10
  minScore?: number;
@@ -25,4 +27,4 @@ declare function altcha(opts: {
25
27
  hmacKey: string;
26
28
  }): CaptchaAdapter;
27
29
  //#endregion
28
- export { CaptchaAdapter, altcha, arkose, friendlyCaptcha, hcaptcha, recaptcha, turnstile };
30
+ export { CaptchaAdapter, altcha, arkose, createCaptchaAdapter, friendlyCaptcha, hcaptcha, recaptcha, turnstile };
@@ -1,4 +1,8 @@
1
1
  //#region src/adapters/captcha.ts
2
+ /** Author a custom captcha adapter — types your verify function against the {@link CaptchaAdapter} contract. */
3
+ function createCaptchaAdapter(adapter) {
4
+ return adapter;
5
+ }
2
6
  function recaptcha(opts) {
3
7
  return async ({ token, ip }) => {
4
8
  const data = await (await fetch("https://www.google.com/recaptcha/api/siteverify", {
@@ -83,4 +87,4 @@ function altcha(opts) {
83
87
  }
84
88
 
85
89
  //#endregion
86
- export { altcha, arkose, friendlyCaptcha, hcaptcha, recaptcha, turnstile };
90
+ export { altcha, arkose, createCaptchaAdapter, friendlyCaptcha, hcaptcha, recaptcha, turnstile };
@@ -0,0 +1,7 @@
1
+ import { CookiesAdapter } from "../shared/types.js";
2
+
3
+ //#region src/adapters/cookies.d.ts
4
+ /** Author a custom cookies adapter, typed against the {@link CookiesAdapter} contract. */
5
+ declare function createCookiesAdapter(adapter: CookiesAdapter): CookiesAdapter;
6
+ //#endregion
7
+ export { createCookiesAdapter };
@@ -0,0 +1,8 @@
1
+ //#region src/adapters/cookies.ts
2
+ /** Author a custom cookies adapter, typed against the {@link CookiesAdapter} contract. */
3
+ function createCookiesAdapter(adapter) {
4
+ return adapter;
5
+ }
6
+
7
+ //#endregion
8
+ export { createCookiesAdapter };
@@ -2,17 +2,28 @@ import { Promisify } from "@kizlo/shared";
2
2
 
3
3
  //#region src/adapters/geo.d.ts
4
4
  interface ConnInfo {
5
+ /** Client IP address, or `null` if unavailable. */
5
6
  ip: string | null;
7
+ /** ISO country code, or `null`. */
6
8
  country: string | null;
9
+ /** City name, or `null`. */
7
10
  city: string | null;
11
+ /** Region/state, or `null`. */
8
12
  state: string | null;
13
+ /** Postal code, or `null`. */
9
14
  postcode: string | null;
15
+ /** IANA timezone, or `null`. */
10
16
  timezone: string | null;
17
+ /** The request's `User-Agent`, or `null`. */
11
18
  userAgent: string | null;
12
19
  }
20
+ type GeoGetConnInfo = (request: Request | null) => Promisify<ConnInfo>;
13
21
  interface GeoAdapter {
14
- getConnInfo(request: Request | null): Promisify<ConnInfo>;
22
+ /** Resolve connection info (IP, geo, user agent) from the request. Return the shape with `null` fields when unknown — never throw. Receives `null` server-side. */
23
+ getConnInfo: GeoGetConnInfo;
15
24
  }
25
+ /** Author a custom geo adapter, typed against the {@link GeoAdapter} contract. */
26
+ declare function createGeoAdapter(adapter: GeoAdapter): GeoAdapter;
16
27
  declare function geoMock(values?: Partial<ConnInfo>): GeoAdapter;
17
28
  //#endregion
18
- export { ConnInfo, GeoAdapter, geoMock };
29
+ export { ConnInfo, GeoAdapter, GeoGetConnInfo, createGeoAdapter, geoMock };
@@ -1,4 +1,8 @@
1
1
  //#region src/adapters/geo.ts
2
+ /** Author a custom geo adapter, typed against the {@link GeoAdapter} contract. */
3
+ function createGeoAdapter(adapter) {
4
+ return adapter;
5
+ }
2
6
  function geoMock(values) {
3
7
  return { getConnInfo(request) {
4
8
  return {
@@ -15,4 +19,4 @@ function geoMock(values) {
15
19
  }
16
20
 
17
21
  //#endregion
18
- export { geoMock };
22
+ export { createGeoAdapter, geoMock };
@@ -4,13 +4,20 @@ import { LiteralUnion } from "@kizlo/shared";
4
4
  type LogLevel = "debug" | "info" | "warn" | "error";
5
5
  type Environment = LiteralUnion<"development" | "production" | "test", string>;
6
6
  interface LogPayload {
7
+ /** Severity of the record: `debug`, `info`, `warn`, or `error`. */
7
8
  level: LogLevel;
9
+ /** The log message. */
8
10
  message: string;
11
+ /** When the record was created. */
9
12
  timestamp: Date;
13
+ /** Structured fields attached to the call, if any. */
10
14
  context?: Record<string, unknown>;
15
+ /** The associated error, present on `error`-level records that pass one. */
11
16
  error?: Error;
12
17
  }
13
18
  type LoggerAdapter = (payload: LogPayload) => void | Promise<void>;
19
+ /** Author a custom logger adapter — types your handler against the {@link LoggerAdapter} contract. */
20
+ declare function createLoggerAdapter(adapter: LoggerAdapter): LoggerAdapter;
14
21
  interface Logger {
15
22
  debug(message: string, context?: Record<string, unknown>): void;
16
23
  info(message: string, context?: Record<string, unknown>): void;
@@ -28,4 +35,4 @@ interface ConsoleLogAdapterOptions {
28
35
  declare function consoleLog(opts?: ConsoleLogAdapterOptions): LoggerAdapter;
29
36
  declare const noopAdapter: LoggerAdapter;
30
37
  //#endregion
31
- export { ConsoleLogAdapterOptions, Environment, LogLevel, LogPayload, Logger, LoggerAdapter, consoleLog, noopAdapter };
38
+ export { ConsoleLogAdapterOptions, Environment, LogLevel, LogPayload, Logger, LoggerAdapter, consoleLog, createLoggerAdapter, noopAdapter };
@@ -1,4 +1,8 @@
1
1
  //#region src/adapters/logger.ts
2
+ /** Author a custom logger adapter — types your handler against the {@link LoggerAdapter} contract. */
3
+ function createLoggerAdapter(adapter) {
4
+ return adapter;
5
+ }
2
6
  function consoleLog(opts = {}) {
3
7
  const levelsOpt = opts.levels ?? true;
4
8
  const allowed = levelsOpt === true ? null : new Set(levelsOpt === false ? [] : levelsOpt);
@@ -27,4 +31,4 @@ function consoleLog(opts = {}) {
27
31
  const noopAdapter = () => {};
28
32
 
29
33
  //#endregion
30
- export { consoleLog, noopAdapter };
34
+ export { consoleLog, createLoggerAdapter, noopAdapter };
@@ -11,6 +11,17 @@ const KIZLO_TAGLINE = "Headless WordPress toolkit for TypeScript";
11
11
  function colorSupported() {
12
12
  return Boolean(process.stdout.isTTY) && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb";
13
13
  }
14
+ /** The CLI's shared ANSI palette (empty strings when color is unsupported). */
15
+ function palette() {
16
+ const on = colorSupported();
17
+ return {
18
+ cyan: on ? "\x1B[38;5;44m" : "",
19
+ green: on ? "\x1B[32m" : "",
20
+ bold: on ? "\x1B[1m" : "",
21
+ dim: on ? "\x1B[2m" : "",
22
+ reset: on ? "\x1B[0m" : ""
23
+ };
24
+ }
14
25
  function printBanner(version) {
15
26
  const color = colorSupported();
16
27
  const cyan = color ? "\x1B[38;5;44m" : "";
@@ -23,4 +34,4 @@ function printBanner(version) {
23
34
  }
24
35
 
25
36
  //#endregion
26
- export { printBanner };
37
+ export { palette, printBanner };
@@ -1,36 +1,218 @@
1
1
  import { createStack } from "../wp/docker.js";
2
- import { ensureGitignored, groupDefault, withSpinner } from "../utils.js";
3
- import { log } from "../daemon/logger.js";
2
+ import { ensureGitignored, envGroups, getVersion, groupDefault, mergeEnv, pickStackPort, withSpinner } from "../utils.js";
3
+ import { palette, printBanner } from "../banner.js";
4
4
  import { resolveDevConfig } from "../daemon/config.js";
5
+ import { startWatcher } from "../daemon/watch.js";
5
6
  import { bootstrapDev } from "../wp/dev.js";
7
+ import { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession } from "../wp/session.js";
8
+ import { syncSiteSettings } from "../wp/settings.js";
6
9
  import { devStack } from "../wp/stack.js";
7
- import { rmSync } from "node:fs";
10
+ import { randomBytes } from "node:crypto";
11
+ import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
+ import { join } from "node:path";
8
13
  import { defineCommand } from "citty";
14
+ import { networkInterfaces } from "node:os";
9
15
 
10
16
  //#region src/cli/commands/dev.ts
11
- async function resolve(cwd) {
17
+ async function resolve$1(cwd) {
12
18
  const cfg = await resolveDevConfig(cwd);
13
19
  return {
14
20
  cfg,
15
21
  stack: createStack(devStack(cfg))
16
22
  };
17
23
  }
18
- /** Print the connection summary. On a fresh install, show the one-time wp-admin password. */
24
+ /** A dim, untagged one-liner (no consola timestamp) for the clean dev output. */
25
+ function note(text) {
26
+ const { dim, reset } = palette();
27
+ process.stdout.write(` ${dim}${text}${reset}\n`);
28
+ }
29
+ /** First non-internal IPv4 address, for the LAN-reachable "Network" URL (undefined when offline). */
30
+ function lanAddress() {
31
+ for (const ifaces of Object.values(networkInterfaces())) for (const iface of ifaces ?? []) if (iface.family === "IPv4" && !iface.internal) return iface.address;
32
+ }
33
+ /**
34
+ * Print the connection summary as an aligned, Next.js-style block. Shows the wp-admin
35
+ * URL on localhost ("Local") and — when a LAN address exists — on the network, so the
36
+ * stack can be opened from other devices. On a fresh install, also show the one-time
37
+ * wp-admin password (it's never stored, so this is the only chance to see it).
38
+ */
19
39
  function printSummary(info) {
20
- log.success(`WordPress dev stack ready at ${info.url}`);
21
- if (info.secrets) {
22
- log.info(`wp-admin: ${info.url}/wp-admin (user: ${info.username} / pass: ${info.secrets.password})`);
23
- log.info("Save the password now — it's shown only once and isn't stored on disk.");
24
- } else if (info.imported) log.info(`wp-admin: ${info.url}/wp-admin — log in with your imported site's own credentials`);
25
- else log.info(`wp-admin: ${info.url}/wp-admin (user: ${info.username}) — password unchanged from first install`);
26
- log.info(`database: 127.0.0.1:${info.dbPort} (db: wordpress, user: wordpress, pass: wppass)`);
27
- if (info.seeded > 0) log.info(`seeded: ${info.seeded} fixture${info.seeded === 1 ? "" : "s"}`);
40
+ const { cyan, dim, reset } = palette();
41
+ const port = new URL(info.url).port;
42
+ const lan = lanAddress();
43
+ const rows = [["WP Local", `${info.url}/wp-admin`]];
44
+ if (lan) rows.push(["WP Network", `http://${lan}:${port}/wp-admin`]);
45
+ rows.push(["Database Connection", `127.0.0.1:${info.dbPort}`]);
46
+ const width = Math.max(...rows.map(([label]) => label.length)) + 1;
47
+ const lines = ["", ...rows.map(([label, value]) => ` ${dim}- ${`${label}:`.padEnd(width)}${reset} ${cyan}${value}${reset}`)];
48
+ if (info.secrets) lines.push("", ` ${dim}admin login:${reset} ${info.username} / ${info.secrets.password}`, ` ${dim}save the password now — it's shown only once${reset}`);
49
+ else if (info.imported) lines.push("", ` ${dim}log in with your imported site's own credentials${reset}`);
50
+ if (info.seeded > 0) lines.push(` ${dim}seeded ${info.seeded} fixture${info.seeded === 1 ? "" : "s"}${reset}`);
51
+ process.stdout.write(`${lines.join("\n")}\n`);
52
+ }
53
+ /**
54
+ * Dev connection keys a fresh dev install must rewrite (the rest of `.env` is untouched). The
55
+ * production `KIZLO_WORDPRESS_*` / `KIZLO_SITE_SECRET` keys live under their own names and are never
56
+ * touched by `kizlo dev`, so pointing them at a real site survives booting the local stack.
57
+ */
58
+ const WP_ENV_KEYS = [
59
+ "KIZLO_DEV_WORDPRESS_URL",
60
+ "KIZLO_DEV_WORDPRESS_USERNAME",
61
+ "KIZLO_DEV_WORDPRESS_APPLICATION_PASSWORD"
62
+ ];
63
+ /** Read a key's value from raw `.env` content (first match, trimmed); undefined when absent or empty. */
64
+ function readEnvValue(content, key) {
65
+ return content.match(new RegExp(`^\\s*${key}\\s*=\\s*(.*)$`, "m"))?.[1]?.trim() || void 0;
66
+ }
67
+ /**
68
+ * The Kizlo backend URL from `.env` — the one key ending in `KIZLO_BACKEND_URL` (`KIZLO_BACKEND_URL`,
69
+ * or a framework-prefixed `NEXT_PUBLIC_KIZLO_BACKEND_URL`). It's the handler mount the plugin delivers
70
+ * webhook events to; its origin is the site's canonical URL. User-managed (init writes it), so dev
71
+ * only reads it. Undefined before `kizlo init` has populated `.env`.
72
+ */
73
+ function readBaseUrl(content) {
74
+ return content.match(/^\s*[A-Z_]*KIZLO_BACKEND_URL\s*=\s*(.*)$/m)?.[1]?.trim() || void 0;
75
+ }
76
+ /**
77
+ * Rewrite the dev WordPress connection in `.env` after a fresh install. A reset (or first boot)
78
+ * wipes the database, so the previous application password — and possibly the port — no longer
79
+ * match; without this the app keeps the stale credentials and REST auth fails. Only the three
80
+ * connection keys are overwritten; everything else in `.env` is preserved, and the file is created
81
+ * if absent. Gated by the caller on a minted `appPassword`, so a warm resume never churns it.
82
+ *
83
+ * `KIZLO_TARGET=dev` and `KIZLO_DEV_SITE_SECRET` are written too but kept out of the overwrite set, so a
84
+ * value the user already set is preserved — booting the dev stack only fills them when absent. The
85
+ * resolved dev secret (the existing one, or a freshly minted one) and the Kizlo server base URL are
86
+ * returned so the caller can sync them into the plugin.
87
+ */
88
+ function updateWpEnv(cfg, info) {
89
+ if (!info.appPassword) return void 0;
90
+ const envPath = join(cfg.configDir, ".env");
91
+ const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
92
+ const siteSecret = readEnvValue(existing, "KIZLO_DEV_SITE_SECRET") ?? randomBytes(32).toString("hex");
93
+ const { content } = mergeEnv(existing, {
94
+ KIZLO_DEV_WORDPRESS_URL: info.url,
95
+ KIZLO_DEV_WORDPRESS_USERNAME: info.username,
96
+ KIZLO_DEV_WORDPRESS_APPLICATION_PASSWORD: info.appPassword,
97
+ KIZLO_DEV_SITE_SECRET: siteSecret,
98
+ KIZLO_TARGET: "dev"
99
+ }, new Set(WP_ENV_KEYS), envGroups("KIZLO_BACKEND_URL"));
100
+ writeFileSync(envPath, content);
101
+ return {
102
+ siteSecret,
103
+ baseUrl: readBaseUrl(existing)
104
+ };
105
+ }
106
+ /**
107
+ * Arm the foreground teardown — call this *before* the containers start, so a cancel at
108
+ * any point (even mid-startup, with mysql up but wordpress still booting) stops whatever
109
+ * already came up. The teardown is owned by a detached **watchdog** process, not by this
110
+ * one: containers belong to the Docker daemon, not our process tree, so — unlike a
111
+ * `next dev` child server — nothing stops them automatically when we die. A dying process
112
+ * also can't be trusted to finish async work (a closed terminal breaks its stdout
113
+ * mid-write), so this process does no docker work on the way out. It only needs to
114
+ * *exit*; the watchdog, watching our PID from its own session, stops the stack the moment
115
+ * we're gone — by Ctrl+C, SIGTERM, a closed tab, SIGKILL, or a crash alike.
116
+ *
117
+ * We trigger that exit two ways, because neither alone is enough: signal handlers (for
118
+ * Ctrl+C / `kill`), and an stdin/TTY-close watch (for a closed terminal, whose SIGHUP
119
+ * an `npm`/`pnpm` wrapper silently swallows — leaving us orphaned but alive otherwise).
120
+ */
121
+ function armForegroundTeardown(cfg) {
122
+ registerSession(cfg.project);
123
+ spawnWatchdog(cfg.project);
124
+ let exiting = false;
125
+ const shutdown = (reason) => {
126
+ if (exiting) return;
127
+ exiting = true;
128
+ unregisterSession(cfg.project);
129
+ try {
130
+ note(`Stopping dev stack (${reason})…`);
131
+ } catch {}
132
+ process.exit(0);
133
+ };
134
+ for (const signal of [
135
+ "SIGINT",
136
+ "SIGTERM",
137
+ "SIGHUP"
138
+ ]) process.on(signal, () => shutdown(signal));
139
+ if (process.stdin.isTTY) {
140
+ process.stdin.resume();
141
+ for (const event of [
142
+ "end",
143
+ "close",
144
+ "error"
145
+ ]) process.stdin.on(event, () => shutdown("terminal closed"));
146
+ }
147
+ }
148
+ /**
149
+ * Boot the stack and run in the foreground until exit — the shared path behind bare
150
+ * `kizlo dev` and `kizlo dev reset`. Arms teardown first (so a mid-startup cancel still
151
+ * stops partial containers), shows a timed spinner, prints the summary + stop hint, then
152
+ * parks. A referenced timer holds the event loop open — an unresolved promise alone would
153
+ * not, so without it Node would empty its loop and exit straight after printing.
154
+ */
155
+ async function startForeground(cfg) {
156
+ await removeProjectContainers(cfg.project);
157
+ const port = await pickStackPort(cfg.port, {
158
+ fixed: cfg.portExplicit,
159
+ configKey: "dev.port"
160
+ });
161
+ const dbPort = await pickStackPort(cfg.dbPort, {
162
+ fixed: cfg.dbPortExplicit,
163
+ host: "127.0.0.1",
164
+ configKey: "dev.dbPort"
165
+ });
166
+ const ready = {
167
+ ...cfg,
168
+ port,
169
+ dbPort
170
+ };
171
+ createStack(devStack(ready));
172
+ if (port !== cfg.port) note(`Port ${cfg.port} is in use — serving on ${port} instead.`);
173
+ if (dbPort !== cfg.dbPort) note(`Database port ${cfg.dbPort} is in use — using ${dbPort} instead.`);
174
+ armForegroundTeardown(ready);
175
+ const start = Date.now();
176
+ const creds = await withSpinner("Starting WordPress dev stack", () => bootstrapDev(ready));
177
+ if (creds.appPassword) {
178
+ const env = updateWpEnv(ready, creds);
179
+ note("Updated .env with the new WordPress credentials");
180
+ if (env) {
181
+ const sync = await syncSiteSettings({
182
+ url: creds.url,
183
+ username: creds.username,
184
+ password: creds.appPassword
185
+ }, {
186
+ secret: env.siteSecret,
187
+ backendUrl: env.baseUrl,
188
+ containerized: true
189
+ });
190
+ if (!sync.ok) note(`Could not sync the site settings to WordPress (${sync.error}) — make sure the kizlo plugin is active, then set them from the Kizlo settings.`);
191
+ }
192
+ }
193
+ printSummary(creds);
194
+ const { green, dim, bold, reset } = palette();
195
+ const ms = Date.now() - start;
196
+ const took = ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
197
+ process.stdout.write(`\n ${dim}Press ${reset}${bold}Ctrl+C${reset}${dim} (or close the terminal) to stop the development stack${reset}\n`);
198
+ process.stdout.write(`\n ${green}✓${reset} Ready in ${took}\n\n`);
199
+ const stopWatcher = await startWatcher(ready.configDir);
200
+ if (stopWatcher) process.on("exit", stopWatcher);
201
+ setInterval(() => {}, 1 << 30);
202
+ await new Promise(() => {});
28
203
  }
29
- /** Start (or resume) the dev stack and print the connection summary. Backs bare `kizlo dev`. */
204
+ /**
205
+ * Start (or resume) the dev stack. Backs bare `kizlo dev`. Always runs in the foreground
206
+ * and stops the stack on exit, so it never outlives the session — the subcommands
207
+ * (`stop`/`down`/`reset`) manage everything else.
208
+ */
30
209
  async function bringUp() {
31
- const { cfg } = await resolve(process.cwd());
210
+ printBanner(getVersion());
211
+ const reaped = await reapOrphans();
212
+ if (reaped.length) note(`Stopped ${reaped.length} orphaned dev stack${reaped.length === 1 ? "" : "s"}: ${reaped.join(", ")}`);
213
+ const cfg = await resolveDevConfig(process.cwd());
32
214
  if (!cfg.wordpressPath.startsWith("..")) ensureGitignored(cfg.configDir, cfg.wordpressPath);
33
- printSummary(await withSpinner("Starting WordPress dev stack", () => bootstrapDev(cfg), "WordPress dev stack ready"));
215
+ await startForeground(cfg);
34
216
  }
35
217
  const subCommands = {
36
218
  stop: defineCommand({
@@ -39,7 +221,7 @@ const subCommands = {
39
221
  description: "Stop the dev stack, keeping the database volume"
40
222
  },
41
223
  async run() {
42
- const { stack } = await resolve(process.cwd());
224
+ const { stack } = await resolve$1(process.cwd());
43
225
  await withSpinner("Stopping WordPress dev stack", () => stack.composeStop(), "Stack stopped (volumes kept)");
44
226
  }
45
227
  }),
@@ -49,7 +231,7 @@ const subCommands = {
49
231
  description: "Stop and remove the dev stack containers (keeps the database and files)"
50
232
  },
51
233
  async run() {
52
- const { stack } = await resolve(process.cwd());
234
+ const { stack } = await resolve$1(process.cwd());
53
235
  await withSpinner("Removing WordPress dev stack", () => stack.composeDown(), "Stack removed (database and files kept)");
54
236
  }
55
237
  }),
@@ -59,13 +241,14 @@ const subCommands = {
59
241
  description: "Wipe the database and the install folder, then rebuild fresh"
60
242
  },
61
243
  async run() {
62
- const { cfg, stack } = await resolve(process.cwd());
244
+ printBanner(getVersion());
245
+ const { cfg, stack } = await resolve$1(process.cwd());
63
246
  await withSpinner("Wiping WordPress dev stack", () => stack.composeDown({ volumes: true }), "Stack wiped");
64
247
  rmSync(cfg.wordpressDir, {
65
248
  recursive: true,
66
249
  force: true
67
250
  });
68
- printSummary(await withSpinner("Starting WordPress dev stack", () => bootstrapDev(cfg), "WordPress dev stack ready"));
251
+ await startForeground(cfg);
69
252
  }
70
253
  })
71
254
  };