kizlo 0.14.0 → 0.15.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.
Files changed (128) hide show
  1. package/dist/category/error.js +10 -4
  2. package/dist/category/index.js +9 -9
  3. package/dist/category/schema.js +10 -1
  4. package/dist/category/utils.js +3 -3
  5. package/dist/cli/commands/_setup.js +5 -3
  6. package/dist/cli/commands/_test-wordpress.js +27 -0
  7. package/dist/cli/commands/_wiring.js +2 -1
  8. package/dist/cli/commands/check.js +99 -0
  9. package/dist/cli/commands/dev.js +27 -18
  10. package/dist/cli/commands/generate.js +54 -11
  11. package/dist/cli/commands/test.js +9 -1
  12. package/dist/cli/daemon/config.js +73 -14
  13. package/dist/cli/daemon/generate.js +213 -6
  14. package/dist/cli/daemon/lock.js +31 -9
  15. package/dist/cli/daemon/watch.js +135 -27
  16. package/dist/cli/index.js +1 -0
  17. package/dist/cli/process-owner.js +96 -0
  18. package/dist/cli/wp/bootstrap.js +6 -2
  19. package/dist/cli/wp/compose/docker-compose.yml +11 -2
  20. package/dist/cli/wp/compose/override.js +20 -6
  21. package/dist/cli/wp/compose/override.test.ts +97 -40
  22. package/dist/cli/wp/compose/override.ts +21 -6
  23. package/dist/cli/wp/constants.d.ts +19 -1
  24. package/dist/cli/wp/constants.js +19 -1
  25. package/dist/cli/wp/dev.js +27 -8
  26. package/dist/cli/wp/docker.d.ts +29 -3
  27. package/dist/cli/wp/docker.js +38 -2
  28. package/dist/cli/wp/ports.js +3 -1
  29. package/dist/cli/wp/session.js +12 -30
  30. package/dist/cli/wp/settings.js +2 -2
  31. package/dist/cli/wp/setup.js +6 -4
  32. package/dist/cli/wp/stack.js +2 -0
  33. package/dist/cli/wp/types.d.ts +28 -3
  34. package/dist/cli/wp/utils.d.ts +19 -3
  35. package/dist/cli/wp/utils.js +23 -4
  36. package/dist/cli/wp/version.js +57 -0
  37. package/dist/cli/wp/watchdog.js +37 -15
  38. package/dist/comment/index.js +68 -56
  39. package/dist/comment/utils.js +16 -8
  40. package/dist/config.d.ts +45 -0
  41. package/dist/context.d.ts +17 -12
  42. package/dist/context.js +71 -22
  43. package/dist/email/service.d.ts +2 -2
  44. package/dist/email/service.js +1 -7
  45. package/dist/index.d.ts +9 -24
  46. package/dist/index.js +6 -18
  47. package/dist/integrations/nextjs/revalidate.js +1 -1
  48. package/dist/kizlo.d.ts +11 -4
  49. package/dist/kizlo.js +35 -17
  50. package/dist/menu/fixture.js +26 -13
  51. package/dist/menu/index.js +22 -8
  52. package/dist/menu/schema.js +15 -1
  53. package/dist/menu/utils.js +10 -11
  54. package/dist/package.js +1 -1
  55. package/dist/page/error.js +10 -4
  56. package/dist/page/index.js +28 -33
  57. package/dist/page/schema.js +21 -1
  58. package/dist/page/utils.js +9 -8
  59. package/dist/post/error.js +10 -4
  60. package/dist/post/fixture.js +33 -20
  61. package/dist/post/index.js +31 -36
  62. package/dist/post/schema.js +33 -1
  63. package/dist/post/utils.js +10 -9
  64. package/dist/post-type/status.js +19 -0
  65. package/dist/router.d.ts +27 -11
  66. package/dist/seo/index.js +43 -11
  67. package/dist/seo/types.d.ts +11 -79
  68. package/dist/seo/utils.js +4 -1
  69. package/dist/settings/service.d.ts +14 -16
  70. package/dist/settings/service.interface.d.ts +7 -5
  71. package/dist/settings/service.js +18 -17
  72. package/dist/shared/extension.d.ts +33 -2
  73. package/dist/shared/extension.js +31 -1
  74. package/dist/tag/error.js +10 -4
  75. package/dist/tag/index.js +9 -9
  76. package/dist/tag/schema.js +10 -1
  77. package/dist/tag/utils.js +3 -3
  78. package/dist/test.d.ts +5 -7
  79. package/dist/test.js +4 -6
  80. package/dist/wordpress/constants.js +7 -5
  81. package/dist/wordpress/endpoint.d.ts +23 -0
  82. package/dist/wordpress/endpoint.js +178 -0
  83. package/dist/wordpress/fetch-introspection.js +74 -0
  84. package/dist/wordpress/generate.js +413 -0
  85. package/dist/wordpress/index.d.ts +3 -18
  86. package/dist/wordpress/introspection.js +144 -0
  87. package/dist/wordpress/{service.d.ts → transport.d.ts} +18 -25
  88. package/dist/wordpress/transport.js +264 -0
  89. package/dist/wordpress/typecheck.js +122 -0
  90. package/dist/wordpress/types.d.ts +136 -17
  91. package/package.json +8 -3
  92. package/dist/post-type/service.js +0 -48
  93. package/dist/taxonomy/service.js +0 -48
  94. package/dist/test/client.d.ts +0 -16
  95. package/dist/test/client.js +0 -19
  96. package/dist/test/server.d.ts +0 -16
  97. package/dist/test/server.js +0 -38
  98. package/dist/wordpress/category/service.d.ts +0 -28
  99. package/dist/wordpress/category/service.js +0 -55
  100. package/dist/wordpress/category/types.d.ts +0 -120
  101. package/dist/wordpress/category/types.js +0 -15
  102. package/dist/wordpress/comment/service.d.ts +0 -28
  103. package/dist/wordpress/comment/service.js +0 -55
  104. package/dist/wordpress/comment/types.d.ts +0 -206
  105. package/dist/wordpress/constants.d.ts +0 -14
  106. package/dist/wordpress/menu/items/service.d.ts +0 -28
  107. package/dist/wordpress/menu/items/service.js +0 -55
  108. package/dist/wordpress/menu/items/types.d.ts +0 -217
  109. package/dist/wordpress/menu/items/types.js +0 -22
  110. package/dist/wordpress/menu/service.d.ts +0 -30
  111. package/dist/wordpress/menu/service.js +0 -58
  112. package/dist/wordpress/menu/types.d.ts +0 -115
  113. package/dist/wordpress/page/service.d.ts +0 -28
  114. package/dist/wordpress/page/service.js +0 -55
  115. package/dist/wordpress/page/types.d.ts +0 -270
  116. package/dist/wordpress/page/types.js +0 -27
  117. package/dist/wordpress/post/service.d.ts +0 -28
  118. package/dist/wordpress/post/service.js +0 -55
  119. package/dist/wordpress/post/types.d.ts +0 -294
  120. package/dist/wordpress/post/types.js +0 -41
  121. package/dist/wordpress/service.js +0 -200
  122. package/dist/wordpress/tag/service.d.ts +0 -28
  123. package/dist/wordpress/tag/service.js +0 -55
  124. package/dist/wordpress/tag/types.d.ts +0 -113
  125. package/dist/wordpress/tag/types.js +0 -15
  126. package/dist/wordpress/user/service.d.ts +0 -28
  127. package/dist/wordpress/user/service.js +0 -55
  128. package/dist/wordpress/user/types.d.ts +0 -221
