kizlo 0.11.0 → 0.12.1

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 (50) hide show
  1. package/dist/astro/server.d.ts +9 -0
  2. package/dist/astro/server.js +10 -0
  3. package/dist/astro.d.ts +2 -0
  4. package/dist/astro.js +3 -0
  5. package/dist/cli/commands/_setup.js +10 -10
  6. package/dist/cli/commands/_wiring.js +13 -11
  7. package/dist/cli/commands/create.js +101 -71
  8. package/dist/cli/commands/generate.js +4 -0
  9. package/dist/cli/commands/init.js +110 -60
  10. package/dist/cli/daemon/config.js +23 -9
  11. package/dist/cli/daemon/generate.js +2 -3
  12. package/dist/cli/daemon/jiti.js +101 -0
  13. package/dist/cli/daemon/watch.js +7 -3
  14. package/dist/cli/presets/patch.js +38 -0
  15. package/dist/cli/presets/source.js +122 -24
  16. package/dist/cli/presets/template.js +47 -35
  17. package/dist/cli/wp/stack.js +2 -2
  18. package/dist/config.d.ts +1 -1
  19. package/dist/context.js +9 -3
  20. package/dist/index.d.ts +3 -3
  21. package/dist/index.js +2 -2
  22. package/dist/integrations/astro/kizlo.client.d.ts +15 -0
  23. package/dist/integrations/astro/kizlo.client.js +13 -0
  24. package/dist/integrations/astro/kizlo.server.d.ts +10 -0
  25. package/dist/integrations/astro/kizlo.server.js +12 -0
  26. package/dist/integrations/astro/manifest.d.ts +9 -0
  27. package/dist/integrations/astro/manifest.js +11 -0
  28. package/dist/integrations/astro/metadata.d.ts +64 -0
  29. package/dist/integrations/astro/metadata.js +71 -0
  30. package/dist/integrations/astro/robots.d.ts +8 -0
  31. package/dist/integrations/astro/robots.js +11 -0
  32. package/dist/integrations/astro/sitemap.d.ts +16 -0
  33. package/dist/integrations/astro/sitemap.js +20 -0
  34. package/dist/integrations/astro/utils.d.ts +19 -0
  35. package/dist/integrations/astro/utils.js +17 -0
  36. package/dist/integrations/nextjs/metadata.d.ts +8 -1
  37. package/dist/integrations/nextjs/metadata.js +13 -1
  38. package/dist/kizlo.d.ts +8 -1
  39. package/dist/kizlo.js +16 -13
  40. package/dist/nextjs/server.d.ts +3 -2
  41. package/dist/nextjs/server.js +3 -2
  42. package/dist/package.js +1 -1
  43. package/dist/router.d.ts +5 -5
  44. package/dist/seo/utils.d.ts +8 -2
  45. package/dist/seo/utils.js +9 -1
  46. package/dist/shared/constants.js +9 -1
  47. package/package.json +18 -2
  48. package/dist/cli/presets/base.js +0 -40
  49. package/dist/cli/presets/index.js +0 -16
  50. package/dist/cli/presets/nextjs.js +0 -16
@@ -1,9 +1,8 @@
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
- import { applyLayoutPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
5
+ import { applyProjectPatches, buildScaffoldContext, kizloConfigTemplate, reportScaffold, scaffoldFile, writeGeneratedContract } from "./_wiring.js";
7
6
  import path from "node:path";
8
7
  import fs from "node:fs";
9
8
  import { defineCommand } from "citty";
@@ -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,73 +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);
188
- const includeExamples = manifest && fetched ? yes ? true : orCancel(await p.confirm({
236
+ const includeExamples = yes ? true : orCancel(await p.confirm({
189
237
  message: "Add example pages?",
190
238
  initialValue: true
191
- })) : false;
239
+ }));
192
240
  if (!hasKizlo) {
193
- const spec = `kizlo@${manifest?.dependencies?.kizlo ?? `^${getVersion()}`}`;
241
+ const spec = `kizlo@${manifest.dependencies?.kizlo ?? `^${getVersion()}`}`;
194
242
  const s = p.spinner();
195
243
  s.start(`Installing kizlo with ${pm}`);
196
244
  const ok = await runCommandAsync(addDependencyArgs(pm, spec), cwd, "ignore");
197
245
  s.stop(ok ? "Installed kizlo" : "Could not install kizlo automatically");
198
246
  if (!ok) p.log.warn(`Install it yourself: ${addDependencyArgs(pm, spec).join(" ")}`);
199
247
  }
200
- if (preset.apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, preset.apiPath);
248
+ if (apiPath && setup.baseUrl) setup.baseUrl = withApiPath(setup.baseUrl, apiPath);
201
249
  await setupLocalWordPress(cwd, setup);
202
250
  await writeEnv(cwd, envKeys, setup, {
203
251
  force,
204
252
  yes,
205
- exampleTemplate: fetched ? readEnvExample(fetched.dir) : void 0
253
+ exampleTemplate: readEnvExample(entry.dir)
206
254
  });
