kizlo 0.10.0 → 0.12.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 (58) hide show
  1. package/README.md +4 -4
  2. package/dist/astro/server.d.ts +9 -0
  3. package/dist/astro/server.js +10 -0
  4. package/dist/astro.d.ts +2 -0
  5. package/dist/astro.js +3 -0
  6. package/dist/cli/commands/_setup.js +19 -10
  7. package/dist/cli/commands/create.js +102 -72
  8. package/dist/cli/commands/dev.js +113 -20
  9. package/dist/cli/commands/generate.js +4 -0
  10. package/dist/cli/commands/init.js +111 -57
  11. package/dist/cli/daemon/config.js +13 -7
  12. package/dist/cli/daemon/generate.js +2 -3
  13. package/dist/cli/daemon/jiti.js +101 -0
  14. package/dist/cli/daemon/logger.js +1 -1
  15. package/dist/cli/daemon/reload.js +66 -0
  16. package/dist/cli/daemon/watch.js +7 -4
  17. package/dist/cli/presets/source.js +122 -24
  18. package/dist/cli/presets/template.js +75 -17
  19. package/dist/cli/wp/settings.js +8 -2
  20. package/dist/comment/schema.js +2 -2
  21. package/dist/context.d.ts +9 -0
  22. package/dist/context.js +20 -2
  23. package/dist/index.d.ts +4 -4
  24. package/dist/index.js +3 -3
  25. package/dist/integrations/astro/kizlo.client.d.ts +15 -0
  26. package/dist/integrations/astro/kizlo.client.js +13 -0
  27. package/dist/integrations/astro/kizlo.server.d.ts +10 -0
  28. package/dist/integrations/astro/kizlo.server.js +12 -0
  29. package/dist/integrations/astro/manifest.d.ts +9 -0
  30. package/dist/integrations/astro/manifest.js +11 -0
  31. package/dist/integrations/astro/metadata.d.ts +64 -0
  32. package/dist/integrations/astro/metadata.js +71 -0
  33. package/dist/integrations/astro/robots.d.ts +8 -0
  34. package/dist/integrations/astro/robots.js +11 -0
  35. package/dist/integrations/astro/sitemap.d.ts +16 -0
  36. package/dist/integrations/astro/sitemap.js +20 -0
  37. package/dist/integrations/astro/utils.d.ts +19 -0
  38. package/dist/integrations/astro/utils.js +17 -0
  39. package/dist/integrations/nextjs/metadata.d.ts +1 -0
  40. package/dist/integrations/nextjs/metadata.js +1 -0
  41. package/dist/kizlo.d.ts +8 -1
  42. package/dist/kizlo.js +16 -13
  43. package/dist/nextjs/server.d.ts +2 -1
  44. package/dist/nextjs/server.js +2 -1
  45. package/dist/package.js +1 -1
  46. package/dist/page/schema.js +2 -2
  47. package/dist/post/schema.js +2 -2
  48. package/dist/seo/utils.d.ts +8 -2
  49. package/dist/seo/utils.js +9 -1
  50. package/dist/shared/constants.js +9 -1
  51. package/dist/shared/schema.d.ts +1 -2
  52. package/dist/shared/schema.js +2 -2
  53. package/dist/wordpress/service.d.ts +4 -0
  54. package/dist/wordpress/service.js +5 -0
  55. package/package.json +18 -2
  56. package/dist/cli/presets/base.js +0 -40
  57. package/dist/cli/presets/index.js +0 -16
  58. package/dist/cli/presets/nextjs.js +0 -16
@@ -1,7 +1,6 @@
1
1
  import { addDependencyArgs, detectImportAlias, detectPackageManager, ensureGitignored, getVersion, loadEnvFiles, runCommandAsync } from "../utils.js";
