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/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(`![${preview.title}](${slug}/${name})`);
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
- const upstream = http.request({ host, port, method: req.method, path: req.url, headers: upstreamHeaders(req) }, (upstreamRes) => {
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
- const upstream = net.connect(port, host, () => {
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 livePreviews = async () => {
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 sendJson(res, 200, {
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
- sendJson(res, 200, {
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(() => sendJson(res, 200, {
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) => sendJson(res, 200, {
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) => sendJson(res, 200, { 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) => sendJson(res, 200, { devServer: target, reachable, cwd }));
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([runner.stop(), browserPool.close(), live.close()]).then(() => new Promise((done) => {
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 worktrees = [];
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
- worktreeErrors.push(error instanceof Error ? error.message : String(error));
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
- worktreeErrors.push(
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
- if (preview.branch === void 0) {
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, ...worktreeErrors],
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 + worktreeErrors.length > 0) {
7911
+ if (loaded.errors.length + previewErrors.length > 0) {
7484
7912
  deps.log("");
7485
- for (const error of [...loaded.errors, ...worktreeErrors]) deps.log(` ! ${error}`);
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();