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.
- package/dist/cli/commands/_setup.js +5 -1
- package/dist/cli/commands/_wiring.js +2 -1
- package/dist/cli/commands/dev.js +63 -20
- package/dist/cli/commands/generate.js +21 -7
- package/dist/cli/daemon/config.js +62 -13
- package/dist/cli/daemon/generate.js +9 -2
- package/dist/cli/daemon/watch.js +22 -16
- package/dist/cli/mcp/index.js +46 -0
- package/dist/cli/mcp/routes.js +42 -0
- package/dist/cli/mcp/schema.js +104 -0
- package/dist/cli/mcp/server.js +157 -0
- package/dist/cli/mcp/state.js +7 -0
- package/dist/cli/mcp/tools.js +103 -0
- package/dist/config.d.ts +29 -10
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/integrations/astro/integration.js +2 -0
- package/dist/integrations/nextjs/integration.js +2 -0
- package/dist/integrations/node/integration.js +2 -0
- package/dist/integrations/tanstack-start/integration.js +2 -0
- package/dist/kizlo.d.ts +7 -1
- package/dist/kizlo.js +19 -4
- package/dist/package.js +1 -1
- package/dist/post/schema.d.ts +4 -4
- package/dist/procedures.d.ts +10 -10
- package/dist/seo/schema.d.ts +5 -5
- package/dist/shared/constants.js +3 -1
- package/dist/shared/integration.d.ts +9 -1
- package/dist/shared/integration.js +6 -3
- package/dist/wordpress/generate.js +6 -1
- package/package.json +2 -1
|
@@ -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
|
|
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. */
|
package/dist/cli/commands/dev.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
253
|
+
async function bootAndSync(cfg) {
|
|
240
254
|
const start = Date.now();
|
|
241
|
-
|
|
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
|
|
260
|
-
return
|
|
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
|
|
299
|
+
const { creds, start } = await bootAndSync(ready);
|
|
277
300
|
const stack = {
|
|
278
301
|
status: () => stackStatus(ready.project),
|
|
279
302
|
onStopped: () => shutdown()
|
|
280
303
|
};
|
|
281
|
-
|
|
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
|
-
|
|
289
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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
|
|
41
|
+
let options;
|
|
42
42
|
if (args.test) try {
|
|
43
|
-
|
|
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
|
-
|
|
50
|
-
credentials
|
|
51
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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
|
|
260
|
-
* on
|
|
261
|
-
* runs the contract watcher alone, the path a project pointing at its
|
|
262
|
-
* `create`/`init` when local WordPress is chosen, so it's committed and
|
|
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
|
-
|
|
266
|
-
|
|
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
|
|
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
|
|
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
|
|
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 });
|
package/dist/cli/daemon/watch.js
CHANGED
|
@@ -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 {
|
|
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,
|
|
17
|
+
async function regenerate(cfg, options) {
|
|
21
18
|
try {
|
|
22
|
-
const result = await generateOnce(cfg,
|
|
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,
|
|
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,
|
|
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,
|
|
110
|
-
const refresh = createWordPressRefresh(cfg,
|
|
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
|
-
|
|
143
|
-
const { credentials }
|
|
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,
|
|
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,
|
|
153
|
-
const wordpressRefresh = refreshWordPress(cfg,
|
|
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 };
|