@kitn.ai/cli 0.1.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.
- package/README.md +58 -0
- package/bin/kai.js +160 -0
- package/bin/route.js +61 -0
- package/dist/assets/dev-C27Rnkul.js +668 -0
- package/dist/builder-page/assets/index-2uphF31h.js +72 -0
- package/dist/builder-page/assets/index-CMlatin-.css +1 -0
- package/dist/builder-page/index.html +20 -0
- package/dist/construct-cli.es.js +3128 -0
- package/dist/doctor.es.js +193 -0
- package/dist/theme-studio/assets/index-C4nWp3LE.js +538 -0
- package/dist/theme-studio/assets/index-CAlkHn6J.css +1 -0
- package/dist/theme-studio/index.html +17 -0
- package/package.json +74 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { existsSync, readFileSync, writeFileSync, watch, readdirSync, statSync, renameSync } from "node:fs";
|
|
7
|
+
import { join, resolve, basename, dirname, extname } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { n as npmInvocation, a as npmArgs, v as validateConstruct, g as generateProject, w as writeProject, b as generationNotices, i as inferTemplateId, t as templateById, c as buildableTemplates } from "../construct-cli.es.js";
|
|
10
|
+
import "zod";
|
|
11
|
+
function workDirFor(name, root) {
|
|
12
|
+
return join(root, ".kai", name);
|
|
13
|
+
}
|
|
14
|
+
function installKey(files) {
|
|
15
|
+
const pkg = files.find((f) => f.path === "package.json");
|
|
16
|
+
return createHash("sha256").update(pkg?.code ?? "").digest("hex");
|
|
17
|
+
}
|
|
18
|
+
function regenerate(raw, sink, dir, opts = {}) {
|
|
19
|
+
const validated = validateConstruct(raw);
|
|
20
|
+
if (!validated.ok) return validated;
|
|
21
|
+
const files = generateProject(validated.construct, opts);
|
|
22
|
+
sink.write(files, dir);
|
|
23
|
+
return { ok: true, files, construct: validated.construct };
|
|
24
|
+
}
|
|
25
|
+
function regenTurn(readRaw, sink, dir, opts, io) {
|
|
26
|
+
try {
|
|
27
|
+
const raw = readRaw();
|
|
28
|
+
const out = regenerate(raw, sink, dir, opts);
|
|
29
|
+
if (!out.ok) {
|
|
30
|
+
io.error("construct rejected — last good preview stays up:");
|
|
31
|
+
for (const p of out.problems) io.error(` ${p.path || "(root)"}: ${p.message}`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
io.log("construct changed — regenerated; Vite will hot-update the tab.");
|
|
35
|
+
for (const n of generationNotices(out.construct)) io.log(n);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
io.error(`regen failed (${err instanceof Error ? err.message : String(err)}) — last good preview stays up`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const KEY_FILE = ".kai-install-key";
|
|
41
|
+
async function ensureInstalled(dir, files, io) {
|
|
42
|
+
const key = installKey(files);
|
|
43
|
+
const keyPath = join(dir, KEY_FILE);
|
|
44
|
+
const installed = existsSync(keyPath) && readFileSync(keyPath, "utf8") === key;
|
|
45
|
+
if (installed) return;
|
|
46
|
+
io.log(`installing dependencies in ${dir} (first run or deps changed)…`);
|
|
47
|
+
const npm = npmInvocation();
|
|
48
|
+
await new Promise((done, fail) => {
|
|
49
|
+
const child = spawn(npm.command, npmArgs(["install"], npm.shell), { cwd: dir, stdio: "inherit", shell: npm.shell });
|
|
50
|
+
child.on("exit", (code) => code === 0 ? done() : fail(new Error(`npm install exited ${code}`)));
|
|
51
|
+
child.on("error", (err) => fail(new Error(`npm install failed to start: ${err.message}`)));
|
|
52
|
+
});
|
|
53
|
+
writeFileSync(keyPath, key);
|
|
54
|
+
}
|
|
55
|
+
async function dev(constructPath, opts = {}) {
|
|
56
|
+
const io = opts.io ?? { log: (s) => console.log(s), error: (s) => console.error(s) };
|
|
57
|
+
const abs = resolve(constructPath);
|
|
58
|
+
const readRaw = () => JSON.parse(readFileSync(abs, "utf8"));
|
|
59
|
+
const first = validateConstruct(readRaw());
|
|
60
|
+
if (!first.ok) {
|
|
61
|
+
for (const p of first.problems) io.error(` ${p.path || "(root)"}: ${p.message}`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
const dir = workDirFor(first.construct.name, process.cwd());
|
|
65
|
+
const files = generateProject(first.construct, { uiSpec: opts.uiSpec });
|
|
66
|
+
writeProject(files, dir);
|
|
67
|
+
for (const n of generationNotices(first.construct)) io.log(n);
|
|
68
|
+
await ensureInstalled(dir, files, io);
|
|
69
|
+
const base = basename(abs);
|
|
70
|
+
watch(dirname(abs), (_event, filename) => {
|
|
71
|
+
if (filename !== base) return;
|
|
72
|
+
regenTurn(readRaw, { write: writeProject }, dir, { uiSpec: opts.uiSpec }, io);
|
|
73
|
+
});
|
|
74
|
+
io.log(`previewing <${first.construct.name}> — edit ${abs} and watch the tab.`);
|
|
75
|
+
const npm = npmInvocation();
|
|
76
|
+
const vite = spawn(npm.command, npmArgs(["run", "dev"], npm.shell), { cwd: dir, stdio: "inherit", shell: npm.shell });
|
|
77
|
+
const killVite = () => vite.kill();
|
|
78
|
+
process.once("exit", killVite);
|
|
79
|
+
process.once("SIGINT", killVite);
|
|
80
|
+
process.once("SIGTERM", killVite);
|
|
81
|
+
return new Promise((_, rejectP) => {
|
|
82
|
+
vite.on("exit", (code) => {
|
|
83
|
+
rejectP(new Error(`vite dev exited ${code}`));
|
|
84
|
+
process.exit(code ?? 0);
|
|
85
|
+
});
|
|
86
|
+
vite.on("error", (err) => {
|
|
87
|
+
io.error(`vite dev failed to start: ${err.message}`);
|
|
88
|
+
rejectP(new Error(`vite dev failed to start: ${err.message}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function atomicWriteJson(abs, value) {
|
|
94
|
+
const tmp = `${abs}.tmp-${process.pid}`;
|
|
95
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}
|
|
96
|
+
`);
|
|
97
|
+
renameSync(tmp, abs);
|
|
98
|
+
}
|
|
99
|
+
function handleConstructPut(raw, abs) {
|
|
100
|
+
const out = validateConstruct(raw);
|
|
101
|
+
if (!out.ok) return out;
|
|
102
|
+
atomicWriteJson(abs, raw);
|
|
103
|
+
return { ok: true, construct: out.construct };
|
|
104
|
+
}
|
|
105
|
+
function shapeConstructGetResponse(onDisk) {
|
|
106
|
+
const checked = validateConstruct(onDisk);
|
|
107
|
+
return checked.ok ? onDisk : { ...onDisk, problems: checked.problems };
|
|
108
|
+
}
|
|
109
|
+
function createEventHub() {
|
|
110
|
+
const clients = /* @__PURE__ */ new Set();
|
|
111
|
+
return {
|
|
112
|
+
attach(res) {
|
|
113
|
+
res.writeHead(200, {
|
|
114
|
+
"content-type": "text/event-stream",
|
|
115
|
+
"cache-control": "no-cache",
|
|
116
|
+
connection: "keep-alive"
|
|
117
|
+
});
|
|
118
|
+
res.write(": connected\n\n");
|
|
119
|
+
clients.add(res);
|
|
120
|
+
res.on("close", () => clients.delete(res));
|
|
121
|
+
},
|
|
122
|
+
broadcast(event, data) {
|
|
123
|
+
const frame = `event: ${event}
|
|
124
|
+
data: ${JSON.stringify(data ?? {})}
|
|
125
|
+
|
|
126
|
+
`;
|
|
127
|
+
for (const res of clients) res.write(frame);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function previewFields(preview) {
|
|
132
|
+
return {
|
|
133
|
+
previewUrl: preview.status === "ready" ? preview.url : void 0,
|
|
134
|
+
previewPending: preview.status === "starting",
|
|
135
|
+
previewError: preview.status === "error" ? preview.message : void 0
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
function starterFor(body) {
|
|
139
|
+
const template = buildableTemplates().find((t) => t.id === body.templateId);
|
|
140
|
+
return body.templateId === "scratch" || !template ? { name: body.name, layout: "fullscreen", provider: { mode: "mock" } } : {
|
|
141
|
+
...template.variants?.find((v) => v.id === body.variantId)?.starter ?? template.starter,
|
|
142
|
+
name: body.name
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
function handleCreate(body, cwd) {
|
|
146
|
+
const starter = starterFor(body);
|
|
147
|
+
const validated = validateConstruct(starter);
|
|
148
|
+
if (!validated.ok) return { ok: false, problems: validated.problems };
|
|
149
|
+
const target = resolve(cwd, `${validated.construct.name}.construct.json`);
|
|
150
|
+
if (existsSync(target)) {
|
|
151
|
+
return {
|
|
152
|
+
ok: false,
|
|
153
|
+
problems: [{
|
|
154
|
+
path: "name",
|
|
155
|
+
message: `a construct named "${validated.construct.name}" already exists here (${basename(target)}) — open it from the home screen, or pick another name.`
|
|
156
|
+
}]
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
atomicWriteJson(target, starter);
|
|
160
|
+
return { ok: true, construct: starter, target };
|
|
161
|
+
}
|
|
162
|
+
async function waitUntilListening(probe, opts = {}) {
|
|
163
|
+
const timeoutMs = opts.timeoutMs ?? 18e4;
|
|
164
|
+
const intervalMs = opts.intervalMs ?? 250;
|
|
165
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
|
|
166
|
+
const now = opts.now ?? Date.now;
|
|
167
|
+
const started = now();
|
|
168
|
+
for (; ; ) {
|
|
169
|
+
const aborted = opts.abort?.();
|
|
170
|
+
if (aborted) return { ok: false, reason: aborted };
|
|
171
|
+
if (await probe()) return { ok: true };
|
|
172
|
+
if (now() - started >= timeoutMs) {
|
|
173
|
+
return { ok: false, reason: `the preview server did not start within ${Math.round(timeoutMs / 1e3)}s` };
|
|
174
|
+
}
|
|
175
|
+
await sleep(intervalMs);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function probeHost(port, host) {
|
|
179
|
+
return new Promise((done) => {
|
|
180
|
+
const socket = connect({ port, host });
|
|
181
|
+
let settled = false;
|
|
182
|
+
const finish = (ok) => {
|
|
183
|
+
if (settled) return;
|
|
184
|
+
settled = true;
|
|
185
|
+
socket.destroy();
|
|
186
|
+
done(ok);
|
|
187
|
+
};
|
|
188
|
+
socket.once("connect", () => finish(true));
|
|
189
|
+
socket.once("error", () => finish(false));
|
|
190
|
+
socket.setTimeout(1e3, () => finish(false));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
function probePort(port, hosts = ["127.0.0.1", "::1"]) {
|
|
194
|
+
return Promise.all(hosts.map((host) => probeHost(port, host))).then((answers) => answers.some(Boolean));
|
|
195
|
+
}
|
|
196
|
+
async function announceBoot(boot, hub, io) {
|
|
197
|
+
try {
|
|
198
|
+
const url = await boot();
|
|
199
|
+
hub.broadcast("preview", { previewUrl: url });
|
|
200
|
+
return { status: "ready", url };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
203
|
+
io.error(`preview failed to start — ${message}`);
|
|
204
|
+
hub.broadcast("preview-error", { message });
|
|
205
|
+
return { status: "error", message };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function listenLoopbackOnly(server, port, onListening) {
|
|
209
|
+
server.listen(port, "127.0.0.1", onListening);
|
|
210
|
+
}
|
|
211
|
+
function portInUseNotice(taken, next) {
|
|
212
|
+
return `port ${taken} is already in use — another \`kai dev --builder\` is probably still running (\`lsof -nP -iTCP:${taken} -sTCP:LISTEN\` names it). Trying ${next} instead.`;
|
|
213
|
+
}
|
|
214
|
+
function portsExhaustedMessage(from, to) {
|
|
215
|
+
return `every port from ${from} to ${to} is in use. Stop the other \`kai dev --builder\` (\`lsof -nP -iTCP:${from} -sTCP:LISTEN\` names it) and try again.`;
|
|
216
|
+
}
|
|
217
|
+
async function listenWithPortFallback(server, startPort, io, opts = {}) {
|
|
218
|
+
const attempts = Math.max(1, opts.attempts ?? 10);
|
|
219
|
+
const isTaken = opts.isTaken ?? ((p) => probePort(p));
|
|
220
|
+
let port = startPort;
|
|
221
|
+
for (let tried = 1; ; tried++) {
|
|
222
|
+
let busy = port !== 0 && await isTaken(port);
|
|
223
|
+
if (!busy) busy = await tryListenLoopback(server, port) === "taken";
|
|
224
|
+
if (!busy) {
|
|
225
|
+
const addr = server.address();
|
|
226
|
+
return typeof addr === "object" && addr !== null ? addr.port : port;
|
|
227
|
+
}
|
|
228
|
+
if (tried >= attempts) throw new Error(portsExhaustedMessage(startPort, port));
|
|
229
|
+
io.error(portInUseNotice(port, port + 1));
|
|
230
|
+
port += 1;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function tryListenLoopback(server, port) {
|
|
234
|
+
return new Promise((done, fail) => {
|
|
235
|
+
let settled = false;
|
|
236
|
+
const cleanup = () => {
|
|
237
|
+
server.off("error", onError);
|
|
238
|
+
server.off("listening", onListening);
|
|
239
|
+
};
|
|
240
|
+
const onListening = () => {
|
|
241
|
+
if (settled) return;
|
|
242
|
+
settled = true;
|
|
243
|
+
cleanup();
|
|
244
|
+
done("ok");
|
|
245
|
+
};
|
|
246
|
+
const onError = (err) => {
|
|
247
|
+
if (settled) return;
|
|
248
|
+
settled = true;
|
|
249
|
+
cleanup();
|
|
250
|
+
if (err.code === "EADDRINUSE") done("taken");
|
|
251
|
+
else fail(err);
|
|
252
|
+
};
|
|
253
|
+
server.on("error", onError);
|
|
254
|
+
server.on("listening", onListening);
|
|
255
|
+
listenLoopbackOnly(server, port, () => {
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
const ASSET_TYPES = {
|
|
260
|
+
".html": "text/html; charset=utf-8",
|
|
261
|
+
".js": "text/javascript; charset=utf-8",
|
|
262
|
+
".css": "text/css; charset=utf-8",
|
|
263
|
+
".svg": "image/svg+xml",
|
|
264
|
+
".map": "application/json",
|
|
265
|
+
".json": "application/json"
|
|
266
|
+
};
|
|
267
|
+
function serveBuilderAsset(urlPath, rootDir) {
|
|
268
|
+
const decoded = decodeURIComponent(urlPath.split("?")[0]);
|
|
269
|
+
const rel = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
270
|
+
const file = resolve(rootDir, rel);
|
|
271
|
+
if (!file.startsWith(resolve(rootDir) + "/") && file !== resolve(rootDir, "index.html")) return void 0;
|
|
272
|
+
if (!existsSync(file)) return void 0;
|
|
273
|
+
return { file, type: ASSET_TYPES[extname(file)] ?? "application/octet-stream" };
|
|
274
|
+
}
|
|
275
|
+
function resolveBuilderPageDir(startDir, dirName = "builder-page") {
|
|
276
|
+
const tried = [];
|
|
277
|
+
let dir = startDir;
|
|
278
|
+
for (let i = 0; i < 6; i++) {
|
|
279
|
+
const candidate = join(dir, dirName);
|
|
280
|
+
tried.push(candidate);
|
|
281
|
+
if (existsSync(join(candidate, "index.html"))) return { dir: candidate };
|
|
282
|
+
const atPackageRoot = existsSync(join(dir, "package.json"));
|
|
283
|
+
const parent = dirname(dir);
|
|
284
|
+
if (atPackageRoot || parent === dir) break;
|
|
285
|
+
dir = parent;
|
|
286
|
+
}
|
|
287
|
+
return { tried };
|
|
288
|
+
}
|
|
289
|
+
function builderPageDir() {
|
|
290
|
+
const out = resolveBuilderPageDir(dirname(fileURLToPath(import.meta.url)));
|
|
291
|
+
if ("dir" in out) return out.dir;
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Missing build artifact: builder-page/index.html — the builder page ships prebuilt. Tried:
|
|
294
|
+
${out.tried.map((p) => ` ${p}`).join("\n")}
|
|
295
|
+
Run \`nx build cli\` (or npm run build in packages/cli) and try again.`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
function themeStudioDir() {
|
|
299
|
+
const out = resolveBuilderPageDir(dirname(fileURLToPath(import.meta.url)), "theme-studio");
|
|
300
|
+
return "dir" in out ? out.dir : void 0;
|
|
301
|
+
}
|
|
302
|
+
function kitDistRoot() {
|
|
303
|
+
return join(dirname(createRequire(import.meta.url).resolve("@kitn.ai/ui/package.json")), "dist");
|
|
304
|
+
}
|
|
305
|
+
function themeStudioAsset(sub, studioDir, kitRoot = kitDistRoot()) {
|
|
306
|
+
if (sub.startsWith("/kit/")) {
|
|
307
|
+
if (!existsSync(kitRoot)) {
|
|
308
|
+
return {
|
|
309
|
+
kind: "problem",
|
|
310
|
+
status: 500,
|
|
311
|
+
message: `The kit's built assets are missing: ${kitRoot} does not exist (resolved from @kitn.ai/ui/package.json). Run \`npm run build\` in packages/ui (or \`nx build ui\`) and reload.`
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const hit2 = serveBuilderAsset(sub.slice("/kit".length), kitRoot);
|
|
315
|
+
if (!hit2) return { kind: "problem", status: 404, message: "not found" };
|
|
316
|
+
return { kind: "file", file: hit2.file, type: hit2.type };
|
|
317
|
+
}
|
|
318
|
+
const hit = serveBuilderAsset(sub, studioDir);
|
|
319
|
+
if (!hit) return { kind: "problem", status: 404, message: "not found" };
|
|
320
|
+
return { kind: "file", file: hit.file, type: hit.type };
|
|
321
|
+
}
|
|
322
|
+
function listConstructs(cwd) {
|
|
323
|
+
let entries;
|
|
324
|
+
try {
|
|
325
|
+
entries = readdirSync(cwd);
|
|
326
|
+
} catch {
|
|
327
|
+
return [];
|
|
328
|
+
}
|
|
329
|
+
const rows = [];
|
|
330
|
+
for (const file of entries) {
|
|
331
|
+
if (!file.endsWith(".construct.json")) continue;
|
|
332
|
+
const abs = join(cwd, file);
|
|
333
|
+
let updatedAt = (/* @__PURE__ */ new Date(0)).toISOString();
|
|
334
|
+
try {
|
|
335
|
+
updatedAt = statSync(abs).mtime.toISOString();
|
|
336
|
+
} catch {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const fallbackName = file.slice(0, -".construct.json".length);
|
|
340
|
+
try {
|
|
341
|
+
const checked = validateConstruct(JSON.parse(readFileSync(abs, "utf8")));
|
|
342
|
+
if (checked.ok) {
|
|
343
|
+
const templateId = inferTemplateId(checked.construct);
|
|
344
|
+
rows.push({
|
|
345
|
+
file,
|
|
346
|
+
name: checked.construct.name,
|
|
347
|
+
templateId,
|
|
348
|
+
templateName: templateId ? templateById(templateId)?.name : void 0,
|
|
349
|
+
updatedAt,
|
|
350
|
+
valid: true
|
|
351
|
+
});
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
} catch {
|
|
355
|
+
}
|
|
356
|
+
rows.push({ file, name: fallbackName, updatedAt, valid: false });
|
|
357
|
+
}
|
|
358
|
+
return rows.sort((a, b) => a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0);
|
|
359
|
+
}
|
|
360
|
+
function resolveConstructArg(arg, cwd) {
|
|
361
|
+
const asPath = resolve(cwd, arg);
|
|
362
|
+
if (existsSync(asPath)) return { ok: true, abs: asPath };
|
|
363
|
+
if (!arg.includes("/") && !arg.endsWith(".json")) {
|
|
364
|
+
const asName = resolve(cwd, `${arg}.construct.json`);
|
|
365
|
+
if (existsSync(asName)) return { ok: true, abs: asName };
|
|
366
|
+
}
|
|
367
|
+
const names = listConstructs(cwd).map((c) => c.file.slice(0, -".construct.json".length));
|
|
368
|
+
const inventory = names.length > 0 ? `this directory has: ${names.join(", ")}` : `no *.construct.json files exist in ${cwd}`;
|
|
369
|
+
return {
|
|
370
|
+
ok: false,
|
|
371
|
+
message: `no construct named "${arg}" — ${inventory}. Run \`kai dev --builder\` with no argument to pick from a list, or pass a path to a .construct.json file.`
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
function handleOpen(body, cwd) {
|
|
375
|
+
const file = body.file;
|
|
376
|
+
if (typeof file !== "string" || file !== basename(file) || !file.endsWith(".construct.json")) {
|
|
377
|
+
return {
|
|
378
|
+
ok: false,
|
|
379
|
+
status: 422,
|
|
380
|
+
problems: [{ path: "file", message: "file must be the basename of a *.construct.json in the project directory" }]
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const abs = resolve(cwd, file);
|
|
384
|
+
if (!existsSync(abs)) {
|
|
385
|
+
return {
|
|
386
|
+
ok: false,
|
|
387
|
+
status: 404,
|
|
388
|
+
problems: [{ path: "file", message: `no construct file "${file}" here — the list may be stale; reload the page.` }]
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
let raw;
|
|
392
|
+
try {
|
|
393
|
+
raw = JSON.parse(readFileSync(abs, "utf8"));
|
|
394
|
+
} catch (err) {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
status: 422,
|
|
398
|
+
problems: [{ path: "file", message: `${file} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` }]
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
const checked = validateConstruct(raw);
|
|
402
|
+
if (!checked.ok) return { ok: false, status: 422, problems: checked.problems };
|
|
403
|
+
return { ok: true, abs, construct: raw };
|
|
404
|
+
}
|
|
405
|
+
function crossOriginProblem(headers) {
|
|
406
|
+
const origin = headers.origin;
|
|
407
|
+
if (origin === void 0) return void 0;
|
|
408
|
+
if (headers.host !== void 0 && origin === `http://${headers.host}`) return void 0;
|
|
409
|
+
return `cross-origin request rejected (origin: ${origin}) — the builder API only accepts requests from its own page`;
|
|
410
|
+
}
|
|
411
|
+
async function readJsonBody(req) {
|
|
412
|
+
const chunks = [];
|
|
413
|
+
let size = 0;
|
|
414
|
+
for await (const chunk of req) {
|
|
415
|
+
size += chunk.length;
|
|
416
|
+
if (size > 1e6) throw new Error("body too large");
|
|
417
|
+
chunks.push(chunk);
|
|
418
|
+
}
|
|
419
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
420
|
+
}
|
|
421
|
+
async function devBuilder(constructPath, opts = {}) {
|
|
422
|
+
const io = opts.io ?? { log: (s) => console.log(s), error: (s) => console.error(s) };
|
|
423
|
+
const port = opts.port ?? 4400;
|
|
424
|
+
let previewPort = opts.previewPort ?? port + 1;
|
|
425
|
+
let pageDir;
|
|
426
|
+
try {
|
|
427
|
+
pageDir = builderPageDir();
|
|
428
|
+
} catch (err) {
|
|
429
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
const hub = createEventHub();
|
|
433
|
+
let abs;
|
|
434
|
+
if (constructPath) {
|
|
435
|
+
const resolved = resolveConstructArg(constructPath, process.cwd());
|
|
436
|
+
if (!resolved.ok) {
|
|
437
|
+
io.error(resolved.message);
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
abs = resolved.abs;
|
|
441
|
+
}
|
|
442
|
+
let preview = { status: "idle" };
|
|
443
|
+
let activeVite;
|
|
444
|
+
let activeWatcher;
|
|
445
|
+
const killActiveVite = () => {
|
|
446
|
+
activeVite?.kill();
|
|
447
|
+
};
|
|
448
|
+
process.once("exit", killActiveVite);
|
|
449
|
+
process.once("SIGINT", killActiveVite);
|
|
450
|
+
process.once("SIGTERM", killActiveVite);
|
|
451
|
+
let bootGeneration = 0;
|
|
452
|
+
const boot = async (absPath, gen) => {
|
|
453
|
+
const superseded = () => gen !== bootGeneration;
|
|
454
|
+
activeWatcher?.close();
|
|
455
|
+
activeWatcher = void 0;
|
|
456
|
+
if (activeVite) {
|
|
457
|
+
const previous = activeVite;
|
|
458
|
+
activeVite = void 0;
|
|
459
|
+
previous.kill();
|
|
460
|
+
const freed = await waitUntilListening(async () => !await probePort(previewPort), { timeoutMs: 15e3 });
|
|
461
|
+
if (!freed.ok) {
|
|
462
|
+
throw new Error(`the previous preview server did not release port ${previewPort} — stop it and reopen`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (superseded()) throw new Error("superseded by a newer open");
|
|
466
|
+
const readRaw = () => JSON.parse(readFileSync(absPath, "utf8"));
|
|
467
|
+
const initialText = readFileSync(absPath, "utf8");
|
|
468
|
+
const first = validateConstruct(JSON.parse(initialText));
|
|
469
|
+
if (!first.ok) {
|
|
470
|
+
for (const p of first.problems) io.error(` ${p.path || "(root)"}: ${p.message}`);
|
|
471
|
+
throw new Error("construct invalid");
|
|
472
|
+
}
|
|
473
|
+
const dir = workDirFor(first.construct.name, process.cwd());
|
|
474
|
+
const files = generateProject(first.construct, { uiSpec: opts.uiSpec });
|
|
475
|
+
writeProject(files, dir);
|
|
476
|
+
await ensureInstalled(dir, files, io);
|
|
477
|
+
if (superseded()) throw new Error("superseded by a newer open");
|
|
478
|
+
const base = basename(absPath);
|
|
479
|
+
activeWatcher = watch(dirname(absPath), (_event, filename) => {
|
|
480
|
+
if (filename !== base) return;
|
|
481
|
+
regenTurn(readRaw, { write: writeProject }, dir, { uiSpec: opts.uiSpec }, io);
|
|
482
|
+
hub.broadcast("construct");
|
|
483
|
+
});
|
|
484
|
+
if (readFileSync(absPath, "utf8") !== initialText) {
|
|
485
|
+
regenTurn(readRaw, { write: writeProject }, dir, { uiSpec: opts.uiSpec }, io);
|
|
486
|
+
}
|
|
487
|
+
const npm = npmInvocation();
|
|
488
|
+
const vite = spawn(npm.command, npmArgs(["run", "dev", "--", "--port", String(previewPort), "--strictPort"], npm.shell), {
|
|
489
|
+
cwd: dir,
|
|
490
|
+
stdio: "inherit",
|
|
491
|
+
shell: npm.shell
|
|
492
|
+
});
|
|
493
|
+
activeVite = vite;
|
|
494
|
+
let viteDied;
|
|
495
|
+
vite.on("exit", (code) => {
|
|
496
|
+
viteDied ??= `the preview server exited with code ${code} before it started listening`;
|
|
497
|
+
});
|
|
498
|
+
vite.on("error", (err) => {
|
|
499
|
+
viteDied ??= `the preview server failed to start: ${err.message}`;
|
|
500
|
+
});
|
|
501
|
+
const listening = await waitUntilListening(() => probePort(previewPort), { abort: () => viteDied });
|
|
502
|
+
if (!listening.ok) throw new Error(listening.reason);
|
|
503
|
+
const url = `http://localhost:${previewPort}/`;
|
|
504
|
+
io.log(`previewing <${first.construct.name}> at ${url}`);
|
|
505
|
+
return url;
|
|
506
|
+
};
|
|
507
|
+
const bootInBackground = (absPath) => {
|
|
508
|
+
const gen = ++bootGeneration;
|
|
509
|
+
preview = { status: "starting" };
|
|
510
|
+
const gatedHub = {
|
|
511
|
+
broadcast: (event, data) => {
|
|
512
|
+
if (gen === bootGeneration) hub.broadcast(event, data);
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
void announceBoot(() => boot(absPath, gen), gatedHub, io).then((next) => {
|
|
516
|
+
if (gen === bootGeneration) preview = next;
|
|
517
|
+
});
|
|
518
|
+
};
|
|
519
|
+
if (abs) {
|
|
520
|
+
try {
|
|
521
|
+
const initial = validateConstruct(JSON.parse(readFileSync(abs, "utf8")));
|
|
522
|
+
if (!initial.ok) {
|
|
523
|
+
for (const p of initial.problems) io.error(` ${p.path || "(root)"}: ${p.message}`);
|
|
524
|
+
process.exit(1);
|
|
525
|
+
}
|
|
526
|
+
} catch (err) {
|
|
527
|
+
io.error(`cannot read ${abs}: ${err instanceof Error ? err.message : String(err)}`);
|
|
528
|
+
process.exit(1);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const server = createServer(async (req, res) => {
|
|
532
|
+
const send = (code, body) => {
|
|
533
|
+
res.writeHead(code, { "content-type": "application/json" });
|
|
534
|
+
res.end(JSON.stringify(body));
|
|
535
|
+
};
|
|
536
|
+
try {
|
|
537
|
+
const url = req.url ?? "/";
|
|
538
|
+
if (req.method !== "GET") {
|
|
539
|
+
const rejected = crossOriginProblem({ origin: req.headers.origin, host: req.headers.host });
|
|
540
|
+
if (rejected) return send(403, { problems: [{ path: "", message: rejected }] });
|
|
541
|
+
}
|
|
542
|
+
if (req.method === "GET" && url === "/api/state") {
|
|
543
|
+
if (abs) {
|
|
544
|
+
return send(200, {
|
|
545
|
+
phase: "panel",
|
|
546
|
+
constructPath: abs,
|
|
547
|
+
construct: JSON.parse(readFileSync(abs, "utf8")),
|
|
548
|
+
...previewFields(preview)
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
const constructs = listConstructs(process.cwd());
|
|
552
|
+
return send(200, { phase: constructs.length > 0 ? "home" : "start", constructs });
|
|
553
|
+
}
|
|
554
|
+
if (req.method === "GET" && url === "/api/constructs") {
|
|
555
|
+
return send(200, { constructs: listConstructs(process.cwd()) });
|
|
556
|
+
}
|
|
557
|
+
if (req.method === "GET" && url === "/api/construct") {
|
|
558
|
+
if (!abs) return send(404, { problems: [{ path: "", message: "no construct yet" }] });
|
|
559
|
+
return send(200, shapeConstructGetResponse(JSON.parse(readFileSync(abs, "utf8"))));
|
|
560
|
+
}
|
|
561
|
+
if (req.method === "GET" && url === "/api/events") return hub.attach(res);
|
|
562
|
+
if (req.method === "POST" && url === "/api/construct") {
|
|
563
|
+
if (!abs) return send(409, { problems: [{ path: "", message: "create a construct first" }] });
|
|
564
|
+
const out = handleConstructPut(await readJsonBody(req), abs);
|
|
565
|
+
return out.ok ? send(200, { ok: true }) : send(422, { problems: out.problems });
|
|
566
|
+
}
|
|
567
|
+
if (req.method === "POST" && url === "/api/open") {
|
|
568
|
+
const out = handleOpen(await readJsonBody(req), process.cwd());
|
|
569
|
+
if (!out.ok) return send(out.status, { problems: out.problems });
|
|
570
|
+
if (abs === out.abs && (preview.status === "ready" || preview.status === "starting")) {
|
|
571
|
+
return send(200, { construct: out.construct, constructPath: abs, ...previewFields(preview) });
|
|
572
|
+
}
|
|
573
|
+
abs = out.abs;
|
|
574
|
+
preview = { status: "starting" };
|
|
575
|
+
send(200, { construct: out.construct, constructPath: abs, ...previewFields(preview) });
|
|
576
|
+
bootInBackground(out.abs);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (req.method === "POST" && url === "/api/create") {
|
|
580
|
+
const out = handleCreate(await readJsonBody(req), process.cwd());
|
|
581
|
+
if (!out.ok) return send(422, { problems: out.problems });
|
|
582
|
+
abs = out.target;
|
|
583
|
+
preview = { status: "starting" };
|
|
584
|
+
send(200, { construct: out.construct, ...previewFields(preview) });
|
|
585
|
+
bootInBackground(out.target);
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (req.method === "GET" && (url === "/theme-studio" || url.startsWith("/theme-studio?"))) {
|
|
589
|
+
const q = url.indexOf("?");
|
|
590
|
+
res.writeHead(302, { location: `/theme-studio/${q === -1 ? "" : url.slice(q)}` });
|
|
591
|
+
return res.end();
|
|
592
|
+
}
|
|
593
|
+
if (req.method === "GET" && url.startsWith("/theme-studio/")) {
|
|
594
|
+
const studioDir = themeStudioDir();
|
|
595
|
+
if (!studioDir) {
|
|
596
|
+
return send(404, {
|
|
597
|
+
problems: [{ path: "", message: "dist/theme-studio is missing — run `npm run build` in packages/cli (or nx build cli) and reload." }]
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
const sub = url.slice("/theme-studio".length);
|
|
601
|
+
const studioAsset = themeStudioAsset(sub, studioDir);
|
|
602
|
+
if (studioAsset.kind === "problem") {
|
|
603
|
+
return send(studioAsset.status, { problems: [{ path: "", message: studioAsset.message }] });
|
|
604
|
+
}
|
|
605
|
+
res.writeHead(200, { "content-type": studioAsset.type });
|
|
606
|
+
return res.end(readFileSync(studioAsset.file));
|
|
607
|
+
}
|
|
608
|
+
const asset = serveBuilderAsset(url, pageDir);
|
|
609
|
+
if (asset) {
|
|
610
|
+
res.writeHead(200, { "content-type": asset.type });
|
|
611
|
+
return res.end(readFileSync(asset.file));
|
|
612
|
+
}
|
|
613
|
+
if (req.method === "GET") {
|
|
614
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
615
|
+
return res.end(readFileSync(join(pageDir, "index.html")));
|
|
616
|
+
}
|
|
617
|
+
return send(404, { problems: [{ path: "", message: "not found" }] });
|
|
618
|
+
} catch (err) {
|
|
619
|
+
return send(400, { problems: [{ path: "", message: err instanceof Error ? err.message : String(err) }] });
|
|
620
|
+
}
|
|
621
|
+
});
|
|
622
|
+
let bound;
|
|
623
|
+
try {
|
|
624
|
+
bound = await listenWithPortFallback(server, port, io);
|
|
625
|
+
} catch (err) {
|
|
626
|
+
io.error(err instanceof Error ? err.message : String(err));
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
if (opts.previewPort === void 0) previewPort = bound + 1;
|
|
630
|
+
io.log(`kai builder at http://localhost:${bound}/ — the construct file stays yours.`);
|
|
631
|
+
if (abs) bootInBackground(abs);
|
|
632
|
+
return new Promise(() => {
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
export {
|
|
636
|
+
announceBoot,
|
|
637
|
+
atomicWriteJson,
|
|
638
|
+
builderPageDir,
|
|
639
|
+
createEventHub,
|
|
640
|
+
crossOriginProblem,
|
|
641
|
+
dev,
|
|
642
|
+
devBuilder,
|
|
643
|
+
ensureInstalled,
|
|
644
|
+
handleConstructPut,
|
|
645
|
+
handleCreate,
|
|
646
|
+
handleOpen,
|
|
647
|
+
installKey,
|
|
648
|
+
kitDistRoot,
|
|
649
|
+
listConstructs,
|
|
650
|
+
listenLoopbackOnly,
|
|
651
|
+
listenWithPortFallback,
|
|
652
|
+
portInUseNotice,
|
|
653
|
+
portsExhaustedMessage,
|
|
654
|
+
previewFields,
|
|
655
|
+
probeHost,
|
|
656
|
+
probePort,
|
|
657
|
+
regenTurn,
|
|
658
|
+
regenerate,
|
|
659
|
+
resolveBuilderPageDir,
|
|
660
|
+
resolveConstructArg,
|
|
661
|
+
serveBuilderAsset,
|
|
662
|
+
shapeConstructGetResponse,
|
|
663
|
+
starterFor,
|
|
664
|
+
themeStudioAsset,
|
|
665
|
+
themeStudioDir,
|
|
666
|
+
waitUntilListening,
|
|
667
|
+
workDirFor
|
|
668
|
+
};
|