leglas 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 +395 -0
- package/dist/args.d.ts +69 -0
- package/dist/baseline.d.ts +14 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +2226 -0
- package/dist/briefs.d.ts +32 -0
- package/dist/ignore.d.ts +11 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +2086 -0
- package/dist/init.d.ts +13 -0
- package/dist/keep.d.ts +32 -0
- package/dist/new.d.ts +34 -0
- package/dist/run-classify.d.ts +14 -0
- package/dist/run-explore.d.ts +18 -0
- package/dist/run-init.d.ts +16 -0
- package/dist/run-keep.d.ts +12 -0
- package/dist/run-new.d.ts +23 -0
- package/dist/run-previews.d.ts +28 -0
- package/dist/run.d.ts +24 -0
- package/dist/shell/assets/Satoshi-Medium-ByP-Zb-9.woff2 +0 -0
- package/dist/shell/assets/Satoshi-Regular-CPM9dct4.woff2 +0 -0
- package/dist/shell/assets/index-D-nej-Uo.js +9 -0
- package/dist/shell/assets/index-DcVuoqfz.css +1 -0
- package/dist/shell/assets/manrope-cyrillic-wght-normal-Dvxsihut.woff2 +0 -0
- package/dist/shell/assets/manrope-greek-wght-normal-DL7QRZyv.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-ext-wght-normal-Ch3YOpNY.woff2 +0 -0
- package/dist/shell/assets/manrope-latin-wght-normal-DHIcAJRg.woff2 +0 -0
- package/dist/shell/assets/manrope-vietnamese-wght-normal-usUDDRr7.woff2 +0 -0
- package/dist/shell/assets/outfit-latin-ext-wght-normal-DdQaqQDo.woff2 +0 -0
- package/dist/shell/assets/outfit-latin-wght-normal-Bc-8i84L.woff2 +0 -0
- package/dist/shell/assets/spline-sans-mono-latin-400-normal-739QRW1l.woff +0 -0
- package/dist/shell/assets/spline-sans-mono-latin-400-normal-mUpA6Mve.woff2 +0 -0
- package/dist/shell/assets/spline-sans-mono-latin-ext-400-normal-BfWvPoNT.woff2 +0 -0
- package/dist/shell/assets/spline-sans-mono-latin-ext-400-normal-BkT5i7fe.woff +0 -0
- package/dist/shell/index.html +14 -0
- package/package.json +35 -0
package/dist/bin.js
ADDED
|
@@ -0,0 +1,2226 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/bin.ts
|
|
4
|
+
import { spawn as spawn2 } from "child_process";
|
|
5
|
+
import { createRequire as createRequire2 } from "module";
|
|
6
|
+
|
|
7
|
+
// src/args.ts
|
|
8
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set(["--port", "--user-port", "--config"]);
|
|
9
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set(["--no-open", "--json"]);
|
|
10
|
+
function parsePort(flag, raw) {
|
|
11
|
+
if (!/^\d+$/.test(raw)) {
|
|
12
|
+
return { error: `${flag} needs a number, received ${JSON.stringify(raw)}.` };
|
|
13
|
+
}
|
|
14
|
+
const port = Number(raw);
|
|
15
|
+
if (port < 1 || port > 65535) {
|
|
16
|
+
return { error: `${flag} must be between 1 and 65535, received ${port}.` };
|
|
17
|
+
}
|
|
18
|
+
return port;
|
|
19
|
+
}
|
|
20
|
+
function parseNew(rest) {
|
|
21
|
+
let surface;
|
|
22
|
+
let print = false;
|
|
23
|
+
let json = false;
|
|
24
|
+
let from;
|
|
25
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
26
|
+
const argument = rest[index];
|
|
27
|
+
if (argument === "--from" || argument.startsWith("--from=")) {
|
|
28
|
+
from = argument.includes("=") ? argument.split("=").slice(1).join("=") : rest[index += 1];
|
|
29
|
+
if (from === void 0 || from === "") {
|
|
30
|
+
return { kind: "error", message: "--from needs a path, for example --from src/Hero.tsx" };
|
|
31
|
+
}
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (argument === "--print") {
|
|
35
|
+
print = true;
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
if (argument === "--json") {
|
|
39
|
+
json = true;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
43
|
+
if (argument.startsWith("-")) {
|
|
44
|
+
return { kind: "error", message: `leglas new does not take ${argument}.` };
|
|
45
|
+
}
|
|
46
|
+
if (surface !== void 0) {
|
|
47
|
+
return { kind: "error", message: `leglas new takes one surface name, received ${JSON.stringify(argument)} as well.` };
|
|
48
|
+
}
|
|
49
|
+
surface = argument;
|
|
50
|
+
}
|
|
51
|
+
if (surface === void 0) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "error",
|
|
54
|
+
message: "leglas new needs a surface name, for example: leglas new hero"
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { kind: "new", surface, print, json, from };
|
|
58
|
+
}
|
|
59
|
+
function parseAdd(rest) {
|
|
60
|
+
let title;
|
|
61
|
+
let url;
|
|
62
|
+
let note;
|
|
63
|
+
let branch;
|
|
64
|
+
let file;
|
|
65
|
+
const tags = [];
|
|
66
|
+
let json = false;
|
|
67
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
68
|
+
const argument = rest[index];
|
|
69
|
+
if (argument === "--json") {
|
|
70
|
+
json = true;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
74
|
+
const equals = argument.indexOf("=");
|
|
75
|
+
const flag = equals === -1 ? argument : argument.slice(0, equals);
|
|
76
|
+
let value;
|
|
77
|
+
if (equals === -1) {
|
|
78
|
+
value = rest[index + 1];
|
|
79
|
+
index += 1;
|
|
80
|
+
} else {
|
|
81
|
+
value = argument.slice(equals + 1);
|
|
82
|
+
}
|
|
83
|
+
if (!["--title", "--url", "--note", "--tag", "--branch", "--file"].includes(flag)) {
|
|
84
|
+
return { kind: "error", message: `leglas add does not take ${flag}.` };
|
|
85
|
+
}
|
|
86
|
+
if (value === void 0 || value === "") {
|
|
87
|
+
return { kind: "error", message: `${flag} needs a value.` };
|
|
88
|
+
}
|
|
89
|
+
if (flag === "--title") title = value;
|
|
90
|
+
else if (flag === "--url") url = value;
|
|
91
|
+
else if (flag === "--note") note = value;
|
|
92
|
+
else if (flag === "--branch") branch = value;
|
|
93
|
+
else if (flag === "--file") file = value;
|
|
94
|
+
else tags.push(value);
|
|
95
|
+
}
|
|
96
|
+
if (title === void 0) {
|
|
97
|
+
return { kind: "error", message: "leglas add needs --title, which is how the preview is identified." };
|
|
98
|
+
}
|
|
99
|
+
if (url === void 0 && file === void 0) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "error",
|
|
102
|
+
message: "leglas add needs --url (for example --url '/?v-hero=aurora') or --file for a page Leglas serves itself."
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
kind: "add",
|
|
107
|
+
preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file },
|
|
108
|
+
json
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function parseClassify(rest) {
|
|
112
|
+
const changes = [];
|
|
113
|
+
let json = false;
|
|
114
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
115
|
+
const argument = rest[index];
|
|
116
|
+
if (argument === "--json") {
|
|
117
|
+
json = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
121
|
+
const equals = argument.indexOf("=");
|
|
122
|
+
const flag = equals === -1 ? argument : argument.slice(0, equals);
|
|
123
|
+
if (flag !== "--change" && flag !== "--rewrite") {
|
|
124
|
+
return { kind: "error", message: `leglas classify does not take ${argument}.` };
|
|
125
|
+
}
|
|
126
|
+
const value = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
|
|
127
|
+
if (value === void 0 || value === "") {
|
|
128
|
+
return { kind: "error", message: `${flag} needs a path, for example ${flag} package.json` };
|
|
129
|
+
}
|
|
130
|
+
changes.push({ path: value, kind: flag === "--change" ? "change" : "rewrite" });
|
|
131
|
+
}
|
|
132
|
+
if (changes.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
kind: "error",
|
|
135
|
+
message: "leglas classify needs what the direction will touch, for example: leglas classify --change package.json --rewrite src/theme.css"
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
return { kind: "classify", changes, json };
|
|
139
|
+
}
|
|
140
|
+
function parseArgs(argv) {
|
|
141
|
+
if (argv[0] === "new") return parseNew(argv.slice(1));
|
|
142
|
+
if (argv[0] === "add") return parseAdd(argv.slice(1));
|
|
143
|
+
if (argv[0] === "classify") return parseClassify(argv.slice(1));
|
|
144
|
+
if (argv[0] === "init") {
|
|
145
|
+
const rest = argv.slice(1);
|
|
146
|
+
const unknown = rest.find((argument) => argument !== "--force" && argument !== "--json");
|
|
147
|
+
if (unknown !== void 0) {
|
|
148
|
+
return { kind: "error", message: `leglas init does not take ${unknown}.` };
|
|
149
|
+
}
|
|
150
|
+
return { kind: "init", force: rest.includes("--force"), json: rest.includes("--json") };
|
|
151
|
+
}
|
|
152
|
+
if (argv[0] === "keep") {
|
|
153
|
+
const rest = argv.slice(1);
|
|
154
|
+
let title;
|
|
155
|
+
let to;
|
|
156
|
+
let json = false;
|
|
157
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
158
|
+
const argument = rest[index];
|
|
159
|
+
if (argument === "--json") {
|
|
160
|
+
json = true;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (argument === "--to" || argument.startsWith("--to=")) {
|
|
164
|
+
to = argument.includes("=") ? argument.split("=").slice(1).join("=") : rest[index += 1];
|
|
165
|
+
if (to === void 0 || to === "") {
|
|
166
|
+
return { kind: "error", message: "--to needs a path, for example --to src/components/hero.tsx" };
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (argument.startsWith("-")) {
|
|
171
|
+
return { kind: "error", message: `leglas keep does not take ${argument}.` };
|
|
172
|
+
}
|
|
173
|
+
if (title !== void 0) {
|
|
174
|
+
return { kind: "error", message: "leglas keep takes one direction title." };
|
|
175
|
+
}
|
|
176
|
+
title = argument;
|
|
177
|
+
}
|
|
178
|
+
if (title === void 0) {
|
|
179
|
+
return {
|
|
180
|
+
kind: "error",
|
|
181
|
+
message: 'leglas keep needs a direction title, for example: leglas keep "Aurora" --to src/components/hero.tsx'
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
if (to === void 0) {
|
|
185
|
+
return { kind: "error", message: "leglas keep needs --to, the path the winner should live at." };
|
|
186
|
+
}
|
|
187
|
+
return { kind: "keep", title, to, json };
|
|
188
|
+
}
|
|
189
|
+
if (argv[0] === "explore") {
|
|
190
|
+
const rest = argv.slice(1);
|
|
191
|
+
let surface;
|
|
192
|
+
let count = 3;
|
|
193
|
+
let json = false;
|
|
194
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
195
|
+
const argument = rest[index];
|
|
196
|
+
if (argument === "--json") {
|
|
197
|
+
json = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (argument === "--count" || argument.startsWith("--count=")) {
|
|
201
|
+
const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
|
|
202
|
+
if (raw === void 0 || !/^\d+$/.test(raw)) {
|
|
203
|
+
return { kind: "error", message: "--count needs a number, for example --count 6." };
|
|
204
|
+
}
|
|
205
|
+
count = Number(raw);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (argument.startsWith("-")) {
|
|
209
|
+
return { kind: "error", message: `leglas explore does not take ${argument}.` };
|
|
210
|
+
}
|
|
211
|
+
if (surface !== void 0) {
|
|
212
|
+
return { kind: "error", message: "leglas explore takes one surface name." };
|
|
213
|
+
}
|
|
214
|
+
surface = argument;
|
|
215
|
+
}
|
|
216
|
+
if (surface === void 0) {
|
|
217
|
+
return {
|
|
218
|
+
kind: "error",
|
|
219
|
+
message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
return { kind: "explore", surface, count, json };
|
|
223
|
+
}
|
|
224
|
+
if (argv[0] === "requests") {
|
|
225
|
+
const rest = argv.slice(1);
|
|
226
|
+
const unknown = rest.find((argument) => argument !== "--json" && argument !== "--clear");
|
|
227
|
+
if (unknown !== void 0) {
|
|
228
|
+
return { kind: "error", message: `leglas requests does not take ${unknown}.` };
|
|
229
|
+
}
|
|
230
|
+
return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
|
|
231
|
+
}
|
|
232
|
+
if (argv[0] === "list") {
|
|
233
|
+
const rest = argv.slice(1);
|
|
234
|
+
const unknown = rest.find((argument) => argument !== "--json");
|
|
235
|
+
if (unknown !== void 0) {
|
|
236
|
+
return { kind: "error", message: `leglas list does not take ${unknown}.` };
|
|
237
|
+
}
|
|
238
|
+
return { kind: "list", json: rest.includes("--json") };
|
|
239
|
+
}
|
|
240
|
+
const options = {
|
|
241
|
+
port: void 0,
|
|
242
|
+
userPort: void 0,
|
|
243
|
+
configPath: void 0,
|
|
244
|
+
open: true,
|
|
245
|
+
json: false
|
|
246
|
+
};
|
|
247
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
248
|
+
const argument = argv[index];
|
|
249
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
250
|
+
if (argument === "--version" || argument === "-v") return { kind: "version" };
|
|
251
|
+
if (BOOLEAN_FLAGS.has(argument)) {
|
|
252
|
+
if (argument === "--no-open") options.open = false;
|
|
253
|
+
if (argument === "--json") options.json = true;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const equals = argument.indexOf("=");
|
|
257
|
+
const flag = equals === -1 ? argument : argument.slice(0, equals);
|
|
258
|
+
if (!VALUE_FLAGS.has(flag)) {
|
|
259
|
+
return {
|
|
260
|
+
kind: "error",
|
|
261
|
+
message: argument.startsWith("-") ? `Unknown flag ${argument}. Run leglas --help to see the options.` : `Unexpected argument ${JSON.stringify(argument)}. leglas takes flags only.`
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
let value;
|
|
265
|
+
if (equals === -1) {
|
|
266
|
+
value = argv[index + 1];
|
|
267
|
+
index += 1;
|
|
268
|
+
} else {
|
|
269
|
+
value = argument.slice(equals + 1);
|
|
270
|
+
}
|
|
271
|
+
if (value === void 0 || value === "" || value.startsWith("--")) {
|
|
272
|
+
return { kind: "error", message: `${flag} needs a value.` };
|
|
273
|
+
}
|
|
274
|
+
if (flag === "--config") {
|
|
275
|
+
options.configPath = value;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
const port = parsePort(flag, value);
|
|
279
|
+
if (typeof port !== "number") return { kind: "error", message: port.error };
|
|
280
|
+
if (flag === "--port") options.port = port;
|
|
281
|
+
else options.userPort = port;
|
|
282
|
+
}
|
|
283
|
+
return { kind: "run", options };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// src/run-classify.ts
|
|
287
|
+
import { stat } from "fs/promises";
|
|
288
|
+
import { join as join6 } from "path";
|
|
289
|
+
|
|
290
|
+
// ../server/dist/config.js
|
|
291
|
+
var DEFAULT_DEV_SERVER = "http://localhost:3000";
|
|
292
|
+
var DEFAULT_INSTALL_COMMAND = "npm install";
|
|
293
|
+
var IMPLICIT_PREVIEW = { title: "App", url: "/" };
|
|
294
|
+
function isRecord(value) {
|
|
295
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
296
|
+
}
|
|
297
|
+
function isValidOrigin(value) {
|
|
298
|
+
try {
|
|
299
|
+
const url = new URL(value);
|
|
300
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
301
|
+
} catch {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
function isSafeBranch(value) {
|
|
306
|
+
if (value === "" || value.startsWith("-"))
|
|
307
|
+
return false;
|
|
308
|
+
if (value.split("/").some((segment) => segment === "." || segment === ".."))
|
|
309
|
+
return false;
|
|
310
|
+
return /^[A-Za-z0-9._/-]+$/.test(value);
|
|
311
|
+
}
|
|
312
|
+
function isValidPreviewUrl(value) {
|
|
313
|
+
return value.startsWith("/") || isValidOrigin(value);
|
|
314
|
+
}
|
|
315
|
+
function isSafePreviewFile(value) {
|
|
316
|
+
if (value === "" || value.startsWith("/") || value.startsWith("\\"))
|
|
317
|
+
return false;
|
|
318
|
+
if (/^[A-Za-z]:/.test(value))
|
|
319
|
+
return false;
|
|
320
|
+
return !value.split(/[/\\]/).some((segment) => segment === "..");
|
|
321
|
+
}
|
|
322
|
+
function normalizeConfig(raw, options = {}) {
|
|
323
|
+
const requireDevCommand = options.requireDevCommand ?? true;
|
|
324
|
+
const errors = [];
|
|
325
|
+
const source = raw === void 0 || raw === null ? {} : raw;
|
|
326
|
+
if (!isRecord(source)) {
|
|
327
|
+
return { config: null, errors: ["Config must export an object."] };
|
|
328
|
+
}
|
|
329
|
+
const devServer = source["devServer"] ?? DEFAULT_DEV_SERVER;
|
|
330
|
+
if (typeof devServer !== "string" || !isValidOrigin(devServer)) {
|
|
331
|
+
errors.push(`devServer must be an http(s) URL, received ${JSON.stringify(devServer)}.`);
|
|
332
|
+
}
|
|
333
|
+
const rawPreviews = source["previews"] ?? [IMPLICIT_PREVIEW];
|
|
334
|
+
if (!Array.isArray(rawPreviews)) {
|
|
335
|
+
errors.push(`previews must be an array, received ${JSON.stringify(rawPreviews)}.`);
|
|
336
|
+
return { config: null, errors };
|
|
337
|
+
}
|
|
338
|
+
const previews = [];
|
|
339
|
+
const seenTitles = /* @__PURE__ */ new Set();
|
|
340
|
+
rawPreviews.forEach((entry, index) => {
|
|
341
|
+
const at = `previews[${index}]`;
|
|
342
|
+
if (!isRecord(entry)) {
|
|
343
|
+
errors.push(`${at} must be an object.`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const title = entry["title"];
|
|
347
|
+
const url = entry["url"];
|
|
348
|
+
const file = entry["file"];
|
|
349
|
+
if (typeof title !== "string" || title.trim() === "") {
|
|
350
|
+
errors.push(`${at} needs a title; the rail has nothing to show without one.`);
|
|
351
|
+
} else if (seenTitles.has(title)) {
|
|
352
|
+
errors.push(`${at} repeats the title ${JSON.stringify(title)}; titles must be unique.`);
|
|
353
|
+
} else {
|
|
354
|
+
seenTitles.add(title);
|
|
355
|
+
}
|
|
356
|
+
if (file !== void 0) {
|
|
357
|
+
if (typeof file !== "string" || !isSafePreviewFile(file)) {
|
|
358
|
+
errors.push(`${at} has an unusable file ${JSON.stringify(file)}; use a path inside the project, like "directions/hero.html".`);
|
|
359
|
+
}
|
|
360
|
+
if (url !== void 0) {
|
|
361
|
+
errors.push(`${at} names a file and a url; a file preview's url is assigned by Leglas.`);
|
|
362
|
+
}
|
|
363
|
+
} else if (typeof url !== "string" || url.trim() === "") {
|
|
364
|
+
errors.push(`${at} needs a url.`);
|
|
365
|
+
} else if (!isValidPreviewUrl(url)) {
|
|
366
|
+
errors.push(`${at} has url ${JSON.stringify(url)}; use a root-relative path ("/pricing") or a full URL.`);
|
|
367
|
+
}
|
|
368
|
+
const branch = entry["branch"];
|
|
369
|
+
if (branch !== void 0) {
|
|
370
|
+
if (typeof branch !== "string" || !isSafeBranch(branch)) {
|
|
371
|
+
errors.push(`${at} has an unusable branch ${JSON.stringify(branch)}; use a plain git branch name.`);
|
|
372
|
+
} else if (typeof url === "string" && !url.startsWith("/")) {
|
|
373
|
+
errors.push(`${at} names a branch and an absolute url; a branch preview is served by Leglas, so its url must be a path.`);
|
|
374
|
+
}
|
|
375
|
+
if (file !== void 0) {
|
|
376
|
+
errors.push(`${at} names a branch and a file; a file preview is served by Leglas itself and has no checkout.`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const tags = entry["tags"];
|
|
380
|
+
previews.push({
|
|
381
|
+
title: typeof title === "string" ? title : "",
|
|
382
|
+
url: typeof url === "string" ? url : "",
|
|
383
|
+
note: typeof entry["note"] === "string" ? entry["note"] : void 0,
|
|
384
|
+
tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
|
|
385
|
+
...typeof branch === "string" ? { branch } : {},
|
|
386
|
+
...typeof file === "string" ? { file } : {}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
const devCommand = source["devCommand"];
|
|
390
|
+
if (devCommand !== void 0 && typeof devCommand !== "string") {
|
|
391
|
+
errors.push("devCommand must be a string.");
|
|
392
|
+
} else if (typeof devCommand === "string" && !devCommand.includes("{port}")) {
|
|
393
|
+
errors.push(`devCommand must include {port}, so Leglas can start each checkout on a free port. Received ${JSON.stringify(devCommand)}.`);
|
|
394
|
+
}
|
|
395
|
+
if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
|
|
396
|
+
errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
|
|
397
|
+
}
|
|
398
|
+
const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
|
|
399
|
+
if (typeof installCommand !== "string" || installCommand.trim() === "") {
|
|
400
|
+
errors.push("installCommand must be a non-empty string.");
|
|
401
|
+
}
|
|
402
|
+
if (errors.length > 0)
|
|
403
|
+
return { config: null, errors };
|
|
404
|
+
return {
|
|
405
|
+
config: {
|
|
406
|
+
devServer,
|
|
407
|
+
previews,
|
|
408
|
+
devCommand: typeof devCommand === "string" ? devCommand : void 0,
|
|
409
|
+
installCommand
|
|
410
|
+
},
|
|
411
|
+
errors: []
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// ../server/dist/classify.js
|
|
416
|
+
var MANIFESTS = /* @__PURE__ */ new Set([
|
|
417
|
+
"package.json",
|
|
418
|
+
"pnpm-lock.yaml",
|
|
419
|
+
"package-lock.json",
|
|
420
|
+
"yarn.lock",
|
|
421
|
+
"bun.lock",
|
|
422
|
+
"bun.lockb",
|
|
423
|
+
"npm-shrinkwrap.json",
|
|
424
|
+
"pnpm-workspace.yaml"
|
|
425
|
+
]);
|
|
426
|
+
function basename(path) {
|
|
427
|
+
const segments = path.split("/");
|
|
428
|
+
return segments[segments.length - 1] ?? path;
|
|
429
|
+
}
|
|
430
|
+
function isManifest(path) {
|
|
431
|
+
return MANIFESTS.has(basename(path));
|
|
432
|
+
}
|
|
433
|
+
function isBuildConfig(path) {
|
|
434
|
+
const name = basename(path);
|
|
435
|
+
if (name.startsWith("leglas.config."))
|
|
436
|
+
return false;
|
|
437
|
+
if (/^tsconfig[^/]*\.json$/.test(name))
|
|
438
|
+
return true;
|
|
439
|
+
if (name === ".env" || name.startsWith(".env."))
|
|
440
|
+
return true;
|
|
441
|
+
if (name === ".babelrc" || name.startsWith(".babelrc."))
|
|
442
|
+
return true;
|
|
443
|
+
if (name === "turbo.json")
|
|
444
|
+
return true;
|
|
445
|
+
return /\.config\.[a-z]+$/i.test(name);
|
|
446
|
+
}
|
|
447
|
+
function isExplorationFile(path) {
|
|
448
|
+
return path.startsWith(".leglas/") || path.includes("/.leglas/");
|
|
449
|
+
}
|
|
450
|
+
var CHECKOUT_STEPS = [
|
|
451
|
+
"Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
|
|
452
|
+
'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
453
|
+
"Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
|
|
454
|
+
];
|
|
455
|
+
var IN_APP_STEPS = [
|
|
456
|
+
"Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
|
|
457
|
+
'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
458
|
+
];
|
|
459
|
+
function classifyDirection(input) {
|
|
460
|
+
const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
|
|
461
|
+
const manifest = input.changes.find((change) => isManifest(change.path));
|
|
462
|
+
if (manifest !== void 0) {
|
|
463
|
+
return checkout(`${manifest.path} changes the dependency set, and one running server cannot hold two.`);
|
|
464
|
+
}
|
|
465
|
+
const config = input.changes.find((change) => isBuildConfig(change.path));
|
|
466
|
+
if (config !== void 0) {
|
|
467
|
+
return checkout(`${config.path} is build configuration, which applies to every direction in the one server.`);
|
|
468
|
+
}
|
|
469
|
+
const rewrite = input.changes.find((change) => change.kind === "rewrite" && change.exists && !isExplorationFile(change.path));
|
|
470
|
+
if (rewrite !== void 0) {
|
|
471
|
+
return checkout(`${rewrite.path} already renders for the other directions; rewriting it makes them contend for one file.`);
|
|
472
|
+
}
|
|
473
|
+
return {
|
|
474
|
+
level: "in-app",
|
|
475
|
+
reason: "Everything declared adds beside what exists, so it renders from the running server.",
|
|
476
|
+
steps: IN_APP_STEPS
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ../server/dist/find-config.js
|
|
481
|
+
import { existsSync } from "fs";
|
|
482
|
+
import { dirname, join, parse } from "path";
|
|
483
|
+
var CONFIG_BASENAMES = [
|
|
484
|
+
"leglas.config.ts",
|
|
485
|
+
"leglas.config.mjs",
|
|
486
|
+
"leglas.config.js",
|
|
487
|
+
"leglas.config.json"
|
|
488
|
+
];
|
|
489
|
+
function findConfigFile(startDir) {
|
|
490
|
+
const { root } = parse(startDir);
|
|
491
|
+
let dir = startDir;
|
|
492
|
+
for (; ; ) {
|
|
493
|
+
for (const basename4 of CONFIG_BASENAMES) {
|
|
494
|
+
const candidate = join(dir, basename4);
|
|
495
|
+
if (existsSync(candidate))
|
|
496
|
+
return candidate;
|
|
497
|
+
}
|
|
498
|
+
if (dir === root)
|
|
499
|
+
return null;
|
|
500
|
+
const parent = dirname(dir);
|
|
501
|
+
if (parent === dir)
|
|
502
|
+
return null;
|
|
503
|
+
dir = parent;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ../server/dist/load-config.js
|
|
508
|
+
import { readFile } from "fs/promises";
|
|
509
|
+
import { relative } from "path";
|
|
510
|
+
import { pathToFileURL } from "url";
|
|
511
|
+
async function loadConfig(cwd) {
|
|
512
|
+
const path = findConfigFile(cwd);
|
|
513
|
+
if (path === null) {
|
|
514
|
+
return { ...normalizeConfig(void 0), path: null };
|
|
515
|
+
}
|
|
516
|
+
const label = relative(cwd, path) || path;
|
|
517
|
+
let exported;
|
|
518
|
+
try {
|
|
519
|
+
if (path.endsWith(".json")) {
|
|
520
|
+
exported = JSON.parse(await readFile(path, "utf8"));
|
|
521
|
+
} else {
|
|
522
|
+
const module = await import(pathToFileURL(path).href);
|
|
523
|
+
if (!("default" in module)) {
|
|
524
|
+
return { config: null, errors: [`${label} has no default export.`], path };
|
|
525
|
+
}
|
|
526
|
+
exported = module.default;
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
530
|
+
return { config: null, errors: [`${label} could not be loaded: ${message}`], path };
|
|
531
|
+
}
|
|
532
|
+
const result2 = normalizeConfig(exported);
|
|
533
|
+
return {
|
|
534
|
+
config: result2.config,
|
|
535
|
+
errors: result2.errors.map((error) => `${label}: ${error}`),
|
|
536
|
+
path
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// ../server/dist/local-previews.js
|
|
541
|
+
import { mkdir, readFile as readFile2, writeFile } from "fs/promises";
|
|
542
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
543
|
+
var LOCAL_PREVIEWS_PATH = ".leglas/previews.json";
|
|
544
|
+
async function readLocalPreviews(cwd) {
|
|
545
|
+
const path = join2(cwd, LOCAL_PREVIEWS_PATH);
|
|
546
|
+
let raw;
|
|
547
|
+
try {
|
|
548
|
+
raw = await readFile2(path, "utf8");
|
|
549
|
+
} catch {
|
|
550
|
+
return { previews: [], errors: [] };
|
|
551
|
+
}
|
|
552
|
+
let parsed2;
|
|
553
|
+
try {
|
|
554
|
+
parsed2 = JSON.parse(raw);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return {
|
|
557
|
+
previews: [],
|
|
558
|
+
errors: [
|
|
559
|
+
`${LOCAL_PREVIEWS_PATH} is not valid JSON (${error instanceof Error ? error.message : String(error)}). Delete it to start over; nothing shared is lost.`
|
|
560
|
+
]
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
const result2 = normalizeConfig(parsed2, { requireDevCommand: false });
|
|
564
|
+
if (result2.config === null) {
|
|
565
|
+
return { previews: [], errors: result2.errors.map((error) => `${LOCAL_PREVIEWS_PATH}: ${error}`) };
|
|
566
|
+
}
|
|
567
|
+
return {
|
|
568
|
+
previews: result2.config.previews.map((preview) => ({ ...preview, local: true })),
|
|
569
|
+
errors: []
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
async function addLocalPreview(cwd, input, shared) {
|
|
573
|
+
const existing = await readLocalPreviews(cwd);
|
|
574
|
+
if (existing.errors.length > 0) {
|
|
575
|
+
return { ok: false, error: existing.errors.join(" ") };
|
|
576
|
+
}
|
|
577
|
+
const taken = [...shared, ...existing.previews].some((preview) => preview.title === input.title);
|
|
578
|
+
if (taken) {
|
|
579
|
+
return {
|
|
580
|
+
ok: false,
|
|
581
|
+
error: `A preview called ${JSON.stringify(input.title)} already exists. Titles identify previews, so they have to be unique.`
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
const candidate = {
|
|
585
|
+
title: input.title,
|
|
586
|
+
...input.url === void 0 ? {} : { url: input.url },
|
|
587
|
+
...input.note === void 0 ? {} : { note: input.note },
|
|
588
|
+
...input.tags === void 0 ? {} : { tags: input.tags },
|
|
589
|
+
...input.branch === void 0 ? {} : { branch: input.branch },
|
|
590
|
+
...input.file === void 0 ? {} : { file: input.file }
|
|
591
|
+
};
|
|
592
|
+
const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
|
|
593
|
+
if (check.config === null) {
|
|
594
|
+
return { ok: false, error: check.errors.join(" ") };
|
|
595
|
+
}
|
|
596
|
+
const path = join2(cwd, LOCAL_PREVIEWS_PATH);
|
|
597
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
598
|
+
await writeFile(path, `${JSON.stringify({ previews: [...existing.previews.map(toStored), candidate] }, null, 2)}
|
|
599
|
+
`, "utf8");
|
|
600
|
+
return { ok: true };
|
|
601
|
+
}
|
|
602
|
+
function toStored(preview) {
|
|
603
|
+
const { local: _local, url, ...rest } = preview;
|
|
604
|
+
return preview.file !== void 0 && url === "" ? rest : { url, ...rest };
|
|
605
|
+
}
|
|
606
|
+
async function dropLocalPreviews(cwd, titles) {
|
|
607
|
+
const existing = await readLocalPreviews(cwd);
|
|
608
|
+
const keep = existing.previews.filter((preview) => !titles.includes(preview.title));
|
|
609
|
+
if (keep.length === existing.previews.length)
|
|
610
|
+
return 0;
|
|
611
|
+
const path = join2(cwd, LOCAL_PREVIEWS_PATH);
|
|
612
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
613
|
+
await writeFile(path, `${JSON.stringify({ previews: keep.map(toStored) }, null, 2)}
|
|
614
|
+
`, "utf8");
|
|
615
|
+
return existing.previews.length - keep.length;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ../server/dist/proxy.js
|
|
619
|
+
import http, {} from "http";
|
|
620
|
+
import net from "net";
|
|
621
|
+
function createProxyHandler(options) {
|
|
622
|
+
const target = new URL(options.target);
|
|
623
|
+
const host = target.hostname;
|
|
624
|
+
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
|
|
625
|
+
const authority = target.port ? `${host}:${target.port}` : host;
|
|
626
|
+
function upstreamHeaders(req) {
|
|
627
|
+
return { ...req.headers, host: authority };
|
|
628
|
+
}
|
|
629
|
+
function rewriteLocation(location, publicOrigin) {
|
|
630
|
+
if (location === void 0)
|
|
631
|
+
return void 0;
|
|
632
|
+
for (const origin of [`${target.protocol}//${authority}`, `${target.protocol}//localhost:${port}`]) {
|
|
633
|
+
if (location.startsWith(origin))
|
|
634
|
+
return publicOrigin + location.slice(origin.length);
|
|
635
|
+
}
|
|
636
|
+
return location;
|
|
637
|
+
}
|
|
638
|
+
return {
|
|
639
|
+
request(req, res, publicOrigin) {
|
|
640
|
+
const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
|
|
641
|
+
const headers = { ...upstreamRes.headers };
|
|
642
|
+
const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
|
|
643
|
+
if (location !== void 0)
|
|
644
|
+
headers.location = location;
|
|
645
|
+
res.writeHead(upstreamRes.statusCode ?? 502, headers);
|
|
646
|
+
upstreamRes.pipe(res);
|
|
647
|
+
});
|
|
648
|
+
upstream.on("error", (error) => {
|
|
649
|
+
if (res.headersSent)
|
|
650
|
+
return res.destroy();
|
|
651
|
+
res.writeHead(502, { "content-type": "text/plain" });
|
|
652
|
+
res.end(`Leglas could not reach the dev server at ${options.target} (${error.message}).
|
|
653
|
+
Start it, or point Leglas somewhere else with --user-port.`);
|
|
654
|
+
});
|
|
655
|
+
req.pipe(upstream);
|
|
656
|
+
},
|
|
657
|
+
upgrade(req, socket, head) {
|
|
658
|
+
const upstream = net.connect(port, host, () => {
|
|
659
|
+
const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
|
|
660
|
+
`).join("");
|
|
661
|
+
upstream.write(`${req.method} ${req.url} HTTP/1.1\r
|
|
662
|
+
${headers}\r
|
|
663
|
+
`);
|
|
664
|
+
if (head.length > 0)
|
|
665
|
+
upstream.write(head);
|
|
666
|
+
upstream.pipe(socket);
|
|
667
|
+
socket.pipe(upstream);
|
|
668
|
+
});
|
|
669
|
+
const shutdown2 = () => {
|
|
670
|
+
upstream.destroy();
|
|
671
|
+
socket.destroy();
|
|
672
|
+
};
|
|
673
|
+
upstream.on("error", shutdown2);
|
|
674
|
+
upstream.on("close", shutdown2);
|
|
675
|
+
socket.on("error", shutdown2);
|
|
676
|
+
socket.on("close", shutdown2);
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ../server/dist/worktree.js
|
|
682
|
+
import { execFile, spawn } from "child_process";
|
|
683
|
+
import { rm } from "fs/promises";
|
|
684
|
+
import net2 from "net";
|
|
685
|
+
import { join as join3 } from "path";
|
|
686
|
+
import { promisify } from "util";
|
|
687
|
+
var run = promisify(execFile);
|
|
688
|
+
var WORKTREES_DIR = ".leglas/worktrees";
|
|
689
|
+
var INSTALL_TIMEOUT_MS = 3e5;
|
|
690
|
+
function worktreeSlug(branch) {
|
|
691
|
+
return branch.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
692
|
+
}
|
|
693
|
+
function substitutePort(command, port) {
|
|
694
|
+
return command.split("{port}").join(String(port));
|
|
695
|
+
}
|
|
696
|
+
async function freePort() {
|
|
697
|
+
return new Promise((resolve, reject) => {
|
|
698
|
+
const probe2 = net2.createServer();
|
|
699
|
+
probe2.once("error", reject);
|
|
700
|
+
probe2.listen(0, "127.0.0.1", () => {
|
|
701
|
+
const address = probe2.address();
|
|
702
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
703
|
+
probe2.close(() => resolve(port));
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
function answers(port) {
|
|
708
|
+
return new Promise((resolve) => {
|
|
709
|
+
const socket = net2.connect(port, "127.0.0.1");
|
|
710
|
+
const settle = (value) => {
|
|
711
|
+
socket.destroy();
|
|
712
|
+
resolve(value);
|
|
713
|
+
};
|
|
714
|
+
socket.setTimeout(400);
|
|
715
|
+
socket.once("connect", () => settle(true));
|
|
716
|
+
socket.once("timeout", () => settle(false));
|
|
717
|
+
socket.once("error", () => settle(false));
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
async function startWorktree(options) {
|
|
721
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
|
|
722
|
+
const path = join3(options.cwd, WORKTREES_DIR, worktreeSlug(options.branch));
|
|
723
|
+
const log = options.onLog ?? (() => {
|
|
724
|
+
});
|
|
725
|
+
await rm(path, { recursive: true, force: true });
|
|
726
|
+
await run("git", ["worktree", "prune"], { cwd: options.cwd }).catch(() => {
|
|
727
|
+
});
|
|
728
|
+
try {
|
|
729
|
+
await run("git", ["worktree", "add", "--detach", path, options.branch], { cwd: options.cwd });
|
|
730
|
+
} catch (error) {
|
|
731
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
732
|
+
throw new Error(`Could not check out ${options.branch}: ${detail.split("\n").slice(-2).join(" ").trim()}`);
|
|
733
|
+
}
|
|
734
|
+
const cleanup = async () => {
|
|
735
|
+
await run("git", ["worktree", "remove", "--force", path], { cwd: options.cwd }).catch(() => {
|
|
736
|
+
});
|
|
737
|
+
await rm(path, { recursive: true, force: true }).catch(() => {
|
|
738
|
+
});
|
|
739
|
+
};
|
|
740
|
+
try {
|
|
741
|
+
log(`installing ${options.branch}`);
|
|
742
|
+
await run(options.installCommand, {
|
|
743
|
+
cwd: path,
|
|
744
|
+
shell: true,
|
|
745
|
+
timeout: INSTALL_TIMEOUT_MS,
|
|
746
|
+
maxBuffer: 32 * 1024 * 1024
|
|
747
|
+
});
|
|
748
|
+
} catch (error) {
|
|
749
|
+
await cleanup();
|
|
750
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
751
|
+
throw new Error(`Install failed in ${options.branch}: ${detail.split("\n")[0]}`);
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
const app = await startAppProcess({
|
|
755
|
+
cwd: path,
|
|
756
|
+
devCommand: options.devCommand,
|
|
757
|
+
label: options.branch,
|
|
758
|
+
readyTimeoutMs,
|
|
759
|
+
onLog: log
|
|
760
|
+
});
|
|
761
|
+
return {
|
|
762
|
+
branch: options.branch,
|
|
763
|
+
path,
|
|
764
|
+
port: app.port,
|
|
765
|
+
url: app.url,
|
|
766
|
+
stop: async () => {
|
|
767
|
+
await app.stop();
|
|
768
|
+
await cleanup();
|
|
769
|
+
}
|
|
770
|
+
};
|
|
771
|
+
} catch (error) {
|
|
772
|
+
await cleanup();
|
|
773
|
+
throw error;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
async function startAppProcess(options) {
|
|
777
|
+
const readyTimeoutMs = options.readyTimeoutMs ?? 9e4;
|
|
778
|
+
const log = options.onLog ?? (() => {
|
|
779
|
+
});
|
|
780
|
+
const port = await freePort();
|
|
781
|
+
let child;
|
|
782
|
+
try {
|
|
783
|
+
child = spawn(substitutePort(options.devCommand, port), {
|
|
784
|
+
cwd: options.cwd,
|
|
785
|
+
shell: true,
|
|
786
|
+
// Own process group, so stopping kills the shell and whatever it spawned
|
|
787
|
+
// rather than orphaning a dev server holding the port.
|
|
788
|
+
detached: true,
|
|
789
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
790
|
+
});
|
|
791
|
+
} catch (error) {
|
|
792
|
+
throw new Error(`Could not start ${options.label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
793
|
+
}
|
|
794
|
+
let exited = null;
|
|
795
|
+
child.once("exit", (code) => {
|
|
796
|
+
exited = code ?? 0;
|
|
797
|
+
});
|
|
798
|
+
child.stdout?.on("data", (chunk) => log(chunk.toString().trimEnd()));
|
|
799
|
+
child.stderr?.on("data", (chunk) => log(chunk.toString().trimEnd()));
|
|
800
|
+
const stop = async () => {
|
|
801
|
+
try {
|
|
802
|
+
if (child.pid !== void 0 && exited === null)
|
|
803
|
+
process.kill(-child.pid, "SIGTERM");
|
|
804
|
+
} catch {
|
|
805
|
+
child.kill("SIGTERM");
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
const deadline = Date.now() + readyTimeoutMs;
|
|
809
|
+
while (Date.now() < deadline) {
|
|
810
|
+
if (exited !== null) {
|
|
811
|
+
throw new Error(`${options.label} did not start: its dev command exited with code ${exited}.`);
|
|
812
|
+
}
|
|
813
|
+
if (await answers(port)) {
|
|
814
|
+
return { port, url: `http://127.0.0.1:${port}`, stop };
|
|
815
|
+
}
|
|
816
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
817
|
+
}
|
|
818
|
+
await stop();
|
|
819
|
+
throw new Error(`${options.label} did not start within ${Math.round(readyTimeoutMs / 1e3)}s. Check that its dev command serves the port it is given.`);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// ../server/dist/requests.js
|
|
823
|
+
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
824
|
+
import { dirname as dirname3, join as join4 } from "path";
|
|
825
|
+
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
826
|
+
function targetFor(url) {
|
|
827
|
+
if (!url.startsWith("/"))
|
|
828
|
+
return null;
|
|
829
|
+
const query = url.slice(url.indexOf("?") + 1);
|
|
830
|
+
if (!url.includes("?"))
|
|
831
|
+
return null;
|
|
832
|
+
for (const pair of query.split("&")) {
|
|
833
|
+
const [rawKey, rawValue] = pair.split("=");
|
|
834
|
+
if (rawKey === void 0 || rawValue === void 0)
|
|
835
|
+
continue;
|
|
836
|
+
if (!rawKey.startsWith("v-"))
|
|
837
|
+
continue;
|
|
838
|
+
const surface = rawKey.slice(2);
|
|
839
|
+
const option = decodeURIComponent(rawValue);
|
|
840
|
+
if (!SAFE_SEGMENT.test(surface) || !SAFE_SEGMENT.test(option))
|
|
841
|
+
return null;
|
|
842
|
+
return `.leglas/variants/${surface}/${option}.tsx`;
|
|
843
|
+
}
|
|
844
|
+
return null;
|
|
845
|
+
}
|
|
846
|
+
function composeRequest(preview, intent) {
|
|
847
|
+
const target = preview.file ?? targetFor(preview.url);
|
|
848
|
+
const cleaned = intent.trim();
|
|
849
|
+
const where = target === null ? `The direction is titled "${preview.title}" and renders at ${preview.url}. Find what produces it.` : `It lives at ${target}.`;
|
|
850
|
+
const prompt = `In this project, change only the "${preview.title}" design direction. ${where}
|
|
851
|
+
|
|
852
|
+
What to change: ${cleaned}
|
|
853
|
+
|
|
854
|
+
Leave every other direction exactly as it is; they are alternatives being compared side by side, so changing a sibling destroys the comparison. The direction is already registered, so nothing needs re-registering. Keep the change additive: do not rewrite shared components that other directions rely on.`;
|
|
855
|
+
return { prompt, target };
|
|
856
|
+
}
|
|
857
|
+
var REQUESTS_PATH = ".leglas/requests.json";
|
|
858
|
+
async function readRequests(cwd) {
|
|
859
|
+
try {
|
|
860
|
+
const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
|
|
861
|
+
const parsed2 = JSON.parse(raw);
|
|
862
|
+
return Array.isArray(parsed2.requests) ? parsed2.requests : [];
|
|
863
|
+
} catch {
|
|
864
|
+
return [];
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function writeQueue(cwd, requests) {
|
|
868
|
+
const path = join4(cwd, REQUESTS_PATH);
|
|
869
|
+
await mkdir2(dirname3(path), { recursive: true });
|
|
870
|
+
await writeFile2(path, `${JSON.stringify({ requests }, null, 2)}
|
|
871
|
+
`, "utf8");
|
|
872
|
+
}
|
|
873
|
+
async function appendRequest(cwd, request) {
|
|
874
|
+
await writeQueue(cwd, [...await readRequests(cwd), request]);
|
|
875
|
+
}
|
|
876
|
+
async function clearRequests(cwd) {
|
|
877
|
+
await writeQueue(cwd, []);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ../server/dist/server.js
|
|
881
|
+
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
882
|
+
import http2 from "http";
|
|
883
|
+
import net3 from "net";
|
|
884
|
+
import { extname, join as join5, normalize } from "path";
|
|
885
|
+
var LEGLAS_PREFIX = "/leglas";
|
|
886
|
+
var DEFAULT_PORT = 4100;
|
|
887
|
+
var PORT_ATTEMPTS = 20;
|
|
888
|
+
var CONTENT_TYPES = {
|
|
889
|
+
".css": "text/css; charset=utf-8",
|
|
890
|
+
".gif": "image/gif",
|
|
891
|
+
".html": "text/html; charset=utf-8",
|
|
892
|
+
".ico": "image/x-icon",
|
|
893
|
+
".jpeg": "image/jpeg",
|
|
894
|
+
".jpg": "image/jpeg",
|
|
895
|
+
".js": "text/javascript; charset=utf-8",
|
|
896
|
+
".json": "application/json; charset=utf-8",
|
|
897
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
898
|
+
".png": "image/png",
|
|
899
|
+
".svg": "image/svg+xml",
|
|
900
|
+
".txt": "text/plain; charset=utf-8",
|
|
901
|
+
".webp": "image/webp",
|
|
902
|
+
".woff": "font/woff",
|
|
903
|
+
".woff2": "font/woff2"
|
|
904
|
+
};
|
|
905
|
+
var FILES_PREFIX = `${LEGLAS_PREFIX}/files`;
|
|
906
|
+
function sendJson(res, status, body) {
|
|
907
|
+
const payload = JSON.stringify(body);
|
|
908
|
+
res.writeHead(status, {
|
|
909
|
+
"content-type": "application/json; charset=utf-8",
|
|
910
|
+
"cache-control": "no-store"
|
|
911
|
+
});
|
|
912
|
+
res.end(payload);
|
|
913
|
+
}
|
|
914
|
+
function probe(target, timeoutMs = 1e3) {
|
|
915
|
+
return new Promise((resolve) => {
|
|
916
|
+
let url;
|
|
917
|
+
try {
|
|
918
|
+
url = new URL(target);
|
|
919
|
+
} catch {
|
|
920
|
+
return resolve(false);
|
|
921
|
+
}
|
|
922
|
+
const port = Number(url.port || (url.protocol === "https:" ? 443 : 80));
|
|
923
|
+
const socket = net3.connect(port, url.hostname);
|
|
924
|
+
const settle = (reachable) => {
|
|
925
|
+
socket.destroy();
|
|
926
|
+
resolve(reachable);
|
|
927
|
+
};
|
|
928
|
+
socket.setTimeout(timeoutMs);
|
|
929
|
+
socket.once("connect", () => settle(true));
|
|
930
|
+
socket.once("timeout", () => settle(false));
|
|
931
|
+
socket.once("error", () => settle(false));
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
function serveFrom(res, dir, relativePath) {
|
|
935
|
+
const relative3 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
936
|
+
const candidate = join5(dir, relative3);
|
|
937
|
+
if (!candidate.startsWith(dir))
|
|
938
|
+
return false;
|
|
939
|
+
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
940
|
+
return false;
|
|
941
|
+
res.writeHead(200, {
|
|
942
|
+
"content-type": CONTENT_TYPES[extname(candidate)] ?? "application/octet-stream",
|
|
943
|
+
"cache-control": "no-store"
|
|
944
|
+
});
|
|
945
|
+
createReadStream(candidate).pipe(res);
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
function serveShellFile(res, shellDir, urlPath) {
|
|
949
|
+
const relative3 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
|
|
950
|
+
const isRoot = relative3 === "" || relative3 === "." || relative3 === "/";
|
|
951
|
+
return serveFrom(res, shellDir, isRoot ? "index.html" : relative3);
|
|
952
|
+
}
|
|
953
|
+
var PLACEHOLDER = `<!doctype html>
|
|
954
|
+
<meta charset="utf-8">
|
|
955
|
+
<title>Leglas</title>
|
|
956
|
+
<body style="font:14px/1.6 ui-sans-serif,system-ui;padding:3rem;max-width:34rem">
|
|
957
|
+
<h1 style="font-size:1rem">Leglas</h1>
|
|
958
|
+
<p>The server is running and proxying your app. The interface has not been
|
|
959
|
+
built into this install yet.</p>
|
|
960
|
+
<p><a href="/leglas/api/config">/leglas/api/config</a> \xB7
|
|
961
|
+
<a href="/leglas/api/health">/leglas/api/health</a></p>
|
|
962
|
+
</body>`;
|
|
963
|
+
function listen(server, port) {
|
|
964
|
+
return new Promise((resolve, reject) => {
|
|
965
|
+
const onError = (error) => {
|
|
966
|
+
server.removeListener("listening", onListening);
|
|
967
|
+
reject(error);
|
|
968
|
+
};
|
|
969
|
+
const onListening = () => {
|
|
970
|
+
server.removeListener("error", onError);
|
|
971
|
+
const address = server.address();
|
|
972
|
+
resolve(typeof address === "object" && address !== null ? address.port : port);
|
|
973
|
+
};
|
|
974
|
+
server.once("error", onError);
|
|
975
|
+
server.once("listening", onListening);
|
|
976
|
+
server.listen(port, "127.0.0.1");
|
|
977
|
+
});
|
|
978
|
+
}
|
|
979
|
+
async function bind(server, requested) {
|
|
980
|
+
if (requested === 0)
|
|
981
|
+
return listen(server, 0);
|
|
982
|
+
for (let attempt = 0; attempt < PORT_ATTEMPTS; attempt += 1) {
|
|
983
|
+
try {
|
|
984
|
+
return await listen(server, requested + attempt);
|
|
985
|
+
} catch (error) {
|
|
986
|
+
if (error.code !== "EADDRINUSE")
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
throw new Error(`No free port between ${requested} and ${requested + PORT_ATTEMPTS - 1}.`);
|
|
991
|
+
}
|
|
992
|
+
async function startServer(options) {
|
|
993
|
+
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
|
|
994
|
+
const target = config?.devServer ?? "http://localhost:3000";
|
|
995
|
+
const proxy = createProxyHandler({ target });
|
|
996
|
+
const server = http2.createServer((req, res) => {
|
|
997
|
+
const url = req.url ?? "/";
|
|
998
|
+
const path = url.split("?")[0] ?? "/";
|
|
999
|
+
if (path === `${LEGLAS_PREFIX}/api/config`) {
|
|
1000
|
+
return sendJson(res, 200, {
|
|
1001
|
+
project,
|
|
1002
|
+
devServer: target,
|
|
1003
|
+
previews: config?.previews ?? [],
|
|
1004
|
+
errors: configErrors
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
|
|
1008
|
+
let body = "";
|
|
1009
|
+
req.on("data", (chunk) => body += chunk);
|
|
1010
|
+
return void req.on("end", () => {
|
|
1011
|
+
let parsed2;
|
|
1012
|
+
try {
|
|
1013
|
+
parsed2 = JSON.parse(body || "{}");
|
|
1014
|
+
} catch {
|
|
1015
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1016
|
+
}
|
|
1017
|
+
const preview = (config?.previews ?? []).find((entry) => entry.title === parsed2.title);
|
|
1018
|
+
if (!preview || !parsed2.intent?.trim()) {
|
|
1019
|
+
return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
|
|
1020
|
+
}
|
|
1021
|
+
const composed = composeRequest(preview, parsed2.intent);
|
|
1022
|
+
void appendRequest(cwd, {
|
|
1023
|
+
title: preview.title,
|
|
1024
|
+
url: preview.url,
|
|
1025
|
+
intent: parsed2.intent.trim(),
|
|
1026
|
+
...composed
|
|
1027
|
+
}).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
if (path === `${LEGLAS_PREFIX}/api/health`) {
|
|
1031
|
+
return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable }));
|
|
1032
|
+
}
|
|
1033
|
+
if (path.startsWith(`${FILES_PREFIX}/`)) {
|
|
1034
|
+
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
1035
|
+
const slash = rest.indexOf("/");
|
|
1036
|
+
const slug = slash === -1 ? rest : rest.slice(0, slash);
|
|
1037
|
+
let relative3 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1038
|
+
try {
|
|
1039
|
+
relative3 = decodeURIComponent(relative3);
|
|
1040
|
+
} catch {
|
|
1041
|
+
relative3 = "";
|
|
1042
|
+
}
|
|
1043
|
+
const dir = fileMounts.get(slug);
|
|
1044
|
+
if (dir !== void 0 && relative3 !== "" && serveFrom(res, dir, relative3))
|
|
1045
|
+
return;
|
|
1046
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1047
|
+
return res.end("Leglas: no such preview file.");
|
|
1048
|
+
}
|
|
1049
|
+
if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
|
|
1050
|
+
if (shellDir !== null && serveShellFile(res, shellDir, path))
|
|
1051
|
+
return;
|
|
1052
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1053
|
+
return res.end(PLACEHOLDER);
|
|
1054
|
+
}
|
|
1055
|
+
return proxy.request(req, res, `http://localhost:${port}`);
|
|
1056
|
+
});
|
|
1057
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1058
|
+
server.on("connection", (socket) => {
|
|
1059
|
+
sockets.add(socket);
|
|
1060
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1061
|
+
});
|
|
1062
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1063
|
+
const path = (req.url ?? "/").split("?")[0] ?? "/";
|
|
1064
|
+
if (path.startsWith(`${LEGLAS_PREFIX}/`))
|
|
1065
|
+
return socket.destroy();
|
|
1066
|
+
proxy.upgrade(req, socket, head);
|
|
1067
|
+
});
|
|
1068
|
+
const port = await bind(server, options.port ?? DEFAULT_PORT);
|
|
1069
|
+
return {
|
|
1070
|
+
port,
|
|
1071
|
+
url: `http://localhost:${port}`,
|
|
1072
|
+
close: () => new Promise((done) => {
|
|
1073
|
+
for (const socket of sockets)
|
|
1074
|
+
socket.destroy();
|
|
1075
|
+
sockets.clear();
|
|
1076
|
+
server.closeAllConnections();
|
|
1077
|
+
server.close(() => done());
|
|
1078
|
+
})
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// src/run-classify.ts
|
|
1083
|
+
async function runClassify(options, deps) {
|
|
1084
|
+
const declared = await Promise.all(
|
|
1085
|
+
options.changes.map(async (change) => ({
|
|
1086
|
+
...change,
|
|
1087
|
+
exists: await stat(join6(options.cwd, change.path)).then(
|
|
1088
|
+
() => true,
|
|
1089
|
+
() => false
|
|
1090
|
+
)
|
|
1091
|
+
}))
|
|
1092
|
+
);
|
|
1093
|
+
const placement = classifyDirection({ changes: declared });
|
|
1094
|
+
if (options.json) {
|
|
1095
|
+
deps.log(
|
|
1096
|
+
JSON.stringify({
|
|
1097
|
+
ok: true,
|
|
1098
|
+
level: placement.level,
|
|
1099
|
+
reason: placement.reason,
|
|
1100
|
+
steps: placement.steps
|
|
1101
|
+
})
|
|
1102
|
+
);
|
|
1103
|
+
return { exitCode: 0 };
|
|
1104
|
+
}
|
|
1105
|
+
deps.log(` ${placement.level}`);
|
|
1106
|
+
deps.log(` ${placement.reason}`);
|
|
1107
|
+
deps.log("");
|
|
1108
|
+
placement.steps.forEach((step, index) => deps.log(` ${index + 1}. ${step}`));
|
|
1109
|
+
return { exitCode: 0 };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// src/baseline.ts
|
|
1113
|
+
var NAMED_EXPORT = /export\s+(?:async\s+)?(?:function|const|class)\s+([A-Z][A-Za-z0-9_]*)/;
|
|
1114
|
+
var DEFAULT_EXPORT = /export\s+default\s+(?:async\s+)?(?:function|class)\s+([A-Z][A-Za-z0-9_]*)/;
|
|
1115
|
+
var BARE_DEFAULT = /export\s+default\s/;
|
|
1116
|
+
function baselineFrom(surface, sourcePath, sourceContents) {
|
|
1117
|
+
const normalised = sourcePath.replace(/\\/g, "/");
|
|
1118
|
+
const withoutExtension = normalised.replace(/\.[jt]sx?$/, "");
|
|
1119
|
+
const importPath = `../../../${withoutExtension}`;
|
|
1120
|
+
const defaultMatch = DEFAULT_EXPORT.exec(sourceContents);
|
|
1121
|
+
const namedMatch = NAMED_EXPORT.exec(sourceContents);
|
|
1122
|
+
let name;
|
|
1123
|
+
let importLine;
|
|
1124
|
+
if (namedMatch?.[1]) {
|
|
1125
|
+
name = namedMatch[1];
|
|
1126
|
+
importLine = `import { ${name} } from "${importPath}";`;
|
|
1127
|
+
} else if (defaultMatch?.[1]) {
|
|
1128
|
+
name = defaultMatch[1];
|
|
1129
|
+
importLine = `import ${name} from "${importPath}";`;
|
|
1130
|
+
} else if (BARE_DEFAULT.test(sourceContents)) {
|
|
1131
|
+
name = `${surface.charAt(0).toUpperCase()}${surface.slice(1)}Current`;
|
|
1132
|
+
importLine = `import ${name} from "${importPath}";`;
|
|
1133
|
+
} else {
|
|
1134
|
+
return null;
|
|
1135
|
+
}
|
|
1136
|
+
return {
|
|
1137
|
+
contents: `// The current design, re-exported from ${normalised} rather than copied,
|
|
1138
|
+
// so this baseline stays live: change that component and the comparison
|
|
1139
|
+
// changes with it.
|
|
1140
|
+
${importLine}
|
|
1141
|
+
|
|
1142
|
+
export function Current() {
|
|
1143
|
+
return <${name} />;
|
|
1144
|
+
}
|
|
1145
|
+
`
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// src/ignore.ts
|
|
1150
|
+
var IGNORED = ".leglas/";
|
|
1151
|
+
function ignoreEntry(current) {
|
|
1152
|
+
const lines = (current ?? "").split("\n").map((line) => line.trim());
|
|
1153
|
+
if (lines.some((line) => line === IGNORED || line === ".leglas")) return null;
|
|
1154
|
+
const body = (current ?? "").replace(/\n*$/, "");
|
|
1155
|
+
const preamble = body === "" ? "" : `${body}
|
|
1156
|
+
|
|
1157
|
+
`;
|
|
1158
|
+
return `${preamble}# Leglas exploration: variant code, caches, logs.
|
|
1159
|
+
${IGNORED}
|
|
1160
|
+
`;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
// src/new.ts
|
|
1164
|
+
function detectFramework(packageJson) {
|
|
1165
|
+
if (!packageJson) return "react";
|
|
1166
|
+
try {
|
|
1167
|
+
const parsed2 = JSON.parse(packageJson);
|
|
1168
|
+
const deps = { ...parsed2.dependencies, ...parsed2.devDependencies };
|
|
1169
|
+
return "next" in deps ? "next" : "react";
|
|
1170
|
+
} catch {
|
|
1171
|
+
return "react";
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
function surfaceSlug(surface) {
|
|
1175
|
+
return surface.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9-]/g, "");
|
|
1176
|
+
}
|
|
1177
|
+
function titleCase(slug) {
|
|
1178
|
+
return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
1179
|
+
}
|
|
1180
|
+
function nextSwitcher(slug) {
|
|
1181
|
+
return `// Generated by leglas new. Plain application code: it imports nothing
|
|
1182
|
+
// from Leglas, so deleting the tool leaves this working.
|
|
1183
|
+
//
|
|
1184
|
+
// Add a direction by dropping a component beside this file and listing it in
|
|
1185
|
+
// DIRECTIONS. Everything renders from one running server, so switching is
|
|
1186
|
+
// instant.
|
|
1187
|
+
import { Current } from "./current";
|
|
1188
|
+
import { ${titleCase(slug).replace(/\s/g, "")}A } from "./${slug}-a";
|
|
1189
|
+
|
|
1190
|
+
const DIRECTIONS = {
|
|
1191
|
+
current: Current,
|
|
1192
|
+
"${slug}-a": ${titleCase(slug).replace(/\s/g, "")}A,
|
|
1193
|
+
} as const;
|
|
1194
|
+
|
|
1195
|
+
type Direction = keyof typeof DIRECTIONS;
|
|
1196
|
+
const FALLBACK: Direction = "current";
|
|
1197
|
+
|
|
1198
|
+
type SearchParams = Record<string, string | string[] | undefined>;
|
|
1199
|
+
|
|
1200
|
+
/**
|
|
1201
|
+
* Production always renders the fallback, whatever the URL says, so a branch
|
|
1202
|
+
* point that reaches a deployed build cannot expose an unreleased direction.
|
|
1203
|
+
*/
|
|
1204
|
+
export function resolve${titleCase(slug).replace(/\s/g, "")}(searchParams: SearchParams): Direction {
|
|
1205
|
+
if (process.env.NODE_ENV === "production") return FALLBACK;
|
|
1206
|
+
const raw = searchParams["v-${slug}"];
|
|
1207
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
1208
|
+
return value !== undefined && value in DIRECTIONS ? (value as Direction) : FALLBACK;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
export function ${titleCase(slug).replace(/\s/g, "")}Switch({ searchParams }: { searchParams: SearchParams }) {
|
|
1212
|
+
const Direction = DIRECTIONS[resolve${titleCase(slug).replace(/\s/g, "")}(searchParams)];
|
|
1213
|
+
return <Direction />;
|
|
1214
|
+
}
|
|
1215
|
+
`;
|
|
1216
|
+
}
|
|
1217
|
+
function reactSwitcher(slug) {
|
|
1218
|
+
const name = titleCase(slug).replace(/\s/g, "");
|
|
1219
|
+
return `// Generated by leglas new. Plain application code: it imports nothing
|
|
1220
|
+
// from Leglas, so deleting the tool leaves this working.
|
|
1221
|
+
//
|
|
1222
|
+
// Add a direction by dropping a component beside this file and listing it in
|
|
1223
|
+
// DIRECTIONS. Everything renders from one running server, so switching is
|
|
1224
|
+
// instant.
|
|
1225
|
+
import { Current } from "./current";
|
|
1226
|
+
import { ${name}A } from "./${slug}-a";
|
|
1227
|
+
|
|
1228
|
+
const DIRECTIONS = {
|
|
1229
|
+
current: Current,
|
|
1230
|
+
"${slug}-a": ${name}A,
|
|
1231
|
+
} as const;
|
|
1232
|
+
|
|
1233
|
+
type Direction = keyof typeof DIRECTIONS;
|
|
1234
|
+
const FALLBACK: Direction = "current";
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* Typed locally rather than relying on bundler-provided globals, so this file
|
|
1238
|
+
* compiles in any project without extra type packages installed.
|
|
1239
|
+
*/
|
|
1240
|
+
function isProduction(): boolean {
|
|
1241
|
+
const meta = import.meta as ImportMeta & { env?: { PROD?: boolean } };
|
|
1242
|
+
if (meta.env?.PROD === true) return true;
|
|
1243
|
+
const runtime = globalThis as { process?: { env?: { NODE_ENV?: string } } };
|
|
1244
|
+
return runtime.process?.env?.NODE_ENV === "production";
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
/**
|
|
1248
|
+
* Production always renders the fallback, whatever the URL says, so a branch
|
|
1249
|
+
* point that reaches a deployed build cannot expose an unreleased direction.
|
|
1250
|
+
*/
|
|
1251
|
+
export function resolve${name}(): Direction {
|
|
1252
|
+
if (isProduction()) return FALLBACK;
|
|
1253
|
+
const value = new URLSearchParams(window.location.search).get("v-${slug}");
|
|
1254
|
+
return value !== null && value in DIRECTIONS ? (value as Direction) : FALLBACK;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
export function ${name}Switch() {
|
|
1258
|
+
const Direction = DIRECTIONS[resolve${name}()];
|
|
1259
|
+
return <Direction />;
|
|
1260
|
+
}
|
|
1261
|
+
`;
|
|
1262
|
+
}
|
|
1263
|
+
function placeholder(label, note) {
|
|
1264
|
+
return `export function ${label}() {
|
|
1265
|
+
return (
|
|
1266
|
+
<div style={{ padding: "3rem", fontFamily: "system-ui" }}>
|
|
1267
|
+
<p>${note}</p>
|
|
1268
|
+
</div>
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
`;
|
|
1272
|
+
}
|
|
1273
|
+
function planNew(options) {
|
|
1274
|
+
const slug = surfaceSlug(options.surface);
|
|
1275
|
+
const framework = detectFramework(options.packageJson);
|
|
1276
|
+
const name = titleCase(slug).replace(/\s/g, "");
|
|
1277
|
+
const dir = `.leglas/variants/${slug}`;
|
|
1278
|
+
const writes = [
|
|
1279
|
+
{
|
|
1280
|
+
path: `${dir}/switch.tsx`,
|
|
1281
|
+
contents: framework === "next" ? nextSwitcher(slug) : reactSwitcher(slug)
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
path: `${dir}/current.tsx`,
|
|
1285
|
+
contents: (options.from ? baselineFrom(slug, options.from.path, options.from.contents) : null)?.contents ?? placeholder(
|
|
1286
|
+
"Current",
|
|
1287
|
+
`Re-export what your ${slug} renders today, for example: import { ${name} } from "../../../src/${name}" and return <${name} />. Re-exporting rather than copying keeps this baseline live.`
|
|
1288
|
+
)
|
|
1289
|
+
},
|
|
1290
|
+
{
|
|
1291
|
+
path: `${dir}/${slug}-a.tsx`,
|
|
1292
|
+
contents: placeholder(`${name}A`, `A first direction for ${slug}. Change it and watch it reload.`)
|
|
1293
|
+
}
|
|
1294
|
+
];
|
|
1295
|
+
const gitignore = ignoreEntry(options.gitignore);
|
|
1296
|
+
const usage = framework === "next" ? `<${name}Switch searchParams={await searchParams} />` : `<${name}Switch />`;
|
|
1297
|
+
return {
|
|
1298
|
+
writes,
|
|
1299
|
+
gitignore,
|
|
1300
|
+
previews: [
|
|
1301
|
+
{ title: "Current", url: `/?v-${slug}=current` },
|
|
1302
|
+
{ title: `${titleCase(slug)} A`, url: `/?v-${slug}=${slug}-a` }
|
|
1303
|
+
],
|
|
1304
|
+
instructions: `One change is left to you, where your ${slug} renders. Import ${name}Switch from ${dir}/switch and use it in place of the current element:
|
|
1305
|
+
|
|
1306
|
+
${usage}
|
|
1307
|
+
|
|
1308
|
+
Rewriting your component automatically is how a tool breaks a codebase it does not understand, so this step stays yours.
|
|
1309
|
+
`
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// src/briefs.ts
|
|
1314
|
+
var ALL_BRIEFS = [
|
|
1315
|
+
{
|
|
1316
|
+
slug: "quiet",
|
|
1317
|
+
name: "Quiet",
|
|
1318
|
+
brief: "Reduce until almost nothing is left. Generous whitespace, a single focal element, and typography carrying the whole hierarchy. Remove decoration rather than softening it.",
|
|
1319
|
+
avoid: "Adding a subtle gradient or a lighter shade and calling the result minimal."
|
|
1320
|
+
},
|
|
1321
|
+
{
|
|
1322
|
+
slug: "image-led",
|
|
1323
|
+
name: "Image-led",
|
|
1324
|
+
brief: "Let imagery be the page. Full-bleed visual, text as a restrained overlay, and a composition that follows the artwork rather than sitting beside it.",
|
|
1325
|
+
avoid: "Keeping the existing layout and enlarging the picture inside it."
|
|
1326
|
+
},
|
|
1327
|
+
{
|
|
1328
|
+
slug: "kinetic",
|
|
1329
|
+
name: "Kinetic",
|
|
1330
|
+
brief: "Motion carries the hierarchy. Something continuous and ambient, with elements arriving in a deliberate sequence. Honour prefers-reduced-motion with a still composition that still works.",
|
|
1331
|
+
avoid: "A fade-in on scroll bolted onto the current design."
|
|
1332
|
+
},
|
|
1333
|
+
{
|
|
1334
|
+
slug: "editorial",
|
|
1335
|
+
name: "Editorial",
|
|
1336
|
+
brief: "Compose it like a magazine spread. Asymmetric grid, large display type with tight leading, rules and captions, imagery treated as a plate rather than a background.",
|
|
1337
|
+
avoid: "A centred headline above a centred paragraph."
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
slug: "dense",
|
|
1341
|
+
name: "Dense",
|
|
1342
|
+
brief: "Information forward. Tighter rhythm, smaller type, several entry points visible at once, and the confidence that the reader wants more rather than less.",
|
|
1343
|
+
avoid: "The same layout with the padding reduced."
|
|
1344
|
+
},
|
|
1345
|
+
{
|
|
1346
|
+
slug: "high-contrast",
|
|
1347
|
+
name: "High contrast",
|
|
1348
|
+
brief: "Commit to a hard palette: near-black against one saturated accent, or the whole thing inverted. Define shapes with edges rather than gradients.",
|
|
1349
|
+
avoid: "Darkening the existing palette by a few steps."
|
|
1350
|
+
},
|
|
1351
|
+
{
|
|
1352
|
+
slug: "material",
|
|
1353
|
+
name: "Material",
|
|
1354
|
+
brief: "Give it depth and surface. Layered planes, grain or noise, shadow used structurally to stack elements, a sense that the parts are physical objects.",
|
|
1355
|
+
avoid: "One drop shadow on an otherwise flat card."
|
|
1356
|
+
},
|
|
1357
|
+
{
|
|
1358
|
+
slug: "type-led",
|
|
1359
|
+
name: "Type-led",
|
|
1360
|
+
brief: "Remove imagery entirely. Build the composition from letterforms: extreme scale contrast, a second typeface earning its place, text as the visual itself.",
|
|
1361
|
+
avoid: "Keeping the image and setting the headline larger."
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
slug: "playful",
|
|
1365
|
+
name: "Playful",
|
|
1366
|
+
brief: "Deliberate imperfection. Rotation, overlap, irregular or hand-made elements, and one colour that ought not to work but does.",
|
|
1367
|
+
avoid: "Increasing the border radius and little else."
|
|
1368
|
+
},
|
|
1369
|
+
{
|
|
1370
|
+
slug: "systemic",
|
|
1371
|
+
name: "Systemic",
|
|
1372
|
+
brief: "Make the structure visible. Modular blocks on a stated grid, consistent module sizes, alignment itself as the aesthetic.",
|
|
1373
|
+
avoid: "Adding borders around the sections that already exist."
|
|
1374
|
+
}
|
|
1375
|
+
];
|
|
1376
|
+
function briefsFor(count) {
|
|
1377
|
+
if (!Number.isFinite(count) || count <= 0) return [];
|
|
1378
|
+
return ALL_BRIEFS.slice(0, Math.min(Math.floor(count), ALL_BRIEFS.length));
|
|
1379
|
+
}
|
|
1380
|
+
function planBriefs(surface, count) {
|
|
1381
|
+
const slug = surfaceSlug(surface);
|
|
1382
|
+
const chosen = briefsFor(count);
|
|
1383
|
+
const previews = chosen.map((brief) => ({
|
|
1384
|
+
title: brief.name,
|
|
1385
|
+
url: `/?v-${slug}=${brief.slug}`
|
|
1386
|
+
}));
|
|
1387
|
+
const commands = chosen.map(
|
|
1388
|
+
(brief) => `leglas add --title ${JSON.stringify(brief.name)} --url ${JSON.stringify(
|
|
1389
|
+
`/?v-${slug}=${brief.slug}`
|
|
1390
|
+
)} --note ${JSON.stringify(`${brief.brief.split(".")[0]}.`)}`
|
|
1391
|
+
);
|
|
1392
|
+
const instructions = `Build ${chosen.length} direction${chosen.length === 1 ? "" : "s"} for "${surface}", one per angle below.
|
|
1393
|
+
|
|
1394
|
+
Each goes in its own file under .leglas/variants/${slug}/, named after its slug, and is listed in the DIRECTIONS map in that folder's switch file. If the surface has no switch file yet, run \`leglas new ${slug}\` first.
|
|
1395
|
+
|
|
1396
|
+
Keep them distinct from each other. The point of exploring several at once is that they disagree; directions that converge on one look waste the exercise. Read each angle's "avoid" line before starting, because it names the obvious reading that collapses the difference.
|
|
1397
|
+
|
|
1398
|
+
Then register them:
|
|
1399
|
+
|
|
1400
|
+
` + commands.map((command) => ` ${command}`).join("\n");
|
|
1401
|
+
return { previews, commands, instructions };
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
// src/run-explore.ts
|
|
1405
|
+
function runExplore(options, deps) {
|
|
1406
|
+
const chosen = briefsFor(options.count);
|
|
1407
|
+
if (chosen.length === 0) {
|
|
1408
|
+
deps.log(
|
|
1409
|
+
options.json ? JSON.stringify({ ok: false, error: "Ask for at least one direction." }) : "Ask for at least one direction, for example --count 4."
|
|
1410
|
+
);
|
|
1411
|
+
return { exitCode: 1 };
|
|
1412
|
+
}
|
|
1413
|
+
const plan = planBriefs(options.surface, options.count);
|
|
1414
|
+
if (options.json) {
|
|
1415
|
+
deps.log(
|
|
1416
|
+
JSON.stringify({
|
|
1417
|
+
ok: true,
|
|
1418
|
+
surface: options.surface,
|
|
1419
|
+
directions: chosen,
|
|
1420
|
+
previews: plan.previews,
|
|
1421
|
+
commands: plan.commands,
|
|
1422
|
+
instructions: plan.instructions
|
|
1423
|
+
})
|
|
1424
|
+
);
|
|
1425
|
+
return { exitCode: 0 };
|
|
1426
|
+
}
|
|
1427
|
+
if (options.count > ALL_BRIEFS.length) {
|
|
1428
|
+
deps.log(
|
|
1429
|
+
`Asked for ${options.count}; there are ${ALL_BRIEFS.length} distinct angles, so ${ALL_BRIEFS.length} follow.`
|
|
1430
|
+
);
|
|
1431
|
+
deps.log("");
|
|
1432
|
+
}
|
|
1433
|
+
for (const brief of chosen) {
|
|
1434
|
+
deps.log(`${brief.name}`);
|
|
1435
|
+
deps.log(` ${brief.brief}`);
|
|
1436
|
+
deps.log(` Avoid: ${brief.avoid}`);
|
|
1437
|
+
deps.log("");
|
|
1438
|
+
}
|
|
1439
|
+
deps.log(plan.instructions);
|
|
1440
|
+
return { exitCode: 0 };
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
// src/run-init.ts
|
|
1444
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1445
|
+
import { join as join7 } from "path";
|
|
1446
|
+
|
|
1447
|
+
// src/init.ts
|
|
1448
|
+
var AGENTS_MARKER_START = "<!-- leglas:start -->";
|
|
1449
|
+
var AGENTS_MARKER_END = "<!-- leglas:end -->";
|
|
1450
|
+
var AGENTS_SECTION = `${AGENTS_MARKER_START}
|
|
1451
|
+
|
|
1452
|
+
## Design directions (Leglas)
|
|
1453
|
+
|
|
1454
|
+
This project uses Leglas to compare design directions inside the running app.
|
|
1455
|
+
A direction is just a URL; the app decides what it renders, and Leglas only
|
|
1456
|
+
displays it.
|
|
1457
|
+
|
|
1458
|
+
This is small, additive work: a few self-contained files and a command per
|
|
1459
|
+
direction. Build it directly. A planning or approval step before implementing
|
|
1460
|
+
costs more than the work itself, and the directions on screen are the thing
|
|
1461
|
+
being asked for.
|
|
1462
|
+
|
|
1463
|
+
When asked for design variations, alternatives, or "a few options":
|
|
1464
|
+
|
|
1465
|
+
1. **Add beside what exists. Never replace it.** Every direction has to render
|
|
1466
|
+
from the one dev server that is already running, which is what makes
|
|
1467
|
+
switching between them instant. Two directions that rewrite the same file
|
|
1468
|
+
cannot both exist, and recovering from that costs a rebuild per direction.
|
|
1469
|
+
If a direction cannot be additive, because it must change dependencies,
|
|
1470
|
+
change build configuration, or rewrite an existing file's behaviour, ask
|
|
1471
|
+
where it should live before writing it:
|
|
1472
|
+
\`leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
1473
|
+
When the answer is \`checkout\`, build the direction on its own git branch
|
|
1474
|
+
and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
1475
|
+
(the config needs \`devCommand\` with \`{port}\`). Everything below is the
|
|
1476
|
+
ordinary, in-app path.
|
|
1477
|
+
2. Run \`leglas explore <surface> --count <n>\` first. It returns distinct
|
|
1478
|
+
angles to build, each with what to avoid, so several directions genuinely
|
|
1479
|
+
disagree instead of becoming shades of one idea. Follow those angles rather
|
|
1480
|
+
than inventing your own variations of the current design.
|
|
1481
|
+
3. If the surface has no switcher yet, run
|
|
1482
|
+
\`leglas new <surface> --from <the component that renders it today>\`. It
|
|
1483
|
+
writes one under \`.leglas/variants/<surface>/\` and prints the single line
|
|
1484
|
+
to add where that surface renders. \`--from\` makes the baseline re-export
|
|
1485
|
+
the real component rather than copying it, so it stays live.
|
|
1486
|
+
4. Put each direction in its own file beside the others in
|
|
1487
|
+
\`.leglas/variants/<surface>/\`, then list it in the \`DIRECTIONS\` map in
|
|
1488
|
+
that folder's \`switch\` file.
|
|
1489
|
+
5. Register each one so it appears in the interface:
|
|
1490
|
+
\`leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
1491
|
+
6. Tell the user to open the interface, or to run \`leglas\` if it is not
|
|
1492
|
+
already running.
|
|
1493
|
+
|
|
1494
|
+
When the user asks to change one direction, check \`leglas requests --json\`
|
|
1495
|
+
first: they may have described it from the interface, and the request names the
|
|
1496
|
+
exact file. Clear the queue with \`leglas requests --clear\` once done.
|
|
1497
|
+
|
|
1498
|
+
When the user picks a winner, run
|
|
1499
|
+
\`leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
1500
|
+
out of the ignored directory, deletes the rest of the exploration, and drops
|
|
1501
|
+
them from the rail. Then change their component to use the kept component
|
|
1502
|
+
instead of the switcher.
|
|
1503
|
+
|
|
1504
|
+
Useful to know:
|
|
1505
|
+
|
|
1506
|
+
- \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
|
|
1507
|
+
ships. Move a direction into real source only when it wins.
|
|
1508
|
+
- If the project has no running app yet, a direction can be a plain HTML
|
|
1509
|
+
file: write it under \`.leglas/pages/\` and register it with
|
|
1510
|
+
\`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
1511
|
+
serves the file itself, so no dev server is needed. Sibling assets in the
|
|
1512
|
+
same directory resolve normally.
|
|
1513
|
+
- Titles identify previews and must be unique.
|
|
1514
|
+
- \`leglas list\` shows every direction, shared and local.
|
|
1515
|
+
- Every command accepts \`--json\` and prints one envelope with a stable exit
|
|
1516
|
+
code, so you can drive it without parsing prose.
|
|
1517
|
+
|
|
1518
|
+
${AGENTS_MARKER_END}
|
|
1519
|
+
`;
|
|
1520
|
+
var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
|
|
1521
|
+
// compare, then run \`leglas\`.
|
|
1522
|
+
export default {
|
|
1523
|
+
// Where your dev server is. Override at the command line with --user-port.
|
|
1524
|
+
devServer: "http://localhost:3000",
|
|
1525
|
+
previews: [{ title: "Current", url: "/" }],
|
|
1526
|
+
};
|
|
1527
|
+
`;
|
|
1528
|
+
function planInit(options) {
|
|
1529
|
+
const writes = [];
|
|
1530
|
+
const agents = options.agents ?? "";
|
|
1531
|
+
const hasSection = agents.includes(AGENTS_MARKER_START);
|
|
1532
|
+
if (!hasSection) {
|
|
1533
|
+
const preamble = agents.replace(/\n*$/, "");
|
|
1534
|
+
writes.push({
|
|
1535
|
+
path: "AGENTS.md",
|
|
1536
|
+
contents: preamble === "" ? AGENTS_SECTION : `${preamble}
|
|
1537
|
+
|
|
1538
|
+
${AGENTS_SECTION}`
|
|
1539
|
+
});
|
|
1540
|
+
} else if (options.force === true) {
|
|
1541
|
+
const start = agents.indexOf(AGENTS_MARKER_START);
|
|
1542
|
+
const end = agents.indexOf(AGENTS_MARKER_END);
|
|
1543
|
+
const after = end === -1 ? "" : agents.slice(end + AGENTS_MARKER_END.length);
|
|
1544
|
+
writes.push({
|
|
1545
|
+
path: "AGENTS.md",
|
|
1546
|
+
contents: `${agents.slice(0, start)}${AGENTS_SECTION.replace(/\n$/, "")}${after}`
|
|
1547
|
+
});
|
|
1548
|
+
}
|
|
1549
|
+
if (options.config === null) {
|
|
1550
|
+
writes.push({ path: "leglas.config.ts", contents: STARTER_CONFIG });
|
|
1551
|
+
}
|
|
1552
|
+
return { writes, gitignore: ignoreEntry(options.gitignore) };
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
// src/run-init.ts
|
|
1556
|
+
async function readIfPresent(path) {
|
|
1557
|
+
try {
|
|
1558
|
+
return await readFile4(path, "utf8");
|
|
1559
|
+
} catch {
|
|
1560
|
+
return null;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
async function runInit(options, deps) {
|
|
1564
|
+
const existingConfig = findConfigFile(options.cwd);
|
|
1565
|
+
const plan = planInit({
|
|
1566
|
+
agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
|
|
1567
|
+
config: existingConfig === null ? null : "present",
|
|
1568
|
+
gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
|
|
1569
|
+
force: options.force
|
|
1570
|
+
});
|
|
1571
|
+
const touched = [];
|
|
1572
|
+
for (const write of plan.writes) {
|
|
1573
|
+
await writeFile3(join7(options.cwd, write.path), write.contents, "utf8");
|
|
1574
|
+
touched.push(write.path);
|
|
1575
|
+
}
|
|
1576
|
+
if (plan.gitignore !== null) {
|
|
1577
|
+
await writeFile3(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1578
|
+
touched.push(".gitignore");
|
|
1579
|
+
}
|
|
1580
|
+
if (options.json) {
|
|
1581
|
+
deps.log(JSON.stringify({ ok: true, written: touched }));
|
|
1582
|
+
return { exitCode: 0 };
|
|
1583
|
+
}
|
|
1584
|
+
if (touched.length === 0) {
|
|
1585
|
+
deps.log("Already set up. Nothing to change.");
|
|
1586
|
+
return { exitCode: 0 };
|
|
1587
|
+
}
|
|
1588
|
+
for (const path of touched) deps.log(` wrote ${path}`);
|
|
1589
|
+
deps.log("");
|
|
1590
|
+
deps.log("Your agents now know how to add design directions to this project.");
|
|
1591
|
+
deps.log("Ask one for a few variations of a surface, then run leglas.");
|
|
1592
|
+
return { exitCode: 0 };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// src/run-keep.ts
|
|
1596
|
+
import { existsSync as existsSync3 } from "fs";
|
|
1597
|
+
import { mkdir as mkdir3, readFile as readFile5, rm as rm2, writeFile as writeFile4 } from "fs/promises";
|
|
1598
|
+
import { dirname as dirname4, join as join8 } from "path";
|
|
1599
|
+
|
|
1600
|
+
// src/keep.ts
|
|
1601
|
+
import { basename as basename2, extname as extname2, normalize as normalize2 } from "path";
|
|
1602
|
+
function surfaceOf(url) {
|
|
1603
|
+
if (!url.startsWith("/") || !url.includes("?")) return null;
|
|
1604
|
+
for (const pair of url.slice(url.indexOf("?") + 1).split("&")) {
|
|
1605
|
+
const key = pair.split("=")[0];
|
|
1606
|
+
if (key?.startsWith("v-")) return key.slice(2);
|
|
1607
|
+
}
|
|
1608
|
+
return null;
|
|
1609
|
+
}
|
|
1610
|
+
function exportNameFor(to) {
|
|
1611
|
+
const stem = basename2(to, extname2(to));
|
|
1612
|
+
return stem.split(/[^a-zA-Z0-9]+/).filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
1613
|
+
}
|
|
1614
|
+
function planKeep(options) {
|
|
1615
|
+
const winner = options.previews.find((preview) => preview.title === options.title);
|
|
1616
|
+
if (!winner) {
|
|
1617
|
+
return {
|
|
1618
|
+
ok: false,
|
|
1619
|
+
error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
const from = targetFor(winner.url);
|
|
1623
|
+
const surface = surfaceOf(winner.url);
|
|
1624
|
+
if (from === null || surface === null) {
|
|
1625
|
+
return {
|
|
1626
|
+
ok: false,
|
|
1627
|
+
error: `Cannot tell which file produces ${JSON.stringify(options.title)}. Its URL (${winner.url}) was not generated by leglas new, so there is nothing safe to move.`
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
const to = normalize2(options.to);
|
|
1631
|
+
if (to.startsWith("..")) {
|
|
1632
|
+
return { ok: false, error: "The destination has to be inside the project." };
|
|
1633
|
+
}
|
|
1634
|
+
if (to.split(/[/\\]/)[0] === ".leglas") {
|
|
1635
|
+
return {
|
|
1636
|
+
ok: false,
|
|
1637
|
+
error: "The destination cannot be inside .leglas, which is ignored and gets deleted."
|
|
1638
|
+
};
|
|
1639
|
+
}
|
|
1640
|
+
const dropTitles = options.previews.filter((preview) => surfaceOf(preview.url) === surface).map((preview) => preview.title);
|
|
1641
|
+
const exportName = exportNameFor(to);
|
|
1642
|
+
return {
|
|
1643
|
+
ok: true,
|
|
1644
|
+
move: { from, to },
|
|
1645
|
+
removeDir: `.leglas/variants/${surface}`,
|
|
1646
|
+
dropTitles,
|
|
1647
|
+
exportName,
|
|
1648
|
+
instructions: `${options.title} now lives at ${to}, exported as ${exportName}. Point your component at it instead of the switcher, and the exploration is gone:
|
|
1649
|
+
|
|
1650
|
+
<${exportName} />
|
|
1651
|
+
`
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// src/run-keep.ts
|
|
1656
|
+
function renameExport(source, to) {
|
|
1657
|
+
const match = /export function ([A-Za-z0-9_]+)\s*\(/.exec(source);
|
|
1658
|
+
if (!match || match[1] === void 0) return source;
|
|
1659
|
+
return source.replace(
|
|
1660
|
+
new RegExp(`\\b${match[1]}\\b`, "g"),
|
|
1661
|
+
to
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
async function runKeep(options, deps) {
|
|
1665
|
+
const loaded = await loadConfig(options.cwd);
|
|
1666
|
+
const local = await readLocalPreviews(options.cwd);
|
|
1667
|
+
const previews = [...loaded.config?.previews ?? [], ...local.previews];
|
|
1668
|
+
const plan = planKeep({ title: options.title, previews, to: options.to });
|
|
1669
|
+
const fail = (error) => {
|
|
1670
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error }));
|
|
1671
|
+
else deps.error(error);
|
|
1672
|
+
return { exitCode: 1 };
|
|
1673
|
+
};
|
|
1674
|
+
if (!plan.ok) return fail(plan.error);
|
|
1675
|
+
const from = join8(options.cwd, plan.move.from);
|
|
1676
|
+
const to = join8(options.cwd, plan.move.to);
|
|
1677
|
+
if (!existsSync3(from)) {
|
|
1678
|
+
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
1679
|
+
}
|
|
1680
|
+
if (existsSync3(to)) {
|
|
1681
|
+
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
1682
|
+
}
|
|
1683
|
+
const source = await readFile5(from, "utf8");
|
|
1684
|
+
await mkdir3(dirname4(to), { recursive: true });
|
|
1685
|
+
await writeFile4(to, renameExport(source, plan.exportName), "utf8");
|
|
1686
|
+
await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
1687
|
+
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
1688
|
+
if (options.json) {
|
|
1689
|
+
deps.log(
|
|
1690
|
+
JSON.stringify({
|
|
1691
|
+
ok: true,
|
|
1692
|
+
kept: options.title,
|
|
1693
|
+
to: plan.move.to,
|
|
1694
|
+
exportName: plan.exportName,
|
|
1695
|
+
removed: plan.removeDir,
|
|
1696
|
+
droppedPreviews: dropped,
|
|
1697
|
+
instructions: plan.instructions
|
|
1698
|
+
})
|
|
1699
|
+
);
|
|
1700
|
+
return { exitCode: 0 };
|
|
1701
|
+
}
|
|
1702
|
+
deps.log(` kept ${plan.move.to}`);
|
|
1703
|
+
deps.log(` removed ${plan.removeDir}`);
|
|
1704
|
+
if (dropped > 0) {
|
|
1705
|
+
deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
|
|
1706
|
+
}
|
|
1707
|
+
const stillShared = plan.dropTitles.filter(
|
|
1708
|
+
(title) => !local.previews.some((preview) => preview.title === title)
|
|
1709
|
+
);
|
|
1710
|
+
if (stillShared.length > 0) {
|
|
1711
|
+
deps.log("");
|
|
1712
|
+
deps.log(` Remove these from leglas.config.ts by hand: ${stillShared.join(", ")}`);
|
|
1713
|
+
}
|
|
1714
|
+
deps.log("");
|
|
1715
|
+
deps.log(plan.instructions);
|
|
1716
|
+
return { exitCode: 0 };
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
// src/run-new.ts
|
|
1720
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1721
|
+
import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
|
|
1722
|
+
import { dirname as dirname5, join as join9 } from "path";
|
|
1723
|
+
async function readIfPresent2(path) {
|
|
1724
|
+
try {
|
|
1725
|
+
return await readFile6(path, "utf8");
|
|
1726
|
+
} catch {
|
|
1727
|
+
return null;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
async function runNew(options, deps) {
|
|
1731
|
+
let from;
|
|
1732
|
+
if (options.from !== void 0) {
|
|
1733
|
+
const contents = await readIfPresent2(join9(options.cwd, options.from));
|
|
1734
|
+
if (contents === null) {
|
|
1735
|
+
const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
1736
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
|
|
1737
|
+
else deps.log(message);
|
|
1738
|
+
return { exitCode: 1, written: [] };
|
|
1739
|
+
}
|
|
1740
|
+
from = { path: options.from, contents };
|
|
1741
|
+
}
|
|
1742
|
+
const plan = planNew({
|
|
1743
|
+
surface: options.surface,
|
|
1744
|
+
packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
|
|
1745
|
+
gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
|
|
1746
|
+
from
|
|
1747
|
+
});
|
|
1748
|
+
const fail = (error) => {
|
|
1749
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error }));
|
|
1750
|
+
else deps.log(error);
|
|
1751
|
+
return { exitCode: 1, written: [] };
|
|
1752
|
+
};
|
|
1753
|
+
if (plan.writes.length === 0) {
|
|
1754
|
+
return fail(`Nothing to scaffold for ${JSON.stringify(options.surface)}.`);
|
|
1755
|
+
}
|
|
1756
|
+
if (options.print) {
|
|
1757
|
+
if (options.json) {
|
|
1758
|
+
deps.log(JSON.stringify({ ok: true, files: plan.writes, instructions: plan.instructions, previews: plan.previews }));
|
|
1759
|
+
return { exitCode: 0, written: [] };
|
|
1760
|
+
}
|
|
1761
|
+
for (const write of plan.writes) {
|
|
1762
|
+
deps.log(`--- ${write.path}`);
|
|
1763
|
+
deps.log(write.contents);
|
|
1764
|
+
}
|
|
1765
|
+
deps.log(plan.instructions);
|
|
1766
|
+
return { exitCode: 0, written: [] };
|
|
1767
|
+
}
|
|
1768
|
+
const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
|
|
1769
|
+
if (existing.length > 0) {
|
|
1770
|
+
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
1771
|
+
}
|
|
1772
|
+
const written = [];
|
|
1773
|
+
for (const write of plan.writes) {
|
|
1774
|
+
const target = join9(options.cwd, write.path);
|
|
1775
|
+
await mkdir4(dirname5(target), { recursive: true });
|
|
1776
|
+
await writeFile5(target, write.contents, "utf8");
|
|
1777
|
+
written.push(write.path);
|
|
1778
|
+
}
|
|
1779
|
+
if (plan.gitignore !== null) {
|
|
1780
|
+
await writeFile5(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1781
|
+
written.push(".gitignore");
|
|
1782
|
+
}
|
|
1783
|
+
if (options.json) {
|
|
1784
|
+
deps.log(
|
|
1785
|
+
JSON.stringify({ ok: true, written, instructions: plan.instructions, previews: plan.previews })
|
|
1786
|
+
);
|
|
1787
|
+
return { exitCode: 0, written };
|
|
1788
|
+
}
|
|
1789
|
+
for (const path of written) deps.log(` created ${path}`);
|
|
1790
|
+
deps.log("");
|
|
1791
|
+
deps.log(plan.instructions);
|
|
1792
|
+
deps.log("Then register them so they appear in the interface:");
|
|
1793
|
+
deps.log("");
|
|
1794
|
+
for (const preview of plan.previews) {
|
|
1795
|
+
deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
1796
|
+
}
|
|
1797
|
+
return { exitCode: 0, written };
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
// src/run-previews.ts
|
|
1801
|
+
import { readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
1802
|
+
import { join as join10 } from "path";
|
|
1803
|
+
function envelope(deps, ok, body) {
|
|
1804
|
+
deps.log(JSON.stringify({ ok, ...body }));
|
|
1805
|
+
}
|
|
1806
|
+
async function ensureIgnored(cwd) {
|
|
1807
|
+
const path = join10(cwd, ".gitignore");
|
|
1808
|
+
let current = null;
|
|
1809
|
+
try {
|
|
1810
|
+
current = await readFile7(path, "utf8");
|
|
1811
|
+
} catch {
|
|
1812
|
+
current = null;
|
|
1813
|
+
}
|
|
1814
|
+
const next = ignoreEntry(current);
|
|
1815
|
+
if (next !== null) await writeFile6(path, next, "utf8");
|
|
1816
|
+
}
|
|
1817
|
+
async function runAdd(options, deps) {
|
|
1818
|
+
const loaded = await loadConfig(options.cwd);
|
|
1819
|
+
const shared = loaded.config?.previews ?? [];
|
|
1820
|
+
const outcome = await addLocalPreview(
|
|
1821
|
+
options.cwd,
|
|
1822
|
+
{
|
|
1823
|
+
title: options.preview.title,
|
|
1824
|
+
url: options.preview.url,
|
|
1825
|
+
note: options.preview.note,
|
|
1826
|
+
tags: options.preview.tags,
|
|
1827
|
+
branch: options.preview.branch,
|
|
1828
|
+
file: options.preview.file
|
|
1829
|
+
},
|
|
1830
|
+
shared
|
|
1831
|
+
);
|
|
1832
|
+
if (!outcome.ok) {
|
|
1833
|
+
if (options.json) envelope(deps, false, { error: outcome.error });
|
|
1834
|
+
else deps.error(outcome.error ?? "Could not add the preview.");
|
|
1835
|
+
return { exitCode: 1 };
|
|
1836
|
+
}
|
|
1837
|
+
await ensureIgnored(options.cwd);
|
|
1838
|
+
const needsDevCommand = options.preview.branch !== void 0 && loaded.config?.devCommand === void 0;
|
|
1839
|
+
if (options.json) {
|
|
1840
|
+
envelope(deps, true, {
|
|
1841
|
+
added: options.preview.title,
|
|
1842
|
+
...options.preview.url === void 0 ? {} : { url: options.preview.url },
|
|
1843
|
+
local: true,
|
|
1844
|
+
...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
|
|
1845
|
+
...options.preview.file === void 0 ? {} : { file: options.preview.file },
|
|
1846
|
+
...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
|
|
1847
|
+
});
|
|
1848
|
+
} else {
|
|
1849
|
+
deps.log(
|
|
1850
|
+
` added ${options.preview.title} ${options.preview.url ?? options.preview.file ?? ""}`
|
|
1851
|
+
);
|
|
1852
|
+
deps.log("");
|
|
1853
|
+
if (needsDevCommand) {
|
|
1854
|
+
deps.log(" ! The config sets no devCommand, so Leglas cannot start this branch yet.");
|
|
1855
|
+
deps.log(" Add devCommand (with {port}) to the config.");
|
|
1856
|
+
deps.log("");
|
|
1857
|
+
}
|
|
1858
|
+
deps.log("Local to this machine. Restart Leglas to see it, or run leglas list.");
|
|
1859
|
+
}
|
|
1860
|
+
return { exitCode: 0 };
|
|
1861
|
+
}
|
|
1862
|
+
async function runList(options, deps) {
|
|
1863
|
+
const loaded = await loadConfig(options.cwd);
|
|
1864
|
+
const local = await readLocalPreviews(options.cwd);
|
|
1865
|
+
const errors = [...loaded.errors, ...local.errors];
|
|
1866
|
+
const previews = [
|
|
1867
|
+
...(loaded.config?.previews ?? []).map((preview) => ({ ...preview, local: false })),
|
|
1868
|
+
...local.previews
|
|
1869
|
+
];
|
|
1870
|
+
if (options.json) {
|
|
1871
|
+
envelope(deps, errors.length === 0, {
|
|
1872
|
+
previews: previews.map((preview) => ({
|
|
1873
|
+
title: preview.title,
|
|
1874
|
+
url: preview.url,
|
|
1875
|
+
local: preview.local,
|
|
1876
|
+
branch: preview.branch ?? null,
|
|
1877
|
+
file: preview.file ?? null
|
|
1878
|
+
})),
|
|
1879
|
+
errors
|
|
1880
|
+
});
|
|
1881
|
+
return { exitCode: errors.length === 0 ? 0 : 1 };
|
|
1882
|
+
}
|
|
1883
|
+
if (previews.length === 0) {
|
|
1884
|
+
deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
|
|
1885
|
+
} else {
|
|
1886
|
+
const width = Math.max(...previews.map((preview) => preview.title.length));
|
|
1887
|
+
for (const preview of previews) {
|
|
1888
|
+
const source = preview.file ?? preview.url;
|
|
1889
|
+
const origin = preview.branch === void 0 ? "" : ` (branch ${preview.branch})`;
|
|
1890
|
+
const scope = preview.local ? " (local)" : "";
|
|
1891
|
+
deps.log(` ${preview.title.padEnd(width)} ${source}${origin}${scope}`);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
for (const error of errors) deps.error(` ! ${error}`);
|
|
1895
|
+
return { exitCode: errors.length === 0 ? 0 : 1 };
|
|
1896
|
+
}
|
|
1897
|
+
async function runRequests(options, deps) {
|
|
1898
|
+
if (options.clear) {
|
|
1899
|
+
await clearRequests(options.cwd);
|
|
1900
|
+
if (options.json) envelope(deps, true, { cleared: true });
|
|
1901
|
+
else deps.log("Queue cleared.");
|
|
1902
|
+
return { exitCode: 0 };
|
|
1903
|
+
}
|
|
1904
|
+
const requests = await readRequests(options.cwd);
|
|
1905
|
+
if (options.json) {
|
|
1906
|
+
envelope(deps, true, { requests });
|
|
1907
|
+
return { exitCode: 0 };
|
|
1908
|
+
}
|
|
1909
|
+
if (requests.length === 0) {
|
|
1910
|
+
deps.log("No pending requests.");
|
|
1911
|
+
return { exitCode: 0 };
|
|
1912
|
+
}
|
|
1913
|
+
for (const request of requests) {
|
|
1914
|
+
deps.log(` ${request.title}: ${request.intent}`);
|
|
1915
|
+
if (request.target !== null) deps.log(` ${request.target}`);
|
|
1916
|
+
}
|
|
1917
|
+
deps.log("");
|
|
1918
|
+
deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
|
|
1919
|
+
return { exitCode: 0 };
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// src/run.ts
|
|
1923
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1924
|
+
import { createRequire } from "module";
|
|
1925
|
+
import { basename as basename3, dirname as dirname6, join as join11, relative as relative2 } from "path";
|
|
1926
|
+
import { fileURLToPath } from "url";
|
|
1927
|
+
function findShellDir() {
|
|
1928
|
+
const bundled = join11(dirname6(fileURLToPath(import.meta.url)), "shell");
|
|
1929
|
+
if (existsSync5(join11(bundled, "index.html"))) return bundled;
|
|
1930
|
+
try {
|
|
1931
|
+
const require2 = createRequire(import.meta.url);
|
|
1932
|
+
return dirname6(require2.resolve("@leglas/shell/dist/index.html"));
|
|
1933
|
+
} catch {
|
|
1934
|
+
return null;
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
async function run2(options, deps) {
|
|
1938
|
+
const loaded = await loadConfig(options.cwd);
|
|
1939
|
+
const local = await readLocalPreviews(options.cwd);
|
|
1940
|
+
let devServer = options.userPort === void 0 ? loaded.config?.devServer ?? "http://localhost:3000" : `http://localhost:${options.userPort}`;
|
|
1941
|
+
const merged = loaded.config === null ? null : { ...loaded.config, devServer, previews: [...loaded.config.previews, ...local.previews] };
|
|
1942
|
+
const worktrees = [];
|
|
1943
|
+
const worktreeErrors = [];
|
|
1944
|
+
const previews = [];
|
|
1945
|
+
let app = null;
|
|
1946
|
+
const needsApp = (merged?.previews ?? []).some(
|
|
1947
|
+
(preview) => preview.file === void 0 && preview.branch === void 0 && preview.url.startsWith("/")
|
|
1948
|
+
);
|
|
1949
|
+
if (needsApp && merged?.devCommand !== void 0 && options.userPort === void 0 && !await probe(devServer)) {
|
|
1950
|
+
if (!options.json) deps.log(` starting your app (${merged.devCommand})\u2026`);
|
|
1951
|
+
try {
|
|
1952
|
+
app = await startAppProcess({
|
|
1953
|
+
cwd: options.cwd,
|
|
1954
|
+
devCommand: merged.devCommand,
|
|
1955
|
+
label: "your app"
|
|
1956
|
+
});
|
|
1957
|
+
devServer = app.url;
|
|
1958
|
+
merged.devServer = app.url;
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
worktreeErrors.push(error instanceof Error ? error.message : String(error));
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
const fileMounts = /* @__PURE__ */ new Map();
|
|
1964
|
+
for (const preview of merged?.previews ?? []) {
|
|
1965
|
+
if (preview.file !== void 0) {
|
|
1966
|
+
const absolute = join11(options.cwd, preview.file);
|
|
1967
|
+
if (!existsSync5(absolute)) {
|
|
1968
|
+
worktreeErrors.push(
|
|
1969
|
+
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
1970
|
+
);
|
|
1971
|
+
continue;
|
|
1972
|
+
}
|
|
1973
|
+
let slug = worktreeSlug(preview.title) || "file";
|
|
1974
|
+
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
1975
|
+
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
1976
|
+
}
|
|
1977
|
+
fileMounts.set(slug, dirname6(absolute));
|
|
1978
|
+
previews.push({
|
|
1979
|
+
...preview,
|
|
1980
|
+
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
1981
|
+
});
|
|
1982
|
+
continue;
|
|
1983
|
+
}
|
|
1984
|
+
if (preview.branch === void 0) {
|
|
1985
|
+
previews.push(preview);
|
|
1986
|
+
continue;
|
|
1987
|
+
}
|
|
1988
|
+
if (merged?.devCommand === void 0) {
|
|
1989
|
+
worktreeErrors.push(
|
|
1990
|
+
`"${preview.title}" names branch ${preview.branch}, but the config sets no devCommand, so Leglas cannot start that checkout. Add devCommand (with {port}) to the config.`
|
|
1991
|
+
);
|
|
1992
|
+
continue;
|
|
1993
|
+
}
|
|
1994
|
+
if (!options.json) deps.log(` starting ${preview.branch}\u2026`);
|
|
1995
|
+
try {
|
|
1996
|
+
const worktree = await startWorktree({
|
|
1997
|
+
cwd: options.cwd,
|
|
1998
|
+
branch: preview.branch,
|
|
1999
|
+
installCommand: merged.installCommand,
|
|
2000
|
+
devCommand: merged.devCommand
|
|
2001
|
+
});
|
|
2002
|
+
worktrees.push(worktree);
|
|
2003
|
+
previews.push({ ...preview, url: `${worktree.url}${preview.url}` });
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
worktreeErrors.push(error instanceof Error ? error.message : String(error));
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
const config = merged === null ? null : { ...merged, previews };
|
|
2009
|
+
const server = await startServer({
|
|
2010
|
+
config,
|
|
2011
|
+
configErrors: [...loaded.errors, ...local.errors, ...worktreeErrors],
|
|
2012
|
+
fileMounts,
|
|
2013
|
+
shellDir: findShellDir(),
|
|
2014
|
+
// The config file identifies the project when there is one; otherwise the
|
|
2015
|
+
// directory does. Either way saved layout survives a port change.
|
|
2016
|
+
project: loaded.path ?? options.cwd,
|
|
2017
|
+
cwd: options.cwd,
|
|
2018
|
+
...options.port === void 0 ? {} : { port: options.port }
|
|
2019
|
+
});
|
|
2020
|
+
const url = `${server.url}${LEGLAS_PREFIX}`;
|
|
2021
|
+
const previewCount = config?.previews.length ?? 0;
|
|
2022
|
+
const health = await (await fetch(`${server.url}${LEGLAS_PREFIX}/api/health`)).json();
|
|
2023
|
+
if (options.json) {
|
|
2024
|
+
deps.log(
|
|
2025
|
+
JSON.stringify({
|
|
2026
|
+
ok: true,
|
|
2027
|
+
url,
|
|
2028
|
+
port: server.port,
|
|
2029
|
+
devServer,
|
|
2030
|
+
devServerReachable: health.reachable,
|
|
2031
|
+
startedApp: app !== null,
|
|
2032
|
+
previews: previewCount,
|
|
2033
|
+
config: loaded.path,
|
|
2034
|
+
errors: loaded.errors
|
|
2035
|
+
})
|
|
2036
|
+
);
|
|
2037
|
+
} else {
|
|
2038
|
+
const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative2(options.cwd, loaded.path) || loaded.path;
|
|
2039
|
+
deps.log(`Leglas ${url}`);
|
|
2040
|
+
deps.log(
|
|
2041
|
+
`app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
|
|
2042
|
+
);
|
|
2043
|
+
deps.log(`config ${configLabel}`);
|
|
2044
|
+
deps.log(` ${previewCount} preview${previewCount === 1 ? "" : "s"}`);
|
|
2045
|
+
if (loaded.errors.length + worktreeErrors.length > 0) {
|
|
2046
|
+
deps.log("");
|
|
2047
|
+
for (const error of [...loaded.errors, ...worktreeErrors]) deps.log(` ! ${error}`);
|
|
2048
|
+
deps.log(" Fix the config and reload; Leglas will pick it up on restart.");
|
|
2049
|
+
}
|
|
2050
|
+
if (!health.reachable && needsApp) {
|
|
2051
|
+
deps.log("");
|
|
2052
|
+
deps.log(` ! ${devServer} is not reachable. Start your dev server, or`);
|
|
2053
|
+
deps.log(" point Leglas elsewhere with --user-port.");
|
|
2054
|
+
if (merged?.devCommand === void 0) {
|
|
2055
|
+
deps.log(" Set devCommand in the config and Leglas will start it for you.");
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
}
|
|
2059
|
+
if (options.open) await deps.open(url);
|
|
2060
|
+
return {
|
|
2061
|
+
exitCode: 0,
|
|
2062
|
+
url,
|
|
2063
|
+
devServer,
|
|
2064
|
+
previewCount,
|
|
2065
|
+
stop: async () => {
|
|
2066
|
+
await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
|
|
2067
|
+
})));
|
|
2068
|
+
await app?.stop().catch(() => {
|
|
2069
|
+
});
|
|
2070
|
+
await server.close();
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/bin.ts
|
|
2076
|
+
var HELP = `leglas - compare design directions inside your own running app
|
|
2077
|
+
|
|
2078
|
+
Usage
|
|
2079
|
+
leglas init Prepare a project and teach its agents
|
|
2080
|
+
leglas [options] Start the server and open the interface
|
|
2081
|
+
leglas new <surface> Scaffold a branch point for a surface
|
|
2082
|
+
leglas explore <surface> Print distinct angles for an agent to build
|
|
2083
|
+
leglas classify Decide where a direction should live
|
|
2084
|
+
leglas add --title T --url U Register a preview on this machine
|
|
2085
|
+
leglas list Show every preview, shared and local
|
|
2086
|
+
leglas requests Show change requests made from the interface
|
|
2087
|
+
leglas keep <title> --to <path> Keep a winner and end the exploration
|
|
2088
|
+
|
|
2089
|
+
Options
|
|
2090
|
+
--user-port <port> Port your dev server is on (default: from config, or 3000)
|
|
2091
|
+
--port <port> Port for Leglas itself (default: 4100, next free if taken)
|
|
2092
|
+
--config <path> Config file to use instead of searching upward
|
|
2093
|
+
--no-open Do not open the browser
|
|
2094
|
+
--json Print a single machine-readable envelope
|
|
2095
|
+
-h, --help Show this
|
|
2096
|
+
-v, --version Show the version
|
|
2097
|
+
|
|
2098
|
+
Options for new
|
|
2099
|
+
--print Print the scaffold instead of writing it
|
|
2100
|
+
--from <path> Use an existing component as the baseline
|
|
2101
|
+
|
|
2102
|
+
Options for explore
|
|
2103
|
+
--count <n> How many angles (default 3)
|
|
2104
|
+
|
|
2105
|
+
Options for classify
|
|
2106
|
+
--change <path> A file the direction creates or wires up (repeatable)
|
|
2107
|
+
--rewrite <path> An existing file whose behaviour it must change (repeatable)
|
|
2108
|
+
|
|
2109
|
+
Options for add
|
|
2110
|
+
--note <text> Second line under the title
|
|
2111
|
+
--tag <text> Repeatable
|
|
2112
|
+
--branch <name> Back the preview with a checkout of this git branch
|
|
2113
|
+
--file <path> Preview a plain HTML file served by Leglas itself
|
|
2114
|
+
`;
|
|
2115
|
+
function version() {
|
|
2116
|
+
const require2 = createRequire2(import.meta.url);
|
|
2117
|
+
const pkg = require2("../package.json");
|
|
2118
|
+
return pkg.version;
|
|
2119
|
+
}
|
|
2120
|
+
async function openBrowser(url) {
|
|
2121
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
2122
|
+
try {
|
|
2123
|
+
spawn2(command, [url], { detached: true, stdio: "ignore" }).unref();
|
|
2124
|
+
} catch {
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
function quietModuleTypeWarning() {
|
|
2128
|
+
const listeners = process.listeners("warning");
|
|
2129
|
+
process.removeAllListeners("warning");
|
|
2130
|
+
process.on("warning", (warning) => {
|
|
2131
|
+
if (warning.code === "MODULE_TYPELESS_PACKAGE_JSON") return;
|
|
2132
|
+
for (const listener of listeners) listener(warning);
|
|
2133
|
+
});
|
|
2134
|
+
}
|
|
2135
|
+
quietModuleTypeWarning();
|
|
2136
|
+
var parsed = parseArgs(process.argv.slice(2));
|
|
2137
|
+
if (parsed.kind === "help") {
|
|
2138
|
+
process.stdout.write(HELP);
|
|
2139
|
+
process.exit(0);
|
|
2140
|
+
}
|
|
2141
|
+
if (parsed.kind === "version") {
|
|
2142
|
+
process.stdout.write(`${version()}
|
|
2143
|
+
`);
|
|
2144
|
+
process.exit(0);
|
|
2145
|
+
}
|
|
2146
|
+
if (parsed.kind === "error") {
|
|
2147
|
+
process.stderr.write(`${parsed.message}
|
|
2148
|
+
`);
|
|
2149
|
+
process.exit(2);
|
|
2150
|
+
}
|
|
2151
|
+
var previewDeps = {
|
|
2152
|
+
log: (line) => process.stdout.write(`${line}
|
|
2153
|
+
`),
|
|
2154
|
+
error: (line) => process.stderr.write(`${line}
|
|
2155
|
+
`)
|
|
2156
|
+
};
|
|
2157
|
+
if (parsed.kind === "init") {
|
|
2158
|
+
const outcome = await runInit(
|
|
2159
|
+
{ cwd: process.cwd(), force: parsed.force, json: parsed.json },
|
|
2160
|
+
{ log: (line) => process.stdout.write(`${line}
|
|
2161
|
+
`) }
|
|
2162
|
+
);
|
|
2163
|
+
process.exit(outcome.exitCode);
|
|
2164
|
+
}
|
|
2165
|
+
if (parsed.kind === "classify") {
|
|
2166
|
+
const outcome = await runClassify(
|
|
2167
|
+
{ changes: parsed.changes, json: parsed.json, cwd: process.cwd() },
|
|
2168
|
+
previewDeps
|
|
2169
|
+
);
|
|
2170
|
+
process.exit(outcome.exitCode);
|
|
2171
|
+
}
|
|
2172
|
+
if (parsed.kind === "add") {
|
|
2173
|
+
const outcome = await runAdd(
|
|
2174
|
+
{ preview: parsed.preview, json: parsed.json, cwd: process.cwd() },
|
|
2175
|
+
previewDeps
|
|
2176
|
+
);
|
|
2177
|
+
process.exit(outcome.exitCode);
|
|
2178
|
+
}
|
|
2179
|
+
if (parsed.kind === "keep") {
|
|
2180
|
+
const outcome = await runKeep(
|
|
2181
|
+
{ title: parsed.title, to: parsed.to, json: parsed.json, cwd: process.cwd() },
|
|
2182
|
+
previewDeps
|
|
2183
|
+
);
|
|
2184
|
+
process.exit(outcome.exitCode);
|
|
2185
|
+
}
|
|
2186
|
+
if (parsed.kind === "explore") {
|
|
2187
|
+
const outcome = runExplore(
|
|
2188
|
+
{ surface: parsed.surface, count: parsed.count, json: parsed.json },
|
|
2189
|
+
{ log: (line) => process.stdout.write(`${line}
|
|
2190
|
+
`) }
|
|
2191
|
+
);
|
|
2192
|
+
process.exit(outcome.exitCode);
|
|
2193
|
+
}
|
|
2194
|
+
if (parsed.kind === "requests") {
|
|
2195
|
+
const outcome = await runRequests(
|
|
2196
|
+
{ json: parsed.json, clear: parsed.clear, cwd: process.cwd() },
|
|
2197
|
+
previewDeps
|
|
2198
|
+
);
|
|
2199
|
+
process.exit(outcome.exitCode);
|
|
2200
|
+
}
|
|
2201
|
+
if (parsed.kind === "list") {
|
|
2202
|
+
const outcome = await runList({ json: parsed.json, cwd: process.cwd() }, previewDeps);
|
|
2203
|
+
process.exit(outcome.exitCode);
|
|
2204
|
+
}
|
|
2205
|
+
if (parsed.kind === "new") {
|
|
2206
|
+
const outcome = await runNew(
|
|
2207
|
+
{ surface: parsed.surface, print: parsed.print, json: parsed.json, from: parsed.from, cwd: process.cwd() },
|
|
2208
|
+
{ log: (line) => process.stdout.write(`${line}
|
|
2209
|
+
`) }
|
|
2210
|
+
);
|
|
2211
|
+
process.exit(outcome.exitCode);
|
|
2212
|
+
}
|
|
2213
|
+
var result = await run2(
|
|
2214
|
+
{ ...parsed.options, cwd: process.cwd() },
|
|
2215
|
+
{ open: openBrowser, log: (line) => process.stdout.write(`${line}
|
|
2216
|
+
`) }
|
|
2217
|
+
);
|
|
2218
|
+
var stopping = false;
|
|
2219
|
+
var shutdown = async () => {
|
|
2220
|
+
if (stopping) return;
|
|
2221
|
+
stopping = true;
|
|
2222
|
+
await result.stop();
|
|
2223
|
+
process.exit(0);
|
|
2224
|
+
};
|
|
2225
|
+
process.on("SIGINT", shutdown);
|
|
2226
|
+
process.on("SIGTERM", shutdown);
|