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.
- package/dist/astro/server.d.ts +9 -0
- package/dist/astro/server.js +10 -0
- package/dist/astro.d.ts +2 -0
- package/dist/astro.js +3 -0
- package/dist/cli/commands/_setup.js +10 -10
- package/dist/cli/commands/_wiring.js +13 -11
- package/dist/cli/commands/create.js +101 -71
- package/dist/cli/commands/generate.js +4 -0
- package/dist/cli/commands/init.js +110 -60
- package/dist/cli/daemon/config.js +23 -9
- package/dist/cli/daemon/generate.js +2 -3
- package/dist/cli/daemon/jiti.js +101 -0
- package/dist/cli/daemon/watch.js +7 -3
- package/dist/cli/presets/patch.js +38 -0
- package/dist/cli/presets/source.js +122 -24
- package/dist/cli/presets/template.js +47 -35
- package/dist/cli/wp/stack.js +2 -2
- package/dist/config.d.ts +1 -1
- package/dist/context.js +9 -3
- package/dist/index.d.ts +3 -3
- package/dist/index.js +2 -2
- package/dist/integrations/astro/kizlo.client.d.ts +15 -0
- package/dist/integrations/astro/kizlo.client.js +13 -0
- package/dist/integrations/astro/kizlo.server.d.ts +10 -0
- package/dist/integrations/astro/kizlo.server.js +12 -0
- package/dist/integrations/astro/manifest.d.ts +9 -0
- package/dist/integrations/astro/manifest.js +11 -0
- package/dist/integrations/astro/metadata.d.ts +64 -0
- package/dist/integrations/astro/metadata.js +71 -0
- package/dist/integrations/astro/robots.d.ts +8 -0
- package/dist/integrations/astro/robots.js +11 -0
- package/dist/integrations/astro/sitemap.d.ts +16 -0
- package/dist/integrations/astro/sitemap.js +20 -0
- package/dist/integrations/astro/utils.d.ts +19 -0
- package/dist/integrations/astro/utils.js +17 -0
- package/dist/integrations/nextjs/metadata.d.ts +8 -1
- package/dist/integrations/nextjs/metadata.js +13 -1
- package/dist/kizlo.d.ts +8 -1
- package/dist/kizlo.js +16 -13
- package/dist/nextjs/server.d.ts +3 -2
- package/dist/nextjs/server.js +3 -2
- package/dist/package.js +1 -1
- package/dist/router.d.ts +5 -5
- package/dist/seo/utils.d.ts +8 -2
- package/dist/seo/utils.js +9 -1
- package/dist/shared/constants.js +9 -1
- package/package.json +18 -2
- package/dist/cli/presets/base.js +0 -40
- package/dist/cli/presets/index.js +0 -16
- 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 {
|
|
3
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
163
|
-
let
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
-
|
|
227
|
+
apiPath,
|
|
180
228
|
envKeys
|
|
181
229
|
}) : await collectInteractively({
|
|
182
230
|
cwd,
|
|
183
231
|
hasSrcDir,
|
|
184
|
-
|
|
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 =
|
|
236
|
+
const includeExamples = yes ? true : orCancel(await p.confirm({
|
|
189
237
|
message: "Add example pages?",
|
|
190
238
|
initialValue: true
|
|
191
|
-
}))
|
|
239
|
+
}));
|
|
192
240
|
if (!hasKizlo) {
|
|
193
|
-
const spec = `kizlo@${manifest
|
|
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 (
|
|
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:
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 };
|
package/dist/cli/daemon/watch.js
CHANGED
|
@@ -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`),
|
|
41
|
-
* watching. Used by `kizlo dev`, both when it boots a local
|
|
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
|