leglas 0.1.1 → 0.3.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/LICENSE +21 -0
- package/README.md +322 -294
- package/dist/args.d.ts +11 -0
- package/dist/bin.js +772 -219
- package/dist/explore.d.ts +25 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +754 -217
- package/dist/resolve-title.d.ts +19 -0
- package/dist/run-explore.d.ts +6 -5
- package/dist/run-show.d.ts +18 -0
- package/dist/run-watch.d.ts +23 -0
- package/dist/shell/assets/index-Bl87Vq9G.js +10 -0
- package/dist/shell/assets/index-dMGYwlE1.css +1 -0
- package/dist/shell/favicon.svg +48 -0
- package/dist/shell/index.html +3 -2
- package/dist/show.d.ts +59 -0
- package/dist/watch.d.ts +45 -0
- package/package.json +9 -3
- package/dist/briefs.d.ts +0 -32
- package/dist/shell/assets/index-D-nej-Uo.js +0 -9
- package/dist/shell/assets/index-DcVuoqfz.css +0 -1
package/dist/index.js
CHANGED
|
@@ -45,7 +45,7 @@ function parseNew(rest) {
|
|
|
45
45
|
if (surface === void 0) {
|
|
46
46
|
return {
|
|
47
47
|
kind: "error",
|
|
48
|
-
message: "leglas new needs a surface name, for example: leglas new hero"
|
|
48
|
+
message: "leglas new needs a surface name, for example: npx leglas new hero"
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
return { kind: "new", surface, print, json, from };
|
|
@@ -56,6 +56,7 @@ function parseAdd(rest) {
|
|
|
56
56
|
let note;
|
|
57
57
|
let branch;
|
|
58
58
|
let file;
|
|
59
|
+
let basedOn;
|
|
59
60
|
const tags = [];
|
|
60
61
|
let json = false;
|
|
61
62
|
for (let index = 0; index < rest.length; index += 1) {
|
|
@@ -74,7 +75,7 @@ function parseAdd(rest) {
|
|
|
74
75
|
} else {
|
|
75
76
|
value = argument.slice(equals + 1);
|
|
76
77
|
}
|
|
77
|
-
if (!["--title", "--url", "--note", "--tag", "--branch", "--file"].includes(flag)) {
|
|
78
|
+
if (!["--title", "--url", "--note", "--tag", "--branch", "--file", "--based-on"].includes(flag)) {
|
|
78
79
|
return { kind: "error", message: `leglas add does not take ${flag}.` };
|
|
79
80
|
}
|
|
80
81
|
if (value === void 0 || value === "") {
|
|
@@ -85,6 +86,7 @@ function parseAdd(rest) {
|
|
|
85
86
|
else if (flag === "--note") note = value;
|
|
86
87
|
else if (flag === "--branch") branch = value;
|
|
87
88
|
else if (flag === "--file") file = value;
|
|
89
|
+
else if (flag === "--based-on") basedOn = value;
|
|
88
90
|
else tags.push(value);
|
|
89
91
|
}
|
|
90
92
|
if (title === void 0) {
|
|
@@ -98,7 +100,7 @@ function parseAdd(rest) {
|
|
|
98
100
|
}
|
|
99
101
|
return {
|
|
100
102
|
kind: "add",
|
|
101
|
-
preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file },
|
|
103
|
+
preview: { title, url, note, tags: tags.length > 0 ? tags : void 0, branch, file, basedOn },
|
|
102
104
|
json
|
|
103
105
|
};
|
|
104
106
|
}
|
|
@@ -126,13 +128,42 @@ function parseClassify(rest) {
|
|
|
126
128
|
if (changes.length === 0) {
|
|
127
129
|
return {
|
|
128
130
|
kind: "error",
|
|
129
|
-
message: "leglas classify needs what the direction will touch, for example: leglas classify --change package.json --rewrite src/theme.css"
|
|
131
|
+
message: "leglas classify needs what the direction will touch, for example: npx leglas classify --change package.json --rewrite src/theme.css"
|
|
130
132
|
};
|
|
131
133
|
}
|
|
132
134
|
return { kind: "classify", changes, json };
|
|
133
135
|
}
|
|
136
|
+
function parseWatch(rest) {
|
|
137
|
+
let run3;
|
|
138
|
+
let port;
|
|
139
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
140
|
+
const argument = rest[index];
|
|
141
|
+
if (argument === "--help" || argument === "-h") return { kind: "help" };
|
|
142
|
+
const equals = argument.indexOf("=");
|
|
143
|
+
const flag = equals === -1 ? argument : argument.slice(0, equals);
|
|
144
|
+
if (flag !== "--run" && flag !== "--port") {
|
|
145
|
+
return { kind: "error", message: `leglas watch does not take ${argument}.` };
|
|
146
|
+
}
|
|
147
|
+
const value = equals === -1 ? rest[index += 1] : argument.slice(equals + 1);
|
|
148
|
+
if (value === void 0 || value === "") {
|
|
149
|
+
return {
|
|
150
|
+
kind: "error",
|
|
151
|
+
message: flag === "--run" ? '--run needs an agent command, for example --run "claude -p {prompt}"' : "--port needs a value."
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (flag === "--run") {
|
|
155
|
+
run3 = value;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const parsed = parsePort(flag, value);
|
|
159
|
+
if (typeof parsed !== "number") return { kind: "error", message: parsed.error };
|
|
160
|
+
port = parsed;
|
|
161
|
+
}
|
|
162
|
+
return { kind: "watch", run: run3, port };
|
|
163
|
+
}
|
|
134
164
|
function parseArgs(argv) {
|
|
135
165
|
if (argv[0] === "new") return parseNew(argv.slice(1));
|
|
166
|
+
if (argv[0] === "watch") return parseWatch(argv.slice(1));
|
|
136
167
|
if (argv[0] === "add") return parseAdd(argv.slice(1));
|
|
137
168
|
if (argv[0] === "classify") return parseClassify(argv.slice(1));
|
|
138
169
|
if (argv[0] === "init") {
|
|
@@ -172,7 +203,7 @@ function parseArgs(argv) {
|
|
|
172
203
|
if (title === void 0) {
|
|
173
204
|
return {
|
|
174
205
|
kind: "error",
|
|
175
|
-
message: 'leglas keep needs a direction title, for example: leglas keep "Aurora" --to src/components/hero.tsx'
|
|
206
|
+
message: 'leglas keep needs a direction title, for example: npx leglas keep "Aurora" --to src/components/hero.tsx'
|
|
176
207
|
};
|
|
177
208
|
}
|
|
178
209
|
if (to === void 0) {
|
|
@@ -184,6 +215,7 @@ function parseArgs(argv) {
|
|
|
184
215
|
const rest = argv.slice(1);
|
|
185
216
|
let surface;
|
|
186
217
|
let count = 3;
|
|
218
|
+
let basedOn = null;
|
|
187
219
|
let json = false;
|
|
188
220
|
for (let index = 0; index < rest.length; index += 1) {
|
|
189
221
|
const argument = rest[index];
|
|
@@ -199,6 +231,17 @@ function parseArgs(argv) {
|
|
|
199
231
|
count = Number(raw);
|
|
200
232
|
continue;
|
|
201
233
|
}
|
|
234
|
+
if (argument === "--based-on" || argument.startsWith("--based-on=")) {
|
|
235
|
+
const raw = argument.includes("=") ? argument.split("=")[1] : rest[index += 1];
|
|
236
|
+
if (raw === void 0 || raw === "") {
|
|
237
|
+
return {
|
|
238
|
+
kind: "error",
|
|
239
|
+
message: '--based-on needs a direction title, for example --based-on "Aurora".'
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
basedOn = raw;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
202
245
|
if (argument.startsWith("-")) {
|
|
203
246
|
return { kind: "error", message: `leglas explore does not take ${argument}.` };
|
|
204
247
|
}
|
|
@@ -210,10 +253,10 @@ function parseArgs(argv) {
|
|
|
210
253
|
if (surface === void 0) {
|
|
211
254
|
return {
|
|
212
255
|
kind: "error",
|
|
213
|
-
message: "leglas explore needs a surface name, for example: leglas explore hero --count 6"
|
|
256
|
+
message: "leglas explore needs a surface name, for example: npx leglas explore hero --count 6"
|
|
214
257
|
};
|
|
215
258
|
}
|
|
216
|
-
return { kind: "explore", surface, count, json };
|
|
259
|
+
return { kind: "explore", surface, count, basedOn, json };
|
|
217
260
|
}
|
|
218
261
|
if (argv[0] === "requests") {
|
|
219
262
|
const rest = argv.slice(1);
|
|
@@ -231,6 +274,31 @@ function parseArgs(argv) {
|
|
|
231
274
|
}
|
|
232
275
|
return { kind: "list", json: rest.includes("--json") };
|
|
233
276
|
}
|
|
277
|
+
if (argv[0] === "show") {
|
|
278
|
+
const rest = argv.slice(1);
|
|
279
|
+
let title;
|
|
280
|
+
let json = false;
|
|
281
|
+
for (const argument of rest) {
|
|
282
|
+
if (argument === "--json") {
|
|
283
|
+
json = true;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (argument.startsWith("-")) {
|
|
287
|
+
return { kind: "error", message: `leglas show does not take ${argument}.` };
|
|
288
|
+
}
|
|
289
|
+
if (title !== void 0) {
|
|
290
|
+
return { kind: "error", message: "leglas show takes one direction title." };
|
|
291
|
+
}
|
|
292
|
+
title = argument;
|
|
293
|
+
}
|
|
294
|
+
if (title === void 0) {
|
|
295
|
+
return {
|
|
296
|
+
kind: "error",
|
|
297
|
+
message: 'leglas show needs a direction title, for example: npx leglas show "Aurora" --json'
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
return { kind: "show", title, json };
|
|
301
|
+
}
|
|
234
302
|
const options = {
|
|
235
303
|
port: void 0,
|
|
236
304
|
userPort: void 0,
|
|
@@ -478,132 +546,40 @@ Rewriting your component automatically is how a tool breaks a codebase it does n
|
|
|
478
546
|
};
|
|
479
547
|
}
|
|
480
548
|
|
|
481
|
-
// src/
|
|
482
|
-
|
|
483
|
-
{
|
|
484
|
-
slug: "quiet",
|
|
485
|
-
name: "Quiet",
|
|
486
|
-
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.",
|
|
487
|
-
avoid: "Adding a subtle gradient or a lighter shade and calling the result minimal."
|
|
488
|
-
},
|
|
489
|
-
{
|
|
490
|
-
slug: "image-led",
|
|
491
|
-
name: "Image-led",
|
|
492
|
-
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.",
|
|
493
|
-
avoid: "Keeping the existing layout and enlarging the picture inside it."
|
|
494
|
-
},
|
|
495
|
-
{
|
|
496
|
-
slug: "kinetic",
|
|
497
|
-
name: "Kinetic",
|
|
498
|
-
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.",
|
|
499
|
-
avoid: "A fade-in on scroll bolted onto the current design."
|
|
500
|
-
},
|
|
501
|
-
{
|
|
502
|
-
slug: "editorial",
|
|
503
|
-
name: "Editorial",
|
|
504
|
-
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.",
|
|
505
|
-
avoid: "A centred headline above a centred paragraph."
|
|
506
|
-
},
|
|
507
|
-
{
|
|
508
|
-
slug: "dense",
|
|
509
|
-
name: "Dense",
|
|
510
|
-
brief: "Information forward. Tighter rhythm, smaller type, several entry points visible at once, and the confidence that the reader wants more rather than less.",
|
|
511
|
-
avoid: "The same layout with the padding reduced."
|
|
512
|
-
},
|
|
513
|
-
{
|
|
514
|
-
slug: "high-contrast",
|
|
515
|
-
name: "High contrast",
|
|
516
|
-
brief: "Commit to a hard palette: near-black against one saturated accent, or the whole thing inverted. Define shapes with edges rather than gradients.",
|
|
517
|
-
avoid: "Darkening the existing palette by a few steps."
|
|
518
|
-
},
|
|
519
|
-
{
|
|
520
|
-
slug: "material",
|
|
521
|
-
name: "Material",
|
|
522
|
-
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.",
|
|
523
|
-
avoid: "One drop shadow on an otherwise flat card."
|
|
524
|
-
},
|
|
525
|
-
{
|
|
526
|
-
slug: "type-led",
|
|
527
|
-
name: "Type-led",
|
|
528
|
-
brief: "Remove imagery entirely. Build the composition from letterforms: extreme scale contrast, a second typeface earning its place, text as the visual itself.",
|
|
529
|
-
avoid: "Keeping the image and setting the headline larger."
|
|
530
|
-
},
|
|
531
|
-
{
|
|
532
|
-
slug: "playful",
|
|
533
|
-
name: "Playful",
|
|
534
|
-
brief: "Deliberate imperfection. Rotation, overlap, irregular or hand-made elements, and one colour that ought not to work but does.",
|
|
535
|
-
avoid: "Increasing the border radius and little else."
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
slug: "systemic",
|
|
539
|
-
name: "Systemic",
|
|
540
|
-
brief: "Make the structure visible. Modular blocks on a stated grid, consistent module sizes, alignment itself as the aesthetic.",
|
|
541
|
-
avoid: "Adding borders around the sections that already exist."
|
|
542
|
-
}
|
|
543
|
-
];
|
|
544
|
-
function briefsFor(count) {
|
|
545
|
-
if (!Number.isFinite(count) || count <= 0) return [];
|
|
546
|
-
return ALL_BRIEFS.slice(0, Math.min(Math.floor(count), ALL_BRIEFS.length));
|
|
547
|
-
}
|
|
548
|
-
function planBriefs(surface, count) {
|
|
549
|
+
// src/explore.ts
|
|
550
|
+
function planExplore(surface, count, basedOn = null) {
|
|
549
551
|
const slug = surfaceSlug(surface);
|
|
550
|
-
const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
url: `/?v-${slug}=${brief.slug}`
|
|
554
|
-
}));
|
|
555
|
-
const commands = chosen.map(
|
|
556
|
-
(brief) => `leglas add --title ${JSON.stringify(brief.name)} --url ${JSON.stringify(
|
|
557
|
-
`/?v-${slug}=${brief.slug}`
|
|
558
|
-
)} --note ${JSON.stringify(`${brief.brief.split(".")[0]}.`)}`
|
|
559
|
-
);
|
|
560
|
-
const instructions = `Build ${chosen.length} direction${chosen.length === 1 ? "" : "s"} for "${surface}", one per angle below.
|
|
552
|
+
const goal = basedOn === null ? `Build ${count} design directions for "${surface}".
|
|
553
|
+
|
|
554
|
+
The set exists to be chosen from, and the choice only means something if the directions genuinely disagree: ${count} variants of one idea would make it empty. What counts as different is yours to decide, and the strongest sets disagree about more than styling.
|
|
561
555
|
|
|
562
|
-
|
|
556
|
+
One trap, seen every time this goes wrong: a set collapses toward whichever direction is built first. Decide all ${count} before building any, and if two would read as the same direction at a glance, replace one of them.` : `Build ${count} variations of the "${basedOn}" direction for "${surface}".
|
|
563
557
|
|
|
564
|
-
|
|
558
|
+
The set exists to pick a variant of a direction already chosen, so every variation must stay recognisably that direction. The trap here is drift: change enough and the comparison stops being about the variant. Vary each one deliberately and hold everything else still; if a variation grows into a new direction, it belongs in its own exploration instead.`;
|
|
559
|
+
const register = basedOn === null ? ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --note "<the idea, one line>"` : ` npx leglas add --title "<name>" --url "/?v-${slug}=<key>" --based-on ${JSON.stringify(basedOn)} --note "<the idea, one line>"`;
|
|
560
|
+
const mechanics = `Each one is its own file under .leglas/variants/${slug}/, listed in the DIRECTIONS map in that folder's switch file. If there is no switch file yet, run \`npx leglas new ${slug}\` first. Register each one the moment it renders, not the set at the end. The interface picks a registration up within seconds, so whoever asked watches the set fill in:
|
|
565
561
|
|
|
566
|
-
|
|
562
|
+
${register}
|
|
567
563
|
|
|
568
|
-
|
|
569
|
-
return {
|
|
564
|
+
The title and note are what the user judges from in the rail, so name each one for its idea rather than numbering it.`;
|
|
565
|
+
return { surface, slug, count, basedOn, instructions: `${goal}
|
|
566
|
+
|
|
567
|
+
${mechanics}` };
|
|
570
568
|
}
|
|
571
569
|
|
|
572
570
|
// src/run-explore.ts
|
|
573
571
|
function runExplore(options, deps) {
|
|
574
|
-
|
|
575
|
-
if (chosen.length === 0) {
|
|
572
|
+
if (!Number.isFinite(options.count) || options.count <= 0) {
|
|
576
573
|
deps.log(
|
|
577
574
|
options.json ? JSON.stringify({ ok: false, error: "Ask for at least one direction." }) : "Ask for at least one direction, for example --count 4."
|
|
578
575
|
);
|
|
579
576
|
return { exitCode: 1 };
|
|
580
577
|
}
|
|
581
|
-
const plan =
|
|
578
|
+
const plan = planExplore(options.surface, Math.floor(options.count), options.basedOn);
|
|
582
579
|
if (options.json) {
|
|
583
|
-
deps.log(
|
|
584
|
-
JSON.stringify({
|
|
585
|
-
ok: true,
|
|
586
|
-
surface: options.surface,
|
|
587
|
-
directions: chosen,
|
|
588
|
-
previews: plan.previews,
|
|
589
|
-
commands: plan.commands,
|
|
590
|
-
instructions: plan.instructions
|
|
591
|
-
})
|
|
592
|
-
);
|
|
580
|
+
deps.log(JSON.stringify({ ok: true, ...plan }));
|
|
593
581
|
return { exitCode: 0 };
|
|
594
582
|
}
|
|
595
|
-
if (options.count > ALL_BRIEFS.length) {
|
|
596
|
-
deps.log(
|
|
597
|
-
`Asked for ${options.count}; there are ${ALL_BRIEFS.length} distinct angles, so ${ALL_BRIEFS.length} follow.`
|
|
598
|
-
);
|
|
599
|
-
deps.log("");
|
|
600
|
-
}
|
|
601
|
-
for (const brief of chosen) {
|
|
602
|
-
deps.log(`${brief.name}`);
|
|
603
|
-
deps.log(` ${brief.brief}`);
|
|
604
|
-
deps.log(` Avoid: ${brief.avoid}`);
|
|
605
|
-
deps.log("");
|
|
606
|
-
}
|
|
607
583
|
deps.log(plan.instructions);
|
|
608
584
|
return { exitCode: 0 };
|
|
609
585
|
}
|
|
@@ -633,34 +609,48 @@ When asked for design variations, alternatives, or "a few options":
|
|
|
633
609
|
If a direction cannot be additive, because it must change dependencies,
|
|
634
610
|
change build configuration, or rewrite an existing file's behaviour, ask
|
|
635
611
|
where it should live before writing it:
|
|
636
|
-
\`leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
612
|
+
\`npx leglas classify --change package.json --rewrite src/theme.css --json\`.
|
|
637
613
|
When the answer is \`checkout\`, build the direction on its own git branch
|
|
638
|
-
and register it with \`leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
614
|
+
and register it with \`npx leglas add --title "\u2026" --url "/" --branch <branch>\`
|
|
639
615
|
(the config needs \`devCommand\` with \`{port}\`). Everything below is the
|
|
640
616
|
ordinary, in-app path.
|
|
641
|
-
2. Run \`leglas explore <surface> --count <n>\` first
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
617
|
+
2. Run \`npx leglas explore <surface> --count <n>\` first, adding
|
|
618
|
+
\`--based-on "<title>"\` when the user wants variations of a direction they
|
|
619
|
+
already like. It prints what the set needs and how to register it. In
|
|
620
|
+
short: new directions must genuinely disagree with each other, variants of
|
|
621
|
+
one must not, and either way decide the whole set before building any of
|
|
622
|
+
it. The designs themselves are yours.
|
|
645
623
|
3. If the surface has no switcher yet, run
|
|
646
|
-
\`leglas new <surface> --from <the component that renders it today>\`. It
|
|
624
|
+
\`npx leglas new <surface> --from <the component that renders it today>\`. It
|
|
647
625
|
writes one under \`.leglas/variants/<surface>/\` and prints the single line
|
|
648
626
|
to add where that surface renders. \`--from\` makes the baseline re-export
|
|
649
627
|
the real component rather than copying it, so it stays live.
|
|
650
|
-
4.
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
628
|
+
4. Before building, make sure the interface is up. If Leglas is not
|
|
629
|
+
already running, tell the user to run \`npx leglas\`, and hand them the
|
|
630
|
+
URL now rather than when the set is done: the rail picks up each
|
|
631
|
+
registration within seconds, so they get to watch the exploration fill in.
|
|
632
|
+
5. Build one direction at a time: its own file beside the others in
|
|
633
|
+
\`.leglas/variants/<surface>/\`, listed in the \`DIRECTIONS\` map in that
|
|
634
|
+
folder's \`switch\` file, then registered the moment it renders:
|
|
635
|
+
\`npx leglas add --title "Aurora" --url "/?v-<surface>=aurora" --note "One line on the idea."\`
|
|
636
|
+
Register each direction as it lands, never the whole set at the end. To
|
|
637
|
+
the user watching the rail, a batch at the end is minutes of nothing and
|
|
638
|
+
then everything at once.
|
|
639
|
+
|
|
640
|
+
When the user asks to change one direction, check \`npx leglas requests --json\`
|
|
659
641
|
first: they may have described it from the interface, and the request names the
|
|
660
|
-
exact file.
|
|
642
|
+
exact file. Acknowledge them with \`npx leglas requests --clear\` once done: it
|
|
643
|
+
drops what you collected and reports anything the user typed while you worked,
|
|
644
|
+
which is yours to collect and do next.
|
|
645
|
+
|
|
646
|
+
If the user wants requests handled the moment they are typed, without relaying
|
|
647
|
+
each one, tell them about \`npx leglas watch --run "claude -p {prompt}"\` (any
|
|
648
|
+
agent command works; {prompt} receives the request). It runs in their
|
|
649
|
+
terminal, hands each request to that command as it arrives, and the interface
|
|
650
|
+
shows the request's progress.
|
|
661
651
|
|
|
662
652
|
When the user picks a winner, run
|
|
663
|
-
\`leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
653
|
+
\`npx leglas keep "<title>" --to <path in real source>\`. It moves that direction
|
|
664
654
|
out of the ignored directory, deletes the rest of the exploration, and drops
|
|
665
655
|
them from the rail. Then change their component to use the kept component
|
|
666
656
|
instead of the switcher.
|
|
@@ -671,18 +661,24 @@ Useful to know:
|
|
|
671
661
|
ships. Move a direction into real source only when it wins.
|
|
672
662
|
- If the project has no running app yet, a direction can be a plain HTML
|
|
673
663
|
file: write it under \`.leglas/pages/\` and register it with
|
|
674
|
-
\`leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
664
|
+
\`npx leglas add --title "Aurora" --file .leglas/pages/aurora.html\`. Leglas
|
|
675
665
|
serves the file itself, so no dev server is needed. Sibling assets in the
|
|
676
666
|
same directory resolve normally.
|
|
677
|
-
- Titles identify previews and must be unique.
|
|
678
|
-
|
|
667
|
+
- Titles identify previews and must be unique. The user may rename one in the
|
|
668
|
+
rail, which renames it on their machine only; the commands answer to either
|
|
669
|
+
name, so use whichever they said.
|
|
670
|
+
- \`npx leglas list\` shows every direction, shared and local.
|
|
671
|
+
- \`npx leglas show "<title>" --json\` answers for one of them: the file behind it,
|
|
672
|
+
the variants based on it, what it is being compared against, and anything
|
|
673
|
+
they have asked for that is not done yet. Run it when handed a direction you
|
|
674
|
+
did not register yourself.
|
|
679
675
|
- Every command accepts \`--json\` and prints one envelope with a stable exit
|
|
680
676
|
code, so you can drive it without parsing prose.
|
|
681
677
|
|
|
682
678
|
${AGENTS_MARKER_END}
|
|
683
679
|
`;
|
|
684
680
|
var STARTER_CONFIG = `// Previews are URLs of your own app. Add one per direction you want to
|
|
685
|
-
// compare, then run \`leglas\`.
|
|
681
|
+
// compare, then run \`npx leglas\`.
|
|
686
682
|
export default {
|
|
687
683
|
// Where your dev server is. Override at the command line with --user-port.
|
|
688
684
|
devServer: "http://localhost:3000",
|
|
@@ -808,6 +804,10 @@ function normalizeConfig(raw, options = {}) {
|
|
|
808
804
|
errors.push(`${at} names a branch and a file; a file preview is served by Leglas itself and has no checkout.`);
|
|
809
805
|
}
|
|
810
806
|
}
|
|
807
|
+
const basedOn = entry["basedOn"];
|
|
808
|
+
if (basedOn !== void 0 && (typeof basedOn !== "string" || basedOn.trim() === "")) {
|
|
809
|
+
errors.push(`${at} has a basedOn that is not a direction title.`);
|
|
810
|
+
}
|
|
811
811
|
const tags = entry["tags"];
|
|
812
812
|
previews.push({
|
|
813
813
|
title: typeof title === "string" ? title : "",
|
|
@@ -815,7 +815,8 @@ function normalizeConfig(raw, options = {}) {
|
|
|
815
815
|
note: typeof entry["note"] === "string" ? entry["note"] : void 0,
|
|
816
816
|
tags: Array.isArray(tags) ? tags.filter((tag) => typeof tag === "string") : [],
|
|
817
817
|
...typeof branch === "string" ? { branch } : {},
|
|
818
|
-
...typeof file === "string" ? { file } : {}
|
|
818
|
+
...typeof file === "string" ? { file } : {},
|
|
819
|
+
...typeof basedOn === "string" && basedOn.trim() !== "" ? { basedOn } : {}
|
|
819
820
|
});
|
|
820
821
|
});
|
|
821
822
|
const devCommand = source["devCommand"];
|
|
@@ -881,12 +882,12 @@ function isExplorationFile(path) {
|
|
|
881
882
|
}
|
|
882
883
|
var CHECKOUT_STEPS = [
|
|
883
884
|
"Build the direction on its own branch: git switch -c <branch>, commit it there, switch back.",
|
|
884
|
-
'Register it: leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
885
|
+
'Register it: npx leglas add --title "<title>" --url "/" --branch <branch>.',
|
|
885
886
|
"Make sure the config sets devCommand (with {port}), so Leglas can start the checkout."
|
|
886
887
|
];
|
|
887
888
|
var IN_APP_STEPS = [
|
|
888
889
|
"Author it additively under .leglas/variants/<surface>/, beside the existing directions.",
|
|
889
|
-
'Register it: leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
890
|
+
'Register it: npx leglas add --title "<title>" --url "/?v-<surface>=<direction>".'
|
|
890
891
|
];
|
|
891
892
|
function classifyDirection(input) {
|
|
892
893
|
const checkout = (reason) => ({ level: "checkout", reason, steps: CHECKOUT_STEPS });
|
|
@@ -1019,7 +1020,8 @@ async function addLocalPreview(cwd, input, shared) {
|
|
|
1019
1020
|
...input.note === void 0 ? {} : { note: input.note },
|
|
1020
1021
|
...input.tags === void 0 ? {} : { tags: input.tags },
|
|
1021
1022
|
...input.branch === void 0 ? {} : { branch: input.branch },
|
|
1022
|
-
...input.file === void 0 ? {} : { file: input.file }
|
|
1023
|
+
...input.file === void 0 ? {} : { file: input.file },
|
|
1024
|
+
...input.basedOn === void 0 ? {} : { basedOn: input.basedOn }
|
|
1023
1025
|
};
|
|
1024
1026
|
const check = normalizeConfig({ previews: [candidate] }, { requireDevCommand: false });
|
|
1025
1027
|
if (check.config === null) {
|
|
@@ -1253,6 +1255,7 @@ async function startAppProcess(options) {
|
|
|
1253
1255
|
|
|
1254
1256
|
// ../server/dist/requests.js
|
|
1255
1257
|
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
|
|
1258
|
+
import { randomBytes } from "crypto";
|
|
1256
1259
|
import { dirname as dirname3, join as join4 } from "path";
|
|
1257
1260
|
var SAFE_SEGMENT = /^[a-z0-9][a-z0-9-]*$/i;
|
|
1258
1261
|
function targetFor(url) {
|
|
@@ -1291,7 +1294,16 @@ async function readRequests(cwd) {
|
|
|
1291
1294
|
try {
|
|
1292
1295
|
const raw = await readFile3(join4(cwd, REQUESTS_PATH), "utf8");
|
|
1293
1296
|
const parsed = JSON.parse(raw);
|
|
1294
|
-
|
|
1297
|
+
if (!Array.isArray(parsed.requests))
|
|
1298
|
+
return [];
|
|
1299
|
+
return parsed.requests.map((request, index) => {
|
|
1300
|
+
const entry = request;
|
|
1301
|
+
return {
|
|
1302
|
+
...entry,
|
|
1303
|
+
id: typeof entry.id === "string" ? entry.id : String(index),
|
|
1304
|
+
status: entry.status === "picked-up" ? "picked-up" : "queued"
|
|
1305
|
+
};
|
|
1306
|
+
});
|
|
1295
1307
|
} catch {
|
|
1296
1308
|
return [];
|
|
1297
1309
|
}
|
|
@@ -1303,20 +1315,83 @@ async function writeQueue(cwd, requests) {
|
|
|
1303
1315
|
`, "utf8");
|
|
1304
1316
|
}
|
|
1305
1317
|
async function appendRequest(cwd, request) {
|
|
1306
|
-
await writeQueue(cwd, [
|
|
1318
|
+
await writeQueue(cwd, [
|
|
1319
|
+
...await readRequests(cwd),
|
|
1320
|
+
{ ...request, id: randomBytes(6).toString("base64url"), status: "queued" }
|
|
1321
|
+
]);
|
|
1322
|
+
}
|
|
1323
|
+
async function collectRequests(cwd) {
|
|
1324
|
+
const requests = await readRequests(cwd);
|
|
1325
|
+
const collected = requests.map((request) => ({ ...request, status: "picked-up" }));
|
|
1326
|
+
if (requests.some((request) => request.status !== "picked-up"))
|
|
1327
|
+
await writeQueue(cwd, collected);
|
|
1328
|
+
return collected;
|
|
1329
|
+
}
|
|
1330
|
+
async function markPickedUp(cwd, id) {
|
|
1331
|
+
const requests = await readRequests(cwd);
|
|
1332
|
+
if (!requests.some((request) => request.id === id && request.status !== "picked-up"))
|
|
1333
|
+
return false;
|
|
1334
|
+
await writeQueue(cwd, requests.map((request) => request.id === id ? { ...request, status: "picked-up" } : request));
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
async function removeRequest(cwd, id) {
|
|
1338
|
+
const requests = await readRequests(cwd);
|
|
1339
|
+
const remaining = requests.filter((request) => request.id !== id);
|
|
1340
|
+
if (remaining.length === requests.length)
|
|
1341
|
+
return false;
|
|
1342
|
+
await writeQueue(cwd, remaining);
|
|
1343
|
+
return true;
|
|
1307
1344
|
}
|
|
1308
1345
|
async function clearRequests(cwd) {
|
|
1309
|
-
await
|
|
1346
|
+
const requests = await readRequests(cwd);
|
|
1347
|
+
const pending = requests.filter((request) => request.status !== "picked-up");
|
|
1348
|
+
const cleared = requests.length - pending.length;
|
|
1349
|
+
if (cleared > 0)
|
|
1350
|
+
await writeQueue(cwd, pending);
|
|
1351
|
+
return { cleared, pending: pending.length };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// ../server/dist/renames.js
|
|
1355
|
+
import { mkdir as mkdir3, readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
|
|
1356
|
+
import { dirname as dirname4, join as join5 } from "path";
|
|
1357
|
+
var RENAMES_PATH = ".leglas/renames.json";
|
|
1358
|
+
async function readRenames(cwd) {
|
|
1359
|
+
try {
|
|
1360
|
+
const raw = await readFile4(join5(cwd, RENAMES_PATH), "utf8");
|
|
1361
|
+
const parsed = JSON.parse(raw);
|
|
1362
|
+
if (parsed.renames === null || typeof parsed.renames !== "object")
|
|
1363
|
+
return {};
|
|
1364
|
+
return Object.fromEntries(Object.entries(parsed.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
|
|
1365
|
+
} catch {
|
|
1366
|
+
return {};
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
async function writeRenames(cwd, renames) {
|
|
1370
|
+
const path = join5(cwd, RENAMES_PATH);
|
|
1371
|
+
await mkdir3(dirname4(path), { recursive: true });
|
|
1372
|
+
await writeFile3(path, `${JSON.stringify({ renames }, null, 2)}
|
|
1373
|
+
`, "utf8");
|
|
1374
|
+
}
|
|
1375
|
+
function resolveTitle(input, titles, renames) {
|
|
1376
|
+
if (titles.includes(input))
|
|
1377
|
+
return { ok: true, title: input };
|
|
1378
|
+
const matches = titles.filter((title) => renames[title] === input);
|
|
1379
|
+
if (matches.length === 1 && matches[0] !== void 0)
|
|
1380
|
+
return { ok: true, title: matches[0] };
|
|
1381
|
+
if (matches.length > 1)
|
|
1382
|
+
return { ok: false, reason: "ambiguous", matches };
|
|
1383
|
+
return { ok: false, reason: "unknown" };
|
|
1310
1384
|
}
|
|
1311
1385
|
|
|
1312
1386
|
// ../server/dist/server.js
|
|
1313
1387
|
import { createReadStream, existsSync as existsSync2, statSync } from "fs";
|
|
1314
1388
|
import http2 from "http";
|
|
1315
1389
|
import net3 from "net";
|
|
1316
|
-
import { extname, join as
|
|
1390
|
+
import { extname, join as join6, normalize, relative as relative2 } from "path";
|
|
1317
1391
|
var LEGLAS_PREFIX = "/leglas";
|
|
1318
1392
|
var DEFAULT_PORT = 4100;
|
|
1319
1393
|
var PORT_ATTEMPTS = 20;
|
|
1394
|
+
var ATTACHED_WINDOW_MS = 6e3;
|
|
1320
1395
|
var CONTENT_TYPES = {
|
|
1321
1396
|
".css": "text/css; charset=utf-8",
|
|
1322
1397
|
".gif": "image/gif",
|
|
@@ -1364,8 +1439,8 @@ function probe(target, timeoutMs = 1e3) {
|
|
|
1364
1439
|
});
|
|
1365
1440
|
}
|
|
1366
1441
|
function serveFrom(res, dir, relativePath) {
|
|
1367
|
-
const
|
|
1368
|
-
const candidate =
|
|
1442
|
+
const relative4 = normalize(relativePath).replace(/^(\.\.[/\\])+/, "");
|
|
1443
|
+
const candidate = join6(dir, relative4);
|
|
1369
1444
|
if (!candidate.startsWith(dir))
|
|
1370
1445
|
return false;
|
|
1371
1446
|
if (!existsSync2(candidate) || !statSync(candidate).isFile())
|
|
@@ -1378,9 +1453,36 @@ function serveFrom(res, dir, relativePath) {
|
|
|
1378
1453
|
return true;
|
|
1379
1454
|
}
|
|
1380
1455
|
function serveShellFile(res, shellDir, urlPath) {
|
|
1381
|
-
const
|
|
1382
|
-
const isRoot =
|
|
1383
|
-
return serveFrom(res, shellDir, isRoot ? "index.html" :
|
|
1456
|
+
const relative4 = normalize(urlPath.slice(LEGLAS_PREFIX.length)).replace(/^(\.\.[/\\])+/, "");
|
|
1457
|
+
const isRoot = relative4 === "" || relative4 === "." || relative4 === "/";
|
|
1458
|
+
return serveFrom(res, shellDir, isRoot ? "index.html" : relative4);
|
|
1459
|
+
}
|
|
1460
|
+
function snapshotConfig(cwd) {
|
|
1461
|
+
const path = findConfigFile(cwd);
|
|
1462
|
+
if (path === null)
|
|
1463
|
+
return null;
|
|
1464
|
+
try {
|
|
1465
|
+
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
1466
|
+
} catch {
|
|
1467
|
+
return null;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
function configStalenessNotice(cwd, boot, current) {
|
|
1471
|
+
if (boot === null && current === null)
|
|
1472
|
+
return null;
|
|
1473
|
+
if (boot === null && current !== null) {
|
|
1474
|
+
const label = relative2(cwd, current.path) || current.path;
|
|
1475
|
+
return `${label} appeared after Leglas started. Restart leglas to pick it up.`;
|
|
1476
|
+
}
|
|
1477
|
+
if (boot !== null && current === null) {
|
|
1478
|
+
const label = relative2(cwd, boot.path) || boot.path;
|
|
1479
|
+
return `${label} was removed after Leglas started. Restart leglas to run without it.`;
|
|
1480
|
+
}
|
|
1481
|
+
if (boot !== null && current !== null && (boot.path !== current.path || boot.mtimeMs !== current.mtimeMs)) {
|
|
1482
|
+
const label = relative2(cwd, current.path) || current.path;
|
|
1483
|
+
return `${label} changed after Leglas started. Restart leglas to pick it up.`;
|
|
1484
|
+
}
|
|
1485
|
+
return null;
|
|
1384
1486
|
}
|
|
1385
1487
|
var PLACEHOLDER = `<!doctype html>
|
|
1386
1488
|
<meta charset="utf-8">
|
|
@@ -1390,7 +1492,8 @@ var PLACEHOLDER = `<!doctype html>
|
|
|
1390
1492
|
<p>The server is running and proxying your app. The interface has not been
|
|
1391
1493
|
built into this install yet.</p>
|
|
1392
1494
|
<p><a href="/leglas/api/config">/leglas/api/config</a> \xB7
|
|
1393
|
-
<a href="/leglas/api/health">/leglas/api/health</a
|
|
1495
|
+
<a href="/leglas/api/health">/leglas/api/health</a> \xB7
|
|
1496
|
+
<a href="/leglas/api/requests">/leglas/api/requests</a></p>
|
|
1394
1497
|
</body>`;
|
|
1395
1498
|
function listen(server, port) {
|
|
1396
1499
|
return new Promise((resolve, reject) => {
|
|
@@ -1425,28 +1528,45 @@ async function startServer(options) {
|
|
|
1425
1528
|
const { config, configErrors = [], shellDir = null, project = "", cwd = process.cwd(), fileMounts = /* @__PURE__ */ new Map() } = options;
|
|
1426
1529
|
const target = config?.devServer ?? "http://localhost:3000";
|
|
1427
1530
|
const proxy = createProxyHandler({ target });
|
|
1531
|
+
const bootConfigSnapshot = snapshotConfig(cwd);
|
|
1532
|
+
let lastSeen = null;
|
|
1428
1533
|
const server = http2.createServer((req, res) => {
|
|
1429
1534
|
const url = req.url ?? "/";
|
|
1430
1535
|
const path = url.split("?")[0] ?? "/";
|
|
1431
1536
|
if (path === `${LEGLAS_PREFIX}/api/config`) {
|
|
1432
|
-
|
|
1537
|
+
const boot = config?.previews ?? [];
|
|
1538
|
+
const errors = [...configErrors];
|
|
1539
|
+
const notice = configStalenessNotice(cwd, bootConfigSnapshot, snapshotConfig(cwd));
|
|
1540
|
+
if (notice !== null)
|
|
1541
|
+
errors.push(notice);
|
|
1542
|
+
return void readLocalPreviews(cwd).then(({ previews: local }) => {
|
|
1543
|
+
const known = new Set(boot.map((preview) => preview.title));
|
|
1544
|
+
const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
|
|
1545
|
+
sendJson(res, 200, {
|
|
1546
|
+
project,
|
|
1547
|
+
devServer: target,
|
|
1548
|
+
previews: [...boot, ...fresh],
|
|
1549
|
+
errors
|
|
1550
|
+
});
|
|
1551
|
+
}).catch(() => sendJson(res, 200, {
|
|
1433
1552
|
project,
|
|
1434
1553
|
devServer: target,
|
|
1435
|
-
previews:
|
|
1436
|
-
errors
|
|
1437
|
-
});
|
|
1554
|
+
previews: boot,
|
|
1555
|
+
errors
|
|
1556
|
+
}));
|
|
1438
1557
|
}
|
|
1439
1558
|
if (path === `${LEGLAS_PREFIX}/api/request` && req.method === "POST") {
|
|
1440
1559
|
let body = "";
|
|
1441
1560
|
req.on("data", (chunk) => body += chunk);
|
|
1442
|
-
return void req.on("end", () => {
|
|
1561
|
+
return void req.on("end", async () => {
|
|
1443
1562
|
let parsed;
|
|
1444
1563
|
try {
|
|
1445
1564
|
parsed = JSON.parse(body || "{}");
|
|
1446
1565
|
} catch {
|
|
1447
1566
|
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1448
1567
|
}
|
|
1449
|
-
const
|
|
1568
|
+
const local = await readLocalPreviews(cwd).then((read) => read.previews, () => []);
|
|
1569
|
+
const preview = [...config?.previews ?? [], ...local].find((entry) => entry.title === parsed.title);
|
|
1450
1570
|
if (!preview || !parsed.intent?.trim()) {
|
|
1451
1571
|
return sendJson(res, 400, { ok: false, error: "Unknown preview, or empty request." });
|
|
1452
1572
|
}
|
|
@@ -1459,6 +1579,46 @@ async function startServer(options) {
|
|
|
1459
1579
|
}).then(() => sendJson(res, 200, { ok: true, ...composed })).catch(() => sendJson(res, 200, { ok: true, ...composed, queued: false }));
|
|
1460
1580
|
});
|
|
1461
1581
|
}
|
|
1582
|
+
if (path === `${LEGLAS_PREFIX}/api/watch` && req.method === "POST") {
|
|
1583
|
+
let body = "";
|
|
1584
|
+
req.on("data", (chunk) => body += chunk);
|
|
1585
|
+
return void req.on("end", () => {
|
|
1586
|
+
let parsed;
|
|
1587
|
+
try {
|
|
1588
|
+
parsed = JSON.parse(body || "{}");
|
|
1589
|
+
} catch {
|
|
1590
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1591
|
+
}
|
|
1592
|
+
if (typeof parsed.watching !== "boolean") {
|
|
1593
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a watching boolean." });
|
|
1594
|
+
}
|
|
1595
|
+
lastSeen = parsed.watching ? Date.now() : null;
|
|
1596
|
+
sendJson(res, 200, { ok: true });
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
if (path === `${LEGLAS_PREFIX}/api/requests` && req.method === "GET") {
|
|
1600
|
+
return void readRequests(cwd).then((requests) => sendJson(res, 200, {
|
|
1601
|
+
requests: requests.map(({ id, title, intent, status }) => ({ id, title, intent, status })),
|
|
1602
|
+
agent: { attached: lastSeen !== null && Date.now() - lastSeen < ATTACHED_WINDOW_MS }
|
|
1603
|
+
}));
|
|
1604
|
+
}
|
|
1605
|
+
if (path === `${LEGLAS_PREFIX}/api/renames` && req.method === "POST") {
|
|
1606
|
+
let body = "";
|
|
1607
|
+
req.on("data", (chunk) => body += chunk);
|
|
1608
|
+
return void req.on("end", () => {
|
|
1609
|
+
let parsed;
|
|
1610
|
+
try {
|
|
1611
|
+
parsed = JSON.parse(body || "{}");
|
|
1612
|
+
} catch {
|
|
1613
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
1614
|
+
}
|
|
1615
|
+
if (parsed.renames === null || typeof parsed.renames !== "object") {
|
|
1616
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a renames object." });
|
|
1617
|
+
}
|
|
1618
|
+
const renames = Object.fromEntries(Object.entries(parsed.renames).filter((entry) => typeof entry[1] === "string" && entry[1] !== ""));
|
|
1619
|
+
void writeRenames(cwd, renames).then(() => sendJson(res, 200, { ok: true }), () => sendJson(res, 200, { ok: false }));
|
|
1620
|
+
});
|
|
1621
|
+
}
|
|
1462
1622
|
if (path === `${LEGLAS_PREFIX}/api/health`) {
|
|
1463
1623
|
return void probe(target).then((reachable) => sendJson(res, 200, { devServer: target, reachable }));
|
|
1464
1624
|
}
|
|
@@ -1466,21 +1626,28 @@ async function startServer(options) {
|
|
|
1466
1626
|
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
1467
1627
|
const slash = rest.indexOf("/");
|
|
1468
1628
|
const slug = slash === -1 ? rest : rest.slice(0, slash);
|
|
1469
|
-
let
|
|
1629
|
+
let relative4 = slash === -1 ? "" : rest.slice(slash + 1);
|
|
1470
1630
|
try {
|
|
1471
|
-
|
|
1631
|
+
relative4 = decodeURIComponent(relative4);
|
|
1472
1632
|
} catch {
|
|
1473
|
-
|
|
1633
|
+
relative4 = "";
|
|
1474
1634
|
}
|
|
1475
1635
|
const dir = fileMounts.get(slug);
|
|
1476
|
-
if (dir !== void 0 &&
|
|
1636
|
+
if (dir !== void 0 && relative4 !== "" && serveFrom(res, dir, relative4))
|
|
1477
1637
|
return;
|
|
1478
1638
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1479
1639
|
return res.end("Leglas: no such preview file.");
|
|
1480
1640
|
}
|
|
1641
|
+
if (path.startsWith(`${LEGLAS_PREFIX}/api/`)) {
|
|
1642
|
+
return sendJson(res, 404, { error: "No such Leglas API path." });
|
|
1643
|
+
}
|
|
1481
1644
|
if (path === LEGLAS_PREFIX || path.startsWith(`${LEGLAS_PREFIX}/`)) {
|
|
1482
1645
|
if (shellDir !== null && serveShellFile(res, shellDir, path))
|
|
1483
1646
|
return;
|
|
1647
|
+
if (shellDir !== null) {
|
|
1648
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
|
|
1649
|
+
return res.end("Leglas: no such path.");
|
|
1650
|
+
}
|
|
1484
1651
|
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1485
1652
|
return res.end(PLACEHOLDER);
|
|
1486
1653
|
}
|
|
@@ -1529,7 +1696,7 @@ function planKeep(options) {
|
|
|
1529
1696
|
if (!winner) {
|
|
1530
1697
|
return {
|
|
1531
1698
|
ok: false,
|
|
1532
|
-
error: `No direction called ${JSON.stringify(options.title)}. Run leglas list to see them.`
|
|
1699
|
+
error: `No direction called ${JSON.stringify(options.title)}. Run npx leglas list to see them.`
|
|
1533
1700
|
};
|
|
1534
1701
|
}
|
|
1535
1702
|
const from = targetFor(winner.url);
|
|
@@ -1566,11 +1733,11 @@ function planKeep(options) {
|
|
|
1566
1733
|
}
|
|
1567
1734
|
|
|
1568
1735
|
// src/run-init.ts
|
|
1569
|
-
import { readFile as
|
|
1570
|
-
import { join as
|
|
1736
|
+
import { readFile as readFile5, writeFile as writeFile4 } from "fs/promises";
|
|
1737
|
+
import { join as join7 } from "path";
|
|
1571
1738
|
async function readIfPresent(path) {
|
|
1572
1739
|
try {
|
|
1573
|
-
return await
|
|
1740
|
+
return await readFile5(path, "utf8");
|
|
1574
1741
|
} catch {
|
|
1575
1742
|
return null;
|
|
1576
1743
|
}
|
|
@@ -1578,18 +1745,18 @@ async function readIfPresent(path) {
|
|
|
1578
1745
|
async function runInit(options, deps) {
|
|
1579
1746
|
const existingConfig = findConfigFile(options.cwd);
|
|
1580
1747
|
const plan = planInit({
|
|
1581
|
-
agents: await readIfPresent(
|
|
1748
|
+
agents: await readIfPresent(join7(options.cwd, "AGENTS.md")),
|
|
1582
1749
|
config: existingConfig === null ? null : "present",
|
|
1583
|
-
gitignore: await readIfPresent(
|
|
1750
|
+
gitignore: await readIfPresent(join7(options.cwd, ".gitignore")),
|
|
1584
1751
|
force: options.force
|
|
1585
1752
|
});
|
|
1586
1753
|
const touched = [];
|
|
1587
1754
|
for (const write of plan.writes) {
|
|
1588
|
-
await
|
|
1755
|
+
await writeFile4(join7(options.cwd, write.path), write.contents, "utf8");
|
|
1589
1756
|
touched.push(write.path);
|
|
1590
1757
|
}
|
|
1591
1758
|
if (plan.gitignore !== null) {
|
|
1592
|
-
await
|
|
1759
|
+
await writeFile4(join7(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1593
1760
|
touched.push(".gitignore");
|
|
1594
1761
|
}
|
|
1595
1762
|
if (options.json) {
|
|
@@ -1609,8 +1776,26 @@ async function runInit(options, deps) {
|
|
|
1609
1776
|
|
|
1610
1777
|
// src/run-keep.ts
|
|
1611
1778
|
import { existsSync as existsSync3 } from "fs";
|
|
1612
|
-
import { mkdir as
|
|
1613
|
-
import { dirname as
|
|
1779
|
+
import { mkdir as mkdir4, readFile as readFile6, rm as rm2, writeFile as writeFile5 } from "fs/promises";
|
|
1780
|
+
import { dirname as dirname5, join as join8 } from "path";
|
|
1781
|
+
|
|
1782
|
+
// src/resolve-title.ts
|
|
1783
|
+
function resolveOrExplain(input, titles, renames) {
|
|
1784
|
+
const resolution = resolveTitle(input, titles, renames);
|
|
1785
|
+
if (resolution.ok) return { ok: true, title: resolution.title };
|
|
1786
|
+
if (resolution.reason === "ambiguous") {
|
|
1787
|
+
return {
|
|
1788
|
+
ok: false,
|
|
1789
|
+
error: `More than one direction is called ${JSON.stringify(input)} on this machine: ${resolution.matches.join(", ")}. Name the one you mean by its title in the config.`
|
|
1790
|
+
};
|
|
1791
|
+
}
|
|
1792
|
+
return {
|
|
1793
|
+
ok: false,
|
|
1794
|
+
error: `No direction called ${JSON.stringify(input)}. Renaming one in the rail only renames it here, and it still answers to its title in the config, which its reference block quotes. npx leglas list shows every title.`
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// src/run-keep.ts
|
|
1614
1799
|
function renameExport(source, to) {
|
|
1615
1800
|
const match = /export function ([A-Za-z0-9_]+)\s*\(/.exec(source);
|
|
1616
1801
|
if (!match || match[1] === void 0) return source;
|
|
@@ -1623,31 +1808,37 @@ async function runKeep(options, deps) {
|
|
|
1623
1808
|
const loaded = await loadConfig(options.cwd);
|
|
1624
1809
|
const local = await readLocalPreviews(options.cwd);
|
|
1625
1810
|
const previews = [...loaded.config?.previews ?? [], ...local.previews];
|
|
1626
|
-
const plan = planKeep({ title: options.title, previews, to: options.to });
|
|
1627
1811
|
const fail = (error) => {
|
|
1628
1812
|
if (options.json) deps.log(JSON.stringify({ ok: false, error }));
|
|
1629
1813
|
else deps.error(error);
|
|
1630
1814
|
return { exitCode: 1 };
|
|
1631
1815
|
};
|
|
1816
|
+
const resolved = resolveOrExplain(
|
|
1817
|
+
options.title,
|
|
1818
|
+
previews.map((preview) => preview.title),
|
|
1819
|
+
await readRenames(options.cwd)
|
|
1820
|
+
);
|
|
1821
|
+
if (!resolved.ok) return fail(resolved.error);
|
|
1822
|
+
const plan = planKeep({ title: resolved.title, previews, to: options.to });
|
|
1632
1823
|
if (!plan.ok) return fail(plan.error);
|
|
1633
|
-
const from =
|
|
1634
|
-
const to =
|
|
1824
|
+
const from = join8(options.cwd, plan.move.from);
|
|
1825
|
+
const to = join8(options.cwd, plan.move.to);
|
|
1635
1826
|
if (!existsSync3(from)) {
|
|
1636
1827
|
return fail(`${plan.move.from} does not exist. Nothing to keep.`);
|
|
1637
1828
|
}
|
|
1638
1829
|
if (existsSync3(to)) {
|
|
1639
1830
|
return fail(`${plan.move.to} already exists. Choose another destination or move it aside.`);
|
|
1640
1831
|
}
|
|
1641
|
-
const source = await
|
|
1642
|
-
await
|
|
1643
|
-
await
|
|
1644
|
-
await rm2(
|
|
1832
|
+
const source = await readFile6(from, "utf8");
|
|
1833
|
+
await mkdir4(dirname5(to), { recursive: true });
|
|
1834
|
+
await writeFile5(to, renameExport(source, plan.exportName), "utf8");
|
|
1835
|
+
await rm2(join8(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
1645
1836
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
1646
1837
|
if (options.json) {
|
|
1647
1838
|
deps.log(
|
|
1648
1839
|
JSON.stringify({
|
|
1649
1840
|
ok: true,
|
|
1650
|
-
kept:
|
|
1841
|
+
kept: resolved.title,
|
|
1651
1842
|
to: plan.move.to,
|
|
1652
1843
|
exportName: plan.exportName,
|
|
1653
1844
|
removed: plan.removeDir,
|
|
@@ -1676,11 +1867,11 @@ async function runKeep(options, deps) {
|
|
|
1676
1867
|
|
|
1677
1868
|
// src/run-new.ts
|
|
1678
1869
|
import { existsSync as existsSync4 } from "fs";
|
|
1679
|
-
import { mkdir as
|
|
1680
|
-
import { dirname as
|
|
1870
|
+
import { mkdir as mkdir5, readFile as readFile7, writeFile as writeFile6 } from "fs/promises";
|
|
1871
|
+
import { dirname as dirname6, join as join9 } from "path";
|
|
1681
1872
|
async function readIfPresent2(path) {
|
|
1682
1873
|
try {
|
|
1683
|
-
return await
|
|
1874
|
+
return await readFile7(path, "utf8");
|
|
1684
1875
|
} catch {
|
|
1685
1876
|
return null;
|
|
1686
1877
|
}
|
|
@@ -1688,7 +1879,7 @@ async function readIfPresent2(path) {
|
|
|
1688
1879
|
async function runNew(options, deps) {
|
|
1689
1880
|
let from;
|
|
1690
1881
|
if (options.from !== void 0) {
|
|
1691
|
-
const contents = await readIfPresent2(
|
|
1882
|
+
const contents = await readIfPresent2(join9(options.cwd, options.from));
|
|
1692
1883
|
if (contents === null) {
|
|
1693
1884
|
const message = `${options.from} does not exist, so there is nothing to use as the baseline.`;
|
|
1694
1885
|
if (options.json) deps.log(JSON.stringify({ ok: false, error: message }));
|
|
@@ -1699,8 +1890,8 @@ async function runNew(options, deps) {
|
|
|
1699
1890
|
}
|
|
1700
1891
|
const plan = planNew({
|
|
1701
1892
|
surface: options.surface,
|
|
1702
|
-
packageJson: await readIfPresent2(
|
|
1703
|
-
gitignore: await readIfPresent2(
|
|
1893
|
+
packageJson: await readIfPresent2(join9(options.cwd, "package.json")),
|
|
1894
|
+
gitignore: await readIfPresent2(join9(options.cwd, ".gitignore")),
|
|
1704
1895
|
from
|
|
1705
1896
|
});
|
|
1706
1897
|
const fail = (error) => {
|
|
@@ -1723,19 +1914,19 @@ async function runNew(options, deps) {
|
|
|
1723
1914
|
deps.log(plan.instructions);
|
|
1724
1915
|
return { exitCode: 0, written: [] };
|
|
1725
1916
|
}
|
|
1726
|
-
const existing = plan.writes.filter((write) => existsSync4(
|
|
1917
|
+
const existing = plan.writes.filter((write) => existsSync4(join9(options.cwd, write.path)));
|
|
1727
1918
|
if (existing.length > 0) {
|
|
1728
1919
|
return fail(`${existing[0]?.path} already exists. Delete it first, or pick another surface name.`);
|
|
1729
1920
|
}
|
|
1730
1921
|
const written = [];
|
|
1731
1922
|
for (const write of plan.writes) {
|
|
1732
|
-
const target =
|
|
1733
|
-
await
|
|
1734
|
-
await
|
|
1923
|
+
const target = join9(options.cwd, write.path);
|
|
1924
|
+
await mkdir5(dirname6(target), { recursive: true });
|
|
1925
|
+
await writeFile6(target, write.contents, "utf8");
|
|
1735
1926
|
written.push(write.path);
|
|
1736
1927
|
}
|
|
1737
1928
|
if (plan.gitignore !== null) {
|
|
1738
|
-
await
|
|
1929
|
+
await writeFile6(join9(options.cwd, ".gitignore"), plan.gitignore, "utf8");
|
|
1739
1930
|
written.push(".gitignore");
|
|
1740
1931
|
}
|
|
1741
1932
|
if (options.json) {
|
|
@@ -1750,31 +1941,41 @@ async function runNew(options, deps) {
|
|
|
1750
1941
|
deps.log("Then register them so they appear in the interface:");
|
|
1751
1942
|
deps.log("");
|
|
1752
1943
|
for (const preview of plan.previews) {
|
|
1753
|
-
deps.log(` leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
1944
|
+
deps.log(` npx leglas add --title ${JSON.stringify(preview.title)} --url ${JSON.stringify(preview.url)}`);
|
|
1754
1945
|
}
|
|
1755
1946
|
return { exitCode: 0, written };
|
|
1756
1947
|
}
|
|
1757
1948
|
|
|
1758
1949
|
// src/run-previews.ts
|
|
1759
|
-
import { readFile as
|
|
1760
|
-
import { join as
|
|
1950
|
+
import { readFile as readFile8, writeFile as writeFile7 } from "fs/promises";
|
|
1951
|
+
import { join as join10 } from "path";
|
|
1761
1952
|
function envelope(deps, ok, body) {
|
|
1762
1953
|
deps.log(JSON.stringify({ ok, ...body }));
|
|
1763
1954
|
}
|
|
1764
1955
|
async function ensureIgnored(cwd) {
|
|
1765
|
-
const path =
|
|
1956
|
+
const path = join10(cwd, ".gitignore");
|
|
1766
1957
|
let current = null;
|
|
1767
1958
|
try {
|
|
1768
|
-
current = await
|
|
1959
|
+
current = await readFile8(path, "utf8");
|
|
1769
1960
|
} catch {
|
|
1770
1961
|
current = null;
|
|
1771
1962
|
}
|
|
1772
1963
|
const next = ignoreEntry(current);
|
|
1773
|
-
if (next !== null) await
|
|
1964
|
+
if (next !== null) await writeFile7(path, next, "utf8");
|
|
1774
1965
|
}
|
|
1775
1966
|
async function runAdd(options, deps) {
|
|
1776
1967
|
const loaded = await loadConfig(options.cwd);
|
|
1777
1968
|
const shared = loaded.config?.previews ?? [];
|
|
1969
|
+
if (options.preview.basedOn !== void 0) {
|
|
1970
|
+
const local = await readLocalPreviews(options.cwd);
|
|
1971
|
+
const titles = new Set([...shared, ...local.previews].map((preview) => preview.title));
|
|
1972
|
+
if (!titles.has(options.preview.basedOn)) {
|
|
1973
|
+
const error = `--based-on names ${JSON.stringify(options.preview.basedOn)}, which is not a registered direction. npx leglas list shows what exists.`;
|
|
1974
|
+
if (options.json) envelope(deps, false, { error });
|
|
1975
|
+
else deps.error(error);
|
|
1976
|
+
return { exitCode: 1 };
|
|
1977
|
+
}
|
|
1978
|
+
}
|
|
1778
1979
|
const outcome = await addLocalPreview(
|
|
1779
1980
|
options.cwd,
|
|
1780
1981
|
{
|
|
@@ -1783,7 +1984,8 @@ async function runAdd(options, deps) {
|
|
|
1783
1984
|
note: options.preview.note,
|
|
1784
1985
|
tags: options.preview.tags,
|
|
1785
1986
|
branch: options.preview.branch,
|
|
1786
|
-
file: options.preview.file
|
|
1987
|
+
file: options.preview.file,
|
|
1988
|
+
basedOn: options.preview.basedOn
|
|
1787
1989
|
},
|
|
1788
1990
|
shared
|
|
1789
1991
|
);
|
|
@@ -1801,6 +2003,7 @@ async function runAdd(options, deps) {
|
|
|
1801
2003
|
local: true,
|
|
1802
2004
|
...options.preview.branch === void 0 ? {} : { branch: options.preview.branch },
|
|
1803
2005
|
...options.preview.file === void 0 ? {} : { file: options.preview.file },
|
|
2006
|
+
note: options.preview.branch === void 0 && options.preview.file === void 0 ? "A running interface picks this up within seconds." : "Restart Leglas to see this preview: branch checkouts and file mounts are built when Leglas starts.",
|
|
1804
2007
|
...needsDevCommand ? { warning: "The config sets no devCommand, so Leglas cannot start this branch yet. Add devCommand (with {port}) to the config." } : {}
|
|
1805
2008
|
});
|
|
1806
2009
|
} else {
|
|
@@ -1813,7 +2016,11 @@ async function runAdd(options, deps) {
|
|
|
1813
2016
|
deps.log(" Add devCommand (with {port}) to the config.");
|
|
1814
2017
|
deps.log("");
|
|
1815
2018
|
}
|
|
1816
|
-
|
|
2019
|
+
if (options.preview.branch === void 0 && options.preview.file === void 0) {
|
|
2020
|
+
deps.log("Local to this machine. A running interface picks it up within seconds.");
|
|
2021
|
+
} else {
|
|
2022
|
+
deps.log("Local to this machine. Restart Leglas to see it, or run npx leglas list.");
|
|
2023
|
+
}
|
|
1817
2024
|
}
|
|
1818
2025
|
return { exitCode: 0 };
|
|
1819
2026
|
}
|
|
@@ -1827,9 +2034,17 @@ async function runList(options, deps) {
|
|
|
1827
2034
|
];
|
|
1828
2035
|
if (options.json) {
|
|
1829
2036
|
envelope(deps, errors.length === 0, {
|
|
2037
|
+
// The whole record, not a summary of it. What the config holds about a
|
|
2038
|
+
// preview — its note, its tags, the direction it is a variant of — is
|
|
2039
|
+
// exactly what tells an agent why these are being compared, and leaving
|
|
2040
|
+
// it out made the listing thinner than the reference block that points
|
|
2041
|
+
// at it.
|
|
1830
2042
|
previews: previews.map((preview) => ({
|
|
1831
2043
|
title: preview.title,
|
|
1832
2044
|
url: preview.url,
|
|
2045
|
+
note: preview.note ?? null,
|
|
2046
|
+
tags: preview.tags,
|
|
2047
|
+
basedOn: preview.basedOn ?? null,
|
|
1833
2048
|
local: preview.local,
|
|
1834
2049
|
branch: preview.branch ?? null,
|
|
1835
2050
|
file: preview.file ?? null
|
|
@@ -1839,7 +2054,7 @@ async function runList(options, deps) {
|
|
|
1839
2054
|
return { exitCode: errors.length === 0 ? 0 : 1 };
|
|
1840
2055
|
}
|
|
1841
2056
|
if (previews.length === 0) {
|
|
1842
|
-
deps.log("No previews yet. Add one with leglas add, or list them in leglas.config.ts.");
|
|
2057
|
+
deps.log("No previews yet. Add one with npx leglas add, or list them in leglas.config.ts.");
|
|
1843
2058
|
} else {
|
|
1844
2059
|
const width = Math.max(...previews.map((preview) => preview.title.length));
|
|
1845
2060
|
for (const preview of previews) {
|
|
@@ -1854,12 +2069,19 @@ async function runList(options, deps) {
|
|
|
1854
2069
|
}
|
|
1855
2070
|
async function runRequests(options, deps) {
|
|
1856
2071
|
if (options.clear) {
|
|
1857
|
-
await clearRequests(options.cwd);
|
|
1858
|
-
if (options.json) envelope(deps, true, { cleared
|
|
1859
|
-
else
|
|
2072
|
+
const { cleared, pending } = await clearRequests(options.cwd);
|
|
2073
|
+
if (options.json) envelope(deps, true, { cleared, pending });
|
|
2074
|
+
else {
|
|
2075
|
+
deps.log(cleared === 1 ? "Cleared 1 request." : `Cleared ${cleared} requests.`);
|
|
2076
|
+
if (pending > 0) {
|
|
2077
|
+
deps.log(
|
|
2078
|
+
`${pending} arrived while you worked. Run npx leglas requests to collect ${pending === 1 ? "it" : "them"}.`
|
|
2079
|
+
);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
1860
2082
|
return { exitCode: 0 };
|
|
1861
2083
|
}
|
|
1862
|
-
const requests = await
|
|
2084
|
+
const requests = await collectRequests(options.cwd);
|
|
1863
2085
|
if (options.json) {
|
|
1864
2086
|
envelope(deps, true, { requests });
|
|
1865
2087
|
return { exitCode: 0 };
|
|
@@ -1873,18 +2095,326 @@ async function runRequests(options, deps) {
|
|
|
1873
2095
|
if (request.target !== null) deps.log(` ${request.target}`);
|
|
1874
2096
|
}
|
|
1875
2097
|
deps.log("");
|
|
1876
|
-
deps.log("Run leglas requests --json to get the full prompts, then --clear when done.");
|
|
2098
|
+
deps.log("Run npx leglas requests --json to get the full prompts, then --clear when done.");
|
|
1877
2099
|
return { exitCode: 0 };
|
|
1878
2100
|
}
|
|
1879
2101
|
|
|
2102
|
+
// src/show.ts
|
|
2103
|
+
function describe(preview) {
|
|
2104
|
+
return {
|
|
2105
|
+
title: preview.title,
|
|
2106
|
+
url: preview.url,
|
|
2107
|
+
note: preview.note ?? null,
|
|
2108
|
+
tags: preview.tags,
|
|
2109
|
+
basedOn: preview.basedOn ?? null,
|
|
2110
|
+
branch: preview.branch ?? null,
|
|
2111
|
+
file: preview.file ?? null,
|
|
2112
|
+
local: preview.local === true,
|
|
2113
|
+
// A file preview names its own source. Everything else is decoded from the
|
|
2114
|
+
// URL, and a URL outside the convention yields nothing rather than a path
|
|
2115
|
+
// that looks authoritative and is not there.
|
|
2116
|
+
target: preview.file ?? targetFor(preview.url)
|
|
2117
|
+
};
|
|
2118
|
+
}
|
|
2119
|
+
function planShow({ title, previews, requests }) {
|
|
2120
|
+
const found = previews.find((preview) => preview.title === title);
|
|
2121
|
+
if (!found) {
|
|
2122
|
+
return {
|
|
2123
|
+
ok: false,
|
|
2124
|
+
error: `No direction called ${JSON.stringify(title)}. Run npx leglas list to see them.`
|
|
2125
|
+
};
|
|
2126
|
+
}
|
|
2127
|
+
const variants = previews.filter((preview) => preview.basedOn === title).map(describe);
|
|
2128
|
+
const variantTitles = new Set(variants.map((variant) => variant.title));
|
|
2129
|
+
return {
|
|
2130
|
+
ok: true,
|
|
2131
|
+
direction: describe(found),
|
|
2132
|
+
variants,
|
|
2133
|
+
// Its own variants are already listed in full, so they are not repeated
|
|
2134
|
+
// here; this is the rest of the comparison.
|
|
2135
|
+
comparedWith: previews.map((preview) => preview.title).filter((other) => other !== title && !variantTitles.has(other)),
|
|
2136
|
+
requests: requests.filter((request) => request.title === title).map((request) => ({
|
|
2137
|
+
id: request.id,
|
|
2138
|
+
intent: request.intent,
|
|
2139
|
+
target: request.target,
|
|
2140
|
+
prompt: request.prompt,
|
|
2141
|
+
status: request.status
|
|
2142
|
+
}))
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
// src/run-show.ts
|
|
2147
|
+
async function runShow(options, deps) {
|
|
2148
|
+
const loaded = await loadConfig(options.cwd);
|
|
2149
|
+
const local = await readLocalPreviews(options.cwd);
|
|
2150
|
+
const requests = await readRequests(options.cwd);
|
|
2151
|
+
const previews = [
|
|
2152
|
+
...(loaded.config?.previews ?? []).map((preview) => ({ ...preview, local: false })),
|
|
2153
|
+
...local.previews
|
|
2154
|
+
];
|
|
2155
|
+
const resolved = resolveOrExplain(
|
|
2156
|
+
options.title,
|
|
2157
|
+
previews.map((preview) => preview.title),
|
|
2158
|
+
await readRenames(options.cwd)
|
|
2159
|
+
);
|
|
2160
|
+
if (!resolved.ok) {
|
|
2161
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error: resolved.error }));
|
|
2162
|
+
else deps.error(resolved.error);
|
|
2163
|
+
return { exitCode: 1 };
|
|
2164
|
+
}
|
|
2165
|
+
const plan = planShow({ title: resolved.title, previews, requests });
|
|
2166
|
+
if (!plan.ok) {
|
|
2167
|
+
if (options.json) deps.log(JSON.stringify({ ok: false, error: plan.error }));
|
|
2168
|
+
else deps.error(plan.error);
|
|
2169
|
+
return { exitCode: 1 };
|
|
2170
|
+
}
|
|
2171
|
+
if (options.json) {
|
|
2172
|
+
deps.log(
|
|
2173
|
+
JSON.stringify({
|
|
2174
|
+
ok: true,
|
|
2175
|
+
direction: plan.direction,
|
|
2176
|
+
variants: plan.variants,
|
|
2177
|
+
comparedWith: plan.comparedWith,
|
|
2178
|
+
requests: plan.requests
|
|
2179
|
+
})
|
|
2180
|
+
);
|
|
2181
|
+
return { exitCode: 0 };
|
|
2182
|
+
}
|
|
2183
|
+
const { direction } = plan;
|
|
2184
|
+
deps.log(` ${direction.title}${direction.local ? " (local)" : ""}`);
|
|
2185
|
+
if (direction.note !== null) deps.log(` ${direction.note}`);
|
|
2186
|
+
deps.log("");
|
|
2187
|
+
if (direction.target !== null) deps.log(` file ${direction.target}`);
|
|
2188
|
+
if (direction.branch !== null) deps.log(` branch ${direction.branch}`);
|
|
2189
|
+
deps.log(` url ${direction.url}`);
|
|
2190
|
+
if (direction.tags.length > 0) deps.log(` tags ${direction.tags.join(", ")}`);
|
|
2191
|
+
if (direction.basedOn !== null) deps.log(` variant of ${direction.basedOn}`);
|
|
2192
|
+
if (plan.variants.length > 0) {
|
|
2193
|
+
deps.log(` variants ${plan.variants.map((variant) => variant.title).join(", ")}`);
|
|
2194
|
+
}
|
|
2195
|
+
if (plan.comparedWith.length > 0) {
|
|
2196
|
+
deps.log(` against ${plan.comparedWith.join(", ")}`);
|
|
2197
|
+
}
|
|
2198
|
+
if (plan.requests.length > 0) {
|
|
2199
|
+
deps.log("");
|
|
2200
|
+
deps.log(` Pending, not yet done (${plan.requests.length}):`);
|
|
2201
|
+
for (const request of plan.requests) deps.log(` ${request.status} ${request.intent}`);
|
|
2202
|
+
deps.log("");
|
|
2203
|
+
deps.log(" Run npx leglas requests --json for the full prompts.");
|
|
2204
|
+
}
|
|
2205
|
+
return { exitCode: 0 };
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
// src/watch.ts
|
|
2209
|
+
var WATCH_PATH = ".leglas/watch.json";
|
|
2210
|
+
var PROMPT_TOKEN = "{prompt}";
|
|
2211
|
+
var EXAMPLE = `npx leglas watch --run "claude -p ${PROMPT_TOKEN}"`;
|
|
2212
|
+
function tokenize(template) {
|
|
2213
|
+
const tokens = [];
|
|
2214
|
+
let current = "";
|
|
2215
|
+
let started = false;
|
|
2216
|
+
let quote = null;
|
|
2217
|
+
for (const character of template) {
|
|
2218
|
+
if (quote !== null) {
|
|
2219
|
+
if (character === quote) quote = null;
|
|
2220
|
+
else current += character;
|
|
2221
|
+
continue;
|
|
2222
|
+
}
|
|
2223
|
+
if (character === '"' || character === "'") {
|
|
2224
|
+
quote = character;
|
|
2225
|
+
started = true;
|
|
2226
|
+
continue;
|
|
2227
|
+
}
|
|
2228
|
+
if (/\s/.test(character)) {
|
|
2229
|
+
if (started) tokens.push(current);
|
|
2230
|
+
current = "";
|
|
2231
|
+
started = false;
|
|
2232
|
+
continue;
|
|
2233
|
+
}
|
|
2234
|
+
current += character;
|
|
2235
|
+
started = true;
|
|
2236
|
+
}
|
|
2237
|
+
if (quote !== null) {
|
|
2238
|
+
return { ok: false, error: `The agent command has an unclosed ${quote} quote.` };
|
|
2239
|
+
}
|
|
2240
|
+
if (started) tokens.push(current);
|
|
2241
|
+
return { ok: true, tokens };
|
|
2242
|
+
}
|
|
2243
|
+
function parseTemplate(raw) {
|
|
2244
|
+
const tokenized = tokenize(raw);
|
|
2245
|
+
if (!tokenized.ok) return tokenized;
|
|
2246
|
+
const { tokens } = tokenized;
|
|
2247
|
+
const [command, ...args] = tokens;
|
|
2248
|
+
if (command === void 0) {
|
|
2249
|
+
return { ok: false, error: `Watch needs an agent command, for example: ${EXAMPLE}` };
|
|
2250
|
+
}
|
|
2251
|
+
const placeholders = tokens.filter((token) => token === PROMPT_TOKEN).length;
|
|
2252
|
+
if (placeholders === 0) {
|
|
2253
|
+
return {
|
|
2254
|
+
ok: false,
|
|
2255
|
+
error: `The agent command needs ${PROMPT_TOKEN} as a word of its own, for example: ${EXAMPLE}`
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
if (placeholders > 1) {
|
|
2259
|
+
return {
|
|
2260
|
+
ok: false,
|
|
2261
|
+
error: `The agent command takes ${PROMPT_TOKEN} once, for example: ${EXAMPLE}`
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
if (command === PROMPT_TOKEN) {
|
|
2265
|
+
return {
|
|
2266
|
+
ok: false,
|
|
2267
|
+
error: `The agent command must name a program before ${PROMPT_TOKEN}, for example: ${EXAMPLE}`
|
|
2268
|
+
};
|
|
2269
|
+
}
|
|
2270
|
+
return { ok: true, template: { command, args } };
|
|
2271
|
+
}
|
|
2272
|
+
function commandFor(template, prompt) {
|
|
2273
|
+
return {
|
|
2274
|
+
command: template.command,
|
|
2275
|
+
args: template.args.map((argument) => argument === PROMPT_TOKEN ? prompt : argument)
|
|
2276
|
+
};
|
|
2277
|
+
}
|
|
2278
|
+
function nextRequest(requests, failed) {
|
|
2279
|
+
return requests.find((request) => request.status === "queued" && !failed.has(request.id)) ?? null;
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// src/run-watch.ts
|
|
2283
|
+
import { spawn as spawn2 } from "child_process";
|
|
2284
|
+
import { mkdir as mkdir6, readFile as readFile9, writeFile as writeFile8 } from "fs/promises";
|
|
2285
|
+
import { dirname as dirname7, join as join11 } from "path";
|
|
2286
|
+
var POLL_MS = 2e3;
|
|
2287
|
+
var HEARTBEAT_TIMEOUT_MS = 1e3;
|
|
2288
|
+
async function readSavedTemplate(cwd) {
|
|
2289
|
+
try {
|
|
2290
|
+
const raw = await readFile9(join11(cwd, WATCH_PATH), "utf8");
|
|
2291
|
+
const parsed = JSON.parse(raw);
|
|
2292
|
+
return typeof parsed.run === "string" && parsed.run !== "" ? parsed.run : null;
|
|
2293
|
+
} catch {
|
|
2294
|
+
return null;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
async function saveTemplate(cwd, run3) {
|
|
2298
|
+
const path = join11(cwd, WATCH_PATH);
|
|
2299
|
+
await mkdir6(dirname7(path), { recursive: true });
|
|
2300
|
+
await writeFile8(path, `${JSON.stringify({ run: run3 }, null, 2)}
|
|
2301
|
+
`, "utf8");
|
|
2302
|
+
}
|
|
2303
|
+
function spawnAgent(command, args, cwd) {
|
|
2304
|
+
return new Promise((resolve) => {
|
|
2305
|
+
let settled = false;
|
|
2306
|
+
const settle = (outcome) => {
|
|
2307
|
+
if (settled) return;
|
|
2308
|
+
settled = true;
|
|
2309
|
+
resolve(outcome);
|
|
2310
|
+
};
|
|
2311
|
+
const child = spawn2(command, args, { cwd, stdio: "inherit" });
|
|
2312
|
+
child.on("error", (error) => settle({ ok: false, error: error.message }));
|
|
2313
|
+
child.on(
|
|
2314
|
+
"close",
|
|
2315
|
+
(code, signal) => settle(
|
|
2316
|
+
signal === null ? { ok: true, code: code ?? 0 } : { ok: false, error: `stopped by ${signal}` }
|
|
2317
|
+
)
|
|
2318
|
+
);
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
async function runWatch(options, deps) {
|
|
2322
|
+
const saved = options.run === void 0 ? await readSavedTemplate(options.cwd) : null;
|
|
2323
|
+
const raw = options.run ?? saved;
|
|
2324
|
+
if (raw === null) {
|
|
2325
|
+
deps.error(
|
|
2326
|
+
'Watch needs an agent command the first time: npx leglas watch --run "claude -p {prompt}"'
|
|
2327
|
+
);
|
|
2328
|
+
return { exitCode: 1 };
|
|
2329
|
+
}
|
|
2330
|
+
const parsed = parseTemplate(raw);
|
|
2331
|
+
if (!parsed.ok) {
|
|
2332
|
+
deps.error(parsed.error);
|
|
2333
|
+
return { exitCode: 1 };
|
|
2334
|
+
}
|
|
2335
|
+
const template = parsed.template;
|
|
2336
|
+
if (options.run !== void 0) await saveTemplate(options.cwd, raw).catch(() => {
|
|
2337
|
+
});
|
|
2338
|
+
const base = `http://localhost:${options.port ?? DEFAULT_PORT}`;
|
|
2339
|
+
const heartbeat = async (watching) => {
|
|
2340
|
+
try {
|
|
2341
|
+
await fetch(`${base}${LEGLAS_PREFIX}/api/watch`, {
|
|
2342
|
+
method: "POST",
|
|
2343
|
+
headers: { "content-type": "application/json" },
|
|
2344
|
+
body: JSON.stringify({ watching }),
|
|
2345
|
+
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS)
|
|
2346
|
+
});
|
|
2347
|
+
} catch {
|
|
2348
|
+
}
|
|
2349
|
+
};
|
|
2350
|
+
deps.log(`Watching for change requests. Each one runs: ${raw}`);
|
|
2351
|
+
deps.log("Stop with Ctrl-C.");
|
|
2352
|
+
const failed = /* @__PURE__ */ new Set();
|
|
2353
|
+
let stopped = false;
|
|
2354
|
+
let busy = false;
|
|
2355
|
+
let inflight = null;
|
|
2356
|
+
const handle = async (request) => {
|
|
2357
|
+
deps.log("");
|
|
2358
|
+
deps.log(` ${request.title}: ${request.intent}`);
|
|
2359
|
+
if (request.target !== null) deps.log(` ${request.target}`);
|
|
2360
|
+
await markPickedUp(options.cwd, request.id);
|
|
2361
|
+
const { command, args } = commandFor(template, request.prompt);
|
|
2362
|
+
const outcome = await spawnAgent(command, args, options.cwd);
|
|
2363
|
+
if (outcome.ok && outcome.code === 0) {
|
|
2364
|
+
await removeRequest(options.cwd, request.id);
|
|
2365
|
+
deps.log(` done ${request.title}`);
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
failed.add(request.id);
|
|
2369
|
+
deps.error(
|
|
2370
|
+
` failed ${request.title}: ${outcome.ok ? `${command} exited ${outcome.code}` : outcome.error}`
|
|
2371
|
+
);
|
|
2372
|
+
deps.error(" Left in the queue and not retried.");
|
|
2373
|
+
};
|
|
2374
|
+
const tick = async () => {
|
|
2375
|
+
if (stopped) return;
|
|
2376
|
+
void heartbeat(true);
|
|
2377
|
+
if (busy) return;
|
|
2378
|
+
busy = true;
|
|
2379
|
+
try {
|
|
2380
|
+
const request = nextRequest(await readRequests(options.cwd), failed);
|
|
2381
|
+
if (request !== null && !stopped) {
|
|
2382
|
+
inflight = handle(request);
|
|
2383
|
+
await inflight;
|
|
2384
|
+
}
|
|
2385
|
+
} catch (error) {
|
|
2386
|
+
deps.error(` ! ${error instanceof Error ? error.message : String(error)}`);
|
|
2387
|
+
} finally {
|
|
2388
|
+
inflight = null;
|
|
2389
|
+
busy = false;
|
|
2390
|
+
}
|
|
2391
|
+
};
|
|
2392
|
+
return new Promise((resolve) => {
|
|
2393
|
+
const timer = setInterval(() => void tick(), POLL_MS);
|
|
2394
|
+
const stop = () => {
|
|
2395
|
+
if (stopped) return;
|
|
2396
|
+
stopped = true;
|
|
2397
|
+
clearInterval(timer);
|
|
2398
|
+
process.off("SIGINT", stop);
|
|
2399
|
+
process.off("SIGTERM", stop);
|
|
2400
|
+
void Promise.resolve(inflight).catch(() => {
|
|
2401
|
+
}).then(() => heartbeat(false)).then(() => resolve({ exitCode: 0 }));
|
|
2402
|
+
};
|
|
2403
|
+
process.on("SIGINT", stop);
|
|
2404
|
+
process.on("SIGTERM", stop);
|
|
2405
|
+
options.signal?.addEventListener("abort", stop, { once: true });
|
|
2406
|
+
void tick();
|
|
2407
|
+
});
|
|
2408
|
+
}
|
|
2409
|
+
|
|
1880
2410
|
// src/run-classify.ts
|
|
1881
2411
|
import { stat } from "fs/promises";
|
|
1882
|
-
import { join as
|
|
2412
|
+
import { join as join12 } from "path";
|
|
1883
2413
|
async function runClassify(options, deps) {
|
|
1884
2414
|
const declared = await Promise.all(
|
|
1885
2415
|
options.changes.map(async (change) => ({
|
|
1886
2416
|
...change,
|
|
1887
|
-
exists: await stat(
|
|
2417
|
+
exists: await stat(join12(options.cwd, change.path)).then(
|
|
1888
2418
|
() => true,
|
|
1889
2419
|
() => false
|
|
1890
2420
|
)
|
|
@@ -1912,14 +2442,14 @@ async function runClassify(options, deps) {
|
|
|
1912
2442
|
// src/run.ts
|
|
1913
2443
|
import { existsSync as existsSync5 } from "fs";
|
|
1914
2444
|
import { createRequire } from "module";
|
|
1915
|
-
import { basename as basename3, dirname as
|
|
2445
|
+
import { basename as basename3, dirname as dirname8, join as join13, relative as relative3 } from "path";
|
|
1916
2446
|
import { fileURLToPath } from "url";
|
|
1917
2447
|
function findShellDir() {
|
|
1918
|
-
const bundled =
|
|
1919
|
-
if (existsSync5(
|
|
2448
|
+
const bundled = join13(dirname8(fileURLToPath(import.meta.url)), "shell");
|
|
2449
|
+
if (existsSync5(join13(bundled, "index.html"))) return bundled;
|
|
1920
2450
|
try {
|
|
1921
2451
|
const require2 = createRequire(import.meta.url);
|
|
1922
|
-
return
|
|
2452
|
+
return dirname8(require2.resolve("@leglas/shell/dist/index.html"));
|
|
1923
2453
|
} catch {
|
|
1924
2454
|
return null;
|
|
1925
2455
|
}
|
|
@@ -1953,7 +2483,7 @@ async function run2(options, deps) {
|
|
|
1953
2483
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
1954
2484
|
for (const preview of merged?.previews ?? []) {
|
|
1955
2485
|
if (preview.file !== void 0) {
|
|
1956
|
-
const absolute =
|
|
2486
|
+
const absolute = join13(options.cwd, preview.file);
|
|
1957
2487
|
if (!existsSync5(absolute)) {
|
|
1958
2488
|
worktreeErrors.push(
|
|
1959
2489
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
@@ -1964,7 +2494,7 @@ async function run2(options, deps) {
|
|
|
1964
2494
|
for (let suffix = 2; fileMounts.has(slug); suffix += 1) {
|
|
1965
2495
|
slug = `${worktreeSlug(preview.title) || "file"}-${suffix}`;
|
|
1966
2496
|
}
|
|
1967
|
-
fileMounts.set(slug,
|
|
2497
|
+
fileMounts.set(slug, dirname8(absolute));
|
|
1968
2498
|
previews.push({
|
|
1969
2499
|
...preview,
|
|
1970
2500
|
url: `${FILES_PREFIX}/${slug}/${encodeURIComponent(basename3(absolute))}`
|
|
@@ -2025,7 +2555,7 @@ async function run2(options, deps) {
|
|
|
2025
2555
|
})
|
|
2026
2556
|
);
|
|
2027
2557
|
} else {
|
|
2028
|
-
const configLabel = loaded.path === null ? "no config file, previewing the app root" :
|
|
2558
|
+
const configLabel = loaded.path === null ? "no config file, previewing the app root" : relative3(options.cwd, loaded.path) || loaded.path;
|
|
2029
2559
|
deps.log(`Leglas ${url}`);
|
|
2030
2560
|
deps.log(
|
|
2031
2561
|
`app ${devServer}${app !== null ? " (started by Leglas)" : health.reachable ? "" : " (not reachable)"}`
|
|
@@ -2064,15 +2594,20 @@ async function run2(options, deps) {
|
|
|
2064
2594
|
export {
|
|
2065
2595
|
AGENTS_MARKER_END,
|
|
2066
2596
|
AGENTS_MARKER_START,
|
|
2067
|
-
|
|
2597
|
+
PROMPT_TOKEN,
|
|
2598
|
+
WATCH_PATH,
|
|
2068
2599
|
baselineFrom,
|
|
2069
|
-
|
|
2600
|
+
commandFor,
|
|
2070
2601
|
detectFramework,
|
|
2602
|
+
nextRequest,
|
|
2071
2603
|
parseArgs,
|
|
2072
|
-
|
|
2604
|
+
parseTemplate,
|
|
2605
|
+
planExplore,
|
|
2073
2606
|
planInit,
|
|
2074
2607
|
planKeep,
|
|
2075
2608
|
planNew,
|
|
2609
|
+
planShow,
|
|
2610
|
+
readRequests,
|
|
2076
2611
|
run2 as run,
|
|
2077
2612
|
runAdd,
|
|
2078
2613
|
runClassify,
|
|
@@ -2082,5 +2617,7 @@ export {
|
|
|
2082
2617
|
runList,
|
|
2083
2618
|
runNew,
|
|
2084
2619
|
runRequests,
|
|
2620
|
+
runShow,
|
|
2621
|
+
runWatch,
|
|
2085
2622
|
surfaceSlug
|
|
2086
2623
|
};
|