leglas 0.7.3 → 0.8.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 +9 -1
- package/dist/args.d.ts +4 -0
- package/dist/bin.js +550 -66
- package/dist/index.js +473 -47
- package/dist/run-log.d.ts +20 -0
- package/dist/shell/assets/index-CpYCCElH.css +1 -0
- package/dist/shell/assets/index-Ct9pp-D-.js +14 -0
- package/dist/shell/index.html +2 -2
- package/package.json +1 -1
- package/dist/shell/assets/index-DT33xxfg.css +0 -1
- package/dist/shell/assets/index-DbONqSbP.js +0 -14
package/dist/index.js
CHANGED
|
@@ -281,6 +281,19 @@ function parseArgs(argv) {
|
|
|
281
281
|
}
|
|
282
282
|
return { kind: "requests", json: rest.includes("--json"), clear: rest.includes("--clear") };
|
|
283
283
|
}
|
|
284
|
+
if (argv[0] === "log") {
|
|
285
|
+
const rest = argv.slice(1);
|
|
286
|
+
const flags = rest.filter((argument) => argument.startsWith("--"));
|
|
287
|
+
const unknown = flags.find((flag) => flag !== "--json");
|
|
288
|
+
if (unknown !== void 0) {
|
|
289
|
+
return { kind: "error", message: `leglas log does not take ${unknown}.` };
|
|
290
|
+
}
|
|
291
|
+
const names = rest.filter((argument) => !argument.startsWith("--"));
|
|
292
|
+
if (names.length > 1) {
|
|
293
|
+
return { kind: "error", message: "leglas log takes one entry at most." };
|
|
294
|
+
}
|
|
295
|
+
return { kind: "log", entry: names[0] ?? null, json: flags.includes("--json") };
|
|
296
|
+
}
|
|
284
297
|
if (argv[0] === "list") {
|
|
285
298
|
const rest = argv.slice(1);
|
|
286
299
|
const unknown = rest.find((argument) => argument !== "--json");
|
|
@@ -725,6 +738,12 @@ out of the ignored directory, deletes the rest of the exploration, and drops
|
|
|
725
738
|
them from the rail. Then change their component to use the kept component
|
|
726
739
|
instead of the switcher.
|
|
727
740
|
|
|
741
|
+
Keeping also writes what the exploration was into \`design-log/\`, which is
|
|
742
|
+
committed. Before exploring a surface, read \`npx leglas log --json\` and any
|
|
743
|
+
entry for that surface: it says what was already tried there, in the user's own
|
|
744
|
+
words, and which direction won. Proposing something that was already rejected
|
|
745
|
+
wastes their time, and the record is there so you do not have to ask.
|
|
746
|
+
|
|
728
747
|
Useful to know:
|
|
729
748
|
|
|
730
749
|
- \`.leglas/\` is gitignored. Exploration is disposable and nothing in there
|
|
@@ -785,6 +804,84 @@ ${AGENTS_SECTION}`
|
|
|
785
804
|
// src/keep.ts
|
|
786
805
|
import { basename as basename4, extname as extname4, normalize as normalize2 } from "path";
|
|
787
806
|
|
|
807
|
+
// ../server/dist/log.js
|
|
808
|
+
var DEFAULT_LOG_DIR = "design-log";
|
|
809
|
+
function slugify(value) {
|
|
810
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "untitled";
|
|
811
|
+
}
|
|
812
|
+
function frameFor(title, requests) {
|
|
813
|
+
let found = null;
|
|
814
|
+
for (const request of requests) {
|
|
815
|
+
if (request.title !== title)
|
|
816
|
+
continue;
|
|
817
|
+
for (const attachment of request.attachments ?? []) {
|
|
818
|
+
if (attachment.kind === "frame")
|
|
819
|
+
found = attachment;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
return found;
|
|
823
|
+
}
|
|
824
|
+
function askedOf(title, requests) {
|
|
825
|
+
return requests.filter((request) => request.title === title && request.status !== "failed" && request.intent.trim() !== "").map((request) => request.intent.trim());
|
|
826
|
+
}
|
|
827
|
+
function composeEntry(input) {
|
|
828
|
+
const slug = `${input.date}-${slugify(input.surface)}`;
|
|
829
|
+
const pictures = [];
|
|
830
|
+
const lines2 = [];
|
|
831
|
+
lines2.push(`# ${input.surface}, ${input.date}`);
|
|
832
|
+
lines2.push("");
|
|
833
|
+
lines2.push(`**${input.won.title}** won and became \`${input.won.to}\`. ${input.previews.length === 1 ? "It was the only direction." : `${input.previews.length} directions were compared.`}`);
|
|
834
|
+
lines2.push("");
|
|
835
|
+
for (const preview of input.previews) {
|
|
836
|
+
const won = preview.title === input.won.title;
|
|
837
|
+
lines2.push(`## ${preview.title}${won ? " \u2014 kept" : ""}`);
|
|
838
|
+
lines2.push("");
|
|
839
|
+
if (preview.note !== void 0 && preview.note.trim() !== "") {
|
|
840
|
+
lines2.push(preview.note.trim());
|
|
841
|
+
lines2.push("");
|
|
842
|
+
}
|
|
843
|
+
const frame = frameFor(preview.title, input.requests);
|
|
844
|
+
if (frame !== null) {
|
|
845
|
+
const name = `${slugify(preview.title)}.png`;
|
|
846
|
+
pictures.push({ from: frame.file, to: name });
|
|
847
|
+
lines2.push(``);
|
|
848
|
+
lines2.push("");
|
|
849
|
+
}
|
|
850
|
+
if (preview.basedOn !== void 0) {
|
|
851
|
+
lines2.push(`A variant of ${preview.basedOn}.`);
|
|
852
|
+
lines2.push("");
|
|
853
|
+
}
|
|
854
|
+
const asked = askedOf(preview.title, input.requests);
|
|
855
|
+
if (asked.length > 0) {
|
|
856
|
+
lines2.push("Asked for:");
|
|
857
|
+
lines2.push("");
|
|
858
|
+
for (const words of asked)
|
|
859
|
+
lines2.push(`- ${words}`);
|
|
860
|
+
lines2.push("");
|
|
861
|
+
}
|
|
862
|
+
const notes = input.annotations.filter((note) => note.title === preview.title);
|
|
863
|
+
if (notes.length > 0) {
|
|
864
|
+
lines2.push("Marked on the design:");
|
|
865
|
+
lines2.push("");
|
|
866
|
+
for (const note of notes)
|
|
867
|
+
lines2.push(`- ${note.note}`);
|
|
868
|
+
lines2.push("");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
const failed = input.requests.filter((request) => request.status === "failed");
|
|
872
|
+
if (failed.length > 0) {
|
|
873
|
+
lines2.push("## Changes that did not land");
|
|
874
|
+
lines2.push("");
|
|
875
|
+
for (const request of failed) {
|
|
876
|
+
const why = request.failure?.message;
|
|
877
|
+
lines2.push(`- ${request.title}: ${request.intent}${why === void 0 ? "" : ` (${why})`}`);
|
|
878
|
+
}
|
|
879
|
+
lines2.push("");
|
|
880
|
+
}
|
|
881
|
+
return { slug, markdown: `${lines2.join("\n").trimEnd()}
|
|
882
|
+
`, pictures };
|
|
883
|
+
}
|
|
884
|
+
|
|
788
885
|
// ../server/dist/config.js
|
|
789
886
|
var DEFAULT_DEV_SERVER = "http://localhost:3000";
|
|
790
887
|
var DEFAULT_INSTALL_COMMAND = "npm install";
|
|
@@ -903,6 +1000,10 @@ function normalizeConfig(raw, options = {}) {
|
|
|
903
1000
|
if (requireDevCommand && previews.some((preview) => preview.branch !== void 0) && devCommand === void 0) {
|
|
904
1001
|
errors.push("A preview names a branch, so devCommand is required: Leglas has to start that checkout itself.");
|
|
905
1002
|
}
|
|
1003
|
+
const logDir = source["logDir"] ?? DEFAULT_LOG_DIR;
|
|
1004
|
+
if (typeof logDir !== "string" || logDir.trim() === "") {
|
|
1005
|
+
errors.push("logDir must be a non-empty string.");
|
|
1006
|
+
}
|
|
906
1007
|
const installCommand = source["installCommand"] ?? DEFAULT_INSTALL_COMMAND;
|
|
907
1008
|
if (typeof installCommand !== "string" || installCommand.trim() === "") {
|
|
908
1009
|
errors.push("installCommand must be a non-empty string.");
|
|
@@ -919,7 +1020,8 @@ function normalizeConfig(raw, options = {}) {
|
|
|
919
1020
|
previews,
|
|
920
1021
|
scanPreviews,
|
|
921
1022
|
devCommand: typeof devCommand === "string" ? devCommand : void 0,
|
|
922
|
-
installCommand
|
|
1023
|
+
installCommand,
|
|
1024
|
+
logDir
|
|
923
1025
|
},
|
|
924
1026
|
errors: []
|
|
925
1027
|
};
|
|
@@ -1697,6 +1799,7 @@ import net from "net";
|
|
|
1697
1799
|
function createProxyHandler(options) {
|
|
1698
1800
|
const target = new URL(options.target);
|
|
1699
1801
|
const host = target.hostname;
|
|
1802
|
+
const dialHost = host.replace(/^\[|\]$/g, "");
|
|
1700
1803
|
const port = Number(target.port || (target.protocol === "https:" ? 443 : 80));
|
|
1701
1804
|
const authority = target.port ? `${host}:${target.port}` : host;
|
|
1702
1805
|
function upstreamHeaders(req) {
|
|
@@ -1713,7 +1816,19 @@ function createProxyHandler(options) {
|
|
|
1713
1816
|
}
|
|
1714
1817
|
return {
|
|
1715
1818
|
request(req, res, publicOrigin) {
|
|
1716
|
-
|
|
1819
|
+
options.onActivity?.();
|
|
1820
|
+
options.onOpen?.();
|
|
1821
|
+
let open = true;
|
|
1822
|
+
const close = () => {
|
|
1823
|
+
if (!open)
|
|
1824
|
+
return;
|
|
1825
|
+
open = false;
|
|
1826
|
+
options.onActivity?.();
|
|
1827
|
+
options.onClose?.();
|
|
1828
|
+
};
|
|
1829
|
+
res.once("finish", close);
|
|
1830
|
+
res.once("close", close);
|
|
1831
|
+
const upstream = http.request({ host: dialHost, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
|
|
1717
1832
|
const headers = { ...upstreamRes.headers };
|
|
1718
1833
|
const location = rewriteLocation(typeof headers.location === "string" ? headers.location : void 0, publicOrigin);
|
|
1719
1834
|
if (location !== void 0)
|
|
@@ -1735,7 +1850,17 @@ Start it, or point Leglas somewhere else with --user-port.`);
|
|
|
1735
1850
|
req.pipe(upstream);
|
|
1736
1851
|
},
|
|
1737
1852
|
upgrade(req, socket, head) {
|
|
1738
|
-
|
|
1853
|
+
options.onActivity?.();
|
|
1854
|
+
options.onOpen?.();
|
|
1855
|
+
let open = true;
|
|
1856
|
+
const closeActivity = () => {
|
|
1857
|
+
if (!open)
|
|
1858
|
+
return;
|
|
1859
|
+
open = false;
|
|
1860
|
+
options.onActivity?.();
|
|
1861
|
+
options.onClose?.();
|
|
1862
|
+
};
|
|
1863
|
+
const upstream = net.connect(port, dialHost, () => {
|
|
1739
1864
|
const headers = Object.entries(upstreamHeaders(req)).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(", ") : value}\r
|
|
1740
1865
|
`).join("");
|
|
1741
1866
|
upstream.write(`${req.method} ${req.url} HTTP/1.1\r
|
|
@@ -1747,6 +1872,7 @@ ${headers}\r
|
|
|
1747
1872
|
socket.pipe(upstream);
|
|
1748
1873
|
});
|
|
1749
1874
|
const shutdown = () => {
|
|
1875
|
+
closeActivity();
|
|
1750
1876
|
upstream.destroy();
|
|
1751
1877
|
socket.destroy();
|
|
1752
1878
|
};
|
|
@@ -1757,6 +1883,62 @@ ${headers}\r
|
|
|
1757
1883
|
}
|
|
1758
1884
|
};
|
|
1759
1885
|
}
|
|
1886
|
+
function startProxyServer(options) {
|
|
1887
|
+
return new Promise((resolve5, reject) => {
|
|
1888
|
+
let open = 0;
|
|
1889
|
+
const handler = createProxyHandler({
|
|
1890
|
+
...options,
|
|
1891
|
+
onOpen: () => {
|
|
1892
|
+
open += 1;
|
|
1893
|
+
options.onOpen?.();
|
|
1894
|
+
},
|
|
1895
|
+
onClose: () => {
|
|
1896
|
+
open = Math.max(0, open - 1);
|
|
1897
|
+
options.onClose?.();
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1900
|
+
const server = http.createServer((req, res) => {
|
|
1901
|
+
const address = server.address();
|
|
1902
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
1903
|
+
handler.request(req, res, `http://127.0.0.1:${port}`);
|
|
1904
|
+
});
|
|
1905
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1906
|
+
server.on("connection", (socket) => {
|
|
1907
|
+
sockets.add(socket);
|
|
1908
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1909
|
+
});
|
|
1910
|
+
server.on("upgrade", (req, socket, head) => handler.upgrade(req, socket, head));
|
|
1911
|
+
const onError = (error) => {
|
|
1912
|
+
server.removeListener("listening", onListening);
|
|
1913
|
+
reject(error);
|
|
1914
|
+
};
|
|
1915
|
+
const onListening = () => {
|
|
1916
|
+
server.removeListener("error", onError);
|
|
1917
|
+
const address = server.address();
|
|
1918
|
+
const port = typeof address === "object" && address !== null ? address.port : 0;
|
|
1919
|
+
let closed = null;
|
|
1920
|
+
resolve5({
|
|
1921
|
+
active: () => open > 0,
|
|
1922
|
+
close: () => {
|
|
1923
|
+
if (closed !== null)
|
|
1924
|
+
return closed;
|
|
1925
|
+
closed = new Promise((done) => {
|
|
1926
|
+
for (const socket of sockets)
|
|
1927
|
+
socket.destroy();
|
|
1928
|
+
sockets.clear();
|
|
1929
|
+
server.closeAllConnections();
|
|
1930
|
+
server.close(() => done());
|
|
1931
|
+
});
|
|
1932
|
+
return closed;
|
|
1933
|
+
},
|
|
1934
|
+
url: `http://127.0.0.1:${port}`
|
|
1935
|
+
});
|
|
1936
|
+
};
|
|
1937
|
+
server.once("error", onError);
|
|
1938
|
+
server.once("listening", onListening);
|
|
1939
|
+
server.listen(0, "127.0.0.1");
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1760
1942
|
|
|
1761
1943
|
// ../server/dist/browser.js
|
|
1762
1944
|
import { randomBytes } from "crypto";
|
|
@@ -3121,6 +3303,141 @@ async function startAppProcess(options) {
|
|
|
3121
3303
|
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.`);
|
|
3122
3304
|
}
|
|
3123
3305
|
|
|
3306
|
+
// ../server/dist/branches.js
|
|
3307
|
+
var BRANCH_IDLE_MS = 10 * 60 * 1e3;
|
|
3308
|
+
var BRANCH_SWEEP_MS = 3e4;
|
|
3309
|
+
function publicBranchState(state) {
|
|
3310
|
+
if (state.status === "ready")
|
|
3311
|
+
return { status: "ready" };
|
|
3312
|
+
return state;
|
|
3313
|
+
}
|
|
3314
|
+
function createBranchRegistry(options) {
|
|
3315
|
+
const states = new Map(options.previews.map((preview) => [preview.title, { status: "idle" }]));
|
|
3316
|
+
const previews = new Map(options.previews.map((preview) => [preview.title, preview]));
|
|
3317
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
3318
|
+
const stopping = /* @__PURE__ */ new Map();
|
|
3319
|
+
const lastActivity = /* @__PURE__ */ new Map();
|
|
3320
|
+
const proxies = /* @__PURE__ */ new Map();
|
|
3321
|
+
const boot = options.startWorktree ?? startWorktree;
|
|
3322
|
+
const proxy = options.startProxy ?? startProxyServer;
|
|
3323
|
+
let closed = false;
|
|
3324
|
+
let stopPromise = null;
|
|
3325
|
+
const transition = (title, state) => {
|
|
3326
|
+
const previous = states.get(title);
|
|
3327
|
+
if (previous?.status === "starting" && state.status === "starting" && previous.phase === state.phase) {
|
|
3328
|
+
return previous;
|
|
3329
|
+
}
|
|
3330
|
+
states.set(title, state);
|
|
3331
|
+
options.onChange?.(title, state);
|
|
3332
|
+
return state;
|
|
3333
|
+
};
|
|
3334
|
+
const stopReady = (title, state, toIdle) => {
|
|
3335
|
+
const current = stopping.get(title);
|
|
3336
|
+
if (current !== void 0)
|
|
3337
|
+
return current;
|
|
3338
|
+
const pending = (async () => {
|
|
3339
|
+
await proxies.get(title)?.close().catch(() => {
|
|
3340
|
+
});
|
|
3341
|
+
await state.worktree.stop().catch(() => {
|
|
3342
|
+
});
|
|
3343
|
+
proxies.delete(title);
|
|
3344
|
+
lastActivity.delete(title);
|
|
3345
|
+
if (toIdle && !closed && states.get(title) === state) {
|
|
3346
|
+
transition(title, { status: "idle" });
|
|
3347
|
+
}
|
|
3348
|
+
})().finally(() => {
|
|
3349
|
+
stopping.delete(title);
|
|
3350
|
+
});
|
|
3351
|
+
stopping.set(title, pending);
|
|
3352
|
+
return pending;
|
|
3353
|
+
};
|
|
3354
|
+
const sweep = () => {
|
|
3355
|
+
const now = Date.now();
|
|
3356
|
+
for (const [title, state] of states) {
|
|
3357
|
+
const branchProxy = proxies.get(title);
|
|
3358
|
+
if (state.status !== "ready" || branchProxy === void 0 || stopping.has(title))
|
|
3359
|
+
continue;
|
|
3360
|
+
if (branchProxy.active()) {
|
|
3361
|
+
lastActivity.set(title, now);
|
|
3362
|
+
continue;
|
|
3363
|
+
}
|
|
3364
|
+
const seen = lastActivity.get(title) ?? now;
|
|
3365
|
+
if (now - seen >= BRANCH_IDLE_MS)
|
|
3366
|
+
void stopReady(title, state, true);
|
|
3367
|
+
}
|
|
3368
|
+
};
|
|
3369
|
+
const sweepTimer = setInterval(sweep, BRANCH_SWEEP_MS);
|
|
3370
|
+
sweepTimer.unref();
|
|
3371
|
+
const begin = (title) => {
|
|
3372
|
+
const preview = previews.get(title);
|
|
3373
|
+
const current = states.get(title);
|
|
3374
|
+
if (preview === void 0 || current === void 0 || closed)
|
|
3375
|
+
return void 0;
|
|
3376
|
+
if (current.status === "starting")
|
|
3377
|
+
return inflight.get(title);
|
|
3378
|
+
if (current.status === "ready")
|
|
3379
|
+
return Promise.resolve(current);
|
|
3380
|
+
transition(title, { status: "starting", phase: "checking out" });
|
|
3381
|
+
let checkout;
|
|
3382
|
+
try {
|
|
3383
|
+
checkout = Promise.resolve(boot({
|
|
3384
|
+
cwd: options.cwd,
|
|
3385
|
+
branch: preview.branch,
|
|
3386
|
+
installCommand: options.installCommand,
|
|
3387
|
+
devCommand: options.devCommand ?? "",
|
|
3388
|
+
onLog: (line) => {
|
|
3389
|
+
transition(title, {
|
|
3390
|
+
status: "starting",
|
|
3391
|
+
phase: line.startsWith("installing ") ? "installing" : "starting"
|
|
3392
|
+
});
|
|
3393
|
+
}
|
|
3394
|
+
}));
|
|
3395
|
+
} catch (error) {
|
|
3396
|
+
checkout = Promise.reject(error);
|
|
3397
|
+
}
|
|
3398
|
+
const starting = checkout.then(async (worktree) => {
|
|
3399
|
+
transition(title, { status: "starting", phase: "starting" });
|
|
3400
|
+
try {
|
|
3401
|
+
const branchProxy = await proxy({
|
|
3402
|
+
target: worktree.url,
|
|
3403
|
+
onActivity: () => lastActivity.set(title, Date.now())
|
|
3404
|
+
});
|
|
3405
|
+
proxies.set(title, branchProxy);
|
|
3406
|
+
lastActivity.set(title, Date.now());
|
|
3407
|
+
return transition(title, { status: "ready", worktree });
|
|
3408
|
+
} catch (error) {
|
|
3409
|
+
await worktree.stop().catch(() => {
|
|
3410
|
+
});
|
|
3411
|
+
throw error;
|
|
3412
|
+
}
|
|
3413
|
+
}).catch((error) => transition(title, {
|
|
3414
|
+
status: "failed",
|
|
3415
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
3416
|
+
})).finally(() => {
|
|
3417
|
+
inflight.delete(title);
|
|
3418
|
+
});
|
|
3419
|
+
inflight.set(title, starting);
|
|
3420
|
+
return starting;
|
|
3421
|
+
};
|
|
3422
|
+
return {
|
|
3423
|
+
state: (title) => states.get(title),
|
|
3424
|
+
url: (title) => proxies.get(title)?.url,
|
|
3425
|
+
start: begin,
|
|
3426
|
+
stop: () => {
|
|
3427
|
+
if (stopPromise !== null)
|
|
3428
|
+
return stopPromise;
|
|
3429
|
+
closed = true;
|
|
3430
|
+
clearInterval(sweepTimer);
|
|
3431
|
+
stopPromise = Promise.allSettled([...inflight.values()]).then(async () => {
|
|
3432
|
+
await Promise.allSettled([...stopping.values()]);
|
|
3433
|
+
const ready = [...states.entries()].filter((entry) => entry[1].status === "ready" && proxies.has(entry[0]));
|
|
3434
|
+
await Promise.all(ready.map(([title, state]) => stopReady(title, state, false)));
|
|
3435
|
+
});
|
|
3436
|
+
return stopPromise;
|
|
3437
|
+
}
|
|
3438
|
+
};
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3124
3441
|
// ../server/dist/failure.js
|
|
3125
3442
|
var NEEDS_TRUST = /not inside a trusted directory|--skip-git-repo-check/i;
|
|
3126
3443
|
var MISSING_BINARY = /\b(ENOENT|EACCES|ENOTDIR)\b/;
|
|
@@ -5211,6 +5528,7 @@ function resolveTitle(input, titles, renames) {
|
|
|
5211
5528
|
|
|
5212
5529
|
// ../server/dist/server.js
|
|
5213
5530
|
import { createReadStream, existsSync as existsSync3, statSync, unwatchFile, watch as watchFs, watchFile } from "fs";
|
|
5531
|
+
import { createHash as createHash2 } from "crypto";
|
|
5214
5532
|
import { mkdir as mkdir8, readdir as readdir3, writeFile as writeFile8 } from "fs/promises";
|
|
5215
5533
|
import http2 from "http";
|
|
5216
5534
|
import net3 from "net";
|
|
@@ -5289,6 +5607,30 @@ function sendJson(res, status, body) {
|
|
|
5289
5607
|
});
|
|
5290
5608
|
res.end(payload);
|
|
5291
5609
|
}
|
|
5610
|
+
function etagMatches(value, etag) {
|
|
5611
|
+
if (value === void 0)
|
|
5612
|
+
return false;
|
|
5613
|
+
const values = Array.isArray(value) ? value : [value];
|
|
5614
|
+
return values.some((header) => header.split(",").some((candidate) => {
|
|
5615
|
+
const tag = candidate.trim();
|
|
5616
|
+
return tag === "*" || tag === etag || tag === `W/${etag}`;
|
|
5617
|
+
}));
|
|
5618
|
+
}
|
|
5619
|
+
function sendConditionalJson(req, res, body) {
|
|
5620
|
+
const payload = JSON.stringify(body);
|
|
5621
|
+
const etag = `"${createHash2("sha256").update(payload).digest("base64url")}"`;
|
|
5622
|
+
if (etagMatches(req.headers["if-none-match"], etag)) {
|
|
5623
|
+
res.writeHead(304, { etag, "cache-control": "private, no-cache" });
|
|
5624
|
+
res.end();
|
|
5625
|
+
return;
|
|
5626
|
+
}
|
|
5627
|
+
res.writeHead(200, {
|
|
5628
|
+
"content-type": "application/json; charset=utf-8",
|
|
5629
|
+
"cache-control": "private, no-cache",
|
|
5630
|
+
etag
|
|
5631
|
+
});
|
|
5632
|
+
res.end(payload);
|
|
5633
|
+
}
|
|
5292
5634
|
var CAPTURE_DEADLINE_MS = 15e3;
|
|
5293
5635
|
var CAPTURE_LOAD_MS = Math.floor(CAPTURE_DEADLINE_MS * LOAD_SHARE);
|
|
5294
5636
|
function captureSlug(title) {
|
|
@@ -5668,6 +6010,33 @@ async function startServer(options) {
|
|
|
5668
6010
|
const { config, configErrors = [], configWarnings = [], shellDir = null, project = "", cwd = process.cwd(), leglasCommand = "npx -y leglas", fileMounts = /* @__PURE__ */ new Map(), detect = () => detectAgents() } = options;
|
|
5669
6011
|
const browserPool = options.pool ?? createBrowserPool();
|
|
5670
6012
|
const live = options.live ?? createLiveHub();
|
|
6013
|
+
const branches = createBranchRegistry({
|
|
6014
|
+
cwd,
|
|
6015
|
+
previews: (config?.previews ?? []).flatMap((preview) => preview.branch === void 0 ? [] : [{ title: preview.title, branch: preview.branch }]),
|
|
6016
|
+
installCommand: config?.installCommand ?? DEFAULT_INSTALL_COMMAND,
|
|
6017
|
+
devCommand: config?.devCommand,
|
|
6018
|
+
onChange: () => live.nudge("config"),
|
|
6019
|
+
...options.startWorktree === void 0 ? {} : { startWorktree: options.startWorktree }
|
|
6020
|
+
});
|
|
6021
|
+
const previewForConfig = (preview) => {
|
|
6022
|
+
if (preview.branch === void 0)
|
|
6023
|
+
return preview;
|
|
6024
|
+
const state = branches.state(preview.title) ?? { status: "idle" };
|
|
6025
|
+
const { url: route, ...withoutUrl } = preview;
|
|
6026
|
+
const branchUrl = branches.url(preview.title);
|
|
6027
|
+
return state.status === "ready" ? {
|
|
6028
|
+
...withoutUrl,
|
|
6029
|
+
url: `${branchUrl ?? state.worktree.url}${route}`,
|
|
6030
|
+
state: publicBranchState(state)
|
|
6031
|
+
} : { ...withoutUrl, state: publicBranchState(state) };
|
|
6032
|
+
};
|
|
6033
|
+
const previewsForConfig = (previews) => previews.map(previewForConfig);
|
|
6034
|
+
const readyPreview = (preview) => {
|
|
6035
|
+
if (preview.branch === void 0)
|
|
6036
|
+
return preview;
|
|
6037
|
+
const state = branches.state(preview.title);
|
|
6038
|
+
return state?.status === "ready" ? { ...preview, url: `${branches.url(preview.title) ?? state.worktree.url}${preview.url}` } : null;
|
|
6039
|
+
};
|
|
5671
6040
|
if (options.pool === void 0) {
|
|
5672
6041
|
void reapOrphanedBrowsers().catch(() => {
|
|
5673
6042
|
});
|
|
@@ -5701,7 +6070,7 @@ async function startServer(options) {
|
|
|
5701
6070
|
}
|
|
5702
6071
|
return Promise.resolve(agentsCache.agents);
|
|
5703
6072
|
};
|
|
5704
|
-
const
|
|
6073
|
+
const livePreviewDefinitions = async () => {
|
|
5705
6074
|
const localRead = await readLocalPreviews(cwd).catch(() => null);
|
|
5706
6075
|
const local = localRead?.errors.length === 0 ? localRead.previews : [];
|
|
5707
6076
|
const localTitles = new Set(local.map((entry) => entry.title));
|
|
@@ -5711,6 +6080,7 @@ async function startServer(options) {
|
|
|
5711
6080
|
const fresh = local.filter((entry) => !known.has(entry.title) && entry.branch === void 0 && entry.file === void 0);
|
|
5712
6081
|
return [...boot, ...fresh];
|
|
5713
6082
|
};
|
|
6083
|
+
const livePreviews = async () => (await livePreviewDefinitions()).map(readyPreview).filter((preview) => preview !== null);
|
|
5714
6084
|
void probeAgents().catch(() => {
|
|
5715
6085
|
});
|
|
5716
6086
|
const server = http2.createServer((req, res) => {
|
|
@@ -5728,11 +6098,11 @@ async function startServer(options) {
|
|
|
5728
6098
|
errors.push(notice);
|
|
5729
6099
|
return void readLocalPreviews(cwd).then(({ previews: local, errors: localErrors }) => {
|
|
5730
6100
|
if (localErrors.length > 0) {
|
|
5731
|
-
return
|
|
6101
|
+
return sendConditionalJson(req, res, {
|
|
5732
6102
|
project,
|
|
5733
6103
|
devServer: target,
|
|
5734
6104
|
scanPreviews: config?.scanPreviews ?? true,
|
|
5735
|
-
previews: boot,
|
|
6105
|
+
previews: previewsForConfig(boot),
|
|
5736
6106
|
errors,
|
|
5737
6107
|
warnings: configWarnings
|
|
5738
6108
|
});
|
|
@@ -5741,23 +6111,58 @@ async function startServer(options) {
|
|
|
5741
6111
|
const currentBoot = boot.filter((preview) => preview.local !== true || localTitles.has(preview.title));
|
|
5742
6112
|
const known = new Set(currentBoot.map((preview) => preview.title));
|
|
5743
6113
|
const fresh = local.filter((preview) => !known.has(preview.title) && preview.branch === void 0 && preview.file === void 0);
|
|
5744
|
-
|
|
6114
|
+
sendConditionalJson(req, res, {
|
|
5745
6115
|
project,
|
|
5746
6116
|
devServer: target,
|
|
5747
6117
|
scanPreviews: config?.scanPreviews ?? true,
|
|
5748
|
-
previews: [...currentBoot, ...fresh],
|
|
6118
|
+
previews: previewsForConfig([...currentBoot, ...fresh]),
|
|
5749
6119
|
errors,
|
|
5750
6120
|
warnings: configWarnings
|
|
5751
6121
|
});
|
|
5752
|
-
}).catch(() =>
|
|
6122
|
+
}).catch(() => sendConditionalJson(req, res, {
|
|
5753
6123
|
project,
|
|
5754
6124
|
devServer: target,
|
|
5755
6125
|
scanPreviews: config?.scanPreviews ?? true,
|
|
5756
|
-
previews: boot,
|
|
6126
|
+
previews: previewsForConfig(boot),
|
|
5757
6127
|
errors,
|
|
5758
6128
|
warnings: configWarnings
|
|
5759
6129
|
}));
|
|
5760
6130
|
}
|
|
6131
|
+
if (path === `${LEGLAS_PREFIX}/api/previews/start` && req.method === "POST") {
|
|
6132
|
+
let body = "";
|
|
6133
|
+
req.on("data", (chunk) => body += chunk);
|
|
6134
|
+
return void req.on("end", async () => {
|
|
6135
|
+
const parsed = jsonBody(body);
|
|
6136
|
+
if (parsed === null) {
|
|
6137
|
+
return sendJson(res, 400, { ok: false, error: "Body must be JSON." });
|
|
6138
|
+
}
|
|
6139
|
+
if (typeof parsed.title !== "string" || parsed.title.trim() === "") {
|
|
6140
|
+
return sendJson(res, 400, { ok: false, error: "Body needs a direction title." });
|
|
6141
|
+
}
|
|
6142
|
+
const preview = (await livePreviewDefinitions()).find((entry) => entry.title === parsed.title);
|
|
6143
|
+
if (preview === void 0) {
|
|
6144
|
+
return sendJson(res, 404, { ok: false, error: "No such direction." });
|
|
6145
|
+
}
|
|
6146
|
+
if (preview.branch === void 0) {
|
|
6147
|
+
return sendJson(res, 400, {
|
|
6148
|
+
ok: false,
|
|
6149
|
+
error: `"${preview.title}" is not a branch preview.`
|
|
6150
|
+
});
|
|
6151
|
+
}
|
|
6152
|
+
if (config?.devCommand === void 0) {
|
|
6153
|
+
return sendJson(res, 400, {
|
|
6154
|
+
ok: false,
|
|
6155
|
+
error: `"${preview.title}" cannot start because the config sets no devCommand.`
|
|
6156
|
+
});
|
|
6157
|
+
}
|
|
6158
|
+
void branches.start(preview.title);
|
|
6159
|
+
const state = branches.state(preview.title);
|
|
6160
|
+
if (state === void 0) {
|
|
6161
|
+
return sendJson(res, 404, { ok: false, error: "No such branch preview." });
|
|
6162
|
+
}
|
|
6163
|
+
return sendJson(res, 200, { ok: true, state: publicBranchState(state) });
|
|
6164
|
+
});
|
|
6165
|
+
}
|
|
5761
6166
|
if (path === `${LEGLAS_PREFIX}/api/previews/delete` && req.method === "POST") {
|
|
5762
6167
|
let body = "";
|
|
5763
6168
|
req.on("data", (chunk) => body += chunk);
|
|
@@ -6149,7 +6554,7 @@ async function startServer(options) {
|
|
|
6149
6554
|
waiting: null,
|
|
6150
6555
|
failedIds: []
|
|
6151
6556
|
};
|
|
6152
|
-
return void readRequests(cwd).then((requests) =>
|
|
6557
|
+
return void readRequests(cwd).then((requests) => sendConditionalJson(req, res, {
|
|
6153
6558
|
requests: requests.map(({ id, title, intent, mode, status, failure, notes }) => ({
|
|
6154
6559
|
id,
|
|
6155
6560
|
title,
|
|
@@ -6252,7 +6657,7 @@ async function startServer(options) {
|
|
|
6252
6657
|
});
|
|
6253
6658
|
}
|
|
6254
6659
|
if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "GET") {
|
|
6255
|
-
return void readAnnotations(cwd).then((annotations) =>
|
|
6660
|
+
return void readAnnotations(cwd).then((annotations) => sendConditionalJson(req, res, { annotations }));
|
|
6256
6661
|
}
|
|
6257
6662
|
if (path === `${LEGLAS_PREFIX}/api/annotations` && req.method === "POST") {
|
|
6258
6663
|
if (!hasJsonBody(req)) {
|
|
@@ -6378,7 +6783,7 @@ async function startServer(options) {
|
|
|
6378
6783
|
});
|
|
6379
6784
|
}
|
|
6380
6785
|
if (path === `${LEGLAS_PREFIX}/api/health`) {
|
|
6381
|
-
return void probe(target).then((reachable) =>
|
|
6786
|
+
return void probe(target).then((reachable) => sendConditionalJson(req, res, { devServer: target, reachable, cwd }));
|
|
6382
6787
|
}
|
|
6383
6788
|
if (path.startsWith(`${FILES_PREFIX}/`)) {
|
|
6384
6789
|
const rest = path.slice(FILES_PREFIX.length + 1);
|
|
@@ -6452,7 +6857,12 @@ async function startServer(options) {
|
|
|
6452
6857
|
return closePromise;
|
|
6453
6858
|
liveFiles.close();
|
|
6454
6859
|
liveHealth.close();
|
|
6455
|
-
closePromise = Promise.all([
|
|
6860
|
+
closePromise = Promise.all([
|
|
6861
|
+
branches.stop(),
|
|
6862
|
+
runner.stop(),
|
|
6863
|
+
browserPool.close(),
|
|
6864
|
+
live.close()
|
|
6865
|
+
]).then(() => new Promise((done) => {
|
|
6456
6866
|
for (const socket of sockets)
|
|
6457
6867
|
socket.destroy();
|
|
6458
6868
|
sockets.clear();
|
|
@@ -6563,7 +6973,7 @@ async function runInit(options, deps) {
|
|
|
6563
6973
|
|
|
6564
6974
|
// src/run-keep.ts
|
|
6565
6975
|
import { existsSync as existsSync4 } from "fs";
|
|
6566
|
-
import { mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
|
|
6976
|
+
import { copyFile as copyFile2, mkdir as mkdir9, readFile as readFile13, rm as rm5, writeFile as writeFile10 } from "fs/promises";
|
|
6567
6977
|
import { dirname as dirname9, join as join14 } from "path";
|
|
6568
6978
|
|
|
6569
6979
|
// src/resolve-title.ts
|
|
@@ -6591,6 +7001,29 @@ function renameExport(source, to) {
|
|
|
6591
7001
|
to
|
|
6592
7002
|
);
|
|
6593
7003
|
}
|
|
7004
|
+
async function writeLogEntry(options) {
|
|
7005
|
+
const entry = composeEntry({
|
|
7006
|
+
surface: options.surface,
|
|
7007
|
+
won: options.won,
|
|
7008
|
+
previews: options.previews,
|
|
7009
|
+
requests: await readRequests(options.cwd),
|
|
7010
|
+
annotations: await readAnnotations(options.cwd),
|
|
7011
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10)
|
|
7012
|
+
});
|
|
7013
|
+
const dir = join14(options.cwd, options.logDir);
|
|
7014
|
+
await mkdir9(dir, { recursive: true });
|
|
7015
|
+
const file = join14(dir, `${entry.slug}.md`);
|
|
7016
|
+
await writeFile10(file, entry.markdown, "utf8");
|
|
7017
|
+
if (entry.pictures.length > 0) {
|
|
7018
|
+
const pictureDir = join14(dir, entry.slug);
|
|
7019
|
+
await mkdir9(pictureDir, { recursive: true });
|
|
7020
|
+
for (const picture of entry.pictures) {
|
|
7021
|
+
await copyFile2(join14(options.cwd, picture.from), join14(pictureDir, picture.to)).catch(() => {
|
|
7022
|
+
});
|
|
7023
|
+
}
|
|
7024
|
+
}
|
|
7025
|
+
return `${options.logDir}/${entry.slug}.md`;
|
|
7026
|
+
}
|
|
6594
7027
|
async function runKeep(options, deps) {
|
|
6595
7028
|
const loaded = await loadConfig(options.cwd);
|
|
6596
7029
|
const local = await readLocalPreviews(options.cwd);
|
|
@@ -6619,6 +7052,20 @@ async function runKeep(options, deps) {
|
|
|
6619
7052
|
const source = await readFile13(from, "utf8");
|
|
6620
7053
|
await mkdir9(dirname9(to), { recursive: true });
|
|
6621
7054
|
await writeFile10(to, renameExport(source, plan.exportName), "utf8");
|
|
7055
|
+
const surface = plan.removeDir.slice(plan.removeDir.lastIndexOf("/") + 1);
|
|
7056
|
+
let logged = null;
|
|
7057
|
+
let logError = null;
|
|
7058
|
+
try {
|
|
7059
|
+
logged = await writeLogEntry({
|
|
7060
|
+
cwd: options.cwd,
|
|
7061
|
+
logDir: loaded.config?.logDir ?? DEFAULT_LOG_DIR,
|
|
7062
|
+
surface,
|
|
7063
|
+
won: { title: resolved.title, to: plan.move.to },
|
|
7064
|
+
previews: previews.filter((preview) => plan.dropTitles.includes(preview.title))
|
|
7065
|
+
});
|
|
7066
|
+
} catch (error) {
|
|
7067
|
+
logError = error instanceof Error ? error.message : String(error);
|
|
7068
|
+
}
|
|
6622
7069
|
await rm5(join14(options.cwd, plan.removeDir), { recursive: true, force: true });
|
|
6623
7070
|
const dropped = await dropLocalPreviews(options.cwd, plan.dropTitles);
|
|
6624
7071
|
if (options.json) {
|
|
@@ -6630,6 +7077,8 @@ async function runKeep(options, deps) {
|
|
|
6630
7077
|
exportName: plan.exportName,
|
|
6631
7078
|
removed: plan.removeDir,
|
|
6632
7079
|
droppedPreviews: dropped,
|
|
7080
|
+
logged,
|
|
7081
|
+
logError,
|
|
6633
7082
|
instructions: plan.instructions
|
|
6634
7083
|
})
|
|
6635
7084
|
);
|
|
@@ -6637,6 +7086,8 @@ async function runKeep(options, deps) {
|
|
|
6637
7086
|
}
|
|
6638
7087
|
deps.log(` kept ${plan.move.to}`);
|
|
6639
7088
|
deps.log(` removed ${plan.removeDir}`);
|
|
7089
|
+
if (logged !== null) deps.log(` logged ${logged}`);
|
|
7090
|
+
if (logError !== null) deps.error(` The decision log could not be written: ${logError}`);
|
|
6640
7091
|
if (dropped > 0) {
|
|
6641
7092
|
deps.log(` dropped ${dropped} direction${dropped === 1 ? "" : "s"} from the rail`);
|
|
6642
7093
|
}
|
|
@@ -7367,8 +7818,7 @@ async function run3(options, deps) {
|
|
|
7367
7818
|
const local = await readLocalPreviews(options.cwd);
|
|
7368
7819
|
let devServer = options.userPort === void 0 ? loaded.config?.devServer ?? "http://localhost:3000" : `http://localhost:${options.userPort}`;
|
|
7369
7820
|
const merged = loaded.config === null ? null : { ...loaded.config, devServer, previews: [...loaded.config.previews, ...local.previews] };
|
|
7370
|
-
const
|
|
7371
|
-
const worktreeErrors = [];
|
|
7821
|
+
const previewErrors = [];
|
|
7372
7822
|
const previews = [];
|
|
7373
7823
|
let app = null;
|
|
7374
7824
|
const needsApp = (merged?.previews ?? []).some(
|
|
@@ -7385,7 +7835,7 @@ async function run3(options, deps) {
|
|
|
7385
7835
|
devServer = app.url;
|
|
7386
7836
|
merged.devServer = app.url;
|
|
7387
7837
|
} catch (error) {
|
|
7388
|
-
|
|
7838
|
+
previewErrors.push(error instanceof Error ? error.message : String(error));
|
|
7389
7839
|
}
|
|
7390
7840
|
}
|
|
7391
7841
|
const fileMounts = /* @__PURE__ */ new Map();
|
|
@@ -7393,7 +7843,7 @@ async function run3(options, deps) {
|
|
|
7393
7843
|
if (preview.file !== void 0) {
|
|
7394
7844
|
const absolute = join19(options.cwd, preview.file);
|
|
7395
7845
|
if (!existsSync6(absolute)) {
|
|
7396
|
-
|
|
7846
|
+
previewErrors.push(
|
|
7397
7847
|
`"${preview.title}" names file ${preview.file}, which does not exist. The preview is skipped.`
|
|
7398
7848
|
);
|
|
7399
7849
|
continue;
|
|
@@ -7409,29 +7859,7 @@ async function run3(options, deps) {
|
|
|
7409
7859
|
});
|
|
7410
7860
|
continue;
|
|
7411
7861
|
}
|
|
7412
|
-
|
|
7413
|
-
previews.push(preview);
|
|
7414
|
-
continue;
|
|
7415
|
-
}
|
|
7416
|
-
if (merged?.devCommand === void 0) {
|
|
7417
|
-
worktreeErrors.push(
|
|
7418
|
-
`"${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.`
|
|
7419
|
-
);
|
|
7420
|
-
continue;
|
|
7421
|
-
}
|
|
7422
|
-
if (!options.json) deps.log(` starting ${preview.branch}\u2026`);
|
|
7423
|
-
try {
|
|
7424
|
-
const worktree = await startWorktree({
|
|
7425
|
-
cwd: options.cwd,
|
|
7426
|
-
branch: preview.branch,
|
|
7427
|
-
installCommand: merged.installCommand,
|
|
7428
|
-
devCommand: merged.devCommand
|
|
7429
|
-
});
|
|
7430
|
-
worktrees.push(worktree);
|
|
7431
|
-
previews.push({ ...preview, url: `${worktree.url}${preview.url}` });
|
|
7432
|
-
} catch (error) {
|
|
7433
|
-
worktreeErrors.push(error instanceof Error ? error.message : String(error));
|
|
7434
|
-
}
|
|
7862
|
+
previews.push(preview);
|
|
7435
7863
|
}
|
|
7436
7864
|
const config = merged === null ? null : { ...merged, previews };
|
|
7437
7865
|
const configWarnings = [];
|
|
@@ -7441,7 +7869,7 @@ async function run3(options, deps) {
|
|
|
7441
7869
|
const ownerWarning = needsApp && app === null ? inspectLocalDevServer(devServer).then((owners) => devServerOwnerWarning(devServer, projectRoot, owners)).catch(() => null) : Promise.resolve(null);
|
|
7442
7870
|
const serverPromise = startServer({
|
|
7443
7871
|
config,
|
|
7444
|
-
configErrors: [...loaded.errors, ...local.errors, ...
|
|
7872
|
+
configErrors: [...loaded.errors, ...local.errors, ...previewErrors],
|
|
7445
7873
|
configWarnings,
|
|
7446
7874
|
fileMounts,
|
|
7447
7875
|
shellDir: findShellDir(),
|
|
@@ -7480,9 +7908,9 @@ async function run3(options, deps) {
|
|
|
7480
7908
|
);
|
|
7481
7909
|
deps.log(`config ${configLabel}`);
|
|
7482
7910
|
deps.log(` ${previewCount} preview${previewCount === 1 ? "" : "s"}`);
|
|
7483
|
-
if (loaded.errors.length +
|
|
7911
|
+
if (loaded.errors.length + previewErrors.length > 0) {
|
|
7484
7912
|
deps.log("");
|
|
7485
|
-
for (const error of [...loaded.errors, ...
|
|
7913
|
+
for (const error of [...loaded.errors, ...previewErrors]) deps.log(` ! ${error}`);
|
|
7486
7914
|
deps.log(" Fix the config and reload; Leglas will pick it up on restart.");
|
|
7487
7915
|
}
|
|
7488
7916
|
if (configWarnings.length > 0) {
|
|
@@ -7505,8 +7933,6 @@ async function run3(options, deps) {
|
|
|
7505
7933
|
devServer,
|
|
7506
7934
|
previewCount,
|
|
7507
7935
|
stop: async () => {
|
|
7508
|
-
await Promise.all(worktrees.map((worktree) => worktree.stop().catch(() => {
|
|
7509
|
-
})));
|
|
7510
7936
|
await app?.stop().catch(() => {
|
|
7511
7937
|
});
|
|
7512
7938
|
await server.close();
|