207
255
  await syncRemote(setup);
208
256
  const dirRel = setup.dir.replace(/^\.\//, "").replace(/\/+$/, "");
@@ -210,7 +258,7 @@ const init = defineCommand({
210
258
  const clientUrl = setup.siteUrl && !sameOrigin(setup.siteUrl, setup.baseUrl) ? setup.baseUrl : void 0;
211
259
  const scaffold = buildScaffoldContext(cwd, {
212
260
  dirRel,
213
- appDir: detectAppDir(cwd, hasSrcDir),
261
+ appDir: detectAppDir(cwd, hasSrcDir, manifest.conventions.appDir),
214
262
  alias: setup.alias,
215
263
  clientUrl
216
264
  });
@@ -219,10 +267,8 @@ const init = defineCommand({
219
267
  relPath: "kizlo.config.ts",
220
268
  contents: kizloConfigTemplate(dirRel, setup.alias, setup.mode === "local")
221
269
  }];
222
- if (manifest && fetched) {
223
- await alignDependencies(cwd, pm, pkg, manifest);
224
- for (const entry of fileEntries(changesFor(manifest, "init", { includeExamples }))) files.push(adaptFile(fetched.dir, entry, manifest.conventions, scaffold));
225
- } 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));
226
272
  const scaffolded = [];
227
273
  for (const file of files) scaffolded.push({
228
274
  file,
@@ -235,12 +281,16 @@ const init = defineCommand({
235
281
  const gitignore = ensureGitignored(cwd, ".env");
236
282
  ensureGitignored(cwd, ".kizlo/");
237
283
  for (const { file, result } of scaffolded) reportScaffold(file, result, yes);
238
- if (manifest) applyLayoutPatches(cwd, patchEntries(changesFor(manifest, "init")), manifest.conventions, scaffold);
284
+ applyProjectPatches(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
+ }
239
289
  if (gitignore !== "present") p.log.success(`${gitignore === "created" ? "Created" : "Updated"} .gitignore (ignoring .env)`);
240
290
  nextStepsNote(setup);
241
291
  p.outro("Kizlo is ready 🎉");
242
292
  } finally {
243
- fetched?.cleanup();
293
+ fetchedRegistry.cleanup();
244
294
  }
245
295
  }
246
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 {
@@ -99,6 +105,14 @@ function resolveStackName(configDir, configName) {
99
105
  return sanitizeProjectName(pkgName ?? path.basename(configDir));
100
106
  }
101
107
  /**
108
+ * Compose project id for a stack: `kizlo-<name>-<kind>`. The leading `kizlo-` is what
109
+ * lets every stack this tool starts be filtered together in Docker (e.g.
110
+ * `docker ps --filter name=kizlo-`), independent of each project's own name.
111
+ */
112
+ function stackProject(baseName, kind) {
113
+ return `kizlo-${baseName}-${kind}`;
114
+ }
115
+ /**
102
116
  * Resolve the `test` block from `kizlo.config.*` into concrete values for the
103
117
  * `test` command, applying defaults (port 8889, auto-detected package manager)
104
118
  * and anchoring the credentials artifact to the config directory.
@@ -110,7 +124,7 @@ async function resolveTestConfig(cwd) {
110
124
  return {
111
125
  configDir,
112
126
  local: Boolean(test.local),
113
- project: `${resolveStackName(configDir, fileConfig?.name)}-test`,
127
+ project: stackProject(resolveStackName(configDir, fileConfig?.name), "test"),
114
128
  command: test.command,
115
129
  port: test.port ?? DEFAULT_TEST_PORT,
116
130
  portExplicit: test.port !== void 0,
@@ -142,7 +156,7 @@ async function resolveDevConfig(cwd) {
142
156
  const dev = fileConfig?.dev ?? {};
143
157
  return {
144
158
  configDir,
145
- project: `${resolveStackName(configDir, fileConfig?.name)}-dev`,
159
+ project: stackProject(resolveStackName(configDir, fileConfig?.name), "dev"),
146
160
  port: dev.port ?? DEFAULT_DEV_PORT,
147
161
  portExplicit: dev.port !== void 0,
148
162
  dbPort: dev.dbPort ?? DEFAULT_DEV_DB_PORT,
@@ -154,4 +168,4 @@ async function resolveDevConfig(cwd) {
154
168
  }
155
169
 
156
170
  //#endregion
157
- export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, usesLocalWordPress };
171
+ export { CONFIG_FILES, DEFAULT_DEV_DB_PORT, DEFAULT_DEV_PORT, resolveConfig, resolveDevConfig, resolveStackName, resolveTestConfig, stackProject, 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 };
@@ -37,9 +37,9 @@ async function watch(cfg) {
37
37
  * Acquire the single-instance lock, generate the contract once, and start the file
38
38
  * watcher. Returns a synchronous `stop()` that closes the watcher and releases the
39
39
  * lock — or `undefined` when another watcher already holds the lock (a framework dev
40
- * script, or a second `kizlo dev`), in which case the caller carries on without
41
- * watching. Used by `kizlo dev`, both when it boots a local stack and when it runs
42
- * 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.
43
43
  */
44
44
  async function startWatcher(cwd, opts) {
45
45
  const lock = lockPath(cwd);
@@ -48,6 +48,10 @@ async function startWatcher(cwd, opts) {
48
48
  return;
49
49
  }
50
50
  const cfg = await resolveConfig(cwd, { dir: opts?.dir });
51
+ if (!cfg) {
52
+ log.info("skipping the contract generation.");
53
+ return;
54
+ }
51
55
  acquire(lock);
52
56
  try {
53
57
  if (await generateOnce(cfg)) log.success("Contract generated");
@@ -47,6 +47,36 @@ function staticCounterpart(name) {
47
47
  function sameExpr(a, b) {
48
48
  return a.replace(/\s+/g, "") === b.replace(/\s+/g, "");
49
49
  }
50
+ /** The module's statement list (magicast's `$ast` is a `Program`, but tolerate a `File` wrapper too). */
51
+ function programBody(mod) {
52
+ const ast = mod.$ast;
53
+ return (ast.type === "File" ? ast.program : ast).body;
54
+ }
55
+ /** The name of an `export const <name> = …` statement, or undefined for any other node. */
56
+ function exportedConstName(node) {
57
+ if (node.type !== "ExportNamedDeclaration") return void 0;
58
+ const decl = node.declaration;
59
+ if (decl?.type !== "VariableDeclaration") return void 0;
60
+ const id = decl.declarations?.[0]?.id;
61
+ return id?.type === "Identifier" ? id.name : void 0;
62
+ }
63
+ /**
64
+ * Move the just-inserted exports (named in `names`) up to sit right after the import block, so wiring
65
+ * lands near the top of the file instead of appended below the component. magicast always appends a new
66
+ * export to the end of the program; this re-splices those nodes in after the last import, preserving
67
+ * their declared order. Exports we replaced in place are untouched — only nodes we added this run move.
68
+ */
69
+ function hoistExportsAfterImports(mod, names) {
70
+ const body = programBody(mod);
71
+ const moved = [];
72
+ for (const name of names) {
73
+ const idx = body.findIndex((node) => exportedConstName(node) === name);
74
+ if (idx !== -1) moved.push(body.splice(idx, 1)[0]);
75
+ }
76
+ let insertAt = 0;
77
+ for (let i = 0; i < body.length; i++) if (body[i]?.type === "ImportDeclaration") insertAt = i + 1;
78
+ body.splice(insertAt, 0, ...moved);
79
+ }
50
80
  /**
51
81
  * Merge a resolved patch into `source` by parsing it with magicast (a real TS/JS/JSX parser via Babel,
52
82
  * so declarations are bounded exactly — never by a text heuristic). Missing imports are added; each
@@ -57,6 +87,10 @@ function sameExpr(a, b) {
57
87
  * export body isn't meant to be hand-customized. Idempotent: an export already equal to ours is left
58
88
  * untouched, so re-running never churns the file.
59
89
  *
90
+ * magicast appends new exports to the end of the file; we then hoist the ones we added this run up to
91
+ * sit right after the import block, so wiring reads near the top instead of below the component. Exports
92
+ * replaced in place keep their position — only nodes newly inserted this run move.
93
+ *
60
94
  * Throws if the target cannot be parsed or regenerated. Callers must NOT swallow that — a file we
61
95
  * can't parse is one we must not guess at, so wiring falls back to a printed instruction instead.
62
96
  */
@@ -71,6 +105,7 @@ function applyPatchToSource(source, patch) {
71
105
  addedExports: [],
72
106
  replacedExports: []
73
107
  };
108
+ const insertedNames = [];
74
109
  for (const imp of patch.imports) for (const name of imp.names) {
75
110
  if (mod.imports.$items.some((item) => item.from === imp.module && item.imported === name)) continue;
76
111
  mod.imports.$append({
@@ -92,11 +127,14 @@ function applyPatchToSource(source, patch) {
92
127
  delete mod.exports[counterpart];
93
128
  mod.exports[exp.name] = builders.raw(exp.value);
94
129
  changes.replacedExports.push(exp.name);
130
+ insertedNames.push(exp.name);
95
131
  continue;
96
132
  }
97
133
  mod.exports[exp.name] = builders.raw(exp.value);
98
134
  changes.addedExports.push(exp.name);
135
+ insertedNames.push(exp.name);
99
136
  }
137
+ if (insertedNames.length) hoistExportsAfterImports(mod, insertedNames);
100
138
  return {
101
139
  text: generateCode(mod, { format }).code,
102
140
  changes