kizlo 0.18.0 → 0.19.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,27 +1,28 @@
1
- import { Promisify } from "@kizlo/shared";
1
+ import { JsonValue, 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. */
6
- id: number;
7
- /** The user's email, if known. */
8
- email?: string;
9
- /** The user's phone number, if known. */
10
- phone?: string;
11
- /** The user's first name. */
12
- firstName: string;
13
- /** The user's last name. */
14
- lastName: string;
5
+ /** The third-party auth id. This is the identity provider's own id, never a WordPress user id. */
6
+ id: string;
7
+ /** The user's email. The single join key that maps this session to a WordPress user. */
8
+ email: string;
9
+ /** The user's first name, if known. */
10
+ firstName?: string;
11
+ /** The user's last name, if known. */
12
+ lastName?: string;
13
+ /** Arbitrary claims the auth service carries alongside the identity. */
14
+ meta?: Record<string, JsonValue>;
15
15
  }
16
- type AuthGetUser = (request: Request | null) => Promisify<AuthUser | null>;
16
+ type AuthGetSession = (request: Request | null) => Promisify<AuthUser | null>;
17
17
  interface AuthAdapter {
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;
18
+ /** Resolve the authenticated session from the request, or `null` when there is no session. Receives `null` for server-side (non-HTTP) invocations. */
19
+ getSession: AuthGetSession;
20
20
  }
21
21
  /** Author a custom auth adapter, typed against the {@link AuthAdapter} contract. */
22
22
  declare function createAuthAdapter(adapter: AuthAdapter): AuthAdapter;
23
23
  declare function authMock(options?: {
24
- mockUserId?: number;
24
+ id?: string;
25
+ email?: string;
25
26
  }): AuthAdapter;
26
27
  //#endregion
27
- export { AuthAdapter, AuthGetUser, AuthUser, authMock, createAuthAdapter };
28
+ export { AuthAdapter, AuthGetSession, AuthUser, authMock, createAuthAdapter };
@@ -4,13 +4,12 @@ function createAuthAdapter(adapter) {
4
4
  return adapter;
5
5
  }
6
6
  function authMock(options) {
7
- return { getUser() {
7
+ return { getSession() {
8
8
  return {
9
- id: options?.mockUserId ?? 0,
9
+ id: options?.id ?? "auth-mock-user",
10
+ email: options?.email ?? "karan@gmail.com",
10
11
  firstName: "Karan",
11
- lastName: "Gill",
12
- email: "karan@gmail.con",
13
- phone: "12345678901"
12
+ lastName: "Gill"
14
13
  };
15
14
  } };
16
15
  }
@@ -1,5 +1,5 @@
1
1
  import { aliasWithSlash, resolveModuleImport, writeFileIfAbsent } from "../utils.js";
2
- import { CONTRACT_BARREL, WORDPRESS_STUB } from "../daemon/generate.js";
2
+ import { CONTRACT_BARREL, INTROSPECTION_STUB } from "../daemon/generate.js";
3
3
  import { resolvePatch } from "../presets/template.js";
4
4
  import { orCancel } from "./_setup.js";
5
5
  import { applyPatchToSource, patchChanged, renderPatchCode, resolvePatchTargetPath } from "../presets/patch.js";
@@ -8,16 +8,17 @@ import fs from "node:fs";
8
8
  import * as p from "@clack/prompts";
9
9
 
10
10
  //#region src/cli/commands/_wiring.ts
11
- /** The kizlo.config.ts a scaffolded project gets: the Kizlo directory, the import-alias preference, and —
12
- * when local WordPress is chosen — `dev.local` + `test.local` so `kizlo dev` and `kizlo test` boot the
13
- * fixed `.kizlo/local` install. The alias is always written — `""` for relative imports included — so it
14
- * records a made decision a later `kizlo init` reads back instead of prompting for it again. The alias
15
- * is written in its canonical `@/` form (never a bare `@`) so it reads like the imports it produces. */
11
+ /** The kizlo.config.ts a scaffolded project gets: the Kizlo directory, the import-alias preference, and,
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
14
+ * alias is always written (`""` for relative imports included) so it records a made decision a later
15
+ * `kizlo init` reads back instead of prompting for it again. The alias is written in its canonical `@/`
16
+ * form (never a bare `@`) so it reads like the imports it produces. */
16
17
  function kizloConfigTemplate(dir, alias, localWordPress = false) {
17
18
  return `import { defineConfig } from "kizlo/config"
18
19
 
19
20
  export default defineConfig({
20
- dir: "${dir}",${`\n\talias: "${aliasWithSlash(alias)}",`}${localWordPress ? `\n\tdev: { local: true },\n\ttest: { local: true },` : ""}
21
+ dir: "${dir}",${`\n\talias: "${aliasWithSlash(alias)}",`}${localWordPress ? `\n\tlocal: true,` : ""}
21
22
  })
22
23
  `;
23
24
  }
@@ -77,7 +78,7 @@ function writeGeneratedContract(cwd, serverDirRel) {
77
78
  const generatedDirRel = path.join(serverDirRel, "generated");
78
79
  writeFileIfAbsent(path.join(cwd, generatedDirRel, "contract.json"), "{}\n");
79
80
  writeFileIfAbsent(path.join(cwd, generatedDirRel, "index.ts"), CONTRACT_BARREL);
80
- writeFileIfAbsent(path.join(cwd, generatedDirRel, "wordpress.ts"), WORDPRESS_STUB);
81
+ writeFileIfAbsent(path.join(cwd, generatedDirRel, "introspection.ts"), INTROSPECTION_STUB);
81
82
  }
82
83
  /**
83
84
  * Apply every patch a template declares, merging Kizlo wiring into files the project already owns
@@ -1,6 +1,6 @@
1
1
  import { log } from "../daemon/logger.js";
2
- import { resolveConfig, resolveWordPressClientDir } from "../daemon/config.js";
3
- import { generateWordPressSource, reportGenerationError } from "../daemon/generate.js";
2
+ import { resolveConfig } from "../daemon/config.js";
3
+ import { generateIntrospectionSource, reportGenerationError } from "../daemon/generate.js";
4
4
  import { testWordPressCredentials } from "./_test-wordpress.js";
5
5
  import path from "node:path";
6
6
  import fs from "node:fs";
@@ -26,14 +26,10 @@ function formatGeneratedFileDiff(cwd, mismatch) {
26
26
  const file = path.relative(cwd, mismatch.file) || path.basename(mismatch.file);
27
27
  return createTwoFilesPatch(file, file, mismatch.actual, mismatch.expected, "committed", "generated", { context: 3 });
28
28
  }
29
- /** Every WordPress client the project configuration says it owns, with duplicates collapsed. */
30
- async function wordPressClientFiles(cwd, dir) {
31
- const files = /* @__PURE__ */ new Set();
32
- const workspaceDir = await resolveWordPressClientDir(cwd);
33
- if (workspaceDir) files.add(path.resolve(cwd, workspaceDir, "wordpress.ts"));
29
+ /** The generated introspection the project configuration owns, if any. */
30
+ async function introspectionFiles(cwd, dir) {
34
31
  const config = await resolveConfig(cwd, { dir });
35
- if (config) files.add(path.resolve(cwd, config.wordpressPath));
36
- return [...files];
32
+ return config ? [path.resolve(cwd, config.introspectionPath)] : [];
37
33
  }
38
34
  const check = defineCommand({
39
35
  meta: {
@@ -52,9 +48,9 @@ const check = defineCommand({
52
48
  },
53
49
  async run({ args }) {
54
50
  const cwd = process.cwd();
55
- const files = await wordPressClientFiles(cwd, args.dir);
51
+ const files = await introspectionFiles(cwd, args.dir);
56
52
  if (files.length === 0) {
57
- log.info("No generated WordPress client configured, nothing to check.");
53
+ log.info("No generated WordPress introspection configured, nothing to check.");
58
54
  return;
59
55
  }
60
56
  let credentials;
@@ -67,18 +63,18 @@ const check = defineCommand({
67
63
  }
68
64
  let expected;
69
65
  try {
70
- expected = await generateWordPressSource(cwd, {
66
+ expected = await generateIntrospectionSource(cwd, {
71
67
  credentials,
72
68
  strict: true
73
69
  });
74
70
  } catch (error) {
75
- reportGenerationError("Failed to check the generated WordPress client:", error);
71
+ reportGenerationError("Failed to check the generated WordPress introspection:", error);
76
72
  process.exitCode = 1;
77
73
  return;
78
74
  }
79
75
  const mismatches = findGeneratedFileMismatches(files, expected);
80
76
  if (mismatches.length === 0) {
81
- log.success(`${files.length === 1 ? "WordPress client is" : "WordPress clients are"} current`);
77
+ log.success("WordPress introspection is current");
82
78
  return;
83
79
  }
84
80
  for (const mismatch of mismatches) {
@@ -90,7 +86,7 @@ const check = defineCommand({
90
86
  args.test ? "--test" : "",
91
87
  args.dir ? `--dir ${args.dir}` : ""
92
88
  ].filter(Boolean).join(" ");
93
- log.info(`Run \`${command}\` and commit the updated client.`);
89
+ log.info(`Run \`${command}\` and commit the updated introspection.`);
94
90
  process.exitCode = 1;
95
91
  }
96
92
  });
@@ -25,12 +25,12 @@ async function resolve$1(cwd) {
25
25
  }
26
26
  /**
27
27
  * The lifecycle subcommands (`stop`/`reset`) only act on local WordPress. A project pointing at its
28
- * own WordPress (no `dev.local`) has nothing to manage, so print a plain note and exit cleanly. Returns
28
+ * own WordPress (no `local`) has nothing to manage, so print a plain note and exit cleanly. Returns
29
29
  * true when local WordPress is configured and the caller may proceed.
30
30
  */
31
31
  async function requireLocalWordPress(cwd) {
32
32
  if (await usesLocalWordPress(cwd)) return true;
33
- note("This project doesn't run local WordPress — set `dev.local: true` in kizlo.config.ts to add it.");
33
+ note("This project doesn't run local WordPress — set `local: true` in kizlo.config.ts to add it.");
34
34
  return false;
35
35
  }
36
36
  /** A dim, untagged one-liner (no consola timestamp) for the clean dev output. */
@@ -351,7 +351,7 @@ async function watchOnly(cwd) {
351
351
  async function bringUp() {
352
352
  const cwd = process.cwd();
353
353
  if (!await usesLocalWordPress(cwd)) {
354
- note("This project doesn't run local WordPress — running the contract watcher only. Set `dev.local` in kizlo.config.ts to add it.");
354
+ note("This project doesn't run local WordPress — running the contract watcher only. Set `local` in kizlo.config.ts to add it.");
355
355
  await watchOnly(cwd);
356
356
  return;
357
357
  }
@@ -1,6 +1,6 @@
1
1
  import { log } from "../daemon/logger.js";
2
- import { resolveConfig, resolveWordPressClientDir } from "../daemon/config.js";
3
- import { PartialContractError, generateOnce, generateWorkspaceClientOnce, reportGenerationError } from "../daemon/generate.js";
2
+ import { resolveConfig } from "../daemon/config.js";
3
+ import { PartialContractError, generateOnce, reportGenerationError } from "../daemon/generate.js";
4
4
  import { testWordPressCredentials } from "./_test-wordpress.js";
5
5
  import path from "node:path";
6
6
  import { defineCommand } from "citty";
@@ -12,7 +12,7 @@ import { defineCommand } from "citty";
12
12
  * mode did not do to the files on disk.
13
13
  */
14
14
  function fail(message, error) {
15
- if (error instanceof PartialContractError) log.error(`${error.message} The generated client on disk is unchanged.`);
15
+ if (error instanceof PartialContractError) log.error(`${error.message} The generated introspection on disk is unchanged.`);
16
16
  else reportGenerationError(message, error);
17
17
  process.exit(1);
18
18
  }
@@ -28,7 +28,7 @@ const generate = defineCommand({
28
28
  },
29
29
  strict: {
30
30
  type: "boolean",
31
- description: "Fail instead of generating a client WordPress had to exclude routes or types from"
31
+ description: "Fail instead of generating an introspection WordPress had to exclude routes or types from"
32
32
  },
33
33
  test: {
34
34
  type: "boolean",
@@ -50,29 +50,27 @@ const generate = defineCommand({
50
50
  credentials,
51
51
  strict
52
52
  };
53
- const wordpressClientDir = await resolveWordPressClientDir(cwd);
54
- if (wordpressClientDir) try {
55
- const result = await generateWorkspaceClientOnce(cwd, wordpressClientDir, options);
56
- const file = path.resolve(cwd, wordpressClientDir, "wordpress.ts");
57
- log.success(`WordPress client ${result === "generated" ? "written to" : "already current at"} ${file}`);
58
- } catch (error) {
59
- fail("Failed to generate the WordPress client:", error);
60
- }
61
53
  const cfg = await resolveConfig(cwd, { dir: args.dir });
62
54
  if (!cfg) {
63
- if (!wordpressClientDir) log.info("No Kizlo server directory configured, nothing to generate. Set `dir` in kizlo.config.ts or pass --dir.");
55
+ log.info("Nothing to generate. Set `dir` in kizlo.config.ts or pass --dir.");
64
56
  return;
65
57
  }
66
- let ok;
58
+ let result;
67
59
  try {
68
- ok = await generateOnce(cfg, options);
60
+ result = await generateOnce(cfg, options);
69
61
  } catch (error) {
70
62
  fail("Failed to generate the Kizlo contract:", error);
71
63
  }
72
- if (!ok) {
73
- log.error(`No Kizlo server found in ${cfg.serverEntry}`);
64
+ if (result.contract === "empty" && cfg.server) {
65
+ log.error(`No Kizlo server found in ${cfg.server.entry}`);
74
66
  process.exit(1);
75
67
  }
68
+ if (result.contract === "none") {
69
+ const file = path.resolve(cwd, cfg.introspectionPath);
70
+ log.success(`WordPress introspection ${result.introspection === "generated" ? "written to" : "already current at"} ${file}`);
71
+ return;
72
+ }
73
+ if (result.introspection === "generated") log.success("WordPress introspection generated");
76
74
  log.success("Contract generated");
77
75
  }
78
76
  });
@@ -114,9 +114,9 @@ const runArgs = {
114
114
  }
115
115
  };
116
116
  /**
117
- * Run the project's tests. When `test.local` is set, boot (+ seed) local WordPress first, run the
118
- * suite (JS then the plugin's PHPUnit) against it, then leave it up (or tear down). Without
119
- * `test.local`, there's no Docker WordPress to manage — just run the project's own test script.
117
+ * Run the project's tests. When the test stack is enabled (`local` with `test.enable !== false`), boot
118
+ * (+ seed) local WordPress first, run the suite (JS then the plugin's PHPUnit) against it, then leave it
119
+ * up (or tear down). Otherwise there's no Docker WordPress to manage — just run the project's own test script.
120
120
  */
121
121
  async function runSuite({ args }) {
122
122
  const cfg = await resolveTestConfig(process.cwd());
@@ -1,4 +1,4 @@
1
- import { DEFAULT_WORDPRESS_TAG, LOCAL_DIR_REL, WORDPRESS_META_REL } from "../wp/constants.js";
1
+ import { DEFAULT_WORDPRESS_TAG, INTROSPECTION_META_REL, LOCAL_DIR_REL } from "../wp/constants.js";
2
2
  import { credentialsPath, findConfigDir } from "../wp/utils.js";
3
3
  import { log } from "./logger.js";
4
4
  import { detectPackageManager } from "../utils.js";
@@ -11,39 +11,82 @@ import fs from "node:fs";
11
11
  //#region src/cli/daemon/config.ts
12
12
  const fixtureSchema = z.custom((value) => typeof value === "object" && value !== null && typeof value.name === "string", { message: "must be a fixture object with a string `name` (use defineFixture)" });
13
13
  /**
14
- * `dev.version` / `test.version`. Validated against Docker's tag grammar here rather than at boot,
15
- * so `"wordpress:7.1.0"`, which would resolve to `wordpress:wordpress:7.1.0`, is named as a config
16
- * mistake instead of surfacing as a pull failure seconds into starting a stack.
14
+ * `local.dev.version` / `local.test.version`. Validated against Docker's tag grammar here rather than
15
+ * at boot, so `"wordpress:7.1.0"`, which would resolve to `wordpress:wordpress:7.1.0`, is named as a
16
+ * config mistake instead of surfacing as a pull failure seconds into starting a stack.
17
17
  */
18
18
  const versionSchema = z.string().regex(WORDPRESS_TAG_PATTERN, "must be a WordPress image tag, like \"7.1.0\" or \"7.1.0-php8.3-apache\" (no \"wordpress:\" prefix)");
19
+ /**
20
+ * A key that moved in the config redesign. It accepts only `undefined`, so an absent key passes and a
21
+ * present one fails validation with a message naming where it went: a hard cutover with no silent
22
+ * fallback. {@link loadConfigFile} surfaces the message through `z.prettifyError`.
23
+ */
24
+ const removedKey = (message) => z.undefined({ error: message }).optional();
25
+ const dirSchema = z.union([z.string(), z.object({
26
+ server: z.string().optional(),
27
+ contract: z.string().optional(),
28
+ introspection: z.string().optional()
29
+ })]);
30
+ const devStackSchema = z.object({
31
+ enable: z.boolean().optional(),
32
+ port: z.number().int().positive().optional(),
33
+ version: versionSchema.optional(),
34
+ dbPort: z.number().int().positive().optional(),
35
+ fixtures: z.array(fixtureSchema).optional()
36
+ });
37
+ const testStackSchema = z.object({
38
+ enable: z.boolean().optional(),
39
+ inherit: z.boolean().optional(),
40
+ port: z.number().int().positive().optional(),
41
+ version: versionSchema.optional(),
42
+ fixtures: z.array(fixtureSchema).optional(),
43
+ packageManager: z.enum([
44
+ "npm",
45
+ "pnpm",
46
+ "yarn",
47
+ "bun"
48
+ ]).optional(),
49
+ command: z.string().optional()
50
+ });
51
+ const localSchema = z.union([z.boolean(), z.object({
52
+ enable: z.boolean().optional(),
53
+ name: z.string().optional(),
54
+ worktrees: z.boolean().optional(),
55
+ dev: devStackSchema.optional(),
56
+ test: testStackSchema.optional()
57
+ })]);
19
58
  /** Runtime shape of `kizlo.config.*` — mirrors {@link KizloGlobalConfig}. */
20
59
  const configSchema = z.object({
21
- dir: z.string().optional(),
60
+ dir: dirSchema.optional(),
22
61
  alias: z.string().optional(),
23
- name: z.string().optional(),
24
- worktrees: z.boolean().optional(),
25
- wordpressClientDir: z.string().optional(),
26
- dev: z.object({
27
- local: z.boolean().optional(),
28
- port: z.number().int().positive().optional(),
29
- version: versionSchema.optional(),
30
- dbPort: z.number().int().positive().optional(),
31
- fixtures: z.array(fixtureSchema).optional()
32
- }).optional(),
33
- test: z.object({
34
- local: z.boolean().optional(),
35
- port: z.number().int().positive().optional(),
36
- version: versionSchema.optional(),
37
- fixtures: z.array(fixtureSchema).optional(),
38
- packageManager: z.enum([
39
- "npm",
40
- "pnpm",
41
- "yarn",
42
- "bun"
43
- ]).optional(),
44
- command: z.string().optional()
45
- }).optional()
62
+ local: localSchema.optional(),
63
+ wordpressClientDir: removedKey("`wordpressClientDir` has been removed. Use `dir: { introspection }` instead."),
64
+ name: removedKey("`name` is no longer a config root key. Move it under `local.name`."),
65
+ worktrees: removedKey("`worktrees` is no longer a config root key. Move it under `local.worktrees`."),
66
+ dev: removedKey("`dev` is no longer a config root key. Move it under `local.dev`."),
67
+ test: removedKey("`test` is no longer a config root key. Move it under `local.test`.")
46
68
  });
69
+ /** Normalize `local` (absent, `true`, `false`, or the object form) into one shape the resolvers read. */
70
+ function resolveLocal(fileConfig) {
71
+ const local = fileConfig?.local;
72
+ if (local === true) return {
73
+ enabled: true,
74
+ dev: {},
75
+ test: {}
76
+ };
77
+ if (!local) return {
78
+ enabled: false,
79
+ dev: {},
80
+ test: {}
81
+ };
82
+ return {
83
+ enabled: local.enable !== false,
84
+ name: local.name,
85
+ worktrees: local.worktrees,
86
+ dev: local.dev ?? {},
87
+ test: local.test ?? {}
88
+ };
89
+ }
47
90
  const CONFIG_FILES = [
48
91
  "kizlo.config.ts",
49
92
  "kizlo.config.js",
@@ -52,6 +95,12 @@ const CONFIG_FILES = [
52
95
  const DEFAULT_DEV_PORT = 8080;
53
96
  const DEFAULT_DEV_DB_PORT = 3307;
54
97
  const DEFAULT_TEST_PORT = 8889;
98
+ /** The generated introspection artifact's filename, written under its resolved directory. */
99
+ const INTROSPECTION_FILE = "introspection.ts";
100
+ /** Strip a leading `./` and trailing slashes so a config path joins cleanly. */
101
+ function normalizeDir(value) {
102
+ return value.replace(/^\.\//, "").replace(/\/+$/, "");
103
+ }
55
104
  async function loadConfigFile(cwd) {
56
105
  const file = CONFIG_FILES.map((name) => path.join(cwd, name)).find((p) => fs.existsSync(p));
57
106
  if (!file) return void 0;
@@ -71,40 +120,56 @@ async function loadConfigFile(cwd) {
71
120
  return result.data;
72
121
  }
73
122
  /**
74
- * Resolve the Kizlo server layout from an explicit `dir` — the `--dir` flag or `dir` in
75
- * `kizlo.config.*`. Returns `undefined` when neither is set: there's no Kizlo server to
76
- * generate a contract from, so callers skip generation and (for `dev`) run local WordPress
77
- * alone. There's intentionally no default path — a missing `dir` means "no server", not
78
- * "look under `lib/kizlo`".
123
+ * Resolve what Kizlo generates and watches from `dir`: the `--dir` flag or `dir` in `kizlo.config.*`.
124
+ * A string is the home Kizlo owns the layout under (sources in `<dir>/server`, generated files under
125
+ * `<dir>/server/generated`); the object form sets `server` / `contract` / `introspection` independently.
126
+ *
127
+ * The introspection is always generated at its resolved path, so a project with no server still gets it.
128
+ * `server` is present only when `dir` resolves a server path (the string form always does, the object
129
+ * form only when `server` is set), and its absence means there is nothing to watch and no contract to
130
+ * build. Returns `undefined` only when nothing at all is configured (no `dir`, or an object naming
131
+ * neither a server nor an introspection path): there is then nothing to generate.
79
132
  */
80
133
  async function resolveConfig(cwd, flags) {
81
134
  const fileConfig = await loadConfigFile(cwd);
82
135
  const raw = flags?.dir ?? fileConfig?.dir;
83
- if (!raw) return void 0;
84
- const dir = raw.replace(/^\.\//, "").replace(/\/+$/, "");
85
- const serverDir = path.join(dir, "server");
86
- const generatedDir = path.join(serverDir, "generated");
136
+ if (raw === void 0) return void 0;
137
+ if (typeof raw === "string") {
138
+ const home = normalizeDir(raw);
139
+ const serverDir$1 = path.join(home, "server");
140
+ const contractDir$1 = path.join(serverDir$1, "generated");
141
+ return {
142
+ cwd,
143
+ server: {
144
+ dir: serverDir$1,
145
+ entry: path.join(serverDir$1, "index.ts"),
146
+ contractDir: contractDir$1,
147
+ contractPath: path.join(contractDir$1, "contract.json"),
148
+ barrelPath: path.join(contractDir$1, "index.ts")
149
+ },
150
+ introspectionPath: path.join(contractDir$1, INTROSPECTION_FILE),
151
+ introspectionMetaPath: INTROSPECTION_META_REL
152
+ };
153
+ }
154
+ const serverDir = raw.server ? normalizeDir(raw.server) : void 0;
155
+ const contractDir = serverDir ? raw.contract ? normalizeDir(raw.contract) : path.join(serverDir, "generated") : void 0;
156
+ const server = serverDir && contractDir ? {
157
+ dir: serverDir,
158
+ entry: path.join(serverDir, "index.ts"),
159
+ contractDir,
160
+ contractPath: path.join(contractDir, "contract.json"),
161
+ barrelPath: path.join(contractDir, "index.ts")
162
+ } : void 0;
163
+ const introspectionDir = raw.introspection ? normalizeDir(raw.introspection) : contractDir;
164
+ if (!introspectionDir) return void 0;
87
165
  return {
88
166
  cwd,
89
- dir,
90
- serverDir,
91
- serverEntry: path.join(serverDir, "index.ts"),
92
- generatedDir,
93
- contractPath: path.join(generatedDir, "contract.json"),
94
- barrelPath: path.join(generatedDir, "index.ts"),
95
- wordpressPath: path.join(generatedDir, "wordpress.ts"),
96
- wordpressMetaPath: WORDPRESS_META_REL
167
+ server,
168
+ introspectionPath: path.join(introspectionDir, INTROSPECTION_FILE),
169
+ introspectionMetaPath: INTROSPECTION_META_REL
97
170
  };
98
171
  }
99
172
  /**
100
- * The directory `wordpress.ts` is written to for a workspace with no Kizlo server, from `wordpressClientDir`
101
- * in `kizlo.config.*`. Resolved on its own rather than through {@link resolveConfig}, which describes a
102
- * server layout and returns nothing without `dir` — the two are independent.
103
- */
104
- async function resolveWordPressClientDir(cwd) {
105
- return (await loadConfigFile(cwd))?.wordpressClientDir;
106
- }
107
- /**
108
173
  * Sanitize an arbitrary package/dir name into a valid Docker compose project id:
109
174
  * lowercase, `@scope/pkg` → `scope-pkg`, dropping anything outside `[a-z0-9_-]`.
110
175
  */
@@ -164,61 +229,62 @@ function stackProject(baseName, kind) {
164
229
  return `kizlo-${baseName}-${kind}`;
165
230
  }
166
231
  /**
167
- * Resolve the `test` block from `kizlo.config.*` into concrete values for the
168
- * `test` command, applying defaults (port 8889, auto-detected package manager)
169
- * and anchoring the credentials artifact to the config directory.
232
+ * Resolve the test stack (`local.test`) into concrete values for the `test` command, applying defaults
233
+ * (port 8889, auto-detected package manager) and anchoring the credentials artifact to the config
234
+ * directory. `version` and `fixtures` fall back to the dev stack unless `test.inherit` is `false`;
235
+ * `port`, `packageManager`, and `command` are never inherited.
170
236
  */
171
237
  async function resolveTestConfig(cwd) {
172
238
  const configDir = findConfigDir(cwd);
173
- const fileConfig = await loadConfigFile(configDir);
174
- const test = fileConfig?.test ?? {};
239
+ const local = resolveLocal(await loadConfigFile(configDir));
240
+ const { dev, test } = local;
241
+ const inherit = test.inherit !== false;
175
242
  return {
176
243
  configDir,
177
- local: Boolean(test.local),
244
+ local: local.enabled && test.enable !== false,
178
245
  project: stackProject(resolveStackName(configDir, {
179
- name: fileConfig?.name,
180
- worktrees: fileConfig?.worktrees
246
+ name: local.name,
247
+ worktrees: local.worktrees
181
248
  }), "test"),
182
249
  command: test.command,
183
250
  port: test.port ?? DEFAULT_TEST_PORT,
184
- portExplicit: test.port !== void 0 && !fileConfig?.worktrees,
185
- wordpressTag: test.version ?? DEFAULT_WORDPRESS_TAG,
186
- fixtures: test.fixtures ?? [],
251
+ portExplicit: test.port !== void 0 && !local.worktrees,
252
+ wordpressTag: test.version ?? (inherit ? dev.version : void 0) ?? DEFAULT_WORDPRESS_TAG,
253
+ fixtures: test.fixtures ?? (inherit ? dev.fixtures : void 0) ?? [],
187
254
  credentialsPath: credentialsPath(cwd),
188
255
  packageManager: test.packageManager ?? await detectPackageManager(configDir) ?? "npm"
189
256
  };
190
257
  }
191
258
  /**
192
- * Whether this project runs local WordPress under `kizlo dev` — `dev.local` is `true` in
193
- * `kizlo.config.*`. When false, `kizlo dev` has nothing to boot and runs the contract watcher alone,
194
- * the path a project pointing at its own WordPress takes. The flag is written by `create`/`init` when
195
- * local WordPress is chosen, and lives next to the rest of the `dev` config, so it's committed and
196
- * survives `kizlo dev reset`.
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`.
197
263
  */
198
264
  async function usesLocalWordPress(cwd) {
199
- const fileConfig = await loadConfigFile(findConfigDir(cwd));
200
- return Boolean(fileConfig?.dev?.local);
265
+ const local = resolveLocal(await loadConfigFile(findConfigDir(cwd)));
266
+ return local.enabled && local.dev.enable !== false;
201
267
  }
202
268
  /**
203
- * Resolve the `dev` block from `kizlo.config.*` into concrete values for the `dev` command, applying
204
- * defaults (port 8080). The install folder is fixed at `.kizlo/local` — no longer a config choice — so
205
- * there's nothing required here. Callers gate on {@link usesLocalWordPress} first, since a project
206
- * without local WordPress runs the watcher alone rather than reaching here.
269
+ * Resolve the dev stack (`local.dev`) into concrete values for the `dev` command, applying defaults
270
+ * (port 8080). The install folder is fixed at `.kizlo/local` (no longer a config choice), so there's
271
+ * nothing required here. Callers gate on {@link usesLocalWordPress} first, since a project without
272
+ * local WordPress runs the watcher alone rather than reaching here.
207
273
  */
208
274
  async function resolveDevConfig(cwd) {
209
275
  const configDir = findConfigDir(cwd);
210
- const fileConfig = await loadConfigFile(configDir);
211
- const dev = fileConfig?.dev ?? {};
276
+ const local = resolveLocal(await loadConfigFile(configDir));
277
+ const { dev } = local;
212
278
  return {
213
279
  configDir,
214
280
  project: stackProject(resolveStackName(configDir, {
215
- name: fileConfig?.name,
216
- worktrees: fileConfig?.worktrees
281
+ name: local.name,
282
+ worktrees: local.worktrees
217
283
  }), "dev"),
218
284
  port: dev.port ?? DEFAULT_DEV_PORT,
219
- portExplicit: dev.port !== void 0 && !fileConfig?.worktrees,
285
+ portExplicit: dev.port !== void 0 && !local.worktrees,
220
286
  dbPort: dev.dbPort ?? DEFAULT_DEV_DB_PORT,
221
- dbPortExplicit: dev.dbPort !== void 0 && !fileConfig?.worktrees,
287
+ dbPortExplicit: dev.dbPort !== void 0 && !local.worktrees,
222
288
  wordpressTag: dev.version ?? DEFAULT_WORDPRESS_TAG,
223
289
  fixtures: dev.fixtures ?? [],
224
290
  wordpressPath: LOCAL_DIR_REL,
@@ -227,4 +293,4 @@ async function resolveDevConfig(cwd) {
227
293
  }
228
294
 
229
295
  //#endregion
230
- export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, resolveWordPressClientDir, stackProject, usesLocalWordPress };
296
+ export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, stackProject, usesLocalWordPress };