kizlo 0.20.1 → 0.21.0

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.
@@ -1,7 +1,7 @@
1
1
  import { DEFAULT_WORDPRESS_TAG, LOCAL_DIR_REL } from "../wp/constants.js";
2
2
  import { composeStop, createStack, dockerHint, dockerStatus } from "../wp/docker.js";
3
3
  import { DEFAULT_ENV_KEYS, availablePackageManagers, detectInvokingPackageManager, ensureGitignored, envGroups, envKeysPresent, mergeEnv, pickStackPort, writeFileIfAbsent } from "../utils.js";
4
- import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveStackName, stackProject } from "../daemon/config.js";
4
+ import { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, DEFAULT_MCP_PORT, resolveStackName, stackProject } from "../daemon/config.js";
5
5
  import { promptFragment, resolvePromptDefault } from "../presets/template.js";
6
6
  import { createAdminAppPassword } from "../wp/bootstrap.js";
7
7
  import { bootstrapDev } from "../wp/dev.js";
@@ -324,6 +324,10 @@ function devConfigFor(cwd) {
324
324
  return {
325
325
  configDir: cwd,
326
326
  project: stackProject(resolveStackName(cwd), "dev"),
327
+ mcp: {
328
+ port: DEFAULT_MCP_PORT,
329
+ portExplicit: false
330
+ },
327
331
  port: DEFAULT_DEV_PORT,
328
332
  portExplicit: false,
329
333
  dbPort: DEFAULT_DEV_DB_PORT,
@@ -10,7 +10,8 @@ import * as p from "@clack/prompts";
10
10
  //#region src/cli/commands/_wiring.ts
11
11
  /** The kizlo.config.ts a scaffolded project gets: the Kizlo directory, the import-alias preference, and,
12
12
  * when local WordPress is chosen, `local: true` so `kizlo dev` and `kizlo test` boot the fixed
13
- * `.kizlo/local` install (both stacks on with defaults; the object form is for configuring them). The
13
+ * `.kizlo/local` install (both stacks on with defaults; the object form configures them and turns each
14
+ * on with its own `enable: true`). The
14
15
  * alias is always written (`""` for relative imports included) so it records a made decision a later
15
16
  * `kizlo init` reads back instead of prompting for it again. The alias is written in its canonical `@/`
16
17
  * form (never a bare `@`) so it reads like the imports it produces. */
@@ -2,7 +2,7 @@ import { createStack, dockerHint, dockerStatus, stackStatus } from "../wp/docker
2
2
  import { findConfigDir } from "../wp/utils.js";
3
3
  import { log } from "../daemon/logger.js";
4
4
  import { DEFAULT_ENV_KEYS, ensureGitignored, envGroups, groupDefault, mergeEnv, pickStackPort, withSpinner } from "../utils.js";
5
- import { resolveDevConfig, usesLocalWordPress } from "../daemon/config.js";
5
+ import { resolveDevConfig, resolveMcpConfig, usesLocalWordPress } from "../daemon/config.js";
6
6
  import { bootstrapDev } from "../wp/dev.js";
7
7
  import { reapOrphans, registerSession, removeProjectContainers, spawnWatchdog, unregisterSession } from "../wp/session.js";
8
8
  import { syncSiteSettings } from "../wp/settings.js";
@@ -10,6 +10,8 @@ import { devStack } from "../wp/stack.js";
10
10
  import { palette } from "../banner.js";
11
11
  import { watchReload } from "../daemon/reload.js";
12
12
  import { startWatcher } from "../daemon/watch.js";
13
+ import { createMcpState } from "../mcp/state.js";
14
+ import { resolveMcpPort, startMcp } from "../mcp/index.js";
13
15
  import { join } from "node:path";
14
16
  import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
17
  import { defineCommand } from "citty";
@@ -42,14 +44,16 @@ function note(text) {
42
44
  * Print the connection summary as an aligned, Next.js-style block. Shows the wp-admin URL on
43
45
  * the loopback ("Local") and — when local WordPress is installed on a LAN address rather than
44
46
  * localhost — on the network, so it can be opened from other devices. On a fresh install, also
45
- * show the default wp-admin login.
47
+ * show the default wp-admin login. The MCP row is the address to paste into a harness's own
48
+ * configuration: nothing writes one, because each harness reads a different file.
46
49
  */
47
- function printSummary(info) {
50
+ function printSummary(info, mcpUrl) {
48
51
  const { cyan, dim, reset } = palette();
49
52
  const { port, hostname } = new URL(info.url);
50
53
  const rows = [["WP Local", `http://localhost:${port}/wp-admin`]];
51
54
  if (hostname !== "localhost") rows.push(["WP Network", `${info.url}/wp-admin`]);
52
55
  rows.push(["Database Connection", `127.0.0.1:${info.dbPort}`]);
56
+ if (mcpUrl) rows.push(["MCP", mcpUrl]);
53
57
  const width = Math.max(...rows.map(([label]) => label.length)) + 1;
54
58
  const lines = ["", ...rows.map(([label, value]) => ` ${dim}- ${`${label}:`.padEnd(width)}${reset} ${cyan}${value}${reset}`)];
55
59
  if (info.adminPassword) lines.push("", ` ${dim}admin login:${reset} ${info.username} / ${info.adminPassword}`);
@@ -180,7 +184,7 @@ function stopping(reason) {
180
184
  * and the teardown work against. Shared by the first boot and every reload reboot, so a changed
181
185
  * `dev.port` in `kizlo.config.ts` is re-resolved the same way each time.
182
186
  */
183
- async function prepareStack(cfg) {
187
+ async function prepareStack(cfg, mcpPort) {
184
188
  await removeProjectContainers(cfg.project);
185
189
  const port = await pickStackPort(cfg.port, {
186
190
  fixed: cfg.portExplicit,
@@ -194,7 +198,8 @@ async function prepareStack(cfg) {
194
198
  const ready = {
195
199
  ...cfg,
196
200
  port,
197
- dbPort
201
+ dbPort,
202
+ mcpPort: mcpPort ?? await resolveMcpPort(cfg.mcp)
198
203
  };
199
204
  createStack(devStack(ready));
200
205
  if (port !== cfg.port) note(`Port ${cfg.port} is in use — serving on ${port} instead.`);
@@ -207,7 +212,7 @@ async function prepareStack(cfg) {
207
212
  * one-time Ctrl+C hint. `start` is when the boot began, so the timing spans the whole reboot including
208
213
  * the spinner. The `.env` writes here are why the caller pauses the reload watcher around a reboot.
209
214
  */
210
- async function report(cfg, creds, reloaded, start) {
215
+ async function syncEnv(cfg, creds) {
211
216
  if (creds.appPassword) {
212
217
  const env = updateWpEnv(cfg, creds);
213
218
  note("Updated .env with the new WordPress credentials");
@@ -224,7 +229,16 @@ async function report(cfg, creds, reloaded, start) {
224
229
  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.`);
225
230
  }
226
231
  } else if (syncLocalUrl(cfg.configDir, creds.url)) note("Updated .env WordPress URL to the current network address — restart your app to pick it up");
227
- printSummary(creds);
232
+ }
233
+ /**
234
+ * Print the summary and the timed ready line, once everything it names is actually up. Split from
235
+ * {@link syncEnv} because the two have opposite ordering needs: the `.env` writes have to land before
236
+ * the watcher starts, since that is where it reads the connection from, while the summary has to wait
237
+ * until after, so the MCP address it prints belongs to a server that exists. `mcpUrl` is absent when
238
+ * MCP is not running, and the row is then left out rather than pointing at nothing.
239
+ */
240
+ function printReady(creds, reloaded, start, mcpUrl) {
241
+ printSummary(creds, mcpUrl);
228
242
  const { green, dim, bold, reset } = palette();
229
243
  const ms = Date.now() - start;
230
244
  const took = ms < 1e3 ? `${ms}ms` : `${(ms / 1e3).toFixed(1)}s`;
@@ -236,9 +250,14 @@ async function report(cfg, creds, reloaded, start) {
236
250
  * then report. The first-boot path only — reboots go through {@link rebootStack}, which also folds the
237
251
  * prepare step under the spinner.
238
252
  */
239
- async function bootAndReport(cfg) {
253
+ async function bootAndSync(cfg) {
240
254
  const start = Date.now();
241
- await report(cfg, await withSpinner("Starting local WordPress", () => bootstrapDev(cfg)), false, start);
255
+ const creds = await withSpinner("Starting local WordPress", () => bootstrapDev(cfg));
256
+ await syncEnv(cfg, creds);
257
+ return {
258
+ creds,
259
+ start
260
+ };
242
261
  }
243
262
  /**
244
263
  * Reboot the whole stack in place after a config/env change: re-resolve the config, recreate the
@@ -247,17 +266,21 @@ async function bootAndReport(cfg) {
247
266
  * the first boot, so this never re-arms. Returns the freshly resolved config (ports may have moved) so
248
267
  * the caller can re-target its watchers.
249
268
  */
250
- async function rebootStack(cwd, label) {
269
+ async function rebootStack(cwd, label, mcpPort) {
251
270
  const start = Date.now();
252
271
  const { ready, creds } = await withSpinner(label, async () => {
253
- const ready$1 = await prepareStack(await resolveDevConfig(cwd));
272
+ const ready$1 = await prepareStack(await resolveDevConfig(cwd), mcpPort);
254
273
  return {
255
274
  ready: ready$1,
256
275
  creds: await bootstrapDev(ready$1)
257
276
  };
258
277
  });
259
- await report(ready, creds, true, start);
260
- return ready;
278
+ await syncEnv(ready, creds);
279
+ return {
280
+ ready,
281
+ creds,
282
+ start
283
+ };
261
284
  }
262
285
  /**
263
286
  * Boot local WordPress and run in the foreground until exit — the path behind bare `kizlo dev`. Arms
@@ -273,20 +296,33 @@ async function startForeground(cfg) {
273
296
  const cwd = process.cwd();
274
297
  let ready = await prepareStack(cfg);
275
298
  const shutdown = await armForegroundTeardown(ready);
276
- await bootAndReport(ready);
299
+ const { creds, start } = await bootAndSync(ready);
277
300
  const stack = {
278
301
  status: () => stackStatus(ready.project),
279
302
  onStopped: () => shutdown()
280
303
  };
281
- let stopContract = await startWatcher(ready.configDir, { stack });
304
+ const mcp = createMcpState();
305
+ const mcpPort = ready.mcpPort;
306
+ let stopContract = await startWatcher(ready.configDir, {
307
+ stack,
308
+ mcp
309
+ });
310
+ const mcpServer = stopContract && mcpPort !== void 0 ? await startMcp(mcp, mcpPort) : void 0;
311
+ if (!stopContract) log.info("Another `kizlo dev` is serving MCP for this project.");
312
+ printReady(creds, false, start, mcpServer?.url);
282
313
  let reloading = false;
283
314
  const reload = async (changed) => {
284
315
  if (reloading) return;
285
316
  reloading = true;
286
317
  try {
287
318
  stopContract?.();
288
- ready = await rebootStack(cwd, `${changed} changed — restarting the session`);
289
- stopContract = await startWatcher(ready.configDir, { stack });
319
+ const rebooted = await rebootStack(cwd, `${changed} changed — restarting the session`, mcpPort);
320
+ ready = rebooted.ready;
321
+ stopContract = await startWatcher(ready.configDir, {
322
+ stack,
323
+ mcp
324
+ });
325
+ printReady(rebooted.creds, true, rebooted.start, mcpServer?.url);
290
326
  } catch (error) {
291
327
  log.error("Failed to reload the dev session:", error);
292
328
  } finally {
@@ -299,6 +335,7 @@ async function startForeground(cfg) {
299
335
  process.on("exit", () => {
300
336
  stopContract?.();
301
337
  stopReload();
338
+ mcpServer?.stop();
302
339
  });
303
340
  setInterval(() => {}, 1 << 30);
304
341
  await new Promise(() => {});
@@ -312,15 +349,20 @@ async function startForeground(cfg) {
312
349
  * persistent watcher keeps the process alive on its own.
313
350
  */
314
351
  async function watchOnly(cwd) {
315
- let stop = await startWatcher(cwd);
316
- if (!stop) return;
352
+ const mcp = createMcpState();
353
+ let stop = await startWatcher(cwd, { mcp });
354
+ if (!stop) {
355
+ log.info("Another `kizlo dev` is serving MCP for this project.");
356
+ return;
357
+ }
358
+ const mcpServer = await startMcp(mcp, await resolveMcpPort(await resolveMcpConfig(cwd)));
317
359
  let reloading = false;
318
360
  const reload = async () => {
319
361
  if (reloading) return;
320
362
  reloading = true;
321
363
  try {
322
364
  stop?.();
323
- stop = await startWatcher(cwd);
365
+ stop = await startWatcher(cwd, { mcp });
324
366
  } finally {
325
367
  setTimeout(() => {
326
368
  reloading = false;
@@ -331,6 +373,7 @@ async function watchOnly(cwd) {
331
373
  const shutdown = () => {
332
374
  stop?.();
333
375
  stopReload();
376
+ mcpServer?.stop();
334
377
  };
335
378
  process.on("exit", shutdown);
336
379
  for (const signal of [
@@ -1,5 +1,5 @@
1
1
  import { log } from "../daemon/logger.js";
2
- import { resolveConfig } from "../daemon/config.js";
2
+ import { resolveConfig, resolveWatchCredentials } from "../daemon/config.js";
3
3
  import { PartialContractError, generateOnce, reportGenerationError } from "../daemon/generate.js";
4
4
  import { testWordPressCredentials } from "./_test-wordpress.js";
5
5
  import path from "node:path";
@@ -38,18 +38,31 @@ const generate = defineCommand({
38
38
  async run({ args }) {
39
39
  const cwd = process.cwd();
40
40
  const strict = args.strict === true;
41
- let credentials;
41
+ let options;
42
42
  if (args.test) try {
43
- credentials = await testWordPressCredentials(cwd);
43
+ options = {
44
+ credentials: await testWordPressCredentials(cwd),
45
+ strict
46
+ };
44
47
  } catch (error) {
45
48
  log.error(error instanceof Error ? error.message : String(error));
46
49
  process.exitCode = 1;
47
50
  return;
48
51
  }
49
- const options = {
50
- credentials,
51
- strict
52
- };
52
+ else {
53
+ const credentials = await resolveWatchCredentials(cwd);
54
+ if (credentials) options = {
55
+ credentials,
56
+ strict
57
+ };
58
+ else {
59
+ log.info("No WordPress connection configured; generating the contract only, introspection skipped.");
60
+ options = {
61
+ skipIntrospection: true,
62
+ strict
63
+ };
64
+ }
65
+ }
53
66
  const cfg = await resolveConfig(cwd, { dir: args.dir });
54
67
  if (!cfg) {
55
68
  log.info("Nothing to generate. Set `dir` in kizlo.config.ts or pass --dir.");
@@ -66,6 +79,7 @@ const generate = defineCommand({
66
79
  process.exit(1);
67
80
  }
68
81
  if (result.contract === "none") {
82
+ if (result.introspection === "skipped") return;
69
83
  const file = path.resolve(cwd, cfg.introspectionPath);
70
84
  log.success(`WordPress introspection ${result.introspection === "generated" ? "written to" : "already current at"} ${file}`);
71
85
  return;
@@ -1,7 +1,9 @@
1
+ import { integrationEnv, resolveWordPressConnection, wordPressConnectionComplete } from "../../kizlo.js";
1
2
  import { DEFAULT_WORDPRESS_TAG, INTROSPECTION_META_REL, LOCAL_DIR_REL } from "../wp/constants.js";
2
3
  import { credentialsPath, findConfigDir } from "../wp/utils.js";
4
+ import { node } from "../../integrations/node/integration.js";
3
5
  import { log } from "./logger.js";
4
- import { detectPackageManager } from "../utils.js";
6
+ import { detectPackageManager, loadEnvFiles } from "../utils.js";
5
7
  import { WORDPRESS_TAG_PATTERN } from "../wp/version.js";
6
8
  import { importIgnoringVirtualModules } from "./jiti.js";
7
9
  import z from "zod/v4";
@@ -48,6 +50,7 @@ const testStackSchema = z.object({
48
50
  ]).optional(),
49
51
  command: z.string().optional()
50
52
  });
53
+ const mcpSchema = z.object({ port: z.number().int().positive().optional() });
51
54
  const localSchema = z.union([z.boolean(), z.object({
52
55
  enable: z.boolean().optional(),
53
56
  name: z.string().optional(),
@@ -60,27 +63,38 @@ const configSchema = z.object({
60
63
  dir: dirSchema.optional(),
61
64
  alias: z.string().optional(),
62
65
  local: localSchema.optional(),
66
+ mcp: mcpSchema.optional(),
63
67
  wordpressClientDir: removedKey("`wordpressClientDir` has been removed. Use `dir: { introspection }` instead."),
64
68
  name: removedKey("`name` is no longer a config root key. Move it under `local.name`."),
65
69
  worktrees: removedKey("`worktrees` is no longer a config root key. Move it under `local.worktrees`."),
66
70
  dev: removedKey("`dev` is no longer a config root key. Move it under `local.dev`."),
67
71
  test: removedKey("`test` is no longer a config root key. Move it under `local.test`.")
68
72
  });
69
- /** Normalize `local` (absent, `true`, `false`, or the object form) into one shape the resolvers read. */
73
+ /**
74
+ * Normalize `local` (absent, `true`, `false`, or the object form) into one shape the resolvers read.
75
+ * A stack is off unless its own `enable: true` is set; `local: true` is the shorthand that turns both on.
76
+ */
70
77
  function resolveLocal(fileConfig) {
71
78
  const local = fileConfig?.local;
72
79
  if (local === true) return {
73
80
  enabled: true,
81
+ devEnabled: true,
82
+ testEnabled: true,
74
83
  dev: {},
75
84
  test: {}
76
85
  };
77
86
  if (!local) return {
78
87
  enabled: false,
88
+ devEnabled: false,
89
+ testEnabled: false,
79
90
  dev: {},
80
91
  test: {}
81
92
  };
93
+ const enabled = local.enable !== false;
82
94
  return {
83
- enabled: local.enable !== false,
95
+ enabled,
96
+ devEnabled: enabled && local.dev?.enable === true,
97
+ testEnabled: enabled && local.test?.enable === true,
84
98
  name: local.name,
85
99
  worktrees: local.worktrees,
86
100
  dev: local.dev ?? {},
@@ -94,6 +108,16 @@ const CONFIG_FILES = [
94
108
  ];
95
109
  const DEFAULT_DEV_PORT = 8080;
96
110
  const DEFAULT_DEV_DB_PORT = 3307;
111
+ /**
112
+ * Port the MCP server binds on `127.0.0.1` when `mcp.port` is unset. It steps off a collision like the
113
+ * stack ports do, so two projects need no configuration to run side by side. A harness config names a
114
+ * port and cannot follow one that moves, which is what setting `mcp.port` is for.
115
+ *
116
+ * Deliberately clear of the stack ports' own scan ranges. Each default steps up to `PORT_SCAN_RANGE`
117
+ * ports looking for a free one, so a default inside 8080-8179 could be handed to the dev stack and
118
+ * then claimed again here, since the MCP probe runs before compose has published anything.
119
+ */
120
+ const DEFAULT_MCP_PORT = 8300;
97
121
  const DEFAULT_TEST_PORT = 8889;
98
122
  /** The generated introspection artifact's filename, written under its resolved directory. */
99
123
  const INTROSPECTION_FILE = "introspection.ts";
@@ -241,7 +265,7 @@ async function resolveTestConfig(cwd) {
241
265
  const inherit = test.inherit !== false;
242
266
  return {
243
267
  configDir,
244
- local: local.enabled && test.enable !== false,
268
+ local: local.testEnabled,
245
269
  project: stackProject(resolveStackName(configDir, {
246
270
  name: local.name,
247
271
  worktrees: local.worktrees
@@ -256,14 +280,37 @@ async function resolveTestConfig(cwd) {
256
280
  };
257
281
  }
258
282
  /**
259
- * Whether this project runs local WordPress under `kizlo dev`: local is enabled and the dev stack is
260
- * on (`local.dev.enable !== false`) in `kizlo.config.*`. When false, `kizlo dev` has nothing to boot and
261
- * runs the contract watcher alone, the path a project pointing at its own WordPress takes. Written by
262
- * `create`/`init` when local WordPress is chosen, so it's committed and survives `kizlo dev reset`.
283
+ * Whether this project runs local WordPress under `kizlo dev`: the `local` block is enabled and the dev
284
+ * stack is turned on with `local.dev.enable: true` (or `local: true`) in `kizlo.config.*`. When false,
285
+ * `kizlo dev` has nothing to boot and runs the contract watcher alone, the path a project pointing at its
286
+ * own WordPress takes. Written by `create`/`init` when local WordPress is chosen, so it's committed and
287
+ * survives `kizlo dev reset`.
263
288
  */
264
289
  async function usesLocalWordPress(cwd) {
265
- const local = resolveLocal(await loadConfigFile(findConfigDir(cwd)));
266
- return local.enabled && local.dev.enable !== false;
290
+ return resolveLocal(await loadConfigFile(findConfigDir(cwd))).devEnabled;
291
+ }
292
+ /**
293
+ * WordPress credentials for the dev/generate watcher, or `undefined` when there is nothing to introspect:
294
+ * the active profile is `local` but no local dev stack is enabled, or the active profile's connection
295
+ * envs are incomplete. Callers then generate the contract with introspection skipped rather than fetching
296
+ * from a WordPress that is not there. Unlike {@link resolveWordPressConnection}, this never throws.
297
+ */
298
+ async function resolveWatchCredentials(cwd) {
299
+ loadEnvFiles(cwd);
300
+ const env = integrationEnv([node()]);
301
+ if (env("mode")?.trim() === "local" && !await usesLocalWordPress(cwd)) return void 0;
302
+ if (!wordPressConnectionComplete(env)) return void 0;
303
+ return resolveWordPressConnection(env).credentials;
304
+ }
305
+ function resolveMcp(fileConfig) {
306
+ return {
307
+ port: fileConfig?.mcp?.port ?? DEFAULT_MCP_PORT,
308
+ portExplicit: fileConfig?.mcp?.port !== void 0
309
+ };
310
+ }
311
+ /** Resolve `mcp` from `kizlo.config.*`, applying the defaults for a project that says nothing about it. */
312
+ async function resolveMcpConfig(cwd) {
313
+ return resolveMcp(await loadConfigFile(findConfigDir(cwd)));
267
314
  }
268
315
  /**
269
316
  * Resolve the dev stack (`local.dev`) into concrete values for the `dev` command, applying defaults
@@ -273,7 +320,8 @@ async function usesLocalWordPress(cwd) {
273
320
  */
274
321
  async function resolveDevConfig(cwd) {
275
322
  const configDir = findConfigDir(cwd);
276
- const local = resolveLocal(await loadConfigFile(configDir));
323
+ const fileConfig = await loadConfigFile(configDir);
324
+ const local = resolveLocal(fileConfig);
277
325
  const { dev } = local;
278
326
  return {
279
327
  configDir,
@@ -288,9 +336,10 @@ async function resolveDevConfig(cwd) {
288
336
  wordpressTag: dev.version ?? DEFAULT_WORDPRESS_TAG,
289
337
  fixtures: dev.fixtures ?? [],
290
338
  wordpressPath: LOCAL_DIR_REL,
291
- wordpressDir: path.resolve(configDir, LOCAL_DIR_REL)
339
+ wordpressDir: path.resolve(configDir, LOCAL_DIR_REL),
340
+ mcp: resolveMcp(fileConfig)
292
341
  };
293
342
  }
294
343
 
295
344
  //#endregion
296
- export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, stackProject, usesLocalWordPress };
345
+ export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, DEFAULT_MCP_PORT, resolveConfig, resolveDevConfig, resolveMcpConfig, resolveStackName, resolveTestConfig, resolveWatchCredentials, stackProject, usesLocalWordPress };
@@ -178,7 +178,7 @@ function reportPluginVersion(installed) {
178
178
  async function fetchDocument(cwd, options, etag) {
179
179
  loadEnvFiles(cwd);
180
180
  const result = await fetchIntrospection(options.credentials ?? resolveWordPressConnection(integrationEnv([node()])).credentials, {
181
- etag: options.strict ? void 0 : etag,
181
+ etag: !options.strict && !options.seed ? etag : void 0,
182
182
  fetch: options.fetch
183
183
  });
184
184
  reportPluginVersion(result.pluginVersion);
@@ -205,10 +205,17 @@ async function generateIntrospectionSource(cwd, options = {}) {
205
205
  * it under `generated/`, and a package that only ships procedures writes it at its own `dir.introspection`.
206
206
  */
207
207
  async function generateIntrospectionOnce(cfg, options = {}) {
208
+ if (options.skipIntrospection) return "skipped";
208
209
  const file = path.resolve(cfg.cwd, cfg.introspectionPath);
209
210
  const metaPath = path.resolve(cfg.cwd, cfg.introspectionMetaPath);
210
- const result = await fetchDocument(cfg.cwd, options, readWordPressMeta(metaPath, file)?.etag);
211
+ const meta = readWordPressMeta(metaPath, file);
212
+ const result = await fetchDocument(cfg.cwd, options, meta?.etag);
211
213
  if (!result?.document) return "unchanged";
214
+ options.onDocument?.(result.document);
215
+ if (options.seed && meta && result.document.hash === meta.hash) {
216
+ if (result.etag !== meta.etag) atomicWrite(metaPath, wordPressMeta(result.etag, result.document.hash));
217
+ return "unchanged";
218
+ }
212
219
  const generated = generateWordPressModule(result.document);
213
220
  await assertGeneratedClientCompiles(generated);
214
221
  fs.mkdirSync(path.dirname(file), { recursive: true });
@@ -1,8 +1,5 @@
1
- import { integrationEnv, resolveWordPressConnection } from "../../kizlo.js";
2
- import { node } from "../../integrations/node/integration.js";
3
1
  import { log } from "./logger.js";
4
- import { loadEnvFiles } from "../utils.js";
5
- import { resolveConfig } from "./config.js";
2
+ import { resolveConfig, resolveWatchCredentials } from "./config.js";
6
3
  import { IntrospectionFetchError } from "../../wordpress/fetch-introspection.js";
7
4
  import { generateIntrospectionOnce, generateOnce, reportGenerationError } from "./generate.js";
8
5
  import { acquire, lockPath, release } from "./lock.js";
@@ -17,9 +14,9 @@ function debounce(fn, delay) {
17
14
  timer = setTimeout(() => void fn(...args), delay);
18
15
  });
19
16
  }
20
- async function regenerate(cfg, credentials) {
17
+ async function regenerate(cfg, options) {
21
18
  try {
22
- const result = await generateOnce(cfg, { credentials });
19
+ const result = await generateOnce(cfg, options);
23
20
  if (result.contract === "built") log.success("Contract updated");
24
21
  else if (result.contract === "empty" && cfg.server) log.warn(`No Kizlo server found in ${cfg.server.entry}`);
25
22
  } catch (error) {
@@ -27,13 +24,13 @@ async function regenerate(cfg, credentials) {
27
24
  }
28
25
  }
29
26
  /** Watches the server directory and regenerates the contract on change. Only called when a server is present. */
30
- async function watch(cfg, server, credentials) {
27
+ async function watch(cfg, server, options) {
31
28
  const watcher = new FSWatcher({
32
29
  persistent: true,
33
30
  ignoreInitial: true,
34
31
  ignored: path.resolve(cfg.cwd, server.contractDir)
35
32
  });
36
- const onChange = debounce(() => regenerate(cfg, credentials), 300);
33
+ const onChange = debounce(() => regenerate(cfg, options), 300);
37
34
  watcher.add(path.resolve(cfg.cwd, server.dir));
38
35
  watcher.on("all", () => void onChange());
39
36
  return watcher;
@@ -106,8 +103,8 @@ function createWordPressRefresh(cfg, options, stack) {
106
103
  };
107
104
  }
108
105
  /** Run {@link createWordPressRefresh} on a timer, skipping a tick while the previous one is still going. */
109
- function refreshWordPress(cfg, credentials, stack) {
110
- const refresh = createWordPressRefresh(cfg, { credentials }, stack);
106
+ function refreshWordPress(cfg, options, stack) {
107
+ const refresh = createWordPressRefresh(cfg, options, stack);
111
108
  let refreshing = false;
112
109
  const timer = setInterval(() => {
113
110
  if (refreshing) return;
@@ -139,25 +136,34 @@ async function startWatcher(cwd, opts) {
139
136
  log.info("skipping the contract generation.");
140
137
  return;
141
138
  }
142
- loadEnvFiles(cwd);
143
- const { credentials } = resolveWordPressConnection(integrationEnv([node()]));
139
+ const credentials = await resolveWatchCredentials(cwd);
140
+ const options = credentials ? { credentials } : { skipIntrospection: true };
141
+ if (!credentials) log.info("No WordPress connection configured; running the contract watcher only, introspection skipped.");
142
+ if (opts?.mcp) {
143
+ opts.mcp.credentials = credentials;
144
+ options.seed = true;
145
+ options.onDocument = (document) => {
146
+ if (opts.mcp) opts.mcp.document = document;
147
+ options.seed = false;
148
+ };
149
+ }
144
150
  try {
145
- const result = await generateOnce(cfg, { credentials });
151
+ const result = await generateOnce(cfg, options);
146
152
  if (result.contract === "built") log.success("Contract generated");
147
153
  else if (result.contract === "empty" && cfg.server) log.warn(`No Kizlo server found in ${cfg.server.entry}`);
148
154
  else if (result.introspection === "generated") log.success("WordPress introspection generated");
149
155
  } catch (error) {
150
156
  reportGenerationError("Failed to generate the Kizlo contract:", error);
151
157
  }
152
- const watcher = cfg.server ? await watch(cfg, cfg.server, credentials) : void 0;
153
- const wordpressRefresh = refreshWordPress(cfg, credentials, opts?.stack);
158
+ const watcher = cfg.server ? await watch(cfg, cfg.server, options) : void 0;
159
+ const wordpressRefresh = credentials ? refreshWordPress(cfg, options, opts?.stack) : void 0;
154
160
  let stopped = false;
155
161
  handedOver = true;
156
162
  return () => {
157
163
  if (stopped) return;
158
164
  stopped = true;
159
165
  release(lock);
160
- clearInterval(wordpressRefresh);
166
+ if (wordpressRefresh) clearInterval(wordpressRefresh);
161
167
  watcher?.close();
162
168
  };
163
169
  } finally {
@@ -0,0 +1,46 @@
1
+ import { log } from "../daemon/logger.js";
2
+ import { pickStackPort } from "../utils.js";
3
+ import { MCP_HOST, MCP_PATH, isAllowedOrigin, startMcpServer } from "./server.js";
4
+ import { createMcpState } from "./state.js";
5
+
6
+ //#region src/cli/mcp/index.ts
7
+ /**
8
+ * The port the server will bind, stepping off a collision unless the project pinned one.
9
+ *
10
+ * Resolved before the server starts so the dev summary can print the address alongside WordPress's.
11
+ * A pinned port that is taken stops the command the way `dev.port` and `test.port` do: the developer
12
+ * wrote that number, a harness config elsewhere names it, and quietly serving on a different one
13
+ * would leave the harness connecting to nothing.
14
+ */
15
+ async function resolveMcpPort(config) {
16
+ return pickStackPort(config.port, {
17
+ fixed: config.portExplicit,
18
+ host: MCP_HOST,
19
+ configKey: "mcp.port"
20
+ });
21
+ }
22
+ /**
23
+ * Start the MCP server for a session that has a watcher, or say why it is not running.
24
+ *
25
+ * Called after the watcher starts and never inside its restart cycle. Two cases leave it off, and
26
+ * neither is a failure of the session: another `kizlo dev` already holds the watcher lock and is
27
+ * serving MCP itself (the caller skips this entirely), or there is no WordPress connection to expose,
28
+ * where the contract watcher still has work to do and MCP has nothing to answer with.
29
+ */
30
+ async function startMcp(state, port) {
31
+ if (!state.credentials) {
32
+ log.info("No WordPress connection configured; the MCP server is off for this session.");
33
+ return;
34
+ }
35
+ try {
36
+ const handle = await startMcpServer(state, { port });
37
+ log.success(`MCP server on ${handle.url}`);
38
+ return handle;
39
+ } catch (error) {
40
+ log.error("Failed to start the MCP server:", error);
41
+ return;
42
+ }
43
+ }
44
+
45
+ //#endregion
46
+ export { resolveMcpPort, startMcp };
@@ -0,0 +1,42 @@
1
+ import { camel, endpointDefinition } from "../../wordpress/generate.js";
2
+
3
+ //#region src/cli/mcp/routes.ts
4
+ /**
5
+ * Every operation in the document, keyed by route name.
6
+ *
7
+ * Built per call rather than cached: the watcher replaces the document whenever WordPress moves, and
8
+ * a registry that outlived one would answer for routes that no longer exist while missing the ones
9
+ * that just appeared. Walking a contract of a few hundred routes is far cheaper than getting that wrong.
10
+ */
11
+ function routesOf(document) {
12
+ const routes = /* @__PURE__ */ new Map();
13
+ for (const [apiId, api] of Object.entries(document.apis)) {
14
+ const prefix = apiId.split(".").map(camel);
15
+ for (const [path, operations] of Object.entries(api.paths)) for (const [operationId, operation] of Object.entries(operations)) {
16
+ const name = [...prefix, camel(operationId)].join(".");
17
+ routes.set(name, {
18
+ name,
19
+ namespace: api.namespace,
20
+ method: operation.method,
21
+ path,
22
+ ...operation.summary !== void 0 ? { summary: operation.summary } : {},
23
+ ...operation.description !== void 0 ? { description: operation.description } : {},
24
+ ...operation.deprecated !== void 0 ? { deprecated: operation.deprecated } : {},
25
+ operation,
26
+ definition: endpointDefinition({
27
+ namespace: api.namespace,
28
+ path,
29
+ operation
30
+ })
31
+ });
32
+ }
33
+ }
34
+ return routes;
35
+ }
36
+ /** Route names in the order a listing shows them, so two calls against one document agree. */
37
+ function sortRoutes(routes) {
38
+ return [...routes].sort((left, right) => left.name.localeCompare(right.name));
39
+ }
40
+
41
+ //#endregion
42
+ export { routesOf, sortRoutes };