@@ -1,7 +1,8 @@
1
- import { LOCAL_DIR_REL } from "../wp/constants.js";
1
+ import { DEFAULT_WORDPRESS_TAG, LOCAL_DIR_REL, WORDPRESS_META_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";
5
+ import { WORDPRESS_TAG_PATTERN } from "../wp/version.js";
5
6
  import { importIgnoringVirtualModules } from "./jiti.js";
6
7
  import z from "zod/v4";
7
8
  import path from "node:path";
@@ -9,20 +10,30 @@ import fs from "node:fs";
9
10
 
10
11
  //#region src/cli/daemon/config.ts
11
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
+ /**
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.
17
+ */
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)");
12
19
  /** Runtime shape of `kizlo.config.*` — mirrors {@link KizloGlobalConfig}. */
13
20
  const configSchema = z.object({
14
21
  dir: z.string().optional(),
15
22
  alias: z.string().optional(),
16
23
  name: z.string().optional(),
24
+ worktrees: z.boolean().optional(),
25
+ wordpressClientDir: z.string().optional(),
17
26
  dev: z.object({
18
27
  local: z.boolean().optional(),
19
28
  port: z.number().int().positive().optional(),
29
+ version: versionSchema.optional(),
20
30
  dbPort: z.number().int().positive().optional(),
21
31
  fixtures: z.array(fixtureSchema).optional()
22
32
  }).optional(),
23
33
  test: z.object({
24
34
  local: z.boolean().optional(),
25
35
  port: z.number().int().positive().optional(),
36
+ version: versionSchema.optional(),
26
37
  fixtures: z.array(fixtureSchema).optional(),
27
38
  packageManager: z.enum([
28
39
  "npm",
@@ -80,29 +91,69 @@ async function resolveConfig(cwd, flags) {
80
91
  serverEntry: path.join(serverDir, "index.ts"),
81
92
  generatedDir,
82
93
  contractPath: path.join(generatedDir, "contract.json"),
83
- barrelPath: path.join(generatedDir, "index.ts")
94
+ barrelPath: path.join(generatedDir, "index.ts"),
95
+ wordpressPath: path.join(generatedDir, "wordpress.ts"),
96
+ wordpressMetaPath: WORDPRESS_META_REL
84
97
  };
85
98
  }
86
99
  /**
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
+ /**
87
108
  * Sanitize an arbitrary package/dir name into a valid Docker compose project id:
88
109
  * lowercase, `@scope/pkg` → `scope-pkg`, dropping anything outside `[a-z0-9_-]`.
89
110
  */
90
111
  function sanitizeProjectName(raw) {
91
112
  return raw.toLowerCase().replace(/^@/, "").replace(/\//g, "-").replace(/[^a-z0-9_-]/g, "").replace(/^[-_]+|[-_]+$/g, "") || "kizlo";
92
113
  }
114
+ const HEAD_REF_PREFIX = "ref: refs/heads/";
115
+ /**
116
+ * The branch checked out at `dir`, read out of git's own files rather than by running git, so this
117
+ * stays synchronous and works where git isn't on PATH. `.git` is a directory in a main checkout and
118
+ * a file holding `gitdir: <path>` in a linked worktree, which is what makes each worktree report its
119
+ * own branch. Returns undefined when there is no branch to name: no repository, an unreadable one, or
120
+ * a detached `HEAD`, which holds a commit id instead of a ref.
121
+ */
122
+ function currentBranch(dir) {
123
+ try {
124
+ const dotGit = path.join(dir, ".git");
125
+ let gitDir = dotGit;
126
+ if (fs.statSync(dotGit).isFile()) {
127
+ const pointer = fs.readFileSync(dotGit, "utf8").match(/^gitdir:\s*(.+)$/m);
128
+ if (!pointer?.[1]) return void 0;
129
+ gitDir = path.resolve(dir, pointer[1].trim());
130
+ }
131
+ const head = fs.readFileSync(path.join(gitDir, "HEAD"), "utf8").trim();
132
+ return head.startsWith(HEAD_REF_PREFIX) ? head.slice(16) : void 0;
133
+ } catch {
134
+ return;
135
+ }
136
+ }
93
137
  /**
94
138
  * Base name for the local stacks: the config `name` if set, else the `package.json`
95
139
  * `name` at `configDir`, else the config dir basename — sanitized to a Docker id.
140
+ *
141
+ * With `worktrees` on, the checked-out branch is appended rather than replacing that name, so
142
+ * sibling checkouts of one project separate while two unrelated projects sitting on the same branch
143
+ * stay apart. Replacing it would collide those two, which is this option's own bug one level up.
96
144
  */
97
- function resolveStackName(configDir, configName) {
98
- if (configName) return sanitizeProjectName(configName);
99
- let pkgName;
145
+ function resolveStackName(configDir, { name, worktrees } = {}) {
146
+ const base = name ? sanitizeProjectName(name) : sanitizeProjectName(packageName(configDir) ?? path.basename(configDir));
147
+ if (!worktrees) return base;
148
+ const branch = currentBranch(configDir);
149
+ return branch ? `${base}-${sanitizeProjectName(branch)}` : base;
150
+ }
151
+ function packageName(configDir) {
100
152
  try {
101
- pkgName = JSON.parse(fs.readFileSync(path.join(configDir, "package.json"), "utf8")).name;
153
+ return JSON.parse(fs.readFileSync(path.join(configDir, "package.json"), "utf8")).name;
102
154
  } catch {
103
- pkgName = void 0;
155
+ return;
104
156
  }
105
- return sanitizeProjectName(pkgName ?? path.basename(configDir));
106
157
  }
107
158
  /**
108
159
  * Compose project id for a stack: `kizlo-<name>-<kind>`. The leading `kizlo-` is what
@@ -124,10 +175,14 @@ async function resolveTestConfig(cwd) {
124
175
  return {
125
176
  configDir,
126
177
  local: Boolean(test.local),
127
- project: stackProject(resolveStackName(configDir, fileConfig?.name), "test"),
178
+ project: stackProject(resolveStackName(configDir, {
179
+ name: fileConfig?.name,
180
+ worktrees: fileConfig?.worktrees
181
+ }), "test"),
128
182
  command: test.command,
129
183
  port: test.port ?? DEFAULT_TEST_PORT,
130
- portExplicit: test.port !== void 0,
184
+ portExplicit: test.port !== void 0 && !fileConfig?.worktrees,
185
+ wordpressTag: test.version ?? DEFAULT_WORDPRESS_TAG,
131
186
  fixtures: test.fixtures ?? [],
132
187
  credentialsPath: credentialsPath(cwd),
133
188
  packageManager: test.packageManager ?? await detectPackageManager(configDir) ?? "npm"
@@ -156,11 +211,15 @@ async function resolveDevConfig(cwd) {
156
211
  const dev = fileConfig?.dev ?? {};
157
212
  return {
158
213
  configDir,
159
- project: stackProject(resolveStackName(configDir, fileConfig?.name), "dev"),
214
+ project: stackProject(resolveStackName(configDir, {
215
+ name: fileConfig?.name,
216
+ worktrees: fileConfig?.worktrees
217
+ }), "dev"),
160
218
  port: dev.port ?? DEFAULT_DEV_PORT,
161
- portExplicit: dev.port !== void 0,
219
+ portExplicit: dev.port !== void 0 && !fileConfig?.worktrees,
162
220
  dbPort: dev.dbPort ?? DEFAULT_DEV_DB_PORT,
163
- dbPortExplicit: dev.dbPort !== void 0,
221
+ dbPortExplicit: dev.dbPort !== void 0 && !fileConfig?.worktrees,
222
+ wordpressTag: dev.version ?? DEFAULT_WORDPRESS_TAG,
164
223
  fixtures: dev.fixtures ?? [],
165
224
  wordpressPath: LOCAL_DIR_REL,
166
225
  wordpressDir: path.resolve(configDir, LOCAL_DIR_REL)
@@ -168,4 +227,4 @@ async function resolveDevConfig(cwd) {
168
227
  }
169
228
 
170
229
  //#endregion
171
- export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, stackProject, usesLocalWordPress };
230
+ export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, resolveWordPressClientDir, stackProject, usesLocalWordPress };
@@ -1,39 +1,246 @@
1
1
  import { generateContract } from "../../shared/contract.js";
2
+ import { resolveWordPressConnection } from "../../kizlo.js";
3
+ import { WORDPRESS_CLIENT_META_REL } from "../wp/constants.js";
4
+ import { log } from "./logger.js";
2
5
  import { loadEnvFiles } from "../utils.js";
3
6
  import { importIgnoringVirtualModules } from "./jiti.js";
7
+ import { IntrospectionVersionError } from "../../wordpress/introspection.js";
8
+ import { fetchIntrospection } from "../../wordpress/fetch-introspection.js";
9
+ import { generateWordPressModule } from "../../wordpress/generate.js";
10
+ import { GeneratedClientTypeError, assertGeneratedClientCompiles } from "../../wordpress/typecheck.js";
11
+ import { isPluginVersionSupported, pluginUpdateMessage } from "@kizlo/shared";
4
12
  import path from "node:path";
5
13
  import fs from "node:fs";
6
14
 
7
15
  //#region src/cli/daemon/generate.ts
8
- /** The generated contract barrel — also written as a stub by `kizlo init`. */
16
+ /**
17
+ * The generated barrel — also written as a stub by `kizlo init`. It re-exports `endpoints` rather
18
+ * than pre-wrapping it: a server entry passes `wordpress: { endpoints }` itself, so the rest of the
19
+ * WordPress options stay visible at the call site instead of hiding inside a generated object.
20
+ */
9
21
  const CONTRACT_BARREL = [
10
22
  `import type { router } from ".."`,
11
23
  `import contractJson from "./contract.json"`,
12
24
  ``,
13
25
  `export const contract = contractJson as unknown as typeof router`,
26
+ `export { endpoints, type WordPressClient } from "./wordpress"`,
27
+ ``
28
+ ].join("\n");
29
+ /**
30
+ * `wordpress.ts` before the first generation. WordPress is reachable but untyped until then, so a
31
+ * freshly scaffolded project compiles — including kizlo's own procedures — before it has ever
32
+ * connected. `kizlo dev` / `kizlo generate` replaces this with the real client. `any` rather than an
33
+ * empty tree on purpose: every endpoint a procedure names has to resolve to something until the
34
+ * real shapes arrive, which `WP_Client` preserves by passing `any` through.
35
+ */
36
+ const WORDPRESS_STUB = [
37
+ `import type { WP_Client } from "kizlo"`,
38
+ ``,
39
+ `export const endpoints = {} as any`,
40
+ ``,
41
+ `export type WordPressClient = WP_Client<typeof endpoints>`,
42
+ ``,
43
+ `declare module "kizlo" {`,
44
+ `\tinterface WordPressClientRegistry {`,
45
+ `\t\tendpoints: typeof endpoints`,
46
+ `\t}`,
47
+ `}`,
14
48
  ``
15
49
  ].join("\n");
16
50
  /** Write atomically: a reader never observes a half-written file. */
51
+ let atomicWriteSequence = 0;
17
52
  function atomicWrite(file, data) {
18
- const tmp = `${file}.${process.pid}.tmp`;
19
- fs.writeFileSync(tmp, data);
20
- fs.renameSync(tmp, file);
53
+ const tmp = `${file}.${process.pid}.${atomicWriteSequence++}.tmp`;
54
+ try {
55
+ fs.writeFileSync(tmp, data);
56
+ fs.renameSync(tmp, file);
57
+ } finally {
58
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
59
+ }
60
+ }
61
+ /** Enough of a generated file to hold its header: `// Generated by Kizlo` then `// Introspection <hash>`. */
62
+ const INTROSPECTION_STAMP_BYTES = 256;
63
+ /** The introspection hash a generated file was written from, read without loading the whole file. */
64
+ function introspectionStamp(file) {
65
+ let handle;
66
+ try {
67
+ handle = fs.openSync(file, "r");
68
+ const buffer = Buffer.alloc(INTROSPECTION_STAMP_BYTES);
69
+ const bytes = fs.readSync(handle, buffer, 0, buffer.length, 0);
70
+ return /^\/\/ Introspection (\S+)$/m.exec(buffer.toString("utf8", 0, bytes))?.[1];
71
+ } catch {
72
+ return;
73
+ } finally {
74
+ if (handle !== void 0) fs.closeSync(handle);
75
+ }
76
+ }
77
+ /**
78
+ * The ETag to revalidate with, or undefined to fetch afresh. An ETag is only worth sending when the
79
+ * file it describes is still on disk and was written from the same introspection document it
80
+ * names: a stub, a hand-edit, or output from an older generator all have to be replaced, and
81
+ * answering 304 to any of them would leave the project compiling against something WordPress never served.
82
+ */
83
+ function readWordPressMeta(metaPath, generatedFile) {
84
+ try {
85
+ const value = JSON.parse(fs.readFileSync(metaPath, "utf8"));
86
+ if (typeof value.etag !== "string" || typeof value.hash !== "string") return void 0;
87
+ if (introspectionStamp(generatedFile) !== value.hash) return void 0;
88
+ return value;
89
+ } catch {
90
+ return;
91
+ }
92
+ }
93
+ /** The fetch cache a generated file revalidates against, keyed to the document it was written from. */
94
+ function wordPressMeta(etag, hash) {
95
+ return `${JSON.stringify({
96
+ etag,
97
+ hash
98
+ }, null, " ")}\n`;
99
+ }
100
+ /** Where a diagnostic landed: `schema_id > api_id > path > operation > pointer > keyword`, as sent. */
101
+ function diagnosticLocation(diagnostic) {
102
+ return Object.values(diagnostic.data).filter(Boolean).join(" > ");
103
+ }
104
+ function formatDiagnostic(diagnostic) {
105
+ const location = diagnosticLocation(diagnostic);
106
+ return `${diagnostic.type}: ${location ? `${location}: ` : ""}${diagnostic.message}`;
107
+ }
108
+ /**
109
+ * WordPress publishes everything that survived validation and names what it had to drop, so what a
110
+ * partial contract is worth is the generator's call rather than the transport's. Taking it is the
111
+ * default: a third-party plugin's broken declaration then costs its own routes and nothing else,
112
+ * where refusing the document costs the project every route that still works, for a mistake in code
113
+ * it does not own. Strict generation makes the opposite trade for CI, where a client silently short
114
+ * of routes typechecks clean and ships the gap.
115
+ */
116
+ var PartialContractError = class extends Error {
117
+ excluded;
118
+ constructor(excluded) {
119
+ super(`WordPress excluded ${excluded} ${excluded === 1 ? "contribution" : "contributions"} from the contract.`);
120
+ this.name = "PartialContractError";
121
+ this.excluded = excluded;
122
+ }
123
+ };
124
+ /**
125
+ * Report a failed generation under `message`. A version mismatch and a refused partial contract are
126
+ * diagnoses rather than faults: the message is the whole report, and the stack consola would print
127
+ * beneath it buries the one line the user has to act on. Anything else is a fault and keeps its stack.
128
+ */
129
+ function reportGenerationError(message, error) {
130
+ if (error instanceof IntrospectionVersionError || error instanceof PartialContractError) log.error(error.message);
131
+ else if (error instanceof GeneratedClientTypeError) log.error(error.message);
132
+ else log.error(message, error);
133
+ }
134
+ /**
135
+ * Say what the document says about itself, then apply the policy. Warnings mean a declaration lost a
136
+ * constraint and still types correctly, so they never gate anything; errors mean a contribution was
137
+ * dropped and its routes and types are absent from what follows.
138
+ */
139
+ function reportDiagnostics(document, strict) {
140
+ for (const diagnostic of document.diagnostics) if (diagnostic.type === "warning") log.warn(formatDiagnostic(diagnostic));
141
+ else log.error(formatDiagnostic(diagnostic));
142
+ const excluded = new Set(document.diagnostics.filter((diagnostic) => diagnostic.type === "error").map(diagnosticLocation));
143
+ if (!excluded.size) return;
144
+ if (strict) throw new PartialContractError(excluded.size);
145
+ log.warn(`Generating without the ${excluded.size} excluded ${excluded.size === 1 ? "contribution" : "contributions"} above. Their routes and types are missing from the client; \`kizlo generate --strict\` refuses the contract instead.`);
146
+ }
147
+ /**
148
+ * Plugin versions already named as outdated. `kizlo dev` refetches every few seconds, and the answer
149
+ * cannot change until the plugin does, so the warning is worth one line per version rather than one
150
+ * per poll. An updated plugin that is still too old is a version this has not seen, and says so again.
151
+ */
152
+ const reportedPluginVersions = /* @__PURE__ */ new Set();
153
+ /**
154
+ * Name a plugin too old for this package at generation, where it is still actionable, rather than
155
+ * leaving it to the runtime's first request. A warning either way, strict included: strict decides
156
+ * what to do about routes WordPress excluded, and an outdated plugin excludes nothing by itself.
157
+ */
158
+ function reportPluginVersion(installed) {
159
+ if (isPluginVersionSupported(installed)) return;
160
+ const key = installed ?? "";
161
+ if (reportedPluginVersions.has(key)) return;
162
+ reportedPluginVersions.add(key);
163
+ log.warn(pluginUpdateMessage(installed));
164
+ }
165
+ /** Fetch `/introspect`, reporting its diagnostics in the CLI's voice. Returns undefined on a 304. */
166
+ async function fetchDocument(cwd, options, etag) {
167
+ loadEnvFiles(cwd);
168
+ const result = await fetchIntrospection(options.credentials ?? resolveWordPressConnection(void 0).credentials, {
169
+ etag: options.strict ? void 0 : etag,
170
+ fetch: options.fetch
171
+ });
172
+ reportPluginVersion(result.pluginVersion);
173
+ if (result.status === "not-modified") return void 0;
174
+ if (!result.document) throw new Error("WordPress introspection returned no document.");
175
+ reportDiagnostics(result.document, options.strict === true);
176
+ return result;
177
+ }
178
+ /**
179
+ * Produce the WordPress client without writing it or its ETag cache. Health checks use this to ask
180
+ * the same generator as `kizlo generate` what should be on disk, while leaving a contributor's
181
+ * checkout untouched. Callers choose strictness just as generation does.
182
+ */
183
+ async function generateWordPressSource(cwd, options = {}) {
184
+ const result = await fetchDocument(cwd, options);
185
+ if (!result?.document) throw new Error("WordPress introspection returned no document.");
186
+ const generated = generateWordPressModule(result.document);
187
+ await assertGeneratedClientCompiles(generated);
188
+ return generated.source;
189
+ }
190
+ async function generateWordPressOnce(cfg, options = {}) {
191
+ const etag = readWordPressMeta(path.resolve(cfg.cwd, cfg.wordpressMetaPath), path.resolve(cfg.cwd, cfg.wordpressPath))?.etag;
192
+ const result = await fetchDocument(cfg.cwd, options, etag);
193
+ if (!result?.document) return "unchanged";
194
+ const generated = generateWordPressModule(result.document);
195
+ await assertGeneratedClientCompiles(generated);
196
+ writeGeneratedWordPress(cfg, generated.source, wordPressMeta(result.etag, result.document.hash));
197
+ return "generated";
198
+ }
199
+ /**
200
+ * Write `<dir>/wordpress.ts` once for a whole workspace. Every package that ships procedures compiles
201
+ * against the same file, so there is one description of WordPress in the repo rather than one per
202
+ * package drifting apart. One shape too: a package that only wants the types today still gets the
203
+ * class, so reaching for the runtime later needs no regeneration.
204
+ */
205
+ async function generateWorkspaceClientOnce(cwd, dir, options = {}) {
206
+ const file = path.resolve(cwd, dir, "wordpress.ts");
207
+ const metaPath = path.resolve(cwd, WORDPRESS_CLIENT_META_REL);
208
+ const result = await fetchDocument(cwd, options, readWordPressMeta(metaPath, file)?.etag);
209
+ if (!result?.document) return "unchanged";
210
+ const generated = generateWordPressModule(result.document);
211
+ await assertGeneratedClientCompiles(generated);
212
+ const contents = generated.source;
213
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
214
+ atomicWrite(metaPath, wordPressMeta(result.etag, result.document.hash));
215
+ if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === contents) return "unchanged";
216
+ fs.mkdirSync(path.dirname(file), { recursive: true });
217
+ atomicWrite(file, contents);
218
+ return "generated";
219
+ }
220
+ function writeGeneratedWordPress(cfg, client, meta) {
221
+ const metaPath = path.resolve(cfg.cwd, cfg.wordpressMetaPath);
222
+ fs.mkdirSync(path.resolve(cfg.cwd, cfg.generatedDir), { recursive: true });
223
+ fs.mkdirSync(path.dirname(metaPath), { recursive: true });
224
+ atomicWrite(path.resolve(cfg.cwd, cfg.wordpressPath), client);
225
+ atomicWrite(metaPath, meta);
21
226
  }
22
227
  /**
23
228
  * Loads the Kizlo server, builds its contract, and writes `contract.json` and
24
229
  * the generated barrel. Returns false when the entry has no Kizlo server.
25
230
  */
26
- async function generateOnce(cfg) {
231
+ async function generateOnce(cfg, options = {}) {
27
232
  loadEnvFiles(cfg.cwd);
28
233
  const entry = path.resolve(cfg.cwd, cfg.serverEntry);
29
234
  const { router } = await importIgnoringVirtualModules(cfg.cwd, entry);
30
235
  if (!router) return false;
31
236
  const contract = JSON.stringify(await generateContract(router));
237
+ const wordpress = await generateWordPressOnce(cfg, options);
32
238
  fs.mkdirSync(path.resolve(cfg.cwd, cfg.generatedDir), { recursive: true });
33
239
  atomicWrite(path.resolve(cfg.cwd, cfg.contractPath), contract);
34
240
  atomicWrite(path.resolve(cfg.cwd, cfg.barrelPath), CONTRACT_BARREL);
241
+ if (wordpress === "generated") log.success("WordPress client generated");
35
242
  return true;
36
243
  }
37
244
 
38
245
  //#endregion
39
- export { CONTRACT_BARREL, generateOnce };
246
+ export { CONTRACT_BARREL, PartialContractError, WORDPRESS_STUB, generateOnce, generateWordPressOnce, generateWordPressSource, generateWorkspaceClientOnce, reportGenerationError };
@@ -1,3 +1,4 @@
1
+ import { currentProcessOwner, ownerAlive } from "../process-owner.js";
1
2
  import path from "node:path";
2
3
  import fs from "node:fs";
3
4
 
@@ -5,21 +6,42 @@ import fs from "node:fs";
5
6
  function lockPath(cwd) {
6
7
  return path.join(cwd, "node_modules/.cache/kizlo/dev.lock");
7
8
  }
8
- /** True when a live daemon already owns the lock. Stale locks are ignored. */
9
- function isLocked(file) {
9
+ /** True when a live daemon still owns the process identity recorded by the lock. */
10
+ async function isLocked(file) {
10
11
  if (!fs.existsSync(file)) return false;
11
- const pid = Number(fs.readFileSync(file, "utf8").trim());
12
- if (!pid) return false;
12
+ const contents = fs.readFileSync(file, "utf8").trim();
13
+ let owner;
13
14
  try {
14
- process.kill(pid, 0);
15
- return true;
15
+ owner = contents.startsWith("{") ? JSON.parse(contents) : { pid: Number(contents) };
16
16
  } catch {
17
17
  return false;
18
18
  }
19
+ return ownerAlive(owner);
20
+ }
21
+ /** Claim the file for this process, or report that it was already there. */
22
+ function claim(file, owner) {
23
+ try {
24
+ fs.writeFileSync(file, JSON.stringify(owner), { flag: "wx" });
25
+ return true;
26
+ } catch (error) {
27
+ if (error.code !== "EEXIST") throw error;
28
+ return false;
29
+ }
19
30
  }
20
- function acquire(file) {
31
+ /**
32
+ * Take the lock for this process, answering false when a live daemon already holds it. The file is
33
+ * created with `wx`, so two `kizlo dev` starting together cannot both come away believing they won.
34
+ * A lock whose owner has died or whose PID belongs to another process is removed and taken over.
35
+ * Losing the second claim means another process reached the same stale lock first, so this one
36
+ * stands down rather than trying again.
37
+ */
38
+ async function acquire(file) {
21
39
  fs.mkdirSync(path.dirname(file), { recursive: true });
22
- fs.writeFileSync(file, String(process.pid));
40
+ const owner = await currentProcessOwner();
41
+ if (claim(file, owner)) return true;
42
+ if (await isLocked(file)) return false;
43
+ fs.rmSync(file, { force: true });
44
+ return claim(file, owner);
23
45
  }
24
46
  function release(file) {
25
47
  try {
@@ -28,4 +50,4 @@ function release(file) {
28
50
  }
29
51
 
30
52
  //#endregion
31
- export { acquire, isLocked, lockPath, release };
53
+ export { acquire, lockPath, release };
@@ -1,7 +1,10 @@
1
+ import { resolveWordPressConnection } from "../../kizlo.js";
1
2
  import { log } from "./logger.js";
2
- import { resolveConfig } from "./config.js";
3
- import { generateOnce } from "./generate.js";
4
- import { acquire, isLocked, lockPath, release } from "./lock.js";
3
+ import { loadEnvFiles } from "../utils.js";
4
+ import { resolveConfig, resolveWordPressClientDir } from "./config.js";
5
+ import { IntrospectionFetchError } from "../../wordpress/fetch-introspection.js";
6
+ import { generateOnce, generateWordPressOnce, generateWorkspaceClientOnce, reportGenerationError } from "./generate.js";
7
+ import { acquire, lockPath, release } from "./lock.js";
5
8
  import path from "node:path";
6
9
  import { FSWatcher } from "chokidar";
7
10
 
@@ -13,27 +16,117 @@ function debounce(fn, delay) {
13
16
  timer = setTimeout(() => void fn(...args), delay);
14
17
  });
15
18
  }
16
- async function regenerate(cfg) {
19
+ async function regenerate(cfg, credentials) {
17
20
  try {
18
- if (await generateOnce(cfg)) log.success("Contract updated");
21
+ if (await generateOnce(cfg, { credentials })) log.success("Contract updated");
19
22
  else log.warn(`No Kizlo server found in ${cfg.serverEntry}`);
20
23
  } catch (error) {
21
- log.error("Failed to update the Kizlo contract:", error);
24
+ reportGenerationError("Failed to update the Kizlo contract:", error);
22
25
  }
23
26
  }
24
27
  /** Watches the server directory and regenerates the contract on change. */
25
- async function watch(cfg) {
28
+ async function watch(cfg, credentials) {
26
29
  const watcher = new FSWatcher({
27
30
  persistent: true,
28
31
  ignoreInitial: true,
29
32
  ignored: path.resolve(cfg.cwd, cfg.generatedDir)
30
33
  });
31
- const onChange = debounce(() => regenerate(cfg), 300);
34
+ const onChange = debounce(() => regenerate(cfg, credentials), 300);
32
35
  watcher.add(path.resolve(cfg.cwd, cfg.serverDir));
33
36
  watcher.on("all", () => void onChange());
34
37
  return watcher;
35
38
  }
36
39
  /**
40
+ * Report one generation's pass under its own name. The poll refreshes two, and one message for both sent
41
+ * a client-only workspace looking for a WordPress service its project does not have.
42
+ *
43
+ * Each reporter carries the last failure it reported, because most of what can fail here cannot clear
44
+ * without the user acting — WordPress down, credentials wrong, a document that will not parse, an
45
+ * introspection version this package does not speak — and repeating the same line every few seconds says
46
+ * nothing the first one did not. A failure that reads differently is a different answer and is reported.
47
+ * The state is per reporter rather than per process, so restarting the watcher reports afresh, and one
48
+ * generation failing never silences the other.
49
+ */
50
+ function reportGeneration(subject, stack) {
51
+ let reported;
52
+ return async (run) => {
53
+ try {
54
+ if (await run() === "generated") log.success(`WordPress ${subject} updated`);
55
+ stack?.answered();
56
+ if (reported !== void 0) {
57
+ reported = void 0;
58
+ log.success(`Updating the WordPress ${subject} again`);
59
+ }
60
+ } catch (error) {
61
+ if (error instanceof IntrospectionFetchError && error.unreachable && await stack?.ended()) return;
62
+ const message = error instanceof Error ? error.message : String(error);
63
+ if (message === reported) return;
64
+ reported = message;
65
+ reportGenerationError(`Failed to update the WordPress ${subject}:`, error);
66
+ }
67
+ };
68
+ }
69
+ /**
70
+ * How often the guard may ask Docker while the poll keeps failing. The poll retries every 3 seconds,
71
+ * and asking on every failing pass would spawn a subprocess that often for as long as WordPress is
72
+ * unreachable — which, when the stack is up and WordPress is merely broken, is indefinitely. A stack
73
+ * does not stop and restart between two passes, so the slower clock costs at most one interval before
74
+ * the session ends and saves the other four probes.
75
+ */
76
+ const STACK_PROBE_INTERVAL_MS = 15e3;
77
+ function endOnStoppedStack(stack, now = Date.now) {
78
+ let done = false;
79
+ let probedAt;
80
+ return {
81
+ answered: () => {
82
+ probedAt = void 0;
83
+ },
84
+ ended: async () => {
85
+ if (done) return true;
86
+ const at = now();
87
+ if (probedAt !== void 0 && at - probedAt < STACK_PROBE_INTERVAL_MS) return false;
88
+ probedAt = at;
89
+ if (await stack.status() !== "stopped") return false;
90
+ done = true;
91
+ log.error("Local WordPress is no longer running. Ending the dev session.");
92
+ stack.onStopped();
93
+ return true;
94
+ }
95
+ };
96
+ }
97
+ /**
98
+ * One pass of the WordPress poll. `cfg` covers an app's client next to its contract; `wordpressClientDir`
99
+ * covers a workspace that has only the client. A project has one or the other, but both are refreshed
100
+ * together so the plugin's PHP changing is picked up either way. Each is refreshed independently, so a
101
+ * service that cannot generate does not cost the pass its client.
102
+ *
103
+ * Both reporters share one stack guard, so a stopped stack ends the session once rather than once per
104
+ * generation the pass refreshes.
105
+ */
106
+ function createWordPressRefresh(cwd, cfg, wordpressClientDir, options, stack) {
107
+ const guard = stack ? endOnStoppedStack(stack) : void 0;
108
+ const service = reportGeneration("service", guard);
109
+ const client = reportGeneration("client", guard);
110
+ return async () => {
111
+ if (cfg) await service(() => generateWordPressOnce(cfg, options));
112
+ if (wordpressClientDir) await client(() => generateWorkspaceClientOnce(cwd, wordpressClientDir, options));
113
+ };
114
+ }
115
+ /** Run {@link createWordPressRefresh} on a timer, skipping a tick while the previous one is still going. */
116
+ function refreshWordPress(cwd, cfg, wordpressClientDir, credentials, stack) {
117
+ const refresh = createWordPressRefresh(cwd, cfg, wordpressClientDir, { credentials }, stack);
118
+ let refreshing = false;
119
+ const timer = setInterval(() => {
120
+ if (refreshing) return;
121
+ refreshing = true;
122
+ refresh().finally(() => {
123
+ refreshing = false;
124
+ });
125
+ }, 3e3);
126
+ timer.unref();
127
+ return timer;
128
+ }
129
+ /**
37
130
  * Acquire the single-instance lock, generate the contract once, and start the file
38
131
  * watcher. Returns a synchronous `stop()` that closes the watcher and releases the
39
132
  * lock — or `undefined` when another watcher already holds the lock (a framework dev
@@ -43,30 +136,45 @@ async function watch(cfg) {
43
136
  */
44
137
  async function startWatcher(cwd, opts) {
45
138
  const lock = lockPath(cwd);
46
- if (isLocked(lock)) {
139
+ if (!await acquire(lock)) {
47
140
  log.info("Watcher already running — skipping the contract watcher.");
48
141
  return;
49
142
  }
50
- const cfg = await resolveConfig(cwd, { dir: opts?.dir });
51
- if (!cfg) {
52
- log.info("skipping the contract generation.");
53
- return;
54
- }
55
- acquire(lock);
143
+ let handedOver = false;
56
144
  try {
57
- if (await generateOnce(cfg)) log.success("Contract generated");
58
- else log.warn(`No Kizlo server found in ${cfg.serverEntry}`);
59
- } catch (error) {
60
- log.error("Failed to generate the Kizlo contract:", error);
145
+ const cfg = await resolveConfig(cwd, { dir: opts?.dir });
146
+ const wordpressClientDir = await resolveWordPressClientDir(cwd);
147
+ if (!cfg && !wordpressClientDir) {
148
+ log.info("skipping the contract generation.");
149
+ return;
150
+ }
151
+ loadEnvFiles(cwd);
152
+ const { credentials } = resolveWordPressConnection(void 0);
153
+ if (wordpressClientDir) try {
154
+ if (await generateWorkspaceClientOnce(cwd, wordpressClientDir, { credentials }) === "generated") log.success("WordPress client generated");
155
+ } catch (error) {
156
+ reportGenerationError("Failed to generate the WordPress client:", error);
157
+ }
158
+ if (cfg) try {
159
+ if (await generateOnce(cfg, { credentials })) log.success("Contract generated");
160
+ else log.warn(`No Kizlo server found in ${cfg.serverEntry}`);
161
+ } catch (error) {
162
+ reportGenerationError("Failed to generate the Kizlo contract:", error);
163
+ }
164
+ const watcher = cfg ? await watch(cfg, credentials) : void 0;
165
+ const wordpressRefresh = refreshWordPress(cwd, cfg, wordpressClientDir, credentials, opts?.stack);
166
+ let stopped = false;
167
+ handedOver = true;
168
+ return () => {
169
+ if (stopped) return;
170
+ stopped = true;
171
+ release(lock);
172
+ clearInterval(wordpressRefresh);
173
+ watcher?.close();
174
+ };
175
+ } finally {
176
+ if (!handedOver) release(lock);
61
177
  }
62
- const watcher = await watch(cfg);
63
- let stopped = false;
64
- return () => {
65
- if (stopped) return;
66
- stopped = true;
67
- release(lock);
68
- watcher.close();
69
- };
70
178
  }
71
179
 
72
180
  //#endregion