2
- import { PRESETS, detectPreset, getPreset } from "../presets/index.js";
3
- import { fetchTemplate } from "../presets/source.js";
4
- import { adaptFile, changesFor, fileEntries, isOlderVersion, minCliError, patchEntries, readManifest, resolveDependencies } from "../presets/template.js";
2
+ import { DEFAULT_REGISTRY, detectTemplates, fetchRegistry, isSingleTemplate, listTemplates, resolveRegistry } from "../presets/source.js";
3
+ import { adaptFile, changesFor, fileEntries, isOlderVersion, minCliError, patchEntries, readManifest, renderNote, resolveDependencies } from "../presets/template.js";
5
4
  import { collectConnectionFromEnv, collectConnectionInteractively, dirPath, nextStepsNote, orCancel, readEnvExample, resolveEnvKeys, setupLocalWordPress, syncRemote, validate, withApiPath, writeEnv } from "./_setup.js";
6
5
  import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
7
6
  import path from "node:path";
@@ -25,7 +24,20 @@ function sameOrigin(a, b) {
25
24
  return false;
26
25
  }
27
26
  }
28
- function detectAppDir(cwd, hasSrcDir) {
27
+ /**
28
+ * The framework's route directory in the user's project. When a template is present its
29
+ * `conventions.appDir` (e.g. `src/app` for Next, `src/pages` for Astro) is the expected layout; we
30
+ * probe that path and its no-`src` variant so a project that keeps routes at the top level is still
31
+ * found, falling back to whether the project has a `src` dir. With no known template appDir we keep the
32
+ * App Router default.
33
+ */
34
+ function detectAppDir(cwd, hasSrcDir, templateAppDir) {
35
+ if (templateAppDir) {
36
+ const withoutSrc = templateAppDir.replace(/^src\//, "");
37
+ if (fs.existsSync(path.join(cwd, templateAppDir))) return templateAppDir;
38
+ if (fs.existsSync(path.join(cwd, withoutSrc))) return withoutSrc;
39
+ return hasSrcDir ? templateAppDir : withoutSrc;
40
+ }
29
41
  if (fs.existsSync(path.join(cwd, "src", "app"))) return "src/app";
30
42
  if (fs.existsSync(path.join(cwd, "app"))) return "app";
31
43
  return hasSrcDir ? "src/app" : "app";
@@ -37,7 +49,7 @@ async function collectInteractively(ctx) {
37
49
  validate: validate(dirPath)
38
50
  }));
39
51
  let alias = "";
40
- if (ctx.preset.apiPath) {
52
+ if (ctx.apiPath) {
41
53
  const serverDir = path.join(dir.replace(/^\.\//, "").replace(/\/+$/, ""), "server");
42
54
  const detected = detectImportAlias(ctx.cwd, serverDir)?.prefix;
43
55
  alias = orCancel(await p.text({
@@ -47,7 +59,7 @@ async function collectInteractively(ctx) {
47
59
  })).trim();
48
60
  }
49
61
  return {
50
- ...await collectConnectionInteractively(ctx.preset),
62
+ ...await collectConnectionInteractively(ctx.apiPath),
51
63
  dir,
52
64
  alias
53
65
  };
@@ -63,7 +75,7 @@ function collectFromEnv(ctx) {
63
75
  return {
64
76
  ...conn,
65
77
  dir,
66
- alias: ctx.preset.apiPath ? aliasWithSlash(detectImportAlias(ctx.cwd, path.join(dir, "server"))?.prefix) : ""
78
+ alias: ctx.apiPath ? aliasWithSlash(detectImportAlias(ctx.cwd, path.join(dir, "server"))?.prefix) : ""
67
79
  };
68
80
  }
69
81
  function readPkg(pkgPath) {
@@ -105,13 +117,13 @@ const init = defineCommand({
105
117
  description: "Overwrite existing .env values and server entry without asking",
106
118
  default: false
107
119
  },
108
- preset: {
109
- type: "string",
110
- description: `Force a setup preset (${PRESETS.map((preset) => preset.id).join(", ")})`
111
- },
112
120
  alias: {
113
121
  type: "string",
114
122
  description: "Import alias prefix for generated imports (e.g. @); blank for relative"
123
+ },
124
+ source: {
125
+ type: "string",
126
+ description: "Where to wire from — a local dir or giget source, either a registry of templates or a single template (default: Kizlo's GitHub templates)"
115
127
  }
116
128
  },
117
129
  async run({ args }) {
@@ -136,69 +148,109 @@ const init = defineCommand({
136
148
  const pm = detectPackageManager(cwd);
137
149
  const hasKizlo = Boolean(pkg.dependencies?.kizlo) || Boolean(pkg.devDependencies?.kizlo);
138
150
  const hasSrcDir = fs.existsSync(path.join(cwd, "src"));
139
- const initCtx = {
140
- cwd,
141
- pkg,
142
- pm,
143
- hasSrcDir
144
- };
145
- let preset;
146
- if (args.preset) {
147
- const chosen = getPreset(String(args.preset));
148
- if (!chosen) {
149
- p.cancel(`Unknown preset "${args.preset}". Available: ${PRESETS.map((pr) => pr.id).join(", ")}`);
150
- process.exit(1);
151
- }
152
- preset = chosen;
153
- } else {
154
- preset = detectPreset(initCtx);
155
- if (preset.id !== "base") p.log.success(`${preset.label} detected`);
156
- }
157
- if (preset.template === "nextjs" && !fs.existsSync(path.join(cwd, "app")) && !fs.existsSync(path.join(cwd, "src/app"))) {
158
- p.cancel("Kizlo needs the Next.js App Router — no `app` or `src/app` directory found. The Pages Router isn't supported.");
159
- process.exit(1);
160
- }
161
151
  if (yes) loadEnvFiles(cwd);
162
- let manifest;
163
- let fetched;
164
- if (preset.template) {
165
- fetched = await fetchTemplate(preset.template);
166
- manifest = readManifest(fetched.dir);
167
- const minErr = minCliError(manifest);
168
- if (minErr) {
169
- fetched.cleanup();
170
- p.cancel(minErr);
171
- process.exit(1);
172
- }
152
+ const registry = resolveRegistry(args.source ? String(args.source) : void 0);
153
+ let fetchedRegistry;
154
+ try {
155
+ fetchedRegistry = await fetchRegistry(registry);
156
+ } catch (error) {
157
+ p.cancel(error instanceof Error ? error.message : String(error));
158
+ process.exit(1);
173
159
  }
174
- const envKeys = resolveEnvKeys(preset, manifest);
160
+ const cancelFrom = (message) => {
161
+ fetchedRegistry.cleanup();
162
+ p.cancel(message);
163
+ process.exit(1);
164
+ };
175
165
  try {
166
+ const entries = listTemplates(fetchedRegistry.dir);
167
+ if (entries.length === 0) cancelFrom(`No templates found in ${registry.source}. Each template needs a template.json.`);
168
+ const projectDeps = {
169
+ ...pkg.dependencies,
170
+ ...pkg.devDependencies
171
+ };
172
+ let entry;
173
+ if (isSingleTemplate(fetchedRegistry.dir)) {
174
+ const [only] = entries;
175
+ entry = only;
176
+ if (only && detectTemplates(entries, projectDeps).length === 0) {
177
+ p.log.warn(`The ${only.label} template doesn't match this project's dependencies — detection may be off on either side.`);
178
+ if (!(yes || orCancel(await p.confirm({
179
+ message: `Apply ${only.label} anyway?`,
180
+ initialValue: false
181
+ })))) cancelFrom("Cancelled. Point --source at a template that matches this project, or omit it to auto-detect.");
182
+ }
183
+ } else {
184
+ const matched = detectTemplates(entries, projectDeps);
185
+ const [first] = matched;
186
+ if (matched.length === 0) {
187
+ p.log.warn(`Couldn't find a template matching this project's framework ${registry.source === DEFAULT_REGISTRY ? "in Kizlo templates" : `at your provided source ${registry.source}`}.`);
188
+ if (yes) cancelFrom(`Re-run with --source pointing at the single template you want, or run without --yes to pick one manually.`);
189
+ if (!orCancel(await p.confirm({
190
+ message: "Pick a template manually anyway?",
191
+ initialValue: false
192
+ }))) cancelFrom("Cancelled.");
193
+ const id = orCancel(await p.select({
194
+ message: "Choose a template to apply",
195
+ options: entries.map((e) => ({
196
+ value: e.id,
197
+ label: e.label
198
+ }))
199
+ }));
200
+ entry = entries.find((e) => e.id === id);
201
+ } else if (matched.length === 1 && first) {
202
+ entry = first;
203
+ p.log.success(`${first.label} detected`);
204
+ } else {
205
+ const ids = matched.map((e) => e.id).join(", ");
206
+ if (yes) cancelFrom(`Multiple templates match this project (${ids}). Re-run with --source pointing at the single template you want.`);
207
+ const id = orCancel(await p.select({
208
+ message: "Multiple templates match this project — pick one",
209
+ options: matched.map((e) => ({
210
+ value: e.id,
211
+ label: e.label
212
+ }))
213
+ }));
214
+ entry = matched.find((e) => e.id === id);
215
+ }
216
+ }
217
+ if (!entry) return cancelFrom("No template selected.");
218
+ const manifest = readManifest(entry.dir);
219
+ const minErr = minCliError(manifest);
220
+ if (minErr) cancelFrom(minErr);
221
+ if (manifest.requires && !manifest.requires.anyDir.some((rel) => fs.existsSync(path.join(cwd, rel)))) cancelFrom(manifest.requires.message);
222
+ const apiPath = manifest.apiPath;
223
+ const envKeys = resolveEnvKeys(manifest);
176
224
  const setup = yes ? collectFromEnv({
177
225
  cwd,
178
226
  hasSrcDir,
179
- preset,
227
+ apiPath,
180
228
  envKeys
181
229
  }) : await collectInteractively({
182
230
  cwd,
183
231
  hasSrcDir,
184
- preset
232
+ apiPath
185
233
  });
186
234
  if (args.alias !== void 0) setup.alias = String(args.alias).trim();
187
235
  setup.alias = aliasWithSlash(setup.alias);
236
+ const includeExamples = yes ? true : orCancel(await p.confirm({
237
+ message: "Add example pages?",
238
+ initialValue: true
239
+ }));
188
240
  if (!hasKizlo) {
189
- const spec = `kizlo@${manifest?.dependencies?.kizlo ?? `^${getVersion()}`}`;
241
+ const spec = `kizlo@${manifest.dependencies?.kizlo ?? `^${getVersion()}`}`;
190
242
  const s = p.spinner();
191
243
  s.start(`Installing kizlo with ${pm}`);
192
244
  const ok = await runCommandAsync(addDependencyArgs(pm, spec), cwd, "ignore");
193
245
  s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
194
246
  if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
195
247
  }
196
- if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
248
+ if (apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, apiPath);
197
249
  await setupLocalWordPress(cwd, setup);
198
250
  await writeEnv(cwd, envKeys, setup, {
199
251
  force,
200
252
  yes,
201
- exampleTemplate: fetched ? readEnvExample(fetched.dir) : void 0
253
+ exampleTemplate: readEnvExample(entry.dir)
202
254
  });
203
255
  await syncRemote(setup);
204
256
  const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
@@ -206,7 +258,7 @@ const init = defineCommand({
206
258
  const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
207
259
  const scaffold = buildScaffoldContext(cwd, {
208
260
  dirRel,
209
- appDir: detectAppDir(cwd, hasSrcDir),
261
+ appDir: detectAppDir(cwd, hasSrcDir, manifest.conventions.appDir),
210
262
  alias: setup.alias,
211
263
  clientUrl
212
264
  });
@@ -215,10 +267,8 @@ const init = defineCommand({
215
267
  relPath: "kizlo.config.ts",
216
268
  contents: kizloConfigTemplate(dirRel, setup.alias, setup.mode === "local")
217
269
  }];
218
- if (manifest && fetched) {
219
- await alignDependencies(cwd, pm, pkg, manifest);
220
- for (const entry of fileEntries(changesFor(manifest, "init"))) files.push(adaptFile(fetched.dir, entry, manifest.conventions, scaffold));
221
- } else if (preset.scaffolds) files.push(...preset.scaffolds(scaffold));
270
+ await alignDependencies(cwd, pm, pkg, manifest);
271
+ for (const fileEntry of fileEntries(changesFor(manifest, "init", { includeExamples }))) files.push(adaptFile(entry.dir, fileEntry, manifest.conventions, scaffold));
222
272
  const scaffolded = [];
223
273
  for (const file of files) scaffolded.push({
224
274
  file,
@@ -231,12 +281,16 @@ const init = defineCommand({
231
281
  const gitignore = ensureGitignored(cwd, ".env");
232
282
  ensureGitignored(cwd, ".kizlo/");
233
283
  for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
234
- if (manifest) applyLayoutPatches(cwd, patchEntries(changesFor(manifest, "init")), manifest.conventions, scaffold);
284
+ applyLayoutPatches(cwd, patchEntries(changesFor(manifest, "init")), manifest.conventions, scaffold);
285
+ for (const note of manifest.notes ?? []) {
286
+ const rendered = renderNote(note, setup.alias);
287
+ p.note(rendered.body, rendered.title);
288
+ }
235
289
  if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
236
290
  nextStepsNote(setup);
237
291
  p.outro("Kizlo is ready 🎉");
238
292
  } finally {
239
- fetched?.cleanup();
293
+ fetchedRegistry.cleanup();
240
294
  }
241
295
  }
242
296
  });
@@ -2,10 +2,10 @@ import { 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";
5
+ import { importIgnoringVirtualModules } from "./jiti.js";
5
6
  import z from "zod/v4";
6
7
  import path from "node:path";
7
8
  import fs from "node:fs";
8
- import { createJiti } from "jiti";
9
9
 
10
10
  //#region src/cli/daemon/config.ts
11
11
  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)" });
@@ -41,15 +41,12 @@ const CONFIG_FILES = [
41
41
  const DEFAULT_DEV_PORT = 8080;
42
42
  const DEFAULT_DEV_DB_PORT = 3307;
43
43
  const DEFAULT_TEST_PORT = 8889;
44
- function defaultDir(cwd) {
45
- return fs.existsSync(path.join(cwd, "src")) ? "src/lib/kizlo" : "lib/kizlo";
46
- }
47
44
  async function loadConfigFile(cwd) {
48
45
  const file = CONFIG_FILES.map((name) => path.join(cwd, name)).find((p) => fs.existsSync(p));
49
46
  if (!file) return void 0;
50
47
  let raw;
51
48
  try {
52
- const mod = await createJiti(cwd, { moduleCache: false }).import(file);
49
+ const mod = await importIgnoringVirtualModules(cwd, file);
53
50
  raw = mod.default ?? mod;
54
51
  } catch (error) {
55
52
  log.error(`Could not load ${path.basename(file)}:`, error);
@@ -62,9 +59,18 @@ async function loadConfigFile(cwd) {
62
59
  }
63
60
  return result.data;
64
61
  }
62
+ /**
63
+ * Resolve the Kizlo server layout from an explicit `dir` — the `--dir` flag or `dir` in
64
+ * `kizlo.config.*`. Returns `undefined` when neither is set: there's no Kizlo server to
65
+ * generate a contract from, so callers skip generation and (for `dev`) run local WordPress
66
+ * alone. There's intentionally no default path — a missing `dir` means "no server", not
67
+ * "look under `lib/kizlo`".
68
+ */
65
69
  async function resolveConfig(cwd, flags) {
66
70
  const fileConfig = await loadConfigFile(cwd);
67
- const dir = (flags?.dir ?? fileConfig?.dir ?? defaultDir(cwd)).replace(/^\.\//, "").replace(/\/+$/, "");
71
+ const raw = flags?.dir ?? fileConfig?.dir;
72
+ if (!raw) return void 0;
73
+ const dir = raw.replace(/^\.\//, "").replace(/\/+$/, "");
68
74
  const serverDir = path.join(dir, "server");
69
75
  const generatedDir = path.join(serverDir, "generated");
70
76
  return {
@@ -154,4 +160,4 @@ async function resolveDevConfig(cwd) {
154
160
  }
155
161
 
156
162
  //#endregion
157
- export { DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, usesLocalWordPress };
163
+ export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, usesLocalWordPress };
@@ -1,8 +1,8 @@
1
1
  import { generateContract } from "../../shared/contract.js";
2
2
  import { loadEnvFiles } from "../utils.js";
3
+ import { importIgnoringVirtualModules } from "./jiti.js";
3
4
  import path from "node:path";
4
5
  import fs from "node:fs";
5
- import { createJiti } from "jiti";
6
6
 
7
7
  //#region src/cli/daemon/generate.ts
8
8
  /** The generated contract barrel — also written as a stub by `kizlo init`. */
@@ -25,9 +25,8 @@ function atomicWrite(file, data) {
25
25
  */
26
26
  async function generateOnce(cfg) {
27
27
  loadEnvFiles(cfg.cwd);
28
- const jiti = createJiti(cfg.cwd, { moduleCache: false });
29
28
  const entry = path.resolve(cfg.cwd, cfg.serverEntry);
30
- const { router } = await jiti.import(entry);
29
+ const { router } = await importIgnoringVirtualModules(cfg.cwd, entry);
31
30
  if (!router) return false;
32
31
  const contract = JSON.stringify(await generateContract(router));
33
32
  fs.mkdirSync(path.resolve(cfg.cwd, cfg.generatedDir), { recursive: true });
@@ -0,0 +1,101 @@
1
+ import { CONTRACT_GENERATION_ENV } from "../../shared/constants.js";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import { createJiti } from "jiti";
5
+
6
+ //#region src/cli/daemon/jiti.ts
7
+ /**
8
+ * Loads a TypeScript module through jiti while tolerating framework virtual modules
9
+ * (`astro:env/server`, `virtual:*`, `#imports`, `$env/*`, …). These are synthesized by the
10
+ * framework's bundler at build time and don't exist on disk, so jiti can't resolve them — but
11
+ * contract generation only needs the module's exported *shape*, never the runtime values a
12
+ * virtual module carries. We discover them from the project's own source rather than maintaining
13
+ * a per-framework list: import, and whenever jiti reports a missing module, stub it and retry.
14
+ *
15
+ * A virtual module is distinguished from a genuinely missing dependency by the one property that
16
+ * always holds — a virtual module is never a declared dependency, so a bare specifier whose
17
+ * package isn't in `package.json` is treated as virtual, while a declared-but-unresolvable
18
+ * dependency is surfaced as the real error it is.
19
+ */
20
+ async function importIgnoringVirtualModules(cwd, entry) {
21
+ const deps = declaredDeps(cwd);
22
+ const virtualModules = {};
23
+ const previousGenerationFlag = process.env[CONTRACT_GENERATION_ENV];
24
+ process.env[CONTRACT_GENERATION_ENV] = "1";
25
+ try {
26
+ return await importLoop(cwd, entry, deps, virtualModules);
27
+ } finally {
28
+ if (previousGenerationFlag === void 0) delete process.env[CONTRACT_GENERATION_ENV];
29
+ else process.env[CONTRACT_GENERATION_ENV] = previousGenerationFlag;
30
+ }
31
+ }
32
+ async function importLoop(cwd, entry, deps, virtualModules) {
33
+ for (;;) {
34
+ const jiti = createJiti(cwd, {
35
+ moduleCache: false,
36
+ tsconfigPaths: true,
37
+ virtualModules
38
+ });
39
+ try {
40
+ return await jiti.import(entry);
41
+ } catch (error) {
42
+ const spec = missingSpecifier(error);
43
+ if (!spec || !isBareSpecifier(spec) || spec in virtualModules || deps.has(packageNameOf(spec))) throw error;
44
+ virtualModules[spec] = makeVirtualStub();
45
+ }
46
+ }
47
+ }
48
+ /**
49
+ * A no-op module namespace that satisfies any import shape (named, default, nested, callable),
50
+ * so a virtual module can be stubbed without knowing what it exports. The `has` trap is required:
51
+ * without it jiti's `interopDefault` sees a truthy `.default` and routes named lookups down a path
52
+ * that misses.
53
+ *
54
+ * Every export — named, default, nested, callable — is a no-op, so contract generation only ever
55
+ * reads the module's shape, never a real value a virtual module would carry at runtime.
56
+ */
57
+ function makeVirtualStub() {
58
+ const stub = new Proxy(() => {}, {
59
+ apply: () => void 0,
60
+ has: (_t, prop) => typeof prop === "string" && prop !== "then",
61
+ get: (_t, prop) => {
62
+ if (prop === "__esModule") return true;
63
+ if (prop === "then") return void 0;
64
+ if (typeof prop === "symbol") return void 0;
65
+ return stub;
66
+ }
67
+ });
68
+ return stub;
69
+ }
70
+ /** Names of every declared dependency; empty when `package.json` is absent or unreadable. */
71
+ function declaredDeps(cwd) {
72
+ try {
73
+ const pkg = JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"));
74
+ return new Set([
75
+ ...Object.keys(pkg.dependencies ?? {}),
76
+ ...Object.keys(pkg.devDependencies ?? {}),
77
+ ...Object.keys(pkg.peerDependencies ?? {}),
78
+ ...Object.keys(pkg.optionalDependencies ?? {})
79
+ ]);
80
+ } catch {
81
+ return /* @__PURE__ */ new Set();
82
+ }
83
+ }
84
+ /** The specifier jiti/Node reported unresolvable, or undefined when the error isn't a resolution failure. */
85
+ function missingSpecifier(error) {
86
+ const err = error;
87
+ if (err?.code !== "MODULE_NOT_FOUND" && err?.code !== "ERR_MODULE_NOT_FOUND") return void 0;
88
+ return /Cannot find module '([^']+)'/.exec(err.message ?? "")?.[1];
89
+ }
90
+ /** Virtual specifiers are always bare; a missing relative/absolute path is a real bug, not a virtual module. */
91
+ function isBareSpecifier(spec) {
92
+ return !spec.startsWith(".") && !spec.startsWith("/");
93
+ }
94
+ /** Package name of a bare specifier: `@scope/x/sub` → `@scope/x`, `astro:env/server` → `astro:env`. */
95
+ function packageNameOf(spec) {
96
+ const parts = spec.split("/");
97
+ return spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0] ?? spec;
98
+ }
99
+
100
+ //#endregion
101
+ export { importIgnoringVirtualModules };
@@ -2,7 +2,7 @@ import { consola } from "consola";
2
2
 
3
3
  //#region src/cli/daemon/logger.ts
4
4
  /** Tagged logger for the daemon — every line is prefixed with `kizlo`. */
5
- const log = consola.withTag("kizlo");
5
+ const log = consola;
6
6
  log.options.formatOptions.date = false;
7
7
 
8
8
  //#endregion
@@ -0,0 +1,66 @@
1
+ import { CONFIG_FILES } from "./config.js";
2
+ import path from "node:path";
3
+ import { readFileSync } from "node:fs";
4
+ import { createHash } from "node:crypto";
5
+ import { FSWatcher } from "chokidar";
6
+
7
+ //#region src/cli/daemon/reload.ts
8
+ /** Env files `loadEnvFiles` reads, in the same order — the runtime values a reload must pick up. */
9
+ const ENV_FILES = [".env", ".env.local"];
10
+ /** A combined hash of the watched files' contents; a missing file counts as empty. */
11
+ function fingerprint(files) {
12
+ const hash = createHash("sha1");
13
+ for (const file of files) {
14
+ hash.update(file);
15
+ hash.update("\0");
16
+ try {
17
+ hash.update(readFileSync(file));
18
+ } catch {}
19
+ hash.update("\0");
20
+ }
21
+ return hash.digest("hex");
22
+ }
23
+ /**
24
+ * Watch the project's `.env` files and `kizlo.config.*` and call `onReload` with the changed file's
25
+ * basename when their *contents* change. `kizlo dev` uses this to restart its whole session — rebooting
26
+ * local WordPress and regenerating the contract — so a changed port, connection, or fixture takes
27
+ * effect without stopping and rerunning the command by hand. Debounced so an editor's multi-write save
28
+ * fires once, and gated on a content hash so a no-op save (`touch`, or writing the file back unchanged)
29
+ * is ignored rather than triggering a pointless reboot. Returns a synchronous `stop()` that closes the
30
+ * watcher.
31
+ *
32
+ * `dev` rewrites `.env` itself while booting (fresh credentials, the current LAN URL); the caller
33
+ * pauses its reload handler around a reboot so that self-write doesn't trigger another reload. The hash
34
+ * is still refreshed for it here, so once the reboot settles the new content is the baseline and won't
35
+ * re-fire.
36
+ */
37
+ function watchReload(configDir, onReload) {
38
+ const files = [...ENV_FILES, ...CONFIG_FILES].map((name) => path.join(configDir, name));
39
+ const watcher = new FSWatcher({
40
+ persistent: true,
41
+ ignoreInitial: true
42
+ });
43
+ let last = fingerprint(files);
44
+ let timer;
45
+ const schedule = (changed) => {
46
+ clearTimeout(timer);
47
+ timer = setTimeout(() => {
48
+ const next = fingerprint(files);
49
+ if (next === last) return;
50
+ last = next;
51
+ onReload(path.basename(changed));
52
+ }, 300);
53
+ };
54
+ watcher.add(files);
55
+ watcher.on("all", (_event, changed) => schedule(changed));
56
+ let stopped = false;
57
+ return () => {
58
+ if (stopped) return;
59
+ stopped = true;
60
+ clearTimeout(timer);
61
+ watcher.close();
62
+ };
63
+ }
64
+
65
+ //#endregion
66
+ export { watchReload };
@@ -30,7 +30,6 @@ async function watch(cfg) {
30
30
  });
31
31
  const onChange = debounce(() => regenerate(cfg), 300);
32
32
  watcher.add(path.resolve(cfg.cwd, cfg.serverDir));
33
- watcher.on("ready", () => log.start("Watching for contract changes..."));
34
33
  watcher.on("all", () => void onChange());
35
34
  return watcher;
36
35
  }
@@ -38,9 +37,9 @@ async function watch(cfg) {
38
37
  * Acquire the single-instance lock, generate the contract once, and start the file
39
38
  * watcher. Returns a synchronous `stop()` that closes the watcher and releases the
40
39
  * lock — or `undefined` when another watcher already holds the lock (a framework dev
41
- * script, or a second `kizlo dev`), in which case the caller carries on without
42
- * watching. Used by `kizlo dev`, both when it boots a local stack and when it runs
43
- * the watcher alone, so a single terminal covers the whole dev loop.
40
+ * script, or a second `kizlo dev`), or when no server `dir` is configured, in which case
41
+ * the caller carries on without watching. Used by `kizlo dev`, both when it boots a local
42
+ * stack and when it runs the watcher alone, so a single terminal covers the whole dev loop.
44
43
  */
45
44
  async function startWatcher(cwd, opts) {
46
45
  const lock = lockPath(cwd);
@@ -49,6 +48,10 @@ async function startWatcher(cwd, opts) {
49
48
  return;
50
49
  }
51
50
  const cfg = await resolveConfig(cwd, { dir: opts?.dir });
51
+ if (!cfg) {
52
+ log.info("skipping the contract generation.");
53
+ return;
54
+ }
52
55
  acquire(lock);
53
56
  try {
54
57
  if (await generateOnce(cfg)) log.success("Contract generated");