foss-design 0.8.1 → 0.9.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/cli.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/index.ts
4
- import path30 from "path";
4
+ import path34 from "path";
5
5
  import { parseArgs } from "util";
6
6
 
7
7
  // src/core/paths.ts
@@ -346,8 +346,8 @@ function routeError(pattern) {
346
346
  }
347
347
  return null;
348
348
  }
349
- function split(path31) {
350
- return path31.split("/").filter(Boolean);
349
+ function split(path35) {
350
+ return path35.split("/").filter(Boolean);
351
351
  }
352
352
  function compileRoute(pattern) {
353
353
  return {
@@ -366,8 +366,8 @@ function decode2(part) {
366
366
  return part;
367
367
  }
368
368
  }
369
- function scoreRoute(route2, path31) {
370
- const parts = split(path31.split(/[?#]/)[0] ?? "").map(decode2);
369
+ function scoreRoute(route2, path35) {
370
+ const parts = split(path35.split(/[?#]/)[0] ?? "").map(decode2);
371
371
  const walk2 = (s, p) => {
372
372
  if (s === route2.segments.length) return p === parts.length ? 0 : null;
373
373
  const segment = route2.segments[s];
@@ -385,11 +385,11 @@ function scoreRoute(route2, path31) {
385
385
  };
386
386
  return walk2(0, 0);
387
387
  }
388
- function matchRoute(entries, path31) {
388
+ function matchRoute(entries, path35) {
389
389
  let best = null;
390
390
  for (const entry of entries) {
391
391
  for (const route2 of entry.routes) {
392
- const score = scoreRoute(route2, path31);
392
+ const score = scoreRoute(route2, path35);
393
393
  if (score !== null && (!best || score > best.score)) best = { score, value: entry.value };
394
394
  }
395
395
  }
@@ -511,12 +511,14 @@ var CanvasSchema = z.strictObject({
511
511
  system: z.boolean().optional(),
512
512
  /** Theme screens open in (default follows the viewer). */
513
513
  theme: theme.optional(),
514
+ /** The canvas's picture in lists: a screen id, or an image in the canvas folder (PNG, JPEG, WebP, SVG, 512 KB). */
515
+ cover: z.string().min(1).optional(),
514
516
  pages: z.array(z.unknown()).min(1, "add at least one page")
515
517
  });
516
518
  var ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/i;
517
- function formatPath(base, path31) {
519
+ function formatPath(base, path35) {
518
520
  let out = base;
519
- for (const part of path31) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
521
+ for (const part of path35) out += typeof part === "number" ? `[${part}]` : out ? `.${String(part)}` : String(part);
520
522
  return out;
521
523
  }
522
524
  function describeZodError(error, base = "") {
@@ -754,8 +756,37 @@ function loadCanvas(paths, canvasId, urls, snapshots) {
754
756
  doc.pages.push(out);
755
757
  });
756
758
  checkNavigation(doc, canvasCodeFiles(dir, screens), issues, fileRel, (file2) => relToRoot(paths, file2));
759
+ if (top.success && top.data.cover) doc.cover = resolveCover(top.data.cover, dir, screens, paths, urls, report);
757
760
  return { doc, screens };
758
761
  }
762
+ var MAX_COVER_BYTES = 512 * 1024;
763
+ var COVER_IMAGE = /\.(png|jpe?g|webp|svg)$/i;
764
+ function resolveCover(cover, dir, screens, paths, urls, report) {
765
+ if (screens.some((screen) => screen.id === cover)) return { screen: cover };
766
+ const file = path3.resolve(dir, cover);
767
+ if (!isInside(dir, file) || !COVER_IMAGE.test(file)) {
768
+ report(
769
+ "error",
770
+ `cover "${cover}" is neither a screen id nor a PNG, JPEG, WebP or SVG in the canvas folder`,
771
+ "cover"
772
+ );
773
+ return void 0;
774
+ }
775
+ if (!fs3.existsSync(file)) {
776
+ report("error", `cover "${cover}" does not exist`, "cover");
777
+ return void 0;
778
+ }
779
+ const { size: size2, mtimeMs } = fs3.statSync(file);
780
+ if (size2 > MAX_COVER_BYTES) {
781
+ report(
782
+ "error",
783
+ `cover "${cover}" is ${Math.ceil(size2 / 1024)} KB; the limit is ${MAX_COVER_BYTES / 1024} KB`,
784
+ "cover"
785
+ );
786
+ return void 0;
787
+ }
788
+ return { url: `${urls.file(relToDesign(paths, file))}?v=${Math.round(mtimeMs)}` };
789
+ }
759
790
  var GO_IMPORT = /import\s*\{[^}]*\bgo\b[^}]*\}\s*from\s*['"]@design\/runtime['"]/;
760
791
  var GO_USE = /\bgo\s*\(/g;
761
792
  var GO_REMOVED = 'go() is no longer supported: link to the screen instead (<a href="/verify">, or history.pushState in code) and give that screen a "route" in canvas.json';
@@ -820,7 +851,8 @@ function checkNavigation(doc, codeFiles, issues, canvasFile, rel) {
820
851
  function summarize(canvas) {
821
852
  const { doc } = canvas;
822
853
  const screens = doc.pages.flatMap((page) => page.sections.flatMap((section) => section.items));
823
- const firstScreen = screens.find((item) => item.kind === "screen" && item.snapshots);
854
+ const chosen = doc.cover && "screen" in doc.cover ? doc.cover.screen : null;
855
+ const firstScreen = chosen ? screens.find((item) => item.kind === "screen" && item.id === chosen) : screens.find((item) => item.kind === "screen" && item.snapshots);
824
856
  return {
825
857
  id: doc.id,
826
858
  title: doc.title,
@@ -829,19 +861,21 @@ function summarize(canvas) {
829
861
  screens: screens.filter((item) => item.kind === "screen" || item.kind === "url").length,
830
862
  updatedAt: doc.updatedAt,
831
863
  issues: doc.issues.filter((issue) => issue.severity === "error").length,
832
- cover: firstScreen && firstScreen.kind === "screen" ? firstScreen.snapshots?.light ?? firstScreen.snapshots?.dark : void 0
864
+ cover: doc.cover && "url" in doc.cover ? doc.cover.url : firstScreen && firstScreen.kind === "screen" ? firstScreen.snapshots?.light ?? firstScreen.snapshots?.dark : void 0
833
865
  };
834
866
  }
835
867
 
836
868
  // src/core/icon.ts
869
+ import { createHash as createHash2 } from "crypto";
837
870
  import fs4 from "fs";
838
871
  import path4 from "path";
839
872
  var ICON_EXTENSIONS = ["svg", "png", "webp"];
840
873
  var MAX_ICON_BYTES = 256 * 1024;
841
874
  var iconName = (ext) => `icon.${ext}`;
842
875
  function iconFile(paths) {
876
+ migrateIcon(paths);
843
877
  for (const ext of ICON_EXTENSIONS) {
844
- const file = path4.join(paths.system, iconName(ext));
878
+ const file = path4.join(paths.design, iconName(ext));
845
879
  if (fs4.existsSync(file) && fs4.statSync(file).isFile()) return file;
846
880
  }
847
881
  return null;
@@ -864,21 +898,33 @@ function iconProblem(data, ext) {
864
898
  return null;
865
899
  }
866
900
  function writeIcon(paths, data) {
867
- for (const ext2 of ICON_EXTENSIONS) fs4.rmSync(path4.join(paths.system, iconName(ext2)), { force: true });
901
+ for (const ext2 of ICON_EXTENSIONS) fs4.rmSync(path4.join(paths.design, iconName(ext2)), { force: true });
868
902
  if (!data) return null;
869
903
  const ext = sniffIcon(data);
870
- fs4.mkdirSync(paths.system, { recursive: true });
871
- const file = path4.join(paths.system, iconName(ext));
904
+ fs4.mkdirSync(paths.design, { recursive: true });
905
+ const file = path4.join(paths.design, iconName(ext));
872
906
  fs4.writeFileSync(file, data);
873
907
  return file;
874
908
  }
875
- function onlyIcon(dir) {
909
+ function migrateIcon(paths) {
910
+ for (const ext of ICON_EXTENSIONS) {
911
+ const old = path4.join(paths.system, iconName(ext));
912
+ if (!fs4.existsSync(old)) continue;
913
+ const target = path4.join(paths.design, iconName(ext));
914
+ if (ICON_EXTENSIONS.some((other) => fs4.existsSync(path4.join(paths.design, iconName(other))))) fs4.rmSync(old);
915
+ else fs4.renameSync(old, target);
916
+ }
876
917
  try {
877
- return fs4.readdirSync(dir).filter((name) => !name.startsWith(".")).every((name) => ICON_EXTENSIONS.some((ext) => name === iconName(ext)));
918
+ fs4.rmdirSync(paths.system);
878
919
  } catch {
879
- return false;
880
920
  }
881
921
  }
922
+ var iconHash = (file) => createHash2("sha256").update(fs4.readFileSync(file)).digest("hex");
923
+ var ICON_TYPES = {
924
+ svg: "image/svg+xml",
925
+ png: "image/png",
926
+ webp: "image/webp"
927
+ };
882
928
 
883
929
  // src/core/public.ts
884
930
  import fs5 from "fs";
@@ -1251,7 +1297,7 @@ function guessSources(componentsDir, stem) {
1251
1297
  return [];
1252
1298
  }
1253
1299
  function loadSystem(paths, urls) {
1254
- if (!fs7.existsSync(paths.system) || onlyIcon(paths.system)) return null;
1300
+ if (!fs7.existsSync(paths.system)) return null;
1255
1301
  const issues = [];
1256
1302
  const configFile = path7.join(paths.system, "system.json");
1257
1303
  const report = (severity, file, message, at) => issues.push({ severity, file: relToRoot(paths, file), message, at });
@@ -1480,6 +1526,11 @@ var DesignProject = class {
1480
1526
  return this.publicCache?.set.has(rel) ?? false;
1481
1527
  }
1482
1528
  name() {
1529
+ try {
1530
+ const linked = JSON.parse(fs8.readFileSync(path8.join(this.paths.design, "cloud.json"), "utf8")).name;
1531
+ if (typeof linked === "string" && linked) return linked;
1532
+ } catch {
1533
+ }
1483
1534
  const configured = this.config().config.name;
1484
1535
  if (configured) return configured;
1485
1536
  try {
@@ -1902,7 +1953,7 @@ function linkShippedPackages(paths, appDir = null) {
1902
1953
  }
1903
1954
 
1904
1955
  // src/server/vite.ts
1905
- import { createHash as createHash2 } from "crypto";
1956
+ import { createHash as createHash3 } from "crypto";
1906
1957
  import fs11 from "fs";
1907
1958
  import path11 from "path";
1908
1959
  import tailwindcss from "@tailwindcss/vite";
@@ -1928,7 +1979,7 @@ function dropStaleDepCache(paths, appDir = null) {
1928
1979
  const stamp = path11.join(paths.cache, "vite-runtime");
1929
1980
  let runtime = "";
1930
1981
  try {
1931
- runtime = createHash2("sha256").update(fs11.readFileSync(PKG.runtime)).digest("hex");
1982
+ runtime = createHash3("sha256").update(fs11.readFileSync(PKG.runtime)).digest("hex");
1932
1983
  } catch {
1933
1984
  }
1934
1985
  if (appDir) runtime += ` ${appDir}`;
@@ -2127,12 +2178,14 @@ async function buildSite(paths, out, options = {}) {
2127
2178
  }
2128
2179
  const files = /* @__PURE__ */ new Set();
2129
2180
  for (const canvas of canvases) {
2181
+ const cover = canvas.doc.cover && "url" in canvas.doc.cover ? canvas.doc.cover.url.split("?")[0] : null;
2182
+ if (cover?.startsWith("_f/")) files.add(cover);
2130
2183
  for (const page of canvas.doc.pages)
2131
2184
  for (const section of page.sections)
2132
2185
  for (const item of section.items) if (item.kind === "image" && item.url.startsWith("_f/")) files.add(item.url);
2133
2186
  }
2134
2187
  for (const asset of system?.doc.assets ?? []) files.add(asset.url);
2135
- const icon = project.iconUrl();
2188
+ const icon = options.includeViewer === false ? null : project.iconUrl();
2136
2189
  if (icon) files.add(icon.split("?")[0]);
2137
2190
  for (const url of files) {
2138
2191
  const rel = url.slice("_f/".length).split("/").map(decodeURIComponent).join("/");
@@ -2308,12 +2361,12 @@ async function runBuild(paths, canvases, options) {
2308
2361
  import os from "os";
2309
2362
 
2310
2363
  // src/cloud/units.ts
2311
- import { createHash as createHash3 } from "crypto";
2364
+ import { createHash as createHash4 } from "crypto";
2312
2365
  import fs14 from "fs";
2313
2366
  import path14 from "path";
2314
2367
  var SYSTEM_UNIT = "system";
2315
2368
  var CANVAS_PREFIX = "canvas/";
2316
- var LOCAL_ONLY = /* @__PURE__ */ new Set(["cloud.json", "node_modules"]);
2369
+ var LOCAL_ONLY = /* @__PURE__ */ new Set(["cloud.json", "node_modules", "icon.svg", "icon.png", "icon.webp"]);
2317
2370
  var canvasUnit = (id2) => `${CANVAS_PREFIX}${id2}`;
2318
2371
  function unitCanvasId(key) {
2319
2372
  return key.startsWith(CANVAS_PREFIX) ? key.slice(CANVAS_PREFIX.length) : null;
@@ -2340,10 +2393,6 @@ function unitRoot(paths, key) {
2340
2393
  const id2 = unitCanvasId(key);
2341
2394
  return id2 === null ? paths.design : path14.join(paths.canvases, id2);
2342
2395
  }
2343
- function unitRelative(key, rel) {
2344
- const id2 = unitCanvasId(key);
2345
- return id2 === null ? rel : rel.slice(`${CANVAS_PREFIX}${id2}/`.length);
2346
- }
2347
2396
  function isUnitPath(key, rel) {
2348
2397
  if (!rel || rel.includes("\\") || rel.includes("\0") || rel.startsWith("/")) return false;
2349
2398
  const segments = rel.split("/");
@@ -2353,7 +2402,7 @@ function isUnitPath(key, rel) {
2353
2402
  return id2 !== null && segments[0] === "canvas" && segments[1] === id2 && segments.length > 2;
2354
2403
  }
2355
2404
  function hashBuffer(data) {
2356
- return createHash3("sha256").update(data).digest("hex");
2405
+ return createHash4("sha256").update(data).digest("hex");
2357
2406
  }
2358
2407
  function hashFile(file) {
2359
2408
  const data = fs14.readFileSync(file);
@@ -2526,7 +2575,8 @@ function parseProject(value, fallbackRole) {
2526
2575
  name: text(data.name) ?? id2,
2527
2576
  role: text(data.role) ?? fallbackRole,
2528
2577
  archived: flag(data.archived, data.archivedAt, data.archived_at),
2529
- banned: flag(data.banned, data.bannedAt, data.banned_at)
2578
+ banned: flag(data.banned, data.bannedAt, data.banned_at),
2579
+ icon: /[?&]v=([0-9a-f]{64})/.exec(text(data.icon) ?? "")?.[1] ?? null
2530
2580
  };
2531
2581
  }
2532
2582
  function parseUnit(value) {
@@ -2580,7 +2630,7 @@ var CloudClient = class {
2580
2630
  }
2581
2631
  host;
2582
2632
  token;
2583
- async send(method, path31, init) {
2633
+ async send(method, path35, init) {
2584
2634
  const headers = {
2585
2635
  "User-Agent": `foss-design/${packageVersion()} (${os.platform()})`,
2586
2636
  Accept: "application/json"
@@ -2588,7 +2638,7 @@ var CloudClient = class {
2588
2638
  if (this.token) headers.Authorization = `Bearer ${this.token}`;
2589
2639
  if (init.type) headers["Content-Type"] = init.type;
2590
2640
  try {
2591
- return await fetch(`${this.host}/api${path31}`, {
2641
+ return await fetch(`${this.host}/api${path35}`, {
2592
2642
  method,
2593
2643
  headers,
2594
2644
  body: init.body,
@@ -2609,8 +2659,8 @@ var CloudClient = class {
2609
2659
  throw errorFor(this.host, response.status, body, response.headers.get("retry-after"));
2610
2660
  }
2611
2661
  /** JSON in, JSON out; any non-2xx answer throws a CloudError. */
2612
- async request(method, path31, body) {
2613
- const response = await this.send(method, path31, {
2662
+ async request(method, path35, body) {
2663
+ const response = await this.send(method, path35, {
2614
2664
  body: body === void 0 ? void 0 : JSON.stringify(body),
2615
2665
  type: body === void 0 ? void 0 : "application/json",
2616
2666
  timeout: JSON_TIMEOUT
@@ -2622,7 +2672,7 @@ var CloudClient = class {
2622
2672
  try {
2623
2673
  return JSON.parse(text2);
2624
2674
  } catch {
2625
- throw new CloudError(`${this.host} answered ${method} ${path31} with something other than JSON`, 0, "bad_response");
2675
+ throw new CloudError(`${this.host} answered ${method} ${path35} with something other than JSON`, 0, "bad_response");
2626
2676
  }
2627
2677
  }
2628
2678
  /** POST /device/code. */
@@ -2663,6 +2713,34 @@ var CloudClient = class {
2663
2713
  if (!project) throw new CloudError(`${this.host} did not return the new project`, 0, "bad_response");
2664
2714
  return project;
2665
2715
  }
2716
+ /** One of the caller's projects, from the project list; null when it is not among them. */
2717
+ async project(projectId) {
2718
+ const { owned, shared } = await this.projects();
2719
+ return [...owned, ...shared].find((item) => item.id === projectId) ?? null;
2720
+ }
2721
+ /** PATCH /projects/:id: the owner renames the project. */
2722
+ async renameProject(projectId, name) {
2723
+ const body = record(await this.request("PATCH", `/projects/${projectId}`, { name }));
2724
+ const project = parseProject(body.project, "owner");
2725
+ if (!project) throw new CloudError(`${this.host} sent no project back`, 0, "bad_response");
2726
+ return project;
2727
+ }
2728
+ /** GET /projects/:id/icon: the icon's bytes and type, or null without one. */
2729
+ async projectIcon(projectId) {
2730
+ const response = await this.send("GET", `/projects/${projectId}/icon`, { timeout: JSON_TIMEOUT });
2731
+ if (response.status === 404) return null;
2732
+ if (!response.ok) await this.fail(response);
2733
+ return { data: Buffer.from(await response.arrayBuffer()), type: response.headers.get("content-type") ?? "" };
2734
+ }
2735
+ /** PUT or DELETE /projects/:id/icon (owner and editors). */
2736
+ async setProjectIcon(projectId, icon) {
2737
+ const response = await this.send(icon ? "PUT" : "DELETE", `/projects/${projectId}/icon`, {
2738
+ body: icon ? new Uint8Array(icon.data) : void 0,
2739
+ type: icon?.type,
2740
+ timeout: JSON_TIMEOUT
2741
+ });
2742
+ if (!response.ok) await this.fail(response);
2743
+ }
2666
2744
  async units(projectId) {
2667
2745
  const body = await this.request("GET", `/projects/${encodeURIComponent(projectId)}/units`);
2668
2746
  const list = Array.isArray(body) ? body : record(body).units;
@@ -2841,7 +2919,14 @@ function readLink(paths) {
2841
2919
  }
2842
2920
  const conflicts = {};
2843
2921
  for (const [key, rev] of Object.entries(data.conflicts ?? {})) if (typeof rev === "number") conflicts[key] = rev;
2844
- return { host: data.host, project: data.project, units, conflicts };
2922
+ return {
2923
+ host: data.host,
2924
+ project: data.project,
2925
+ units,
2926
+ conflicts,
2927
+ ...typeof data.name === "string" ? { name: data.name } : {},
2928
+ ...typeof data.icon === "string" || data.icon === null ? { icon: data.icon } : {}
2929
+ };
2845
2930
  }
2846
2931
  function requireLink(paths) {
2847
2932
  const link = readLink(paths);
@@ -2855,6 +2940,8 @@ function writeLink(paths, link) {
2855
2940
  const data = {
2856
2941
  host: link.host,
2857
2942
  project: link.project,
2943
+ ...link.name !== void 0 ? { name: link.name } : {},
2944
+ ...link.icon !== void 0 ? { icon: link.icon } : {},
2858
2945
  units: sorted(link.units),
2859
2946
  conflicts: sorted(link.conflicts)
2860
2947
  };
@@ -2937,9 +3024,178 @@ var unique = (keys) => [...new Set(keys)];
2937
3024
  var byUnit = (a, b) => a === SYSTEM_UNIT ? -1 : b === SYSTEM_UNIT ? 1 : a < b ? -1 : a > b ? 1 : 0;
2938
3025
  var plural = (count, word, many = `${word}s`) => `${count} ${count === 1 ? word : many}`;
2939
3026
 
2940
- // src/cloud/pull.ts
3027
+ // src/capture/cover.ts
3028
+ import fs19 from "fs";
3029
+
3030
+ // src/server/snapshots.ts
2941
3031
  import fs17 from "fs";
2942
3032
  import path17 from "path";
3033
+ var SAFE = /^@?[a-z0-9][a-z0-9_-]*$/i;
3034
+ function isSafeSegment(value) {
3035
+ return SAFE.test(value);
3036
+ }
3037
+ var SnapshotStore = class {
3038
+ constructor(dir, urlBase = "/api/snapshots") {
3039
+ this.dir = dir;
3040
+ this.urlBase = urlBase;
3041
+ }
3042
+ dir;
3043
+ urlBase;
3044
+ meta = /* @__PURE__ */ new Map();
3045
+ metaFile(canvas) {
3046
+ return path17.join(this.dir, canvas, "meta.json");
3047
+ }
3048
+ load(canvas) {
3049
+ const cached = this.meta.get(canvas);
3050
+ if (cached) return cached;
3051
+ let data = {};
3052
+ try {
3053
+ data = JSON.parse(fs17.readFileSync(this.metaFile(canvas), "utf8"));
3054
+ } catch {
3055
+ }
3056
+ this.meta.set(canvas, data);
3057
+ return data;
3058
+ }
3059
+ url(canvas, id2, theme2, version, thumb = false) {
3060
+ const name = `${encodeURIComponent(id2)}.${theme2}${thumb ? ".thumb" : ""}.png`;
3061
+ return `${this.urlBase}/${encodeURIComponent(canvas)}/${name}?v=${version}`;
3062
+ }
3063
+ lookup = (canvas, id2) => {
3064
+ if (!isSafeSegment(canvas) || !isSafeSegment(id2)) return null;
3065
+ const entry = this.load(canvas)[id2];
3066
+ if (!entry) return null;
3067
+ const urls = {};
3068
+ const thumbs = {};
3069
+ for (const theme2 of ["light", "dark"]) {
3070
+ const version = entry[theme2];
3071
+ if (!version) continue;
3072
+ urls[theme2] = this.url(canvas, id2, theme2, version);
3073
+ thumbs[theme2] = this.url(canvas, id2, theme2, version, true);
3074
+ }
3075
+ return { urls, thumbs, height: entry.height };
3076
+ };
3077
+ /** When the snapshot was taken (ms), or null without one. */
3078
+ version(canvas, id2, theme2) {
3079
+ if (!isSafeSegment(canvas) || !isSafeSegment(id2)) return null;
3080
+ return this.load(canvas)[id2]?.[theme2] ?? null;
3081
+ }
3082
+ save(canvas, id2, theme2, png, height) {
3083
+ if (!isSafeSegment(canvas) || !isSafeSegment(id2)) throw new Error("bad snapshot key");
3084
+ const dir = path17.join(this.dir, canvas);
3085
+ fs17.mkdirSync(dir, { recursive: true });
3086
+ fs17.writeFileSync(path17.join(dir, `${id2}.${theme2}.png`), png);
3087
+ const data = this.load(canvas);
3088
+ const version = Date.now();
3089
+ data[id2] = { ...data[id2], [theme2]: version, ...height ? { height } : {} };
3090
+ fs17.writeFileSync(this.metaFile(canvas), JSON.stringify(data));
3091
+ return { url: this.url(canvas, id2, theme2, version) };
3092
+ }
3093
+ /** Absolute path of `<id>.<theme>.png`, or of its thumbnail for `<id>.<theme>.thumb.png` (made on first use); null when missing. */
3094
+ file(canvas, name) {
3095
+ const match = /^(@?[a-z0-9][a-z0-9_-]*)\.(light|dark)(\.thumb)?\.png$/i.exec(name);
3096
+ if (!isSafeSegment(canvas) || !match) return null;
3097
+ const file = path17.join(this.dir, canvas, `${match[1]}.${match[2]}.png`);
3098
+ if (!fs17.existsSync(file)) return null;
3099
+ return match[3] ? ensureThumb(file) : file;
3100
+ }
3101
+ };
3102
+
3103
+ // src/capture/chrome.ts
3104
+ import fs18 from "fs";
3105
+ var CANDIDATES = {
3106
+ darwin: [
3107
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
3108
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
3109
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
3110
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
3111
+ ],
3112
+ linux: [
3113
+ "/usr/bin/google-chrome",
3114
+ "/usr/bin/google-chrome-stable",
3115
+ "/usr/bin/chromium",
3116
+ "/usr/bin/chromium-browser",
3117
+ "/snap/bin/chromium",
3118
+ "/usr/bin/microsoft-edge"
3119
+ ],
3120
+ win32: [
3121
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
3122
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
3123
+ "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
3124
+ ]
3125
+ };
3126
+ function findChrome() {
3127
+ const configured = process.env.DESIGN_CHROME;
3128
+ if (configured && fs18.existsSync(configured)) return configured;
3129
+ for (const candidate of CANDIDATES[process.platform] ?? []) if (fs18.existsSync(candidate)) return candidate;
3130
+ return null;
3131
+ }
3132
+ async function launchChrome() {
3133
+ const executablePath = findChrome();
3134
+ if (!executablePath) {
3135
+ throw new Error(
3136
+ "No Chrome, Chromium or Edge found. Install one, or point DESIGN_CHROME at a Chromium-based browser binary."
3137
+ );
3138
+ }
3139
+ const { chromium } = await import("playwright-core");
3140
+ return chromium.launch({ executablePath, headless: true, args: ["--hide-scrollbars", "--force-color-profile=srgb"] });
3141
+ }
3142
+ var OUTDATED_DEP = /Outdated Optimize Dep/;
3143
+ async function openFrame(page, url, options = {}) {
3144
+ let errors = [];
3145
+ let outdated = () => {
3146
+ };
3147
+ const record2 = (text2) => {
3148
+ errors.push(text2);
3149
+ if (OUTDATED_DEP.test(text2)) outdated();
3150
+ };
3151
+ page.on("pageerror", (error) => record2(error.message));
3152
+ page.on("console", (message) => {
3153
+ if (message.type() !== "error") return;
3154
+ let text2 = message.text().replace(/^(?:\s*%[osdifcO])+\s*/, "");
3155
+ const where = message.location().url;
3156
+ if (/^Failed to load resource/.test(text2) && where && !text2.includes(where)) text2 += ` (${where})`;
3157
+ record2(text2);
3158
+ });
3159
+ let ready = false;
3160
+ let reloads = 0;
3161
+ const onNavigate = () => {
3162
+ reloads++;
3163
+ };
3164
+ for (let attempt = 0; attempt < 2; attempt++) {
3165
+ errors = [];
3166
+ reloads = 0;
3167
+ ready = options.waitForReady === false;
3168
+ const turnedAway = new Promise((resolve) => {
3169
+ outdated = () => resolve("outdated");
3170
+ });
3171
+ await page.goto(url, { waitUntil: "load", timeout: options.timeoutMs ?? 3e4 });
3172
+ page.on("load", onNavigate);
3173
+ if (!ready) {
3174
+ const signal = page.waitForFunction(() => window.__DESIGN_READY__ === true, null, {
3175
+ timeout: options.timeoutMs ?? 2e4
3176
+ }).then(() => "ready");
3177
+ signal.catch(() => {
3178
+ });
3179
+ ready = await Promise.race([signal, turnedAway]).catch(() => null) === "ready";
3180
+ }
3181
+ if (ready || options.waitForReady === false) await page.waitForTimeout(options.settleMs ?? 700);
3182
+ page.off("load", onNavigate);
3183
+ if (!reloads && !errors.some((error) => OUTDATED_DEP.test(error))) break;
3184
+ }
3185
+ if (reloads) errors.push(`the page reloaded itself ${reloads === 1 ? "once" : `${reloads} times`} while it loaded`);
3186
+ const held = ready ? 0 : await page.evaluate(() => window.__DESIGN_HOLDS__ ?? 0).catch(() => 0);
3187
+ const runtimeErrors = await page.evaluate(() => window.__DESIGN_ERRORS__ ?? []).catch(() => []);
3188
+ for (const error of runtimeErrors) if (!errors.includes(error)) errors.push(error);
3189
+ const overlay = await page.evaluate(() => {
3190
+ const el = document.querySelector("vite-error-overlay");
3191
+ return el?.shadowRoot?.querySelector(".message")?.textContent ?? null;
3192
+ }).catch(() => null);
3193
+ if (overlay) errors.unshift(overlay.trim());
3194
+ const unique2 = errors.filter(
3195
+ (error, index) => error && !errors.some((other, j) => j !== index && other && other.length < error.length && error.includes(other))
3196
+ );
3197
+ return { errors: [...new Set(unique2)], ready, ...held ? { held } : {} };
3198
+ }
2943
3199
 
2944
3200
  // src/capture/frames.ts
2945
3201
  var AUTO_VIEWPORT_HEIGHT = 900;
@@ -3013,73 +3269,323 @@ async function mapLimit(items, limit, fn) {
3013
3269
  return results;
3014
3270
  }
3015
3271
 
3016
- // src/cloud/pull.ts
3017
- var HASH = /^[0-9a-f]{64}$/;
3018
- function checkManifest(key, manifest) {
3019
- for (const [rel, entry] of Object.entries(manifest)) {
3020
- if (!isUnitPath(key, rel)) throw new CliError(`The cloud sent a path that does not belong to ${key}: "${rel}"`);
3021
- if (!HASH.test(entry.hash)) throw new CliError(`The cloud sent a bad hash for ${rel} in ${key}`);
3272
+ // src/capture/cover.ts
3273
+ var SNAPSHOT_WIDTH = 900;
3274
+ async function refreshCovers(paths, canvasIds, base) {
3275
+ const store = new SnapshotStore(paths.snapshots);
3276
+ const project = new DesignProject(paths, DEV_URLS, store.lookup);
3277
+ const stale = [];
3278
+ for (const id2 of canvasIds) {
3279
+ const canvas = project.canvas(id2);
3280
+ if (!canvas || canvas.doc.cover && "url" in canvas.doc.cover) continue;
3281
+ const screen = canvas.doc.cover?.screen ?? canvas.screens[0]?.id;
3282
+ const source = canvas.screens.find((item) => item.id === screen);
3283
+ if (!screen || !source || !fs19.existsSync(source.file)) continue;
3284
+ const taken = store.version(id2, screen, "light");
3285
+ if (!taken || taken < fs19.statSync(source.file).mtimeMs) stale.push({ canvas: id2, screen });
3286
+ }
3287
+ if (!stale.length) return [];
3288
+ if (!findChrome()) return ["No Chrome here, so canvas covers keep their last snapshot."];
3289
+ const url = await base();
3290
+ const browser = await launchChrome();
3291
+ const lines = [];
3292
+ try {
3293
+ for (const { canvas, screen } of stale) {
3294
+ const doc = project.canvas(canvas).doc;
3295
+ const job = canvasFrames(doc, url, "light", { id: screen })[0];
3296
+ if (!job) continue;
3297
+ const page = await browser.newPage({
3298
+ viewport: { width: job.width, height: job.height },
3299
+ deviceScaleFactor: Math.min(1, SNAPSHOT_WIDTH / job.width)
3300
+ });
3301
+ try {
3302
+ const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
3303
+ if (!report.ready) {
3304
+ lines.push(`${canvas}: ${screen} did not finish rendering, so its cover keeps the last snapshot`);
3305
+ continue;
3306
+ }
3307
+ const png = await page.screenshot({ fullPage: job.fullPage });
3308
+ const height = job.fullPage ? await page.evaluate(() => document.documentElement.scrollHeight) : void 0;
3309
+ store.save(canvas, screen, "light", png, height);
3310
+ } finally {
3311
+ await page.close().catch(() => {
3312
+ });
3313
+ }
3314
+ }
3315
+ } finally {
3316
+ await browser.close();
3022
3317
  }
3318
+ return lines;
3023
3319
  }
3320
+
3321
+ // src/cloud/pull.ts
3322
+ import fs23 from "fs";
3323
+ import path21 from "path";
3324
+
3325
+ // src/cloud/merge.ts
3326
+ import fs21 from "fs";
3327
+ import path19 from "path";
3328
+ import { merge as diff3 } from "node-diff3";
3329
+
3330
+ // src/cloud/write.ts
3331
+ import fs20 from "fs";
3332
+ import path18 from "path";
3024
3333
  function writeInside(root, rel, data) {
3025
- const file = path17.join(root, rel);
3334
+ const file = path18.join(root, rel);
3026
3335
  if (!isInside(root, file)) throw new CliError(`Refusing to write outside ${root}: ${rel}`);
3027
- fs17.mkdirSync(path17.dirname(file), { recursive: true });
3028
- if (!isInside(fs17.realpathSync(root), fs17.realpathSync(path17.dirname(file))))
3336
+ fs20.mkdirSync(path18.dirname(file), { recursive: true });
3337
+ if (!isInside(fs20.realpathSync(root), fs20.realpathSync(path18.dirname(file))))
3029
3338
  throw new CliError(`Refusing to write through a link out of ${root}: ${rel}`);
3030
- if (fs17.existsSync(file) && fs17.lstatSync(file).isSymbolicLink()) fs17.unlinkSync(file);
3031
- fs17.writeFileSync(file, data);
3339
+ if (fs20.existsSync(file) && fs20.lstatSync(file).isSymbolicLink()) fs20.unlinkSync(file);
3340
+ fs20.writeFileSync(file, data);
3032
3341
  }
3033
3342
  function pruneEmpty(dir, stop) {
3034
3343
  let current = dir;
3035
3344
  while (isInside(stop, current) && current !== stop) {
3036
3345
  try {
3037
- if (fs17.readdirSync(current).length) return;
3038
- fs17.rmdirSync(current);
3346
+ if (fs20.readdirSync(current).length) return;
3347
+ fs20.rmdirSync(current);
3039
3348
  } catch {
3040
3349
  return;
3041
3350
  }
3042
- current = path17.dirname(current);
3351
+ current = path18.dirname(current);
3043
3352
  }
3044
3353
  }
3045
- function applyUnit(paths, key, local, target, blobs) {
3046
- const counts = { added: 0, changed: 0, removed: 0 };
3047
- const stop = key === SYSTEM_UNIT ? paths.design : path17.dirname(unitRoot(paths, key));
3048
- for (const rel of Object.keys(local)) {
3049
- if (rel in target) continue;
3050
- const file = path17.join(paths.design, rel);
3051
- fs17.rmSync(file, { force: true });
3052
- pruneEmpty(path17.dirname(file), stop);
3053
- counts.removed++;
3054
- }
3055
- for (const [rel, entry] of Object.entries(target)) {
3056
- if (local[rel]?.hash === entry.hash) continue;
3057
- writeInside(paths.design, rel, blobs.get(entry.hash));
3058
- if (local[rel]) counts.changed++;
3059
- else counts.added++;
3354
+
3355
+ // src/cloud/merge.ts
3356
+ var TEXT = /\.(?:[cm]?[jt]sx?|json|css|scss|html?|mdx?|svg|txt|ya?ml|csv|xml|toml)$/i;
3357
+ var isText = (rel, ...data) => TEXT.test(rel) && data.every((buffer) => !buffer || !buffer.subarray(0, 8192).includes(0));
3358
+ var mergeDir = (paths, key) => path19.join(cloudCache(paths), "merge", ...key.split("/"));
3359
+ var stateFile = (paths, key) => path19.join(mergeDir(paths, key), "state.json");
3360
+ var sideFile = (paths, key, rel, side) => path19.join(mergeDir(paths, key), side, rel);
3361
+ function readMergeState(paths, key) {
3362
+ try {
3363
+ return JSON.parse(fs21.readFileSync(stateFile(paths, key), "utf8"));
3364
+ } catch {
3365
+ return null;
3060
3366
  }
3061
- return counts;
3062
3367
  }
3063
- async function pull(ctx, options) {
3064
- const { paths, client } = ctx;
3065
- const link = structuredClone(ctx.link);
3066
- const report = newReport("pull", link);
3067
- const selected = unique(options.units.map(unitArg));
3068
- const theirs = unique(options.theirs.map(unitArg));
3069
- const scan = scanLocal(paths);
3070
- const remote = await client.units(link.project);
3071
- const local = (key) => scan.units.get(key)?.manifest ?? {};
3072
- const keys = selected.length || theirs.length ? unique([...selected, ...theirs]) : allUnitKeys(link, /* @__PURE__ */ new Map(), remote);
3073
- const clashes = caseClashes([...remote.keys(), ...scan.units.keys(), ...Object.keys(link.units)]);
3074
- const plans = [];
3075
- for (const key of keys.sort(byUnit)) {
3076
- const clash = clashes.get(key);
3077
- if (clash !== void 0) {
3078
- report.units.push({
3079
- unit: key,
3080
- action: "conflict",
3081
- message: `differs from ${clash} only by letter case; rename one of them before pulling`
3082
- });
3368
+ function saveState(paths, state) {
3369
+ fs21.mkdirSync(mergeDir(paths, state.unit), { recursive: true });
3370
+ fs21.writeFileSync(stateFile(paths, state.unit), `${JSON.stringify(state, null, 2)}
3371
+ `);
3372
+ }
3373
+ function mergingUnits(paths) {
3374
+ const root = path19.join(cloudCache(paths), "merge");
3375
+ const found = [];
3376
+ const visit = (dir, prefix) => {
3377
+ if (fs21.existsSync(path19.join(dir, "state.json"))) {
3378
+ const state = readMergeState(paths, prefix);
3379
+ if (state) found.push(state);
3380
+ return;
3381
+ }
3382
+ let entries = [];
3383
+ try {
3384
+ entries = fs21.readdirSync(dir, { withFileTypes: true });
3385
+ } catch {
3386
+ }
3387
+ for (const entry of entries)
3388
+ if (entry.isDirectory()) visit(path19.join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name);
3389
+ };
3390
+ visit(root, "");
3391
+ return found;
3392
+ }
3393
+ var MARKER = /^(<{7}|={7}|>{7})( |$)/m;
3394
+ function openConflicts(paths, state) {
3395
+ return state.conflicts.filter((conflict) => {
3396
+ if (conflict.kind !== "text") return true;
3397
+ try {
3398
+ return MARKER.test(fs21.readFileSync(path19.join(paths.design, conflict.path), "utf8"));
3399
+ } catch {
3400
+ return false;
3401
+ }
3402
+ });
3403
+ }
3404
+ function keep(paths, key, rel, side, data) {
3405
+ if (!data) return;
3406
+ const file = sideFile(paths, key, rel, side);
3407
+ fs21.mkdirSync(path19.dirname(file), { recursive: true });
3408
+ fs21.writeFileSync(file, data);
3409
+ }
3410
+ function mergeUnit(paths, key, headRev, base, here, cloud2, blob) {
3411
+ const files = { added: 0, changed: 0, removed: 0, merged: 0 };
3412
+ const conflicts = [];
3413
+ const stop = key === SYSTEM_UNIT ? paths.design : path19.dirname(unitRoot(paths, key));
3414
+ fs21.rmSync(mergeDir(paths, key), { recursive: true, force: true });
3415
+ const read = (rel) => fs21.readFileSync(path19.join(paths.design, rel));
3416
+ const remove = (rel) => {
3417
+ const file = path19.join(paths.design, rel);
3418
+ fs21.rmSync(file, { force: true });
3419
+ pruneEmpty(path19.dirname(file), stop);
3420
+ };
3421
+ for (const rel of [.../* @__PURE__ */ new Set([...Object.keys(base), ...Object.keys(here), ...Object.keys(cloud2)])].sort()) {
3422
+ const b = base[rel];
3423
+ const h = here[rel]?.hash;
3424
+ const c = cloud2[rel]?.hash;
3425
+ if (h === c || c === b) continue;
3426
+ if (h === b) {
3427
+ if (c) {
3428
+ writeInside(paths.design, rel, blob(c));
3429
+ if (h) files.changed++;
3430
+ else files.added++;
3431
+ } else {
3432
+ remove(rel);
3433
+ files.removed++;
3434
+ }
3435
+ continue;
3436
+ }
3437
+ const cloudData = c ? blob(c) : void 0;
3438
+ const hereData = h ? read(rel) : void 0;
3439
+ if (!h || !c) {
3440
+ const kind = h ? "deleted_in_cloud" : "deleted_here";
3441
+ keep(paths, key, rel, "here", hereData);
3442
+ keep(paths, key, rel, "cloud", cloudData);
3443
+ if (!h && cloudData) writeInside(paths.design, rel, cloudData);
3444
+ conflicts.push({ path: rel, kind });
3445
+ continue;
3446
+ }
3447
+ const baseData = b ? blob(b) : void 0;
3448
+ if (isText(rel, hereData, cloudData, baseData)) {
3449
+ const lines = (data) => data ? data.toString("utf8").split("\n") : [];
3450
+ const result = diff3(lines(hereData), lines(baseData), lines(cloudData), {
3451
+ label: { a: "here", b: "cloud" }
3452
+ });
3453
+ writeInside(paths.design, rel, Buffer.from(result.result.join("\n")));
3454
+ if (!result.conflict) {
3455
+ files.merged++;
3456
+ continue;
3457
+ }
3458
+ keep(paths, key, rel, "base", baseData);
3459
+ keep(paths, key, rel, "here", hereData);
3460
+ keep(paths, key, rel, "cloud", cloudData);
3461
+ conflicts.push({ path: rel, kind: "text" });
3462
+ continue;
3463
+ }
3464
+ keep(paths, key, rel, "here", hereData);
3465
+ keep(paths, key, rel, "cloud", cloudData);
3466
+ conflicts.push({ path: rel, kind: "binary" });
3467
+ }
3468
+ if (conflicts.length) saveState(paths, { unit: key, headRev, conflicts });
3469
+ return { files, conflicts };
3470
+ }
3471
+ function resolveConflict(paths, state, rel, side) {
3472
+ const conflict = state.conflicts.find((item) => item.path === rel);
3473
+ if (!conflict) return;
3474
+ const kept = sideFile(paths, state.unit, rel, side);
3475
+ const stop = state.unit === SYSTEM_UNIT ? paths.design : path19.dirname(unitRoot(paths, state.unit));
3476
+ if (fs21.existsSync(kept)) writeInside(paths.design, rel, fs21.readFileSync(kept));
3477
+ else {
3478
+ fs21.rmSync(path19.join(paths.design, rel), { force: true });
3479
+ pruneEmpty(path19.dirname(path19.join(paths.design, rel)), stop);
3480
+ }
3481
+ state.conflicts = state.conflicts.filter((item) => item.path !== rel);
3482
+ saveState(paths, state);
3483
+ }
3484
+ function finishMerge(paths, link, key, accept = false) {
3485
+ const state = readMergeState(paths, key);
3486
+ if (state) {
3487
+ const open2 = openConflicts(paths, state).filter((conflict) => !(accept && conflict.kind !== "text"));
3488
+ if (open2.length) return open2;
3489
+ fs21.rmSync(mergeDir(paths, key), { recursive: true, force: true });
3490
+ }
3491
+ delete link.conflicts[key];
3492
+ return [];
3493
+ }
3494
+
3495
+ // src/cloud/meta.ts
3496
+ import fs22 from "fs";
3497
+ import path20 from "path";
3498
+ async function syncProjectMeta(paths, client, link) {
3499
+ const remote = await client.project(link.project);
3500
+ if (!remote) return [];
3501
+ const lines = [];
3502
+ link.name = remote.name;
3503
+ const file = iconFile(paths);
3504
+ const local = file ? iconHash(file) : null;
3505
+ const cloud2 = remote.icon;
3506
+ if (local === cloud2) {
3507
+ link.icon = cloud2;
3508
+ return lines;
3509
+ }
3510
+ const base = link.icon;
3511
+ const localChanged = base === void 0 ? cloud2 === null : local !== base;
3512
+ const cloudChanged = base === void 0 ? local === null : cloud2 !== base;
3513
+ if (localChanged && !cloudChanged) {
3514
+ try {
3515
+ if (file) {
3516
+ const ext = path20.extname(file).slice(1);
3517
+ await client.setProjectIcon(link.project, { data: fs22.readFileSync(file), type: ICON_TYPES[ext] });
3518
+ lines.push("The project icon went up to the cloud.");
3519
+ } else {
3520
+ await client.setProjectIcon(link.project, null);
3521
+ lines.push("The project icon was removed in the cloud too.");
3522
+ }
3523
+ link.icon = local;
3524
+ } catch (error) {
3525
+ if (!(error instanceof CloudError) || error.status === 0) throw error;
3526
+ lines.push(`The project icon stays here only: ${error.message}`);
3527
+ }
3528
+ return lines;
3529
+ }
3530
+ const icon = cloud2 ? await client.projectIcon(link.project) : null;
3531
+ if (icon && !sniffIcon(icon.data))
3532
+ return [...lines, "The cloud sent a project icon that is no image; it was left out."];
3533
+ writeIcon(paths, icon?.data ?? null);
3534
+ link.icon = cloud2;
3535
+ lines.push(
3536
+ localChanged ? "The project icon changed here and in the cloud; the cloud's is kept." : cloud2 ? "The project icon came down from the cloud." : "The project icon was removed in the cloud, so here too."
3537
+ );
3538
+ return lines;
3539
+ }
3540
+
3541
+ // src/cloud/pull.ts
3542
+ var HASH = /^[0-9a-f]{64}$/;
3543
+ var MERGE_HINT = "Resolve with `design merge` (it lists the files; `--here` or `--cloud` settles one or a unit), then `design push`.";
3544
+ function checkManifest(key, manifest) {
3545
+ for (const [rel, entry] of Object.entries(manifest)) {
3546
+ if (!isUnitPath(key, rel)) throw new CliError(`The cloud sent a path that does not belong to ${key}: "${rel}"`);
3547
+ if (!HASH.test(entry.hash)) throw new CliError(`The cloud sent a bad hash for ${rel} in ${key}`);
3548
+ }
3549
+ }
3550
+ function applyUnit(paths, key, local, target, blobs) {
3551
+ const counts = { added: 0, changed: 0, removed: 0 };
3552
+ const stop = key === SYSTEM_UNIT ? paths.design : path21.dirname(unitRoot(paths, key));
3553
+ for (const rel of Object.keys(local)) {
3554
+ if (rel in target) continue;
3555
+ const file = path21.join(paths.design, rel);
3556
+ fs23.rmSync(file, { force: true });
3557
+ pruneEmpty(path21.dirname(file), stop);
3558
+ counts.removed++;
3559
+ }
3560
+ for (const [rel, entry] of Object.entries(target)) {
3561
+ if (local[rel]?.hash === entry.hash) continue;
3562
+ writeInside(paths.design, rel, blobs.get(entry.hash));
3563
+ if (local[rel]) counts.changed++;
3564
+ else counts.added++;
3565
+ }
3566
+ return counts;
3567
+ }
3568
+ async function pull(ctx, options) {
3569
+ const { paths, client } = ctx;
3570
+ const link = structuredClone(ctx.link);
3571
+ const report = newReport("pull", link);
3572
+ for (const line of await syncProjectMeta(ctx.paths, ctx.client, link)) report.hints.push(line);
3573
+ const selected = unique(options.units.map(unitArg));
3574
+ const theirs = unique(options.theirs.map(unitArg));
3575
+ const scan = scanLocal(paths);
3576
+ const remote = await client.units(link.project);
3577
+ const local = (key) => scan.units.get(key)?.manifest ?? {};
3578
+ const keys = selected.length || theirs.length ? unique([...selected, ...theirs]) : allUnitKeys(link, /* @__PURE__ */ new Map(), remote);
3579
+ const clashes = caseClashes([...remote.keys(), ...scan.units.keys(), ...Object.keys(link.units)]);
3580
+ const plans = [];
3581
+ for (const key of keys.sort(byUnit)) {
3582
+ const clash = clashes.get(key);
3583
+ if (clash !== void 0) {
3584
+ report.units.push({
3585
+ unit: key,
3586
+ action: "conflict",
3587
+ message: `differs from ${clash} only by letter case; rename one of them before pulling`
3588
+ });
3083
3589
  continue;
3084
3590
  }
3085
3591
  const unit = remote.get(key) ?? null;
@@ -3110,9 +3616,15 @@ async function pull(ctx, options) {
3110
3616
  entry.message = "local changes discarded";
3111
3617
  plans.push({ key, kind: "apply", remote: unit, report: entry });
3112
3618
  } else if (!s.ahead) {
3619
+ const merging = readMergeState(paths, key);
3620
+ const open2 = merging ? openConflicts(paths, merging) : [];
3621
+ if (open2.length) {
3622
+ report.units.push({ ...reportFor("conflict"), rev: s.baseRev, conflicts: open2, message: MERGE_HINT });
3623
+ continue;
3624
+ }
3113
3625
  if (s.conflictRev !== void 0) {
3114
- delete link.conflicts[key];
3115
- fs17.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3626
+ finishMerge(paths, link, key, true);
3627
+ fs23.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3116
3628
  }
3117
3629
  report.units.push({ ...reportFor("up_to_date"), rev: s.baseRev });
3118
3630
  } else if (!s.changed.length) {
@@ -3136,31 +3648,33 @@ async function pull(ctx, options) {
3136
3648
  message: "deleted in the cloud; your local changes are kept and `design push` creates it again"
3137
3649
  });
3138
3650
  } else {
3139
- const entry = {
3140
- ...reportFor("conflict"),
3141
- rev: void 0,
3142
- headRev: unit.headRev,
3143
- incoming: incomingDir(paths, key)
3144
- };
3145
- entry.message = `changed here and in the cloud; the cloud version (r${unit.headRev}) is in ${path17.relative(paths.root, entry.incoming)}`;
3146
- if (s.conflictRev === unit.headRev && fs17.existsSync(entry.incoming)) report.units.push(entry);
3147
- else plans.push({ key, kind: "incoming", remote: unit, report: entry });
3651
+ plans.push({ key, kind: "merge", remote: unit, report: { ...reportFor("merged"), headRev: unit.headRev } });
3148
3652
  }
3149
3653
  }
3150
3654
  const byHash = /* @__PURE__ */ new Map();
3151
3655
  for (const unit of scan.units.values())
3152
- for (const [rel, entry] of Object.entries(unit.manifest)) byHash.set(entry.hash, path17.join(paths.design, rel));
3656
+ for (const [rel, entry] of Object.entries(unit.manifest)) byHash.set(entry.hash, path21.join(paths.design, rel));
3153
3657
  const needed = /* @__PURE__ */ new Set();
3658
+ const bases = /* @__PURE__ */ new Set();
3154
3659
  for (const plan of plans) {
3155
3660
  if (!plan.remote) continue;
3156
3661
  checkManifest(plan.key, plan.remote.manifest);
3157
3662
  for (const entry of Object.values(plan.remote.manifest)) needed.add(entry.hash);
3663
+ if (plan.kind !== "merge") continue;
3664
+ const base = link.units[plan.key]?.files ?? {};
3665
+ const here = local(plan.key);
3666
+ for (const [rel, hash] of Object.entries(base)) {
3667
+ const h = here[rel]?.hash;
3668
+ const c = plan.remote.manifest[rel]?.hash;
3669
+ if (h && c && h !== hash && c !== hash && h !== c) bases.add(hash);
3670
+ }
3158
3671
  }
3672
+ for (const hash of bases) if (!needed.has(hash) && byHash.has(hash)) needed.add(hash);
3159
3673
  const blobs = /* @__PURE__ */ new Map();
3160
3674
  const download = [];
3161
3675
  for (const hash of needed) {
3162
3676
  const file = byHash.get(hash);
3163
- if (file) blobs.set(hash, fs17.readFileSync(file));
3677
+ if (file) blobs.set(hash, fs23.readFileSync(file));
3164
3678
  else download.push(hash);
3165
3679
  }
3166
3680
  if (download.length) {
@@ -3179,6 +3693,16 @@ async function pull(ctx, options) {
3179
3693
  });
3180
3694
  bar.done(`Downloaded ${plural(download.length, "file")} (${megabytes(gotBytes)}) in ${seconds(started)}`);
3181
3695
  }
3696
+ await mapLimit(
3697
+ [...bases].filter((hash) => !blobs.has(hash)),
3698
+ TRANSFERS,
3699
+ async (hash) => {
3700
+ try {
3701
+ blobs.set(hash, await client.getBlob(link.project, hash));
3702
+ } catch {
3703
+ }
3704
+ }
3705
+ );
3182
3706
  for (const plan of plans) {
3183
3707
  const { key, report: entry } = plan;
3184
3708
  if (plan.kind === "apply") {
@@ -3186,14 +3710,30 @@ async function pull(ctx, options) {
3186
3710
  if (plan.remote) link.units[key] = { rev: plan.remote.headRev, files: hashes(plan.remote.manifest) };
3187
3711
  else delete link.units[key];
3188
3712
  delete link.conflicts[key];
3189
- fs17.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3713
+ fs23.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3190
3714
  } else {
3191
- const dir = incomingDir(paths, key);
3192
- fs17.rmSync(dir, { recursive: true, force: true });
3193
- fs17.mkdirSync(dir, { recursive: true });
3194
- for (const [rel, file] of Object.entries(plan.remote.manifest))
3195
- writeInside(dir, unitRelative(key, rel), blobs.get(file.hash));
3196
- link.conflicts[key] = plan.remote.headRev;
3715
+ const result = mergeUnit(
3716
+ paths,
3717
+ key,
3718
+ plan.remote.headRev,
3719
+ link.units[key]?.files ?? {},
3720
+ local(key),
3721
+ plan.remote.manifest,
3722
+ (hash) => blobs.get(hash)
3723
+ );
3724
+ fs23.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3725
+ link.units[key] = { rev: plan.remote.headRev, files: hashes(plan.remote.manifest) };
3726
+ entry.rev = plan.remote.headRev;
3727
+ entry.files = result.files;
3728
+ if (result.conflicts.length) {
3729
+ link.conflicts[key] = plan.remote.headRev;
3730
+ entry.action = "conflict";
3731
+ entry.conflicts = result.conflicts;
3732
+ entry.message = `${plural(result.conflicts.length, "file")} changed on both sides did not merge. ${MERGE_HINT}`;
3733
+ } else {
3734
+ delete link.conflicts[key];
3735
+ entry.message = `merged with the cloud's changes (r${plan.remote.headRev})${result.files.merged ? `, ${plural(result.files.merged, "file")} line by line` : ""}; \`design push\` takes your changes up`;
3736
+ }
3197
3737
  }
3198
3738
  report.units.push(entry);
3199
3739
  }
@@ -3204,15 +3744,15 @@ async function pull(ctx, options) {
3204
3744
  report.ok = false;
3205
3745
  report.error = {
3206
3746
  code: "conflict",
3207
- message: `${plural(conflicts.length, "unit")} changed both here and in the cloud. Merge the cloud version from incoming/ into your files, then \`design push --resolved <unit>\`; or \`design pull --theirs <unit>\` to take the cloud version.`
3747
+ message: `${plural(conflicts.length, "unit")} changed both here and in the cloud and did not merge cleanly. ${MERGE_HINT}`
3208
3748
  };
3209
3749
  }
3210
3750
  return report;
3211
3751
  }
3212
3752
 
3213
3753
  // src/cloud/push.ts
3214
- import fs18 from "fs";
3215
- import path18 from "path";
3754
+ import fs24 from "fs";
3755
+ import path22 from "path";
3216
3756
  function blockedReport(status, message) {
3217
3757
  return {
3218
3758
  unit: status.key,
@@ -3226,6 +3766,7 @@ async function push(ctx, options) {
3226
3766
  const { paths, client, log } = ctx;
3227
3767
  const link = structuredClone(ctx.link);
3228
3768
  const report = newReport("push", link);
3769
+ for (const line of await syncProjectMeta(ctx.paths, ctx.client, link)) report.hints.push(line);
3229
3770
  const selected = unique(options.units.map(unitArg));
3230
3771
  const resolved = unique(options.resolved.map(unitArg));
3231
3772
  const scan = scanLocal(paths);
@@ -3237,10 +3778,19 @@ async function push(ctx, options) {
3237
3778
  `These canvases differ only by letter case: ${pairs.join(", ")}. On macOS and Windows they share one folder; rename one of them.`
3238
3779
  );
3239
3780
  }
3781
+ for (const key of Object.keys(link.conflicts)) if (readMergeState(paths, key)) finishMerge(paths, link, key);
3240
3782
  for (const key of resolved) {
3241
3783
  const head = link.conflicts[key];
3242
- if (head === void 0)
3784
+ if (head === void 0) {
3785
+ if (link.units[key]) continue;
3243
3786
  throw new CliError(`${key} has no pending conflict; --resolved is for units \`design pull\` reported in conflict`);
3787
+ }
3788
+ if (readMergeState(paths, key)) {
3789
+ const open2 = finishMerge(paths, link, key, true);
3790
+ if (open2.length)
3791
+ throw new CliError(`${key} still has conflict markers in ${open2.map((item) => item.path).join(", ")}`);
3792
+ continue;
3793
+ }
3244
3794
  link.units[key] = { rev: head, files: link.units[key]?.files ?? {} };
3245
3795
  delete link.conflicts[key];
3246
3796
  }
@@ -3318,9 +3868,12 @@ async function push(ctx, options) {
3318
3868
  for (const key of toPush) {
3319
3869
  const s = status(key);
3320
3870
  if (s.conflictRev !== void 0) {
3321
- const dir = path18.relative(paths.root, incomingDir(paths, key));
3871
+ const dir = path22.relative(paths.root, incomingDir(paths, key));
3322
3872
  blocked.push(
3323
- blockedReport(s, `pull left the cloud version in ${dir}; merge it, then \`design push --resolved ${key}\``)
3873
+ blockedReport(
3874
+ s,
3875
+ readMergeState(paths, key) ? "a merge with the cloud has conflicts left: `design merge` lists them and settles them" : `pull left the cloud version in ${dir}; merge it, then \`design push --resolved ${key}\``
3876
+ )
3324
3877
  );
3325
3878
  } else if (s.ahead) {
3326
3879
  blocked.push(
@@ -3356,13 +3909,15 @@ async function push(ctx, options) {
3356
3909
  return false;
3357
3910
  });
3358
3911
  if (!pushing.length) return report;
3912
+ const canvasIds = pushing.map(unitCanvasId).filter((id2) => id2 !== null);
3913
+ if (options.beforeBuild) for (const line of await options.beforeBuild(canvasIds)) report.hints.push(line);
3359
3914
  const project = new DesignProject(paths, STATIC_URLS);
3360
3915
  const systemRev = remote.get(SYSTEM_UNIT)?.headRev ?? 0;
3361
3916
  const units = [];
3362
3917
  const files = /* @__PURE__ */ new Map();
3363
3918
  const addFiles = (manifest, dir) => {
3364
3919
  for (const [rel, entry] of Object.entries(manifest))
3365
- if (!files.has(entry.hash)) files.set(entry.hash, path18.join(dir, rel));
3920
+ if (!files.has(entry.hash)) files.set(entry.hash, path22.join(dir, rel));
3366
3921
  };
3367
3922
  for (const key of pushing) {
3368
3923
  const id2 = unitCanvasId(key);
@@ -3394,7 +3949,7 @@ async function push(ctx, options) {
3394
3949
  if (missing.length) {
3395
3950
  const unknown = missing.find((hash) => !files.has(hash));
3396
3951
  if (unknown) throw new CliError(`The cloud asked for ${unknown.slice(0, 12)}, which is not part of this push`);
3397
- const bytes = missing.reduce((sum, hash) => sum + fs18.statSync(files.get(hash)).size, 0);
3952
+ const bytes = missing.reduce((sum, hash) => sum + fs24.statSync(files.get(hash)).size, 0);
3398
3953
  const bar = ctx.progress();
3399
3954
  const started = Date.now();
3400
3955
  let sentFiles = 0;
@@ -3403,12 +3958,12 @@ async function push(ctx, options) {
3403
3958
  bar.update(line());
3404
3959
  await mapLimit(missing, TRANSFERS, async (hash) => {
3405
3960
  const file = files.get(hash);
3406
- const data = fs18.readFileSync(file);
3961
+ const data = fs24.readFileSync(file);
3407
3962
  try {
3408
3963
  await client.putBlob(link.project, hash, data);
3409
3964
  } catch (error) {
3410
3965
  if (error instanceof CloudError && error.status === 413)
3411
- throw new CliError(`${path18.relative(process.cwd(), file)} (${megabytes(data.length)}): ${error.message}`);
3966
+ throw new CliError(`${path22.relative(process.cwd(), file)} (${megabytes(data.length)}): ${error.message}`);
3412
3967
  throw error;
3413
3968
  }
3414
3969
  sentFiles++;
@@ -3430,7 +3985,7 @@ async function push(ctx, options) {
3430
3985
  url: webUrl(link, unit.key)
3431
3986
  });
3432
3987
  }
3433
- for (const key of resolved) fs18.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3988
+ for (const key of resolved) fs24.rmSync(incomingDir(paths, key), { recursive: true, force: true });
3434
3989
  writeLink(paths, link);
3435
3990
  report.units.sort((a, b) => byUnit(a.unit, b.unit));
3436
3991
  return report;
@@ -3441,7 +3996,7 @@ function refuseLegacyGo(paths, keys) {
3441
3996
  for (const key of keys) {
3442
3997
  if (!key.startsWith("canvas/")) continue;
3443
3998
  const id2 = key.slice("canvas/".length);
3444
- for (const file of canvasCodeFiles(path18.join(paths.canvases, id2), project.canvas(id2)?.screens ?? [])) {
3999
+ for (const file of canvasCodeFiles(path22.join(paths.canvases, id2), project.canvas(id2)?.screens ?? [])) {
3445
4000
  for (const line of legacyGoLines(file)) hits.add(`${relToRoot(paths, file)}:${line}`);
3446
4001
  }
3447
4002
  }
@@ -3455,496 +4010,50 @@ Still using go():
3455
4010
  );
3456
4011
  }
3457
4012
 
3458
- // src/cli/commands/sync.ts
3459
- var GOOD = /* @__PURE__ */ new Set(["pushed", "created", "updated", "deleted"]);
3460
- var BAD = /* @__PURE__ */ new Set(["conflict", "remote_ahead"]);
3461
- var QUIET = /* @__PURE__ */ new Set(["up_to_date", "unchanged"]);
3462
- function revs(entry) {
3463
- if (entry.action === "created") return entry.rev !== void 0 ? `r${entry.rev}` : "";
3464
- if (entry.action === "deleted") return "";
3465
- if (GOOD.has(entry.action))
3466
- return entry.baseRev && entry.baseRev !== entry.rev ? `r${entry.baseRev} \u2192 r${entry.rev}` : `r${entry.rev}`;
3467
- if (BAD.has(entry.action) && entry.headRev !== void 0) return `r${entry.baseRev ?? 0} \u2192 r${entry.headRev}`;
3468
- return "";
4013
+ // src/cli/commands/preview.ts
4014
+ import { spawn } from "child_process";
4015
+ import fs28 from "fs";
4016
+
4017
+ // src/server/index.ts
4018
+ import fs27 from "fs";
4019
+ import http from "http";
4020
+ import path25 from "path";
4021
+ import sirv from "sirv";
4022
+ import { createServer, searchForWorkspaceRoot } from "vite";
4023
+
4024
+ // src/server/api.ts
4025
+ import fs25 from "fs";
4026
+ import path23 from "path";
4027
+ function sendJson(res, status, body) {
4028
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
4029
+ res.end(JSON.stringify(body));
3469
4030
  }
3470
- function printReport(report) {
3471
- const verb = report.command === "push" ? "Pushed to" : "Pulled from";
3472
- const shown = report.units.filter((entry) => !QUIET.has(entry.action));
3473
- const quiet = report.units.length - shown.length;
3474
- const moved = report.units.some((entry) => GOOD.has(entry.action));
3475
- if (report.ok && !moved) print(`${bold(`Nothing to ${report.command}`)} ${dim(report.url)}`);
3476
- else if (report.ok) print(`${bold(verb)} ${report.url}`);
3477
- else print(`${bold(report.command === "push" ? "Push stopped" : "Pull finished with conflicts")} ${dim(report.host)}`);
3478
- const width = Math.max(0, ...shown.map((entry) => entry.unit.length));
3479
- for (const entry of shown) {
3480
- const mark = GOOD.has(entry.action) ? green("\u2713") : BAD.has(entry.action) ? red("\u2717") : yellow("!");
3481
- const files = entry.files ? dim(
3482
- [
3483
- entry.files.added && `+${entry.files.added}`,
3484
- entry.files.changed && `~${entry.files.changed}`,
3485
- entry.files.removed && `-${entry.files.removed}`
3486
- ].filter(Boolean).join(" ")
3487
- ) : "";
3488
- const detail2 = [entry.action.replaceAll("_", " "), revs(entry), files].filter(Boolean).join(" ");
3489
- print(` ${mark} ${entry.unit.padEnd(width)} ${detail2}`);
3490
- if (entry.url && GOOD.has(entry.action)) print(` ${dim("\u2192")} ${entry.url}`);
3491
- if (entry.message) print(` ${dim(entry.message)}`);
3492
- }
3493
- if (quiet) print(dim(` ${plural(quiet, "unit")} ${report.command === "push" ? "unchanged" : "up to date"}`));
3494
- for (const hint of report.hints) print(dim(hint));
4031
+ function readBody(req, limit = 25 * 1024 * 1024) {
4032
+ return new Promise((resolve, reject) => {
4033
+ const chunks = [];
4034
+ let size2 = 0;
4035
+ req.on("data", (chunk2) => {
4036
+ size2 += chunk2.length;
4037
+ if (size2 > limit) {
4038
+ reject(new Error("body too large"));
4039
+ req.destroy();
4040
+ } else chunks.push(chunk2);
4041
+ });
4042
+ req.on("end", () => resolve(Buffer.concat(chunks)));
4043
+ req.on("error", reject);
4044
+ });
3495
4045
  }
3496
- async function runSync(paths, command, json, run) {
3497
- let report;
3498
- try {
3499
- report = await run(json ? openSync(paths, () => {
3500
- }) : openSync(paths, (line) => print(dim(line)), progress));
3501
- } catch (error) {
3502
- if (!json) throw error;
3503
- let link = null;
3504
- try {
3505
- link = readLink(paths);
3506
- } catch {
3507
- }
3508
- const cloud2 = error instanceof CloudError ? error : null;
3509
- const failure = {
3510
- ok: false,
3511
- command,
3512
- host: link?.host ?? "",
3513
- project: link?.project ?? "",
3514
- url: link ? webUrl(link) : "",
3515
- units: [],
3516
- hints: [],
3517
- error: {
3518
- code: cloud2?.error ?? (error instanceof CliError ? "error" : "internal"),
3519
- message: error.message,
3520
- status: cloud2?.status || void 0,
3521
- limit: cloud2?.limit
3522
- }
3523
- };
3524
- print(JSON.stringify(failure, null, 2));
3525
- process.exitCode = error instanceof CliError ? error.code : 1;
3526
- return;
4046
+ async function handleApi(ctx, req, res, url) {
4047
+ const { project, events, snapshots } = ctx;
4048
+ const route2 = url.pathname.slice("/api".length);
4049
+ const method = req.method ?? "GET";
4050
+ if (route2 === "/health") {
4051
+ sendJson(res, 200, { ok: true, root: project.paths.root, version: packageVersion(), pid: process.pid });
4052
+ return true;
3527
4053
  }
3528
- if (json) {
3529
- print(JSON.stringify(report, null, 2));
3530
- if (!report.ok) process.exitCode = 2;
3531
- return;
3532
- }
3533
- printReport(report);
3534
- if (!report.ok && report.error) throw new CliError(report.error.message, 2);
3535
- }
3536
- function runPush(paths, units, options) {
3537
- return runSync(paths, "push", options.json, (ctx) => push(ctx, { units, resolved: options.resolved }));
3538
- }
3539
- function runPull(paths, units, options) {
3540
- return runSync(paths, "pull", options.json, (ctx) => pull(ctx, { units, theirs: options.theirs }));
3541
- }
3542
-
3543
- // src/cli/commands/canvases.ts
3544
- var publicUrl = (host, publicId) => `${host.replace(/\/+$/, "")}/s/${publicId}`;
3545
- function ago(iso) {
3546
- const seconds2 = Math.max(0, (Date.now() - Date.parse(iso)) / 1e3);
3547
- if (!Number.isFinite(seconds2)) return "";
3548
- if (seconds2 < 60) return "just now";
3549
- const [value, unit] = seconds2 < 3600 ? [seconds2 / 60, "minute"] : seconds2 < 86400 ? [seconds2 / 3600, "hour"] : seconds2 < 86400 * 60 ? [seconds2 / 86400, "day"] : [seconds2 / (86400 * 30), "month"];
3550
- return `${plural(Math.floor(value), unit)} ago`;
3551
- }
3552
- function cloud(paths) {
3553
- const link = requireLink(paths);
3554
- const credential = requireCredential(link.host);
3555
- return { link, client: new CloudClient(link.host, credential.token) };
3556
- }
3557
- function canvasArg(value, usage) {
3558
- if (!value) throw new CliError(`Usage: ${usage}`);
3559
- const key = unitArg(value);
3560
- if (key === SYSTEM_UNIT) throw new CliError(`The design system is not a canvas. Usage: ${usage}`);
3561
- return key.slice("canvas/".length);
3562
- }
3563
- async function remoteCanvas(client, project, canvas) {
3564
- const unit = (await client.units(project)).get(canvasUnit(canvas));
3565
- if (!unit) throw new CliError(`${canvas} is not in the cloud yet: \`design push ${canvas}\` uploads it`);
3566
- return unit;
3567
- }
3568
- async function runCanvases(paths, options) {
3569
- const project = new DesignProject(paths, STATIC_URLS);
3570
- const local = project.canvasIds().map((id2) => project.canvas(id2).doc);
3571
- const link = readLink(paths);
3572
- let remote = null;
3573
- let cloudNote = link ? null : "not linked to foss-design Cloud \u2014 `design link`";
3574
- if (link) {
3575
- const credential = getCredential(link.host);
3576
- if (!credential) cloudNote = `not signed in to ${link.host} \u2014 \`design login\``;
3577
- else {
3578
- try {
3579
- remote = await new CloudClient(link.host, credential.token).units(link.project);
3580
- } catch (error) {
3581
- cloudNote = `cloud unavailable: ${error.message}`;
3582
- }
3583
- }
3584
- }
3585
- const scan = link ? scanLocal(paths) : null;
3586
- const ids = [
3587
- .../* @__PURE__ */ new Set([
3588
- ...local.map((doc) => doc.id),
3589
- ...[...remote?.keys() ?? []].filter((key) => key.startsWith("canvas/")).map((key) => key.slice(7))
3590
- ])
3591
- ].sort();
3592
- const rows = ids.map((id2) => {
3593
- const doc = local.find((entry) => entry.id === id2);
3594
- const unit = remote?.get(canvasUnit(id2));
3595
- const status = link && remote ? unitStatus(canvasUnit(id2), link, scan?.units.get(canvasUnit(id2))?.manifest, remote) : null;
3596
- const frames = doc?.pages.flatMap((page) => page.sections.flatMap((section) => section.items)).filter((item) => item.kind === "screen" || item.kind === "url");
3597
- return {
3598
- id: id2,
3599
- title: doc?.title ?? unit?.title ?? id2,
3600
- local: !!doc,
3601
- pages: doc?.pages.length ?? null,
3602
- screens: frames?.length ?? null,
3603
- errors: doc?.issues.filter((issue) => issue.severity === "error").length ?? 0,
3604
- cloud: unit ? {
3605
- rev: unit.headRev,
3606
- state: status?.state ?? null,
3607
- archived: unit.archived,
3608
- url: webUrl(link, canvasUnit(id2)),
3609
- publicUrl: unit.publicId ? publicUrl(link.host, unit.publicId) : null
3610
- } : null
3611
- };
3612
- });
3613
- if (options.json) return print(JSON.stringify({ canvases: rows, cloud: cloudNote }, null, 2));
3614
- if (!rows.length) print(dim("No canvases yet: `design new <canvas>` creates one."));
3615
- const width = Math.max(0, ...rows.map((row) => row.id.length));
3616
- for (const row of rows) {
3617
- const facts = row.local ? [`${plural(row.pages ?? 0, "page")} \xB7 ${plural(row.screens ?? 0, "screen")}`] : [yellow("only in the cloud \u2014 `design pull`")];
3618
- if (row.errors) facts.push(red(plural(row.errors, "error")));
3619
- if (row.cloud) {
3620
- const state = row.cloud.archived ? dim("archived") : row.cloud.state === "in_sync" ? green("in sync") : row.cloud.state ? yellow(row.cloud.state.replaceAll("_", " ")) : "";
3621
- facts.push([`r${row.cloud.rev}`, state].filter(Boolean).join(" "));
3622
- } else if (remote && row.local) facts.push(dim("not pushed"));
3623
- print(`${bold(row.id.padEnd(width))} ${row.title} ${dim("\xB7")} ${facts.join(dim(" \xB7 "))}`);
3624
- if (row.cloud && !row.cloud.archived) print(`${" ".repeat(width)} ${dim("\u2192")} ${row.cloud.url}`);
3625
- if (row.cloud?.publicUrl) print(`${" ".repeat(width)} ${dim("public")} ${cyan(row.cloud.publicUrl)}`);
3626
- }
3627
- if (cloudNote) print(dim(cloudNote));
3628
- }
3629
- async function runUrl(paths, value, options) {
3630
- const canvas = canvasArg(value, "design url <canvas>");
3631
- const { link, client } = cloud(paths);
3632
- const unit = await remoteCanvas(client, link.project, canvas);
3633
- const url = webUrl(link, canvasUnit(canvas));
3634
- const pub = unit.publicId ? publicUrl(link.host, unit.publicId) : null;
3635
- if (options.json) return print(JSON.stringify({ canvas, url, publicUrl: pub, archived: unit.archived }, null, 2));
3636
- print(url);
3637
- if (pub) print(`${dim("public")} ${pub}`);
3638
- if (unit.archived) print(dim("archived \u2014 `design unarchive` brings it back"));
3639
- }
3640
- async function runPublish(paths, value, publish) {
3641
- const verb = publish ? "publish" : "unpublish";
3642
- const canvas = canvasArg(value, `design ${verb} <canvas>`);
3643
- const { link, client } = cloud(paths);
3644
- await remoteCanvas(client, link.project, canvas);
3645
- const { publicId } = await client.canvasAction(link.project, canvas, verb);
3646
- if (publish) {
3647
- const id2 = publicId ?? (await remoteCanvas(client, link.project, canvas)).publicId;
3648
- print(`${green("\u2713")} Published ${bold(canvas)}: anyone with the link can view it`);
3649
- if (id2) print(` ${cyan(publicUrl(link.host, id2))}`);
3650
- } else print(`${green("\u2713")} ${bold(canvas)} has no public link now; members still open it in the project`);
3651
- }
3652
- async function runArchive(paths, value, archive) {
3653
- const verb = archive ? "archive" : "unarchive";
3654
- const canvas = canvasArg(value, `design ${verb} <canvas>`);
3655
- const { link, client } = cloud(paths);
3656
- await remoteCanvas(client, link.project, canvas);
3657
- await client.canvasAction(link.project, canvas, verb);
3658
- print(
3659
- archive ? `${green("\u2713")} Archived ${bold(canvas)} in the cloud; its history is kept. \`design unarchive ${canvas}\` brings it back.` : `${green("\u2713")} ${bold(canvas)} is active again ${dim(webUrl(link, canvasUnit(canvas)))}`
3660
- );
3661
- }
3662
- async function runHistory(paths, value, options) {
3663
- if (!value) throw new CliError("Usage: design history <canvas|system>");
3664
- const key = unitArg(value);
3665
- const { link, client } = cloud(paths);
3666
- let revisions;
3667
- try {
3668
- revisions = await client.revisions(link.project, key);
3669
- } catch (error) {
3670
- if (error instanceof CloudError && error.status === 404)
3671
- throw new CliError(`${key} is not in the cloud yet: \`design push\` uploads it`);
3672
- throw error;
3673
- }
3674
- if (options.json) return print(JSON.stringify({ unit: key, revisions }, null, 2));
3675
- print(`${bold(key)} ${dim(webUrl(link, key))}`);
3676
- for (const rev of revisions) {
3677
- const parts = [
3678
- rev.head ? green("current") : rev.rollbackAllowed ? "" : dim("built on an older system"),
3679
- ago(rev.createdAt),
3680
- rev.author ?? "",
3681
- rev.screens !== null ? plural(rev.screens, "screen") : "",
3682
- rev.bytes !== null ? megabytes(rev.bytes) : ""
3683
- ].filter(Boolean);
3684
- print(` ${bold(`r${rev.rev}`.padEnd(5))} ${parts.join(dim(" \xB7 "))}`);
3685
- }
3686
- const target = revisions.find((rev) => rev.rollbackAllowed);
3687
- if (key !== SYSTEM_UNIT && target)
3688
- print(dim(`\`design rollback ${key.slice(7)} ${target.rev}\` makes a revision current again`));
3689
- if (key === SYSTEM_UNIT) print(dim("The design system is not rolled back; change it and push."));
3690
- }
3691
- async function runRollback(paths, value, revArg) {
3692
- const canvas = canvasArg(value, "design rollback <canvas> <rev>");
3693
- const rev = Number((revArg ?? "").replace(/^r/i, ""));
3694
- if (!Number.isInteger(rev) || rev < 1)
3695
- throw new CliError("Usage: design rollback <canvas> <rev> (see `design history <canvas>`)");
3696
- const { link, client } = cloud(paths);
3697
- const head = await client.rollback(link.project, canvasUnit(canvas), rev);
3698
- print(`${green("\u2713")} ${bold(canvas)}: r${rev} is current again as r${head} in the cloud`);
3699
- await runPull(paths, [canvas], { theirs: [], json: false });
3700
- }
3701
- var obj = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
3702
- var n = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
3703
- async function runMe(paths, options) {
3704
- const link = paths ? readLink(paths) : null;
3705
- const host = link?.host ?? cloudHost();
3706
- const credential = requireCredential(host);
3707
- const client = new CloudClient(host, credential.token);
3708
- const [me, usage, activity] = await Promise.all([
3709
- client.request("GET", "/me").then(obj),
3710
- client.request("GET", "/me/usage").then(obj),
3711
- client.request("GET", "/me/activity").then(obj)
3712
- ]);
3713
- const plan = obj(usage.plan);
3714
- const limits = obj(usage.limits);
3715
- const totals = obj(activity.totals);
3716
- let linked = null;
3717
- if (link) {
3718
- const projects = await client.projects().catch(() => null);
3719
- const project = projects && [...projects.owned, ...projects.shared].find((p) => p.id === link.project);
3720
- linked = project ? { id: project.id, name: project.name, role: project.role } : { id: link.project, name: "", role: "" };
3721
- }
3722
- const data = {
3723
- host,
3724
- email: me.email,
3725
- name: me.name,
3726
- memberSince: me.createdAt,
3727
- plan: { id: plan.id, name: plan.name, expiresAt: plan.expiresAt ?? null },
3728
- storage: { usedBytes: n(usage.storageBytes), limitBytes: n(limits.storage_bytes) },
3729
- activeProjects: { used: n(usage.activeProjects), limit: n(limits.active_projects) },
3730
- canvases: n(totals.canvases),
3731
- screens: n(totals.screens),
3732
- pushesLastYear: n(totals.pushesLastYear),
3733
- pushesPerHour: n(limits.pushes_per_hour),
3734
- linked
3735
- };
3736
- if (options.json) return print(JSON.stringify(data, null, 2));
3737
- const label = (text2) => dim(text2.padEnd(10));
3738
- const date = (iso) => typeof iso === "string" ? new Date(iso).toDateString().slice(4) : "";
3739
- const share = data.storage.limitBytes ? Math.round(data.storage.usedBytes / data.storage.limitBytes * 100) : 0;
3740
- print(
3741
- `${bold(String(data.email ?? ""))}${data.name && data.name !== data.email ? ` ${dim(`(${data.name})`)}` : ""} ${dim(host)}`
3742
- );
3743
- print(
3744
- `${label("Plan")}${bold(String(data.plan.name ?? "Free"))} ${dim(data.plan.expiresAt ? `until ${date(data.plan.expiresAt)}` : "no end date")}`
3745
- );
3746
- print(
3747
- `${label("Storage")}${megabytes(data.storage.usedBytes)} of ${megabytes(data.storage.limitBytes)} ${(share >= 90 ? red : share >= 80 ? yellow : dim)(`(${share}%)`)}`
3748
- );
3749
- print(`${label("Projects")}${data.activeProjects.used} of ${data.activeProjects.limit} active`);
3750
- print(
3751
- `${label("Canvases")}${plural(data.canvases, "canvas", "canvases")} in your projects \xB7 ${plural(data.screens, "screen")}`
3752
- );
3753
- print(
3754
- `${label("Pushes")}${plural(data.pushesLastYear, "push", "pushes")} in the last year ${dim(`\xB7 up to ${data.pushesPerHour} an hour`)}`
3755
- );
3756
- if (linked)
3757
- print(
3758
- `${label("Linked")}${linked.name || linked.id}${linked.role ? dim(` as ${linked.role}`) : ""} ${dim(webUrl(link))}`
3759
- );
3760
- else if (paths) print(`${label("Linked")}${dim("no \u2014 `design link`")}`);
3761
- print(`${label("Since")}${date(data.memberSince)} ${dim(`\xB7 plans and upgrades: ${host.replace(/\/+$/, "")}/plans`)}`);
3762
- }
3763
-
3764
- // src/capture/chrome.ts
3765
- import fs19 from "fs";
3766
- var CANDIDATES = {
3767
- darwin: [
3768
- "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
3769
- "/Applications/Chromium.app/Contents/MacOS/Chromium",
3770
- "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
3771
- "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
3772
- ],
3773
- linux: [
3774
- "/usr/bin/google-chrome",
3775
- "/usr/bin/google-chrome-stable",
3776
- "/usr/bin/chromium",
3777
- "/usr/bin/chromium-browser",
3778
- "/snap/bin/chromium",
3779
- "/usr/bin/microsoft-edge"
3780
- ],
3781
- win32: [
3782
- "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
3783
- "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
3784
- "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
3785
- ]
3786
- };
3787
- function findChrome() {
3788
- const configured = process.env.DESIGN_CHROME;
3789
- if (configured && fs19.existsSync(configured)) return configured;
3790
- for (const candidate of CANDIDATES[process.platform] ?? []) if (fs19.existsSync(candidate)) return candidate;
3791
- return null;
3792
- }
3793
- async function launchChrome() {
3794
- const executablePath = findChrome();
3795
- if (!executablePath) {
3796
- throw new Error(
3797
- "No Chrome, Chromium or Edge found. Install one, or point DESIGN_CHROME at a Chromium-based browser binary."
3798
- );
3799
- }
3800
- const { chromium } = await import("playwright-core");
3801
- return chromium.launch({ executablePath, headless: true, args: ["--hide-scrollbars", "--force-color-profile=srgb"] });
3802
- }
3803
- var OUTDATED_DEP = /Outdated Optimize Dep/;
3804
- async function openFrame(page, url, options = {}) {
3805
- let errors = [];
3806
- let outdated = () => {
3807
- };
3808
- const record2 = (text2) => {
3809
- errors.push(text2);
3810
- if (OUTDATED_DEP.test(text2)) outdated();
3811
- };
3812
- page.on("pageerror", (error) => record2(error.message));
3813
- page.on("console", (message) => {
3814
- if (message.type() !== "error") return;
3815
- let text2 = message.text().replace(/^(?:\s*%[osdifcO])+\s*/, "");
3816
- const where = message.location().url;
3817
- if (/^Failed to load resource/.test(text2) && where && !text2.includes(where)) text2 += ` (${where})`;
3818
- record2(text2);
3819
- });
3820
- let ready = false;
3821
- let reloads = 0;
3822
- const onNavigate = () => {
3823
- reloads++;
3824
- };
3825
- for (let attempt = 0; attempt < 2; attempt++) {
3826
- errors = [];
3827
- reloads = 0;
3828
- ready = options.waitForReady === false;
3829
- const turnedAway = new Promise((resolve) => {
3830
- outdated = () => resolve("outdated");
3831
- });
3832
- await page.goto(url, { waitUntil: "load", timeout: options.timeoutMs ?? 3e4 });
3833
- page.on("load", onNavigate);
3834
- if (!ready) {
3835
- const signal = page.waitForFunction(() => window.__DESIGN_READY__ === true, null, {
3836
- timeout: options.timeoutMs ?? 2e4
3837
- }).then(() => "ready");
3838
- signal.catch(() => {
3839
- });
3840
- ready = await Promise.race([signal, turnedAway]).catch(() => null) === "ready";
3841
- }
3842
- if (ready || options.waitForReady === false) await page.waitForTimeout(options.settleMs ?? 700);
3843
- page.off("load", onNavigate);
3844
- if (!reloads && !errors.some((error) => OUTDATED_DEP.test(error))) break;
3845
- }
3846
- if (reloads) errors.push(`the page reloaded itself ${reloads === 1 ? "once" : `${reloads} times`} while it loaded`);
3847
- const held = ready ? 0 : await page.evaluate(() => window.__DESIGN_HOLDS__ ?? 0).catch(() => 0);
3848
- const runtimeErrors = await page.evaluate(() => window.__DESIGN_ERRORS__ ?? []).catch(() => []);
3849
- for (const error of runtimeErrors) if (!errors.includes(error)) errors.push(error);
3850
- const overlay = await page.evaluate(() => {
3851
- const el = document.querySelector("vite-error-overlay");
3852
- return el?.shadowRoot?.querySelector(".message")?.textContent ?? null;
3853
- }).catch(() => null);
3854
- if (overlay) errors.unshift(overlay.trim());
3855
- const unique2 = errors.filter(
3856
- (error, index) => error && !errors.some((other, j) => j !== index && other && other.length < error.length && error.includes(other))
3857
- );
3858
- return { errors: [...new Set(unique2)], ready, ...held ? { held } : {} };
3859
- }
3860
-
3861
- // src/server/state.ts
3862
- import fs20 from "fs";
3863
- import path19 from "path";
3864
- var stateFile = (paths) => path19.join(paths.cache, "server.json");
3865
- var logFile = (paths) => path19.join(paths.cache, "server.log");
3866
- function readState(paths) {
3867
- try {
3868
- return JSON.parse(fs20.readFileSync(stateFile(paths), "utf8"));
3869
- } catch {
3870
- return null;
3871
- }
3872
- }
3873
- function writeState(paths, state) {
3874
- fs20.mkdirSync(paths.cache, { recursive: true });
3875
- fs20.writeFileSync(stateFile(paths), `${JSON.stringify(state, null, 2)}
3876
- `);
3877
- }
3878
- function clearState(paths, pid) {
3879
- const state = readState(paths);
3880
- if (pid !== void 0 && state && state.pid !== pid) return;
3881
- fs20.rmSync(stateFile(paths), { force: true });
3882
- }
3883
- function pidAlive(pid) {
3884
- try {
3885
- process.kill(pid, 0);
3886
- return true;
3887
- } catch (error) {
3888
- return error.code === "EPERM";
3889
- }
3890
- }
3891
- async function liveServer(paths) {
3892
- const state = readState(paths);
3893
- if (!state || !pidAlive(state.pid)) return null;
3894
- try {
3895
- const response = await fetch(`http://127.0.0.1:${state.port}/api/health`, { signal: AbortSignal.timeout(1500) });
3896
- if (!response.ok) return null;
3897
- const health = await response.json();
3898
- return health.root === paths.root ? state : null;
3899
- } catch {
3900
- return null;
3901
- }
3902
- }
3903
-
3904
- // src/cli/commands/preview.ts
3905
- import { spawn } from "child_process";
3906
- import fs24 from "fs";
3907
-
3908
- // src/server/index.ts
3909
- import fs23 from "fs";
3910
- import http from "http";
3911
- import path22 from "path";
3912
- import sirv from "sirv";
3913
- import { createServer, searchForWorkspaceRoot } from "vite";
3914
-
3915
- // src/server/api.ts
3916
- import fs21 from "fs";
3917
- import path20 from "path";
3918
- function sendJson(res, status, body) {
3919
- res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
3920
- res.end(JSON.stringify(body));
3921
- }
3922
- function readBody(req, limit = 25 * 1024 * 1024) {
3923
- return new Promise((resolve, reject) => {
3924
- const chunks = [];
3925
- let size2 = 0;
3926
- req.on("data", (chunk2) => {
3927
- size2 += chunk2.length;
3928
- if (size2 > limit) {
3929
- reject(new Error("body too large"));
3930
- req.destroy();
3931
- } else chunks.push(chunk2);
3932
- });
3933
- req.on("end", () => resolve(Buffer.concat(chunks)));
3934
- req.on("error", reject);
3935
- });
3936
- }
3937
- async function handleApi(ctx, req, res, url) {
3938
- const { project, events, snapshots } = ctx;
3939
- const route2 = url.pathname.slice("/api".length);
3940
- const method = req.method ?? "GET";
3941
- if (route2 === "/health") {
3942
- sendJson(res, 200, { ok: true, root: project.paths.root, version: packageVersion(), pid: process.pid });
3943
- return true;
3944
- }
3945
- if (route2 === "/events") {
3946
- events.attach(req, res);
3947
- return true;
4054
+ if (route2 === "/events") {
4055
+ events.attach(req, res);
4056
+ return true;
3948
4057
  }
3949
4058
  if (route2 === "/project") {
3950
4059
  sendJson(res, 200, project.info(false));
@@ -3965,17 +4074,17 @@ async function handleApi(ctx, req, res, url) {
3965
4074
  }
3966
4075
  if (route2 === "/source") {
3967
4076
  const rel = url.searchParams.get("path") ?? "";
3968
- const file = path20.resolve(project.paths.root, rel);
3969
- if (!rel || !isInside(project.paths.root, file) || !fs21.existsSync(file) || fs21.statSync(file).isDirectory()) {
4077
+ const file = path23.resolve(project.paths.root, rel);
4078
+ if (!rel || !isInside(project.paths.root, file) || !fs25.existsSync(file) || fs25.statSync(file).isDirectory()) {
3970
4079
  sendJson(res, 404, { error: "not found" });
3971
4080
  return true;
3972
4081
  }
3973
- if (fs21.statSync(file).size > 2 * 1024 * 1024) {
4082
+ if (fs25.statSync(file).size > 2 * 1024 * 1024) {
3974
4083
  sendJson(res, 413, { error: "file too large to show" });
3975
4084
  return true;
3976
4085
  }
3977
4086
  res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
3978
- res.end(fs21.readFileSync(file, "utf8"));
4087
+ res.end(fs25.readFileSync(file, "utf8"));
3979
4088
  return true;
3980
4089
  }
3981
4090
  if (route2 === "/icon" && (method === "PUT" || method === "DELETE")) {
@@ -4022,7 +4131,7 @@ async function handleApi(ctx, req, res, url) {
4022
4131
  return true;
4023
4132
  }
4024
4133
  res.writeHead(200, { "content-type": "image/png", "cache-control": "public, max-age=31536000, immutable" });
4025
- fs21.createReadStream(file).pipe(res);
4134
+ fs25.createReadStream(file).pipe(res);
4026
4135
  return true;
4027
4136
  }
4028
4137
  return false;
@@ -4061,73 +4170,48 @@ var EventHub = class {
4061
4170
  }
4062
4171
  };
4063
4172
 
4064
- // src/server/snapshots.ts
4065
- import fs22 from "fs";
4066
- import path21 from "path";
4067
- var SAFE = /^@?[a-z0-9][a-z0-9_-]*$/i;
4068
- function isSafeSegment(value) {
4069
- return SAFE.test(value);
4070
- }
4071
- var SnapshotStore = class {
4072
- constructor(dir, urlBase = "/api/snapshots") {
4073
- this.dir = dir;
4074
- this.urlBase = urlBase;
4075
- }
4076
- dir;
4077
- urlBase;
4078
- meta = /* @__PURE__ */ new Map();
4079
- metaFile(canvas) {
4080
- return path21.join(this.dir, canvas, "meta.json");
4081
- }
4082
- load(canvas) {
4083
- const cached = this.meta.get(canvas);
4084
- if (cached) return cached;
4085
- let data = {};
4086
- try {
4087
- data = JSON.parse(fs22.readFileSync(this.metaFile(canvas), "utf8"));
4088
- } catch {
4089
- }
4090
- this.meta.set(canvas, data);
4091
- return data;
4092
- }
4093
- url(canvas, id2, theme2, version, thumb = false) {
4094
- const name = `${encodeURIComponent(id2)}.${theme2}${thumb ? ".thumb" : ""}.png`;
4095
- return `${this.urlBase}/${encodeURIComponent(canvas)}/${name}?v=${version}`;
4173
+ // src/server/state.ts
4174
+ import fs26 from "fs";
4175
+ import path24 from "path";
4176
+ var stateFile2 = (paths) => path24.join(paths.cache, "server.json");
4177
+ var logFile = (paths) => path24.join(paths.cache, "server.log");
4178
+ function readState(paths) {
4179
+ try {
4180
+ return JSON.parse(fs26.readFileSync(stateFile2(paths), "utf8"));
4181
+ } catch {
4182
+ return null;
4096
4183
  }
4097
- lookup = (canvas, id2) => {
4098
- if (!isSafeSegment(canvas) || !isSafeSegment(id2)) return null;
4099
- const entry = this.load(canvas)[id2];
4100
- if (!entry) return null;
4101
- const urls = {};
4102
- const thumbs = {};
4103
- for (const theme2 of ["light", "dark"]) {
4104
- const version = entry[theme2];
4105
- if (!version) continue;
4106
- urls[theme2] = this.url(canvas, id2, theme2, version);
4107
- thumbs[theme2] = this.url(canvas, id2, theme2, version, true);
4108
- }
4109
- return { urls, thumbs, height: entry.height };
4110
- };
4111
- save(canvas, id2, theme2, png, height) {
4112
- if (!isSafeSegment(canvas) || !isSafeSegment(id2)) throw new Error("bad snapshot key");
4113
- const dir = path21.join(this.dir, canvas);
4114
- fs22.mkdirSync(dir, { recursive: true });
4115
- fs22.writeFileSync(path21.join(dir, `${id2}.${theme2}.png`), png);
4116
- const data = this.load(canvas);
4117
- const version = Date.now();
4118
- data[id2] = { ...data[id2], [theme2]: version, ...height ? { height } : {} };
4119
- fs22.writeFileSync(this.metaFile(canvas), JSON.stringify(data));
4120
- return { url: this.url(canvas, id2, theme2, version) };
4184
+ }
4185
+ function writeState(paths, state) {
4186
+ fs26.mkdirSync(paths.cache, { recursive: true });
4187
+ fs26.writeFileSync(stateFile2(paths), `${JSON.stringify(state, null, 2)}
4188
+ `);
4189
+ }
4190
+ function clearState(paths, pid) {
4191
+ const state = readState(paths);
4192
+ if (pid !== void 0 && state && state.pid !== pid) return;
4193
+ fs26.rmSync(stateFile2(paths), { force: true });
4194
+ }
4195
+ function pidAlive(pid) {
4196
+ try {
4197
+ process.kill(pid, 0);
4198
+ return true;
4199
+ } catch (error) {
4200
+ return error.code === "EPERM";
4121
4201
  }
4122
- /** Absolute path of `<id>.<theme>.png`, or of its thumbnail for `<id>.<theme>.thumb.png` (made on first use); null when missing. */
4123
- file(canvas, name) {
4124
- const match = /^(@?[a-z0-9][a-z0-9_-]*)\.(light|dark)(\.thumb)?\.png$/i.exec(name);
4125
- if (!isSafeSegment(canvas) || !match) return null;
4126
- const file = path21.join(this.dir, canvas, `${match[1]}.${match[2]}.png`);
4127
- if (!fs22.existsSync(file)) return null;
4128
- return match[3] ? ensureThumb(file) : file;
4202
+ }
4203
+ async function liveServer(paths) {
4204
+ const state = readState(paths);
4205
+ if (!state || !pidAlive(state.pid)) return null;
4206
+ try {
4207
+ const response = await fetch(`http://127.0.0.1:${state.port}/api/health`, { signal: AbortSignal.timeout(1500) });
4208
+ if (!response.ok) return null;
4209
+ const health = await response.json();
4210
+ return health.root === paths.root ? state : null;
4211
+ } catch {
4212
+ return null;
4129
4213
  }
4130
- };
4214
+ }
4131
4215
 
4132
4216
  // src/server/index.ts
4133
4217
  var DEFAULT_PORT = 0;
@@ -4163,8 +4247,8 @@ function notFoundPage(res, title, text2) {
4163
4247
  }
4164
4248
  async function startDevServer(options) {
4165
4249
  const paths = designPaths(options.root);
4166
- if (!fs23.existsSync(paths.design)) throw new Error(`No .design folder in ${options.root}; run \`design init\``);
4167
- fs23.mkdirSync(paths.cache, { recursive: true });
4250
+ if (!fs27.existsSync(paths.design)) throw new Error(`No .design folder in ${options.root}; run \`design init\``);
4251
+ fs27.mkdirSync(paths.cache, { recursive: true });
4168
4252
  const snapshots = new SnapshotStore(paths.snapshots);
4169
4253
  const project = new DesignProject(paths, DEV_URLS, snapshots.lookup);
4170
4254
  const entries = new Entries(project);
@@ -4190,8 +4274,8 @@ async function startDevServer(options) {
4190
4274
  // `.cache` is ours (server.log, snapshots, builds): a change there must not reload frames. The
4191
4275
  // generated entries are modules the frames load, so those stay watched.
4192
4276
  ignored: (file) => {
4193
- const rel = toPosix(path22.relative(paths.cache, file));
4194
- const inCache = rel !== "" && !rel.startsWith("..") && !path22.isAbsolute(rel);
4277
+ const rel = toPosix(path25.relative(paths.cache, file));
4278
+ const inCache = rel !== "" && !rel.startsWith("..") && !path25.isAbsolute(rel);
4195
4279
  return inCache && rel !== "entries" && !rel.startsWith("entries/");
4196
4280
  }
4197
4281
  },
@@ -4200,7 +4284,7 @@ async function startDevServer(options) {
4200
4284
  forwardConsole: false
4201
4285
  },
4202
4286
  optimizeDeps: {
4203
- entries: entries.files().map((file) => toPosix(path22.relative(paths.design, file)))
4287
+ entries: entries.files().map((file) => toPosix(path25.relative(paths.design, file)))
4204
4288
  }
4205
4289
  });
4206
4290
  const publicDir = project.publicDir();
@@ -4212,16 +4296,16 @@ async function startDevServer(options) {
4212
4296
  return null;
4213
4297
  }
4214
4298
  };
4215
- const viewer = fs23.existsSync(PKG.viewer) ? sirv(PKG.viewer, { dev: true, etag: true }) : null;
4216
- const viewerIndex = path22.join(PKG.viewer, "index.html");
4299
+ const viewer = fs27.existsSync(PKG.viewer) ? sirv(PKG.viewer, { dev: true, etag: true }) : null;
4300
+ const viewerIndex = path25.join(PKG.viewer, "index.html");
4217
4301
  const serveViewerIndex = (res) => {
4218
- const html = fs23.readFileSync(viewerIndex, "utf8").replace(/<head>/i, '<head>\n<base href="/">');
4302
+ const html = fs27.readFileSync(viewerIndex, "utf8").replace(/<head>/i, '<head>\n<base href="/">');
4219
4303
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
4220
4304
  res.end(html);
4221
4305
  };
4222
4306
  const ctx = { project, events, snapshots };
4223
4307
  const systemCssUrl = `/${relToDesign(paths, entries.systemCssFile())}`;
4224
- const bootUrl = `/${relToDesign(paths, path22.join(entries.dir, HTML_BOOT))}`;
4308
+ const bootUrl = `/${relToDesign(paths, path25.join(entries.dir, HTML_BOOT))}`;
4225
4309
  const frameHead = (source, extra = {}) => {
4226
4310
  const canvasTheme = source.canvas === SYSTEM_CANVAS ? void 0 : project.canvas(source.canvas)?.doc.theme;
4227
4311
  return {
@@ -4242,7 +4326,7 @@ async function startDevServer(options) {
4242
4326
  notFoundPage(res, "Screen not found", `No screen "${id2}" in "${canvas}". Check canvas.json.`);
4243
4327
  return;
4244
4328
  }
4245
- if (!fs23.existsSync(entries.entryFile(source))) entries.sync();
4329
+ if (!fs27.existsSync(entries.entryFile(source))) entries.sync();
4246
4330
  const entryUrl = `/${relToDesign(paths, entries.entryFile(source))}`;
4247
4331
  const html = await vite.transformIndexHtml(url2.pathname + url2.search, moduleShell(frameHead(source), entryUrl));
4248
4332
  res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
@@ -4251,8 +4335,8 @@ async function startDevServer(options) {
4251
4335
  };
4252
4336
  const serveHtmlScreen = async (res, url2) => {
4253
4337
  const rel = decodeURIComponent(url2.pathname.slice("/_fs/".length));
4254
- const file = path22.resolve(paths.design, rel);
4255
- if (!isInside(paths.design, file) || !fs23.existsSync(file)) {
4338
+ const file = path25.resolve(paths.design, rel);
4339
+ if (!isInside(paths.design, file) || !fs27.existsSync(file)) {
4256
4340
  notFoundPage(res, "Screen not found", `No file .design/${rel}`);
4257
4341
  return;
4258
4342
  }
@@ -4262,13 +4346,13 @@ async function startDevServer(options) {
4262
4346
  key: `${canvas}/${id2}`,
4263
4347
  canvas,
4264
4348
  id: id2,
4265
- title: path22.basename(file),
4349
+ title: path25.basename(file),
4266
4350
  file,
4267
4351
  format: "html",
4268
4352
  props: {},
4269
4353
  system: true
4270
4354
  };
4271
- const raw = fs23.readFileSync(file, "utf8");
4355
+ const raw = fs27.readFileSync(file, "utf8");
4272
4356
  const withSystem = source.system && !optsOutOfSystem(raw);
4273
4357
  const html = injectIntoHtml(
4274
4358
  raw,
@@ -4301,7 +4385,7 @@ async function startDevServer(options) {
4301
4385
  }
4302
4386
  if (url2.pathname.startsWith("/_fs/")) {
4303
4387
  const rel = publicRel(url2.pathname.slice("/_fs/".length));
4304
- if (publicDir && rel !== null && !project.isPublicFile(rel) && fs23.existsSync(path22.join(publicDir, rel))) {
4388
+ if (publicDir && rel !== null && !project.isPublicFile(rel) && fs27.existsSync(path25.join(publicDir, rel))) {
4305
4389
  notFoundPage(res, "Not found", url2.pathname);
4306
4390
  return;
4307
4391
  }
@@ -4313,7 +4397,7 @@ async function startDevServer(options) {
4313
4397
  const rel = publicRel(url2.pathname.slice(1));
4314
4398
  if (servePublic && rel !== null && project.isPublicFile(rel))
4315
4399
  servePublic(req, res, () => notFoundPage(res, "Not found", url2.pathname));
4316
- else if (path22.extname(url2.pathname)) notFoundPage(res, "Not found", url2.pathname);
4400
+ else if (path25.extname(url2.pathname)) notFoundPage(res, "Not found", url2.pathname);
4317
4401
  else serveViewerIndex(res);
4318
4402
  };
4319
4403
  if (url2.pathname === "/" || url2.pathname === "/index.html") serveViewerIndex(res);
@@ -4341,7 +4425,7 @@ async function startDevServer(options) {
4341
4425
  };
4342
4426
  vite.watcher.on("all", (event, file) => {
4343
4427
  if (publicDir && isInside(publicDir, file)) project.invalidatePublic();
4344
- const rel = toPosix(path22.relative(paths.design, file));
4428
+ const rel = toPosix(path25.relative(paths.design, file));
4345
4429
  if (rel.startsWith("..") || rel.startsWith(".cache/") || rel.startsWith("node_modules/")) return;
4346
4430
  if (rel === "design.json") {
4347
4431
  queue("config", () => {
@@ -4412,76 +4496,401 @@ function printUrls(paths, url) {
4412
4496
  for (const id2 of project.canvasIds()) print(` ${dim("canvas")} ${url}/c/${id2}`);
4413
4497
  if (project.system()) print(` ${dim("system")} ${url}/system`);
4414
4498
  }
4415
- async function waitForServer(paths, timeoutMs) {
4416
- const until = Date.now() + timeoutMs;
4417
- while (Date.now() < until) {
4418
- const live2 = await liveServer(paths);
4419
- if (live2) return live2;
4420
- await new Promise((resolve) => setTimeout(resolve, 150));
4421
- }
4422
- return null;
4499
+ async function waitForServer(paths, timeoutMs) {
4500
+ const until = Date.now() + timeoutMs;
4501
+ while (Date.now() < until) {
4502
+ const live2 = await liveServer(paths);
4503
+ if (live2) return live2;
4504
+ await new Promise((resolve) => setTimeout(resolve, 150));
4505
+ }
4506
+ return null;
4507
+ }
4508
+ async function stopServer(paths) {
4509
+ const state = readState(paths);
4510
+ if (!state) return false;
4511
+ try {
4512
+ process.kill(state.pid, "SIGTERM");
4513
+ } catch {
4514
+ clearState(paths);
4515
+ return false;
4516
+ }
4517
+ const until = Date.now() + 5e3;
4518
+ while (Date.now() < until) {
4519
+ try {
4520
+ process.kill(state.pid, 0);
4521
+ } catch {
4522
+ break;
4523
+ }
4524
+ await new Promise((resolve) => setTimeout(resolve, 100));
4525
+ }
4526
+ clearState(paths);
4527
+ return true;
4528
+ }
4529
+ async function ensureServer(paths, options) {
4530
+ const live2 = await liveServer(paths);
4531
+ const stale = live2 && live2.version !== packageVersion();
4532
+ if (live2 && !options.restart && !stale) return live2;
4533
+ if (stale)
4534
+ print(dim(`Restarting the preview server: it runs foss-design ${live2.version}, this is ${packageVersion()}.`));
4535
+ if (live2) await stopServer(paths);
4536
+ fs28.mkdirSync(paths.cache, { recursive: true });
4537
+ const log = fs28.openSync(logFile(paths), "w");
4538
+ const args = [PKG.cli, "preview", "--foreground", "--root", paths.root];
4539
+ if (options.port) args.push("--port", String(options.port));
4540
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log], cwd: paths.root });
4541
+ child.unref();
4542
+ const state = await waitForServer(paths, 4e4);
4543
+ if (!state) {
4544
+ const tail = fs28.existsSync(logFile(paths)) ? fs28.readFileSync(logFile(paths), "utf8").split("\n").slice(-25) : [];
4545
+ throw new CliError(`The preview server did not start.
4546
+ ${tail.join("\n")}`);
4547
+ }
4548
+ return state;
4549
+ }
4550
+ async function runPreview(paths, options) {
4551
+ const openPath = typeof options.open === "string" ? options.open : "";
4552
+ if (options.foreground) {
4553
+ const server = await startDevServer({ root: paths.root, port: options.port });
4554
+ printUrls(paths, server.url);
4555
+ if (options.open) openBrowser(`${server.url}${openPath}`);
4556
+ const shutdown = () => {
4557
+ void server.close().finally(() => process.exit(0));
4558
+ };
4559
+ process.on("SIGINT", shutdown);
4560
+ process.on("SIGTERM", shutdown);
4561
+ return;
4562
+ }
4563
+ const state = await ensureServer(paths, options);
4564
+ printUrls(paths, state.url);
4565
+ if (options.open) openBrowser(`${state.url}${openPath}`);
4566
+ }
4567
+ async function runStop(paths) {
4568
+ print(await stopServer(paths) ? "Stopped the preview server." : "No preview server is running for this project.");
4569
+ }
4570
+
4571
+ // src/cli/commands/sync.ts
4572
+ var GOOD = /* @__PURE__ */ new Set(["pushed", "created", "updated", "deleted"]);
4573
+ var BAD = /* @__PURE__ */ new Set(["conflict", "remote_ahead"]);
4574
+ var QUIET = /* @__PURE__ */ new Set(["up_to_date", "unchanged"]);
4575
+ function revs(entry) {
4576
+ if (entry.action === "created") return entry.rev !== void 0 ? `r${entry.rev}` : "";
4577
+ if (entry.action === "deleted") return "";
4578
+ if (GOOD.has(entry.action))
4579
+ return entry.baseRev && entry.baseRev !== entry.rev ? `r${entry.baseRev} \u2192 r${entry.rev}` : `r${entry.rev}`;
4580
+ if (BAD.has(entry.action) && entry.headRev !== void 0) return `r${entry.baseRev ?? 0} \u2192 r${entry.headRev}`;
4581
+ return "";
4582
+ }
4583
+ function printReport(report) {
4584
+ const verb = report.command === "push" ? "Pushed to" : "Pulled from";
4585
+ const shown = report.units.filter((entry) => !QUIET.has(entry.action));
4586
+ const quiet = report.units.length - shown.length;
4587
+ const moved = report.units.some((entry) => GOOD.has(entry.action));
4588
+ if (report.ok && !moved) print(`${bold(`Nothing to ${report.command}`)} ${dim(report.url)}`);
4589
+ else if (report.ok) print(`${bold(verb)} ${report.url}`);
4590
+ else print(`${bold(report.command === "push" ? "Push stopped" : "Pull finished with conflicts")} ${dim(report.host)}`);
4591
+ const width = Math.max(0, ...shown.map((entry) => entry.unit.length));
4592
+ for (const entry of shown) {
4593
+ const mark = GOOD.has(entry.action) ? green("\u2713") : BAD.has(entry.action) ? red("\u2717") : yellow("!");
4594
+ const files = entry.files ? dim(
4595
+ [
4596
+ entry.files.added && `+${entry.files.added}`,
4597
+ entry.files.changed && `~${entry.files.changed}`,
4598
+ entry.files.removed && `-${entry.files.removed}`
4599
+ ].filter(Boolean).join(" ")
4600
+ ) : "";
4601
+ const detail2 = [entry.action.replaceAll("_", " "), revs(entry), files].filter(Boolean).join(" ");
4602
+ print(` ${mark} ${entry.unit.padEnd(width)} ${detail2}`);
4603
+ if (entry.url && GOOD.has(entry.action)) print(` ${dim("\u2192")} ${entry.url}`);
4604
+ if (entry.message) print(` ${dim(entry.message)}`);
4605
+ }
4606
+ if (quiet) print(dim(` ${plural(quiet, "unit")} ${report.command === "push" ? "unchanged" : "up to date"}`));
4607
+ for (const hint of report.hints) print(dim(hint));
4608
+ }
4609
+ async function runSync(paths, command, json, run) {
4610
+ let report;
4611
+ try {
4612
+ report = await run(json ? openSync(paths, () => {
4613
+ }) : openSync(paths, (line) => print(dim(line)), progress));
4614
+ } catch (error) {
4615
+ if (!json) throw error;
4616
+ let link = null;
4617
+ try {
4618
+ link = readLink(paths);
4619
+ } catch {
4620
+ }
4621
+ const cloud2 = error instanceof CloudError ? error : null;
4622
+ const failure = {
4623
+ ok: false,
4624
+ command,
4625
+ host: link?.host ?? "",
4626
+ project: link?.project ?? "",
4627
+ url: link ? webUrl(link) : "",
4628
+ units: [],
4629
+ hints: [],
4630
+ error: {
4631
+ code: cloud2?.error ?? (error instanceof CliError ? "error" : "internal"),
4632
+ message: error.message,
4633
+ status: cloud2?.status || void 0,
4634
+ limit: cloud2?.limit
4635
+ }
4636
+ };
4637
+ print(JSON.stringify(failure, null, 2));
4638
+ process.exitCode = error instanceof CliError ? error.code : 1;
4639
+ return;
4640
+ }
4641
+ if (json) {
4642
+ print(JSON.stringify(report, null, 2));
4643
+ if (!report.ok) process.exitCode = 2;
4644
+ return;
4645
+ }
4646
+ printReport(report);
4647
+ if (!report.ok && report.error) throw new CliError(report.error.message, 2);
4648
+ }
4649
+ function runPush(paths, units, options) {
4650
+ const beforeBuild = (canvasIds) => refreshCovers(paths, canvasIds, async () => `http://127.0.0.1:${(await ensureServer(paths, {})).port}`);
4651
+ return runSync(paths, "push", options.json, async (ctx) => {
4652
+ const first = await push(ctx, { units, resolved: options.resolved, beforeBuild });
4653
+ if (first.ok || first.error?.code !== "pull_first") return first;
4654
+ ctx.log("The cloud is ahead: merging its changes first\u2026");
4655
+ const pulled = await pull(ctx, { units: [], theirs: [] });
4656
+ if (!pulled.ok) {
4657
+ pulled.error = {
4658
+ code: pulled.error?.code ?? "conflict",
4659
+ message: `Nothing was pushed: ${pulled.error?.message ?? "the pull did not finish"}`
4660
+ };
4661
+ return pulled;
4662
+ }
4663
+ ctx.link = readLink(paths);
4664
+ const second = await push(ctx, { units, resolved: [], beforeBuild });
4665
+ second.hints.unshift(
4666
+ `Merged the cloud's changes first: ${pulled.units.filter((entry) => entry.action !== "up_to_date").map((entry) => `${entry.unit} (${entry.action.replaceAll("_", " ")})`).join(", ")}`
4667
+ );
4668
+ return second;
4669
+ });
4670
+ }
4671
+ function runPull(paths, units, options) {
4672
+ return runSync(paths, "pull", options.json, (ctx) => pull(ctx, { units, theirs: options.theirs }));
4673
+ }
4674
+
4675
+ // src/cli/commands/canvases.ts
4676
+ var publicUrl = (host, publicId) => `${host.replace(/\/+$/, "")}/s/${publicId}`;
4677
+ function ago(iso) {
4678
+ const seconds2 = Math.max(0, (Date.now() - Date.parse(iso)) / 1e3);
4679
+ if (!Number.isFinite(seconds2)) return "";
4680
+ if (seconds2 < 60) return "just now";
4681
+ const [value, unit] = seconds2 < 3600 ? [seconds2 / 60, "minute"] : seconds2 < 86400 ? [seconds2 / 3600, "hour"] : seconds2 < 86400 * 60 ? [seconds2 / 86400, "day"] : [seconds2 / (86400 * 30), "month"];
4682
+ return `${plural(Math.floor(value), unit)} ago`;
4683
+ }
4684
+ function cloud(paths) {
4685
+ const link = requireLink(paths);
4686
+ const credential = requireCredential(link.host);
4687
+ return { link, client: new CloudClient(link.host, credential.token) };
4688
+ }
4689
+ function canvasArg(value, usage) {
4690
+ if (!value) throw new CliError(`Usage: ${usage}`);
4691
+ const key = unitArg(value);
4692
+ if (key === SYSTEM_UNIT) throw new CliError(`The design system is not a canvas. Usage: ${usage}`);
4693
+ return key.slice("canvas/".length);
4694
+ }
4695
+ async function remoteCanvas(client, project, canvas) {
4696
+ const unit = (await client.units(project)).get(canvasUnit(canvas));
4697
+ if (!unit) throw new CliError(`${canvas} is not in the cloud yet: \`design push ${canvas}\` uploads it`);
4698
+ return unit;
4699
+ }
4700
+ async function runCanvases(paths, options) {
4701
+ const project = new DesignProject(paths, STATIC_URLS);
4702
+ const local = project.canvasIds().map((id2) => project.canvas(id2).doc);
4703
+ const link = readLink(paths);
4704
+ let remote = null;
4705
+ let cloudNote = link ? null : "not linked to foss-design Cloud \u2014 `design link`";
4706
+ if (link) {
4707
+ const credential = getCredential(link.host);
4708
+ if (!credential) cloudNote = `not signed in to ${link.host} \u2014 \`design login\``;
4709
+ else {
4710
+ try {
4711
+ remote = await new CloudClient(link.host, credential.token).units(link.project);
4712
+ } catch (error) {
4713
+ cloudNote = `cloud unavailable: ${error.message}`;
4714
+ }
4715
+ }
4716
+ }
4717
+ const scan = link ? scanLocal(paths) : null;
4718
+ const ids = [
4719
+ .../* @__PURE__ */ new Set([
4720
+ ...local.map((doc) => doc.id),
4721
+ ...[...remote?.keys() ?? []].filter((key) => key.startsWith("canvas/")).map((key) => key.slice(7))
4722
+ ])
4723
+ ].sort();
4724
+ const rows = ids.map((id2) => {
4725
+ const doc = local.find((entry) => entry.id === id2);
4726
+ const unit = remote?.get(canvasUnit(id2));
4727
+ const status = link && remote ? unitStatus(canvasUnit(id2), link, scan?.units.get(canvasUnit(id2))?.manifest, remote) : null;
4728
+ const frames = doc?.pages.flatMap((page) => page.sections.flatMap((section) => section.items)).filter((item) => item.kind === "screen" || item.kind === "url");
4729
+ return {
4730
+ id: id2,
4731
+ title: doc?.title ?? unit?.title ?? id2,
4732
+ local: !!doc,
4733
+ pages: doc?.pages.length ?? null,
4734
+ screens: frames?.length ?? null,
4735
+ errors: doc?.issues.filter((issue) => issue.severity === "error").length ?? 0,
4736
+ cloud: unit ? {
4737
+ rev: unit.headRev,
4738
+ state: status?.state ?? null,
4739
+ archived: unit.archived,
4740
+ url: webUrl(link, canvasUnit(id2)),
4741
+ publicUrl: unit.publicId ? publicUrl(link.host, unit.publicId) : null
4742
+ } : null
4743
+ };
4744
+ });
4745
+ if (options.json) return print(JSON.stringify({ canvases: rows, cloud: cloudNote }, null, 2));
4746
+ if (!rows.length) print(dim("No canvases yet: `design new <canvas>` creates one."));
4747
+ const width = Math.max(0, ...rows.map((row) => row.id.length));
4748
+ for (const row of rows) {
4749
+ const facts = row.local ? [`${plural(row.pages ?? 0, "page")} \xB7 ${plural(row.screens ?? 0, "screen")}`] : [yellow("only in the cloud \u2014 `design pull`")];
4750
+ if (row.errors) facts.push(red(plural(row.errors, "error")));
4751
+ if (row.cloud) {
4752
+ const state = row.cloud.archived ? dim("archived") : row.cloud.state === "in_sync" ? green("in sync") : row.cloud.state ? yellow(row.cloud.state.replaceAll("_", " ")) : "";
4753
+ facts.push([`r${row.cloud.rev}`, state].filter(Boolean).join(" "));
4754
+ } else if (remote && row.local) facts.push(dim("not pushed"));
4755
+ print(`${bold(row.id.padEnd(width))} ${row.title} ${dim("\xB7")} ${facts.join(dim(" \xB7 "))}`);
4756
+ if (row.cloud && !row.cloud.archived) print(`${" ".repeat(width)} ${dim("\u2192")} ${row.cloud.url}`);
4757
+ if (row.cloud?.publicUrl) print(`${" ".repeat(width)} ${dim("public")} ${cyan(row.cloud.publicUrl)}`);
4758
+ }
4759
+ if (cloudNote) print(dim(cloudNote));
4760
+ }
4761
+ async function runUrl(paths, value, options) {
4762
+ const canvas = canvasArg(value, "design url <canvas>");
4763
+ const { link, client } = cloud(paths);
4764
+ const unit = await remoteCanvas(client, link.project, canvas);
4765
+ const url = webUrl(link, canvasUnit(canvas));
4766
+ const pub = unit.publicId ? publicUrl(link.host, unit.publicId) : null;
4767
+ if (options.json) return print(JSON.stringify({ canvas, url, publicUrl: pub, archived: unit.archived }, null, 2));
4768
+ print(url);
4769
+ if (pub) print(`${dim("public")} ${pub}`);
4770
+ if (unit.archived) print(dim("archived \u2014 `design unarchive` brings it back"));
4771
+ }
4772
+ async function runPublish(paths, value, publish) {
4773
+ const verb = publish ? "publish" : "unpublish";
4774
+ const canvas = canvasArg(value, `design ${verb} <canvas>`);
4775
+ const { link, client } = cloud(paths);
4776
+ await remoteCanvas(client, link.project, canvas);
4777
+ const { publicId } = await client.canvasAction(link.project, canvas, verb);
4778
+ if (publish) {
4779
+ const id2 = publicId ?? (await remoteCanvas(client, link.project, canvas)).publicId;
4780
+ print(`${green("\u2713")} Published ${bold(canvas)}: anyone with the link can view it`);
4781
+ if (id2) print(` ${cyan(publicUrl(link.host, id2))}`);
4782
+ } else print(`${green("\u2713")} ${bold(canvas)} has no public link now; members still open it in the project`);
4783
+ }
4784
+ async function runArchive(paths, value, archive) {
4785
+ const verb = archive ? "archive" : "unarchive";
4786
+ const canvas = canvasArg(value, `design ${verb} <canvas>`);
4787
+ const { link, client } = cloud(paths);
4788
+ await remoteCanvas(client, link.project, canvas);
4789
+ await client.canvasAction(link.project, canvas, verb);
4790
+ print(
4791
+ archive ? `${green("\u2713")} Archived ${bold(canvas)} in the cloud; its history is kept. \`design unarchive ${canvas}\` brings it back.` : `${green("\u2713")} ${bold(canvas)} is active again ${dim(webUrl(link, canvasUnit(canvas)))}`
4792
+ );
4423
4793
  }
4424
- async function stopServer(paths) {
4425
- const state = readState(paths);
4426
- if (!state) return false;
4794
+ async function runHistory(paths, value, options) {
4795
+ if (!value) throw new CliError("Usage: design history <canvas|system>");
4796
+ const key = unitArg(value);
4797
+ const { link, client } = cloud(paths);
4798
+ let revisions;
4427
4799
  try {
4428
- process.kill(state.pid, "SIGTERM");
4429
- } catch {
4430
- clearState(paths);
4431
- return false;
4800
+ revisions = await client.revisions(link.project, key);
4801
+ } catch (error) {
4802
+ if (error instanceof CloudError && error.status === 404)
4803
+ throw new CliError(`${key} is not in the cloud yet: \`design push\` uploads it`);
4804
+ throw error;
4432
4805
  }
4433
- const until = Date.now() + 5e3;
4434
- while (Date.now() < until) {
4435
- try {
4436
- process.kill(state.pid, 0);
4437
- } catch {
4438
- break;
4439
- }
4440
- await new Promise((resolve) => setTimeout(resolve, 100));
4806
+ if (options.json) return print(JSON.stringify({ unit: key, revisions }, null, 2));
4807
+ print(`${bold(key)} ${dim(webUrl(link, key))}`);
4808
+ for (const rev of revisions) {
4809
+ const parts = [
4810
+ rev.head ? green("current") : rev.rollbackAllowed ? "" : dim("built on an older system"),
4811
+ ago(rev.createdAt),
4812
+ rev.author ?? "",
4813
+ rev.screens !== null ? plural(rev.screens, "screen") : "",
4814
+ rev.bytes !== null ? megabytes(rev.bytes) : ""
4815
+ ].filter(Boolean);
4816
+ print(` ${bold(`r${rev.rev}`.padEnd(5))} ${parts.join(dim(" \xB7 "))}`);
4441
4817
  }
4442
- clearState(paths);
4443
- return true;
4818
+ const target = revisions.find((rev) => rev.rollbackAllowed);
4819
+ if (key !== SYSTEM_UNIT && target)
4820
+ print(dim(`\`design rollback ${key.slice(7)} ${target.rev}\` makes a revision current again`));
4821
+ if (key === SYSTEM_UNIT) print(dim("The design system is not rolled back; change it and push."));
4444
4822
  }
4445
- async function ensureServer(paths, options) {
4446
- const live2 = await liveServer(paths);
4447
- const stale = live2 && live2.version !== packageVersion();
4448
- if (live2 && !options.restart && !stale) return live2;
4449
- if (stale)
4450
- print(dim(`Restarting the preview server: it runs foss-design ${live2.version}, this is ${packageVersion()}.`));
4451
- if (live2) await stopServer(paths);
4452
- fs24.mkdirSync(paths.cache, { recursive: true });
4453
- const log = fs24.openSync(logFile(paths), "w");
4454
- const args = [PKG.cli, "preview", "--foreground", "--root", paths.root];
4455
- if (options.port) args.push("--port", String(options.port));
4456
- const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", log, log], cwd: paths.root });
4457
- child.unref();
4458
- const state = await waitForServer(paths, 4e4);
4459
- if (!state) {
4460
- const tail = fs24.existsSync(logFile(paths)) ? fs24.readFileSync(logFile(paths), "utf8").split("\n").slice(-25) : [];
4461
- throw new CliError(`The preview server did not start.
4462
- ${tail.join("\n")}`);
4463
- }
4464
- return state;
4823
+ async function runRollback(paths, value, revArg) {
4824
+ const canvas = canvasArg(value, "design rollback <canvas> <rev>");
4825
+ const rev = Number((revArg ?? "").replace(/^r/i, ""));
4826
+ if (!Number.isInteger(rev) || rev < 1)
4827
+ throw new CliError("Usage: design rollback <canvas> <rev> (see `design history <canvas>`)");
4828
+ const { link, client } = cloud(paths);
4829
+ const head = await client.rollback(link.project, canvasUnit(canvas), rev);
4830
+ print(`${green("\u2713")} ${bold(canvas)}: r${rev} is current again as r${head} in the cloud`);
4831
+ await runPull(paths, [canvas], { theirs: [], json: false });
4465
4832
  }
4466
- async function runPreview(paths, options) {
4467
- const openPath = typeof options.open === "string" ? options.open : "";
4468
- if (options.foreground) {
4469
- const server = await startDevServer({ root: paths.root, port: options.port });
4470
- printUrls(paths, server.url);
4471
- if (options.open) openBrowser(`${server.url}${openPath}`);
4472
- const shutdown = () => {
4473
- void server.close().finally(() => process.exit(0));
4474
- };
4475
- process.on("SIGINT", shutdown);
4476
- process.on("SIGTERM", shutdown);
4477
- return;
4833
+ var obj = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
4834
+ var n = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
4835
+ async function runMe(paths, options) {
4836
+ const link = paths ? readLink(paths) : null;
4837
+ const host = link?.host ?? cloudHost();
4838
+ const credential = requireCredential(host);
4839
+ const client = new CloudClient(host, credential.token);
4840
+ const [me, usage, activity] = await Promise.all([
4841
+ client.request("GET", "/me").then(obj),
4842
+ client.request("GET", "/me/usage").then(obj),
4843
+ client.request("GET", "/me/activity").then(obj)
4844
+ ]);
4845
+ const plan = obj(usage.plan);
4846
+ const limits = obj(usage.limits);
4847
+ const totals = obj(activity.totals);
4848
+ let linked = null;
4849
+ if (link) {
4850
+ const projects = await client.projects().catch(() => null);
4851
+ const project = projects && [...projects.owned, ...projects.shared].find((p) => p.id === link.project);
4852
+ linked = project ? { id: project.id, name: project.name, role: project.role } : { id: link.project, name: "", role: "" };
4478
4853
  }
4479
- const state = await ensureServer(paths, options);
4480
- printUrls(paths, state.url);
4481
- if (options.open) openBrowser(`${state.url}${openPath}`);
4482
- }
4483
- async function runStop(paths) {
4484
- print(await stopServer(paths) ? "Stopped the preview server." : "No preview server is running for this project.");
4854
+ const data = {
4855
+ host,
4856
+ email: me.email,
4857
+ name: me.name,
4858
+ memberSince: me.createdAt,
4859
+ plan: { id: plan.id, name: plan.name, expiresAt: plan.expiresAt ?? null },
4860
+ storage: { usedBytes: n(usage.storageBytes), limitBytes: n(limits.storage_bytes) },
4861
+ activeProjects: { used: n(usage.activeProjects), limit: n(limits.active_projects) },
4862
+ canvases: n(totals.canvases),
4863
+ screens: n(totals.screens),
4864
+ pushesLastYear: n(totals.pushesLastYear),
4865
+ pushesPerHour: n(limits.pushes_per_hour),
4866
+ linked
4867
+ };
4868
+ if (options.json) return print(JSON.stringify(data, null, 2));
4869
+ const label = (text2) => dim(text2.padEnd(10));
4870
+ const date = (iso) => typeof iso === "string" ? new Date(iso).toDateString().slice(4) : "";
4871
+ const share = data.storage.limitBytes ? Math.round(data.storage.usedBytes / data.storage.limitBytes * 100) : 0;
4872
+ print(
4873
+ `${bold(String(data.email ?? ""))}${data.name && data.name !== data.email ? ` ${dim(`(${data.name})`)}` : ""} ${dim(host)}`
4874
+ );
4875
+ print(
4876
+ `${label("Plan")}${bold(String(data.plan.name ?? "Free"))} ${dim(data.plan.expiresAt ? `until ${date(data.plan.expiresAt)}` : "no end date")}`
4877
+ );
4878
+ print(
4879
+ `${label("Storage")}${megabytes(data.storage.usedBytes)} of ${megabytes(data.storage.limitBytes)} ${(share >= 90 ? red : share >= 80 ? yellow : dim)(`(${share}%)`)}`
4880
+ );
4881
+ print(`${label("Projects")}${data.activeProjects.used} of ${data.activeProjects.limit} active`);
4882
+ print(
4883
+ `${label("Canvases")}${plural(data.canvases, "canvas", "canvases")} in your projects \xB7 ${plural(data.screens, "screen")}`
4884
+ );
4885
+ print(
4886
+ `${label("Pushes")}${plural(data.pushesLastYear, "push", "pushes")} in the last year ${dim(`\xB7 up to ${data.pushesPerHour} an hour`)}`
4887
+ );
4888
+ if (linked)
4889
+ print(
4890
+ `${label("Linked")}${linked.name || linked.id}${linked.role ? dim(` as ${linked.role}`) : ""} ${dim(webUrl(link))}`
4891
+ );
4892
+ else if (paths) print(`${label("Linked")}${dim("no \u2014 `design link`")}`);
4893
+ print(`${label("Since")}${date(data.memberSince)} ${dim(`\xB7 plans and upgrades: ${host.replace(/\/+$/, "")}/plans`)}`);
4485
4894
  }
4486
4895
 
4487
4896
  // src/cli/commands/check.ts
@@ -4636,9 +5045,9 @@ function printLinkIssues(issues, project) {
4636
5045
  }
4637
5046
 
4638
5047
  // src/cli/commands/cloud.ts
4639
- import fs25 from "fs";
5048
+ import fs29 from "fs";
4640
5049
  import os3 from "os";
4641
- import path23 from "path";
5050
+ import path26 from "path";
4642
5051
  var sleep2 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4643
5052
  async function runLogin() {
4644
5053
  const host = cloudHost();
@@ -4739,12 +5148,14 @@ async function runLink(paths, wanted, options) {
4739
5148
  print(`${dim("Already linked to")} ${project.name} ${dim(`(${project.id}, ${project.role})`)}`);
4740
5149
  } else {
4741
5150
  writeLink(paths, { host, project: project.id, units: {}, conflicts: {} });
4742
- fs25.rmSync(path23.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
5151
+ fs29.rmSync(path26.join(cloudCache(paths), "incoming"), { recursive: true, force: true });
4743
5152
  const replaced = current ? dim(` (replacing the link to ${current.project})`) : "";
4744
5153
  print(`${green("linked")} .design \u2192 ${bold(project.name)} ${dim(`(${project.id}, ${project.role})`)}${replaced}`);
4745
5154
  }
4746
5155
  if (project.archived) print(yellow("The project is archived: unarchive it in the web app before pushing or pulling."));
4747
5156
  const link = readLink(paths);
5157
+ for (const line of await syncProjectMeta(paths, client, link)) print(dim(line));
5158
+ writeLink(paths, link);
4748
5159
  const remote = project.archived ? null : await client.units(project.id);
4749
5160
  const scan = scanLocal(paths);
4750
5161
  const states = allUnitKeys(link, scan.units, remote).map(
@@ -4764,40 +5175,63 @@ async function runLink(paths, wanted, options) {
4764
5175
  }
4765
5176
 
4766
5177
  // src/cli/commands/icon.ts
4767
- import fs26 from "fs";
4768
- import path24 from "path";
4769
- function runIcon(paths, file, options) {
5178
+ import fs30 from "fs";
5179
+ import path27 from "path";
5180
+ async function toCloud(paths, file) {
5181
+ const link = readLink(paths);
5182
+ if (!link) return;
5183
+ const credential = getCredential(link.host);
5184
+ if (!credential) {
5185
+ print(yellow("Not signed in: the icon goes to the cloud on the next `design push` after `design login`."));
5186
+ return;
5187
+ }
5188
+ const client = new CloudClient(link.host, credential.token);
5189
+ try {
5190
+ const ext = file ? path27.extname(file).slice(1) : null;
5191
+ await client.setProjectIcon(
5192
+ link.project,
5193
+ file && ext ? { data: fs30.readFileSync(file), type: ICON_TYPES[ext] } : null
5194
+ );
5195
+ link.icon = file ? iconHash(file) : null;
5196
+ writeLink(paths, link);
5197
+ print(dim(`The cloud project has it too.`));
5198
+ } catch (error) {
5199
+ print(yellow(`Set here only: ${error.message}`));
5200
+ }
5201
+ }
5202
+ async function runIcon(paths, file, options) {
4770
5203
  if (options.remove) {
4771
5204
  const had = iconFile(paths);
4772
5205
  writeIcon(paths, null);
4773
- print(had ? `${green("Removed")} ${path24.relative(process.cwd(), had)}` : "The project has no icon.");
5206
+ print(had ? `${green("Removed")} ${path27.relative(process.cwd(), had)}` : "The project has no icon.");
5207
+ await toCloud(paths, null);
4774
5208
  return;
4775
5209
  }
4776
5210
  if (!file) {
4777
5211
  const current = iconFile(paths);
4778
5212
  if (!current) return print("The project has no icon. Set one with `design icon <file.svg|png|webp>`.");
4779
- return print(`${path24.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs26.statSync(current).size / 1024)} KB`)}`);
5213
+ return print(`${path27.relative(process.cwd(), current)} ${dim(`${Math.ceil(fs30.statSync(current).size / 1024)} KB`)}`);
4780
5214
  }
4781
5215
  let data;
4782
5216
  try {
4783
- data = fs26.readFileSync(file);
5217
+ data = fs30.readFileSync(file);
4784
5218
  } catch {
4785
5219
  throw new CliError(`No file ${file}`);
4786
5220
  }
4787
- const problem = iconProblem(data, path24.extname(file).slice(1).toLowerCase() || void 0);
5221
+ const problem = iconProblem(data, path27.extname(file).slice(1).toLowerCase() || void 0);
4788
5222
  if (problem) throw new CliError(`${file}: ${problem}`);
4789
5223
  const written = writeIcon(paths, data);
4790
- print(`${green("Set")} the project icon: ${path24.relative(process.cwd(), written)}`);
4791
- print(dim("It syncs with the design system unit: `design push` takes it to the cloud."));
5224
+ print(`${green("Set")} the project icon: ${path27.relative(process.cwd(), written)}`);
5225
+ await toCloud(paths, written);
4792
5226
  }
4793
5227
 
4794
5228
  // src/cli/commands/import.ts
4795
- import fs28 from "fs";
4796
- import path26 from "path";
5229
+ import fs32 from "fs";
5230
+ import path29 from "path";
4797
5231
 
4798
5232
  // src/import/claude-design.ts
4799
- import fs27 from "fs";
4800
- import path25 from "path";
5233
+ import fs31 from "fs";
5234
+ import path28 from "path";
4801
5235
 
4802
5236
  // src/import/markup.ts
4803
5237
  var VOID = /* @__PURE__ */ new Set([
@@ -5405,11 +5839,11 @@ function boardId(file, used) {
5405
5839
  return id2;
5406
5840
  }
5407
5841
  function importClaudeDesign(dir, options = {}) {
5408
- const canvasFile = path25.join(dir, "canvas.json");
5409
- if (!fs27.existsSync(canvasFile))
5842
+ const canvasFile = path28.join(dir, "canvas.json");
5843
+ if (!fs31.existsSync(canvasFile))
5410
5844
  throw new Error(`No canvas.json in ${dir}: point at the exported Claude Design project folder`);
5411
- const source = JSON.parse(fs27.readFileSync(canvasFile, "utf8"));
5412
- const templates = fs27.readdirSync(dir).filter((file) => file.endsWith(EXT));
5845
+ const source = JSON.parse(fs31.readFileSync(canvasFile, "utf8"));
5846
+ const templates = fs31.readdirSync(dir).filter((file) => file.endsWith(EXT));
5413
5847
  if (!templates.length) throw new Error(`No ${EXT} files in ${dir}`);
5414
5848
  const known = new Set(templates.map((file) => file.slice(0, -EXT.length)));
5415
5849
  const warnings = /* @__PURE__ */ new Map();
@@ -5436,7 +5870,7 @@ function importClaudeDesign(dir, options = {}) {
5436
5870
  try {
5437
5871
  files.set(
5438
5872
  `${name}.jsx`,
5439
- convertFile(name, fs27.readFileSync(path25.join(dir, file), "utf8"), { known, routes, warn: warn2 })
5873
+ convertFile(name, fs31.readFileSync(path28.join(dir, file), "utf8"), { known, routes, warn: warn2 })
5440
5874
  );
5441
5875
  } catch (error) {
5442
5876
  warn2(`could not be converted: ${error.message}`);
@@ -5484,7 +5918,7 @@ function importClaudeDesign(dir, options = {}) {
5484
5918
  screens++;
5485
5919
  }
5486
5920
  const canvas = {
5487
- title: options.title ?? source.title ?? path25.basename(path25.resolve(dir)),
5921
+ title: options.title ?? source.title ?? path28.basename(path28.resolve(dir)),
5488
5922
  description: "Imported from Claude Design.",
5489
5923
  // The templates bring their own styles; the design system's reset would restyle them.
5490
5924
  system: false,
@@ -5496,21 +5930,21 @@ function importClaudeDesign(dir, options = {}) {
5496
5930
  // src/cli/commands/import.ts
5497
5931
  var slug = (text2) => text2.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/[\s_]+/g, "-").toLowerCase();
5498
5932
  function runImport(paths, from, options) {
5499
- const dir = path26.resolve(from);
5500
- if (!fs28.existsSync(dir) || !fs28.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
5933
+ const dir = path29.resolve(from);
5934
+ if (!fs32.existsSync(dir) || !fs32.statSync(dir).isDirectory()) throw new CliError(`No folder ${from}`);
5501
5935
  let result;
5502
5936
  try {
5503
5937
  result = importClaudeDesign(dir, { title: options.title });
5504
5938
  } catch (error) {
5505
5939
  throw new CliError(error.message);
5506
5940
  }
5507
- const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path26.basename(dir)) || "imported");
5941
+ const id2 = options.canvas ?? (slug(String(result.canvas.title)) || slug(path29.basename(dir)) || "imported");
5508
5942
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is no canvas id; pass one with --canvas <id>`);
5509
- const out = path26.join(paths.canvases, id2);
5510
- if (fs28.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
5511
- fs28.mkdirSync(path26.join(out, "screens"), { recursive: true });
5512
- for (const [name, content] of result.files) fs28.writeFileSync(path26.join(out, "screens", name), content);
5513
- fs28.writeFileSync(path26.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
5943
+ const out = path29.join(paths.canvases, id2);
5944
+ if (fs32.existsSync(out)) throw new CliError(`.design/canvas/${id2} exists; pick another id with --canvas <id>`);
5945
+ fs32.mkdirSync(path29.join(out, "screens"), { recursive: true });
5946
+ for (const [name, content] of result.files) fs32.writeFileSync(path29.join(out, "screens", name), content);
5947
+ fs32.writeFileSync(path29.join(out, "canvas.json"), `${JSON.stringify(result.canvas, null, 2)}
5514
5948
  `);
5515
5949
  print(`${green("Imported")} ${bold(String(result.canvas.title))} \u2192 .design/canvas/${id2}`);
5516
5950
  print(
@@ -5528,9 +5962,166 @@ function runImport(paths, from, options) {
5528
5962
  print(`Next: \`design check ${id2} --render\`, then \`design preview --open /c/${id2}\`.`);
5529
5963
  }
5530
5964
 
5965
+ // src/cli/commands/merge.ts
5966
+ import { spawnSync as spawnSync2 } from "child_process";
5967
+ import path30 from "path";
5968
+ import { createInterface } from "readline/promises";
5969
+ var KIND = {
5970
+ text: "both sides changed lines, the markers show them",
5971
+ binary: "both sides changed it; yours is in place",
5972
+ deleted_here: "deleted here, changed in the cloud; the cloud version is in place",
5973
+ deleted_in_cloud: "deleted in the cloud, changed here; yours is in place"
5974
+ };
5975
+ function matchTargets(paths, states, targets) {
5976
+ const picked = [];
5977
+ for (const target of targets) {
5978
+ const unit = states.find((state) => state.unit === unitArg(target));
5979
+ if (unit) {
5980
+ for (const conflict of openConflicts(paths, unit)) picked.push({ state: unit, conflict });
5981
+ continue;
5982
+ }
5983
+ const rel = [path30.relative(paths.design, path30.resolve(target)), target.replace(/^\.design\//, "")];
5984
+ const found = states.flatMap(
5985
+ (state) => state.conflicts.filter((item) => rel.includes(item.path)).map((conflict) => ({ state, conflict }))
5986
+ );
5987
+ if (!found.length) throw new CliError(`No open conflict in ${target}. \`design merge\` lists them.`);
5988
+ picked.push(...found);
5989
+ }
5990
+ return picked;
5991
+ }
5992
+ function printOpen(paths, states) {
5993
+ for (const state of states) {
5994
+ const open2 = openConflicts(paths, state);
5995
+ if (!open2.length) continue;
5996
+ print(`${bold(state.unit)} ${dim(`merged with cloud r${state.headRev}`)}`);
5997
+ for (const conflict of open2) {
5998
+ print(` ${red("\u2717")} ${conflict.path} ${dim(KIND[conflict.kind])}`);
5999
+ const sides = ["here", "cloud", "base"].map((side) => `${side}: ${path30.relative(process.cwd(), sideFile(paths, state.unit, conflict.path, side))}`).join(" \xB7 ");
6000
+ print(` ${dim(sides)}`);
6001
+ }
6002
+ }
6003
+ }
6004
+ async function runMerge(paths, targets, options) {
6005
+ if (options.here && options.cloud) throw new CliError("Pick one side: --here or --cloud");
6006
+ const link = requireLink(paths);
6007
+ const states = mergingUnits(paths);
6008
+ const close = (accept, only) => {
6009
+ const closed = [];
6010
+ for (const state of states) {
6011
+ if (only && !only.includes(state.unit)) continue;
6012
+ if (!finishMerge(paths, link, state.unit, accept).length) closed.push(state.unit);
6013
+ }
6014
+ writeLink(paths, link);
6015
+ for (const unit of closed) print(`${green("\u2713")} ${unit} ${dim("merged; `design push` takes it up")}`);
6016
+ return closed;
6017
+ };
6018
+ if (options.here || options.cloud) {
6019
+ const side = options.here ? "here" : "cloud";
6020
+ const picked = targets.length ? matchTargets(paths, states, targets) : states.flatMap((state) => openConflicts(paths, state).map((conflict) => ({ state, conflict })));
6021
+ for (const { state, conflict } of picked) {
6022
+ resolveConflict(paths, state, conflict.path, side);
6023
+ print(`${green("\u2713")} ${conflict.path} ${dim(`(${side === "here" ? "yours" : "the cloud's"})`)}`);
6024
+ }
6025
+ close(false);
6026
+ return;
6027
+ }
6028
+ if (options.done) {
6029
+ const only = targets.length ? targets.map(unitArg) : void 0;
6030
+ close(true, only);
6031
+ const left2 = states.filter(
6032
+ (state) => (!only || only.includes(state.unit)) && openConflicts(paths, state).some((c) => c.kind === "text")
6033
+ );
6034
+ if (left2.length) {
6035
+ printOpen(paths, left2);
6036
+ throw new CliError(
6037
+ "Conflict markers are left in the files above; edit them out, or settle with --here or --cloud."
6038
+ );
6039
+ }
6040
+ return;
6041
+ }
6042
+ const open2 = states.filter((state) => openConflicts(paths, state).length);
6043
+ if (options.json) {
6044
+ print(
6045
+ JSON.stringify({ units: open2.map((state) => ({ ...state, conflicts: openConflicts(paths, state) })) }, null, 2)
6046
+ );
6047
+ if (open2.length) process.exitCode = 2;
6048
+ return;
6049
+ }
6050
+ if (!open2.length) {
6051
+ close(false);
6052
+ if (!states.length) print("Nothing to merge.");
6053
+ return;
6054
+ }
6055
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
6056
+ printOpen(paths, open2);
6057
+ print(
6058
+ dim(
6059
+ "Settle with `design merge <file|unit> --here` or `--cloud`, or edit the markers out; then `design merge --done`."
6060
+ )
6061
+ );
6062
+ process.exitCode = 2;
6063
+ return;
6064
+ }
6065
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
6066
+ try {
6067
+ for (const state of open2) {
6068
+ for (const conflict of openConflicts(paths, state)) {
6069
+ print(`${bold(state.unit)} ${conflict.path} ${dim(`\u2014 ${KIND[conflict.kind]}`)}`);
6070
+ for (; ; ) {
6071
+ const edit = conflict.kind === "text" ? ", [e]dit" : "";
6072
+ const answer = (await rl.question(` keep [h]ere, take [c]loud${edit}, [s]kip? `)).trim().toLowerCase();
6073
+ if (answer === "h" || answer === "c") {
6074
+ resolveConflict(paths, state, conflict.path, answer === "h" ? "here" : "cloud");
6075
+ break;
6076
+ }
6077
+ if (answer === "s") break;
6078
+ if (answer === "e" && edit) {
6079
+ const editor = process.env.VISUAL || process.env.EDITOR || "vi";
6080
+ spawnSync2(editor, [path30.join(paths.design, conflict.path)], { stdio: "inherit", shell: true });
6081
+ if (!openConflicts(paths, state).some((item) => item.path === conflict.path)) break;
6082
+ print(yellow(" Conflict markers are still there."));
6083
+ }
6084
+ }
6085
+ }
6086
+ }
6087
+ } finally {
6088
+ rl.close();
6089
+ }
6090
+ close(false);
6091
+ const left = mergingUnits(paths).filter((state) => openConflicts(paths, state).length);
6092
+ if (left.length) {
6093
+ printOpen(paths, left);
6094
+ process.exitCode = 2;
6095
+ }
6096
+ }
6097
+
6098
+ // src/cli/commands/rename.ts
6099
+ import fs33 from "fs";
6100
+ async function runRename(paths, name) {
6101
+ const trimmed = name.trim();
6102
+ if (!trimmed) throw new CliError("Usage: design rename <name>");
6103
+ const link = readLink(paths);
6104
+ if (link) {
6105
+ const client = new CloudClient(link.host, requireCredential(link.host).token);
6106
+ const project = await client.renameProject(link.project, trimmed);
6107
+ link.name = project.name;
6108
+ writeLink(paths, link);
6109
+ print(`${green("Renamed")} the cloud project to ${project.name}`);
6110
+ return;
6111
+ }
6112
+ let config = {};
6113
+ try {
6114
+ config = JSON.parse(fs33.readFileSync(paths.config, "utf8"));
6115
+ } catch {
6116
+ }
6117
+ fs33.writeFileSync(paths.config, `${JSON.stringify({ ...config, name: trimmed }, null, 2)}
6118
+ `);
6119
+ print(`${green("Renamed")} the project to ${trimmed} (.design/design.json)`);
6120
+ }
6121
+
5531
6122
  // src/cli/commands/scaffold.ts
5532
- import fs29 from "fs";
5533
- import path27 from "path";
6123
+ import fs34 from "fs";
6124
+ import path31 from "path";
5534
6125
 
5535
6126
  // src/cli/templates.ts
5536
6127
  var TOKENS_CSS = `/*
@@ -5741,40 +6332,40 @@ function mainScreen(canvasId) {
5741
6332
 
5742
6333
  // src/cli/commands/scaffold.ts
5743
6334
  function write(file, content, root) {
5744
- if (fs29.existsSync(file)) {
5745
- print(` ${dim("exists ")} ${path27.relative(root, file)}`);
6335
+ if (fs34.existsSync(file)) {
6336
+ print(` ${dim("exists ")} ${path31.relative(root, file)}`);
5746
6337
  return false;
5747
6338
  }
5748
- fs29.mkdirSync(path27.dirname(file), { recursive: true });
5749
- fs29.writeFileSync(file, content);
5750
- print(` ${green("created")} ${path27.relative(root, file)}`);
6339
+ fs34.mkdirSync(path31.dirname(file), { recursive: true });
6340
+ fs34.writeFileSync(file, content);
6341
+ print(` ${green("created")} ${path31.relative(root, file)}`);
5751
6342
  return true;
5752
6343
  }
5753
6344
  function ensureGitignored(root) {
5754
- const file = path27.join(root, ".gitignore");
5755
- const current = fs29.existsSync(file) ? fs29.readFileSync(file, "utf8") : "";
6345
+ const file = path31.join(root, ".gitignore");
6346
+ const current = fs34.existsSync(file) ? fs34.readFileSync(file, "utf8") : "";
5756
6347
  const covered = current.split(/\r?\n/).map((line) => line.trim()).some((line) => [".design", ".design/", "/.design", "/.design/", ".design/*"].includes(line));
5757
6348
  if (covered) return "present";
5758
6349
  const prefix = current && !current.endsWith("\n") ? "\n" : "";
5759
- fs29.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
6350
+ fs34.writeFileSync(file, `${current}${prefix}${current ? "\n" : ""}# Design canvases (foss-design)
5760
6351
  .design/
5761
6352
  `);
5762
6353
  return "added";
5763
6354
  }
5764
6355
  function projectName(root) {
5765
6356
  try {
5766
- const manifest = JSON.parse(fs29.readFileSync(path27.join(root, "package.json"), "utf8"));
6357
+ const manifest = JSON.parse(fs34.readFileSync(path31.join(root, "package.json"), "utf8"));
5767
6358
  if (typeof manifest.name === "string" && manifest.name) return manifest.name;
5768
6359
  } catch {
5769
6360
  }
5770
- return path27.basename(root);
6361
+ return path31.basename(root);
5771
6362
  }
5772
6363
  function runInit(root, options) {
5773
6364
  const paths = designPaths(root);
5774
6365
  print(`Setting up ${DESIGN_DIR} in ${root}`);
5775
6366
  write(paths.config, `${JSON.stringify({ name: options.name ?? projectName(root) }, null, 2)}
5776
6367
  `, root);
5777
- fs29.mkdirSync(paths.canvases, { recursive: true });
6368
+ fs34.mkdirSync(paths.canvases, { recursive: true });
5778
6369
  if (options.gitignore) {
5779
6370
  const result = ensureGitignored(root);
5780
6371
  print(` ${result === "added" ? green("ignored") : dim("ignored")} .design/ in .gitignore`);
@@ -5788,44 +6379,44 @@ function runSystemInit(root, options) {
5788
6379
  const paths = designPaths(root);
5789
6380
  let base = projectName(root);
5790
6381
  try {
5791
- base = JSON.parse(fs29.readFileSync(paths.config, "utf8")).name || base;
6382
+ base = JSON.parse(fs34.readFileSync(paths.config, "utf8")).name || base;
5792
6383
  } catch {
5793
6384
  }
5794
6385
  const name = options.name ?? `${base} design system`;
5795
- print(`Scaffolding the design system in ${path27.relative(root, paths.system)}`);
5796
- write(path27.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
6386
+ print(`Scaffolding the design system in ${path31.relative(root, paths.system)}`);
6387
+ write(path31.join(paths.system, "system.json"), `${JSON.stringify({ name }, null, 2)}
5797
6388
  `, root);
5798
- write(path27.join(paths.system, "tokens.css"), TOKENS_CSS, root);
6389
+ write(path31.join(paths.system, "tokens.css"), TOKENS_CSS, root);
5799
6390
  if (!options.empty) {
5800
- write(path27.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
5801
- write(path27.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
5802
- write(path27.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
6391
+ write(path31.join(paths.system, "guidelines", "01-overview.md"), OVERVIEW_MD, root);
6392
+ write(path31.join(paths.system, "components", "button.tsx"), BUTTON_TSX, root);
6393
+ write(path31.join(paths.system, "specimens", "button.tsx"), BUTTON_SPECIMEN_TSX, root);
5803
6394
  }
5804
6395
  }
5805
6396
  function runNew(root, id2, options) {
5806
6397
  if (!ID_PATTERN.test(id2)) throw new CliError(`"${id2}" is not a valid canvas id: use letters, digits, "-" and "_"`);
5807
6398
  const paths = designPaths(root);
5808
- const dir = path27.join(paths.canvases, id2);
5809
- if (fs29.existsSync(path27.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
6399
+ const dir = path31.join(paths.canvases, id2);
6400
+ if (fs34.existsSync(path31.join(dir, "canvas.json"))) throw new CliError(`Canvas "${id2}" already exists in ${dir}`);
5810
6401
  print(`Creating canvas "${id2}"`);
5811
6402
  const title = options.title ?? titleize(id2);
5812
6403
  if (options.empty) {
5813
6404
  write(
5814
- path27.join(dir, "canvas.json"),
6405
+ path31.join(dir, "canvas.json"),
5815
6406
  `${JSON.stringify({ title, pages: [{ id: "main", title: "Main", sections: [] }] }, null, 2)}
5816
6407
  `,
5817
6408
  root
5818
6409
  );
5819
- fs29.mkdirSync(path27.join(dir, "screens"), { recursive: true });
6410
+ fs34.mkdirSync(path31.join(dir, "screens"), { recursive: true });
5820
6411
  } else {
5821
- write(path27.join(dir, "canvas.json"), canvasJson(title), root);
5822
- write(path27.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
6412
+ write(path31.join(dir, "canvas.json"), canvasJson(title), root);
6413
+ write(path31.join(dir, "screens", "Main.tsx"), mainScreen(id2), root);
5823
6414
  }
5824
6415
  }
5825
6416
 
5826
6417
  // src/cli/commands/shot.ts
5827
- import fs30 from "fs";
5828
- import path28 from "path";
6418
+ import fs35 from "fs";
6419
+ import path32 from "path";
5829
6420
  var OVERVIEW_TIMEOUT = 9e4;
5830
6421
  async function runShot(paths, target, options) {
5831
6422
  const [canvasId = "", screenId] = target.split("/");
@@ -5841,15 +6432,15 @@ async function runShot(paths, target, options) {
5841
6432
  throw new CliError(`No page "${options.page}" in "${canvasId}". Pages: ${doc.pages.map((p) => p.id).join(", ")}`);
5842
6433
  }
5843
6434
  const theme2 = options.theme ?? doc.theme ?? "light";
5844
- const outBase = path28.resolve(options.out ?? paths.shots);
5845
- const outDir = path28.join(outBase, canvasId);
5846
- fs30.mkdirSync(outDir, { recursive: true });
6435
+ const outBase = path32.resolve(options.out ?? paths.shots);
6436
+ const outDir = path32.join(outBase, canvasId);
6437
+ fs35.mkdirSync(outDir, { recursive: true });
5847
6438
  const base = await serverBase(paths);
5848
6439
  const browser = await launchChrome();
5849
6440
  let failed = false;
5850
6441
  try {
5851
6442
  if (options.overview) {
5852
- if (!fs30.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
6443
+ if (!fs35.existsSync(PKG.viewer)) throw new CliError("The viewer is not built, so there is no canvas to shoot.");
5853
6444
  const pages = options.page ? [options.page] : screenId ? doc.pages.filter((page) => page.sections.some((section) => section.items.some((item) => item.id === screenId))).map((page) => page.id) : doc.pages.map((page) => page.id);
5854
6445
  for (const pageId of pages) {
5855
6446
  const page = await browser.newPage({ viewport: { width: 1920, height: 1200 } });
@@ -5865,7 +6456,7 @@ async function runShot(paths, target, options) {
5865
6456
  warn(`page "${pageId}": not every frame reported ready within ${OVERVIEW_TIMEOUT / 1e3}s; shooting anyway`);
5866
6457
  }
5867
6458
  await page.waitForTimeout(400);
5868
- const file = path28.join(outDir, `${pageId}.overview.png`);
6459
+ const file = path32.join(outDir, `${pageId}.overview.png`);
5869
6460
  await page.screenshot({ path: file });
5870
6461
  await page.close();
5871
6462
  print(file);
@@ -5894,7 +6485,7 @@ async function shootFrames(browser, jobs, outDir) {
5894
6485
  let failed = false;
5895
6486
  const results = await mapLimit(jobs, 4, async (job) => {
5896
6487
  const page = await browser.newPage({ viewport: { width: job.width, height: job.height } });
5897
- const file = path28.join(outDir, `${job.id}.${job.theme}.png`);
6488
+ const file = path32.join(outDir, `${job.id}.${job.theme}.png`);
5898
6489
  try {
5899
6490
  const report = await openFrame(page, job.url, { waitForReady: job.waitForReady });
5900
6491
  await page.screenshot({ path: file, fullPage: job.fullPage });
@@ -5926,8 +6517,8 @@ async function shootSystem(paths, project, id2, options) {
5926
6517
  if (id2 && !all.some((job) => job.id === id2)) {
5927
6518
  throw new CliError(`No specimen "${id2}" in the design system. Specimens: ${all.map((job) => job.id).join(", ")}`);
5928
6519
  }
5929
- const outDir = path28.join(path28.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
5930
- fs30.mkdirSync(outDir, { recursive: true });
6520
+ const outDir = path32.join(path32.resolve(options.out ?? paths.shots), SYSTEM_CANVAS);
6521
+ fs35.mkdirSync(outDir, { recursive: true });
5931
6522
  const base = await serverBase(paths);
5932
6523
  const jobs = systemFrames(system.doc, base, theme2).filter((job) => !id2 || job.id === id2);
5933
6524
  const browser = await launchChrome();
@@ -5939,7 +6530,7 @@ async function shootSystem(paths, project, id2, options) {
5939
6530
  }
5940
6531
 
5941
6532
  // src/cli/commands/cloud-status.ts
5942
- import path29 from "path";
6533
+ import path33 from "path";
5943
6534
  var STATE_TEXT = {
5944
6535
  in_sync: green,
5945
6536
  local_changes: yellow,
@@ -5962,7 +6553,7 @@ function detail(paths, status) {
5962
6553
  if (status.state === "remote_ahead" && !status.local && !status.baseRev) parts.push("not pulled yet");
5963
6554
  if (status.state === "remote_ahead" && status.changed.length && !status.deletedRemotely)
5964
6555
  parts.push(`${plural(status.changed.length, "file")} changed here; the cloud only rebuilt it`);
5965
- if (status.conflictRev !== void 0) parts.push(`merge ${path29.relative(paths.root, incomingDir(paths, status.key))}`);
6556
+ if (status.conflictRev !== void 0) parts.push(`merge ${path33.relative(paths.root, incomingDir(paths, status.key))}`);
5966
6557
  return parts.join(" \xB7 ");
5967
6558
  }
5968
6559
  async function printCloudStatus(paths, label) {
@@ -6078,8 +6669,10 @@ ${bold("Project")}
6078
6669
  system init [--name <name>] [--empty] Scaffold .design/system: tokens, a guideline, a component
6079
6670
  new <canvas> [--title <title>] [--empty] Scaffold .design/canvas/<canvas>
6080
6671
  icon [<file>] [--remove] Show, set or remove the project icon: an SVG, PNG or WebP of
6081
- at most 256 KB, kept as .design/system/icon.* and pushed
6082
- with the design system
6672
+ at most 256 KB in .design/icon.*; a linked project's icon is
6673
+ the cloud project's, set there at once
6674
+ rename <name> Rename the project: the cloud project when linked (owner),
6675
+ otherwise the name in design.json
6083
6676
  import <folder> [--canvas <id>] Turn an exported Claude Design project (canvas.json and
6084
6677
  [--title <title>] *.dc.html) into a canvas of React screens, once
6085
6678
  canvases [--json] Every canvas: pages and screens; with a cloud link also its
@@ -6115,13 +6708,19 @@ ${bold("Cloud")} ${dim("units: system (design.json + system/) and canvas/<id>;
6115
6708
  link [<project>] [--new <name>] No args: list your projects. Link .design to a cloud project
6116
6709
  (linking to another project replaces the link)
6117
6710
  push [canvas\u2026] [--json] Build and upload what changed here (or the named units); a
6118
- [--resolved <unit>] system change pushes every active canvas with it. Stops if
6119
- the cloud is ahead; --resolved marks a merged conflict.
6711
+ [--resolved <unit>] system change pushes every active canvas with it. When the
6712
+ cloud is ahead it pulls and merges first, then pushes; it
6713
+ stops on conflicts. Refreshes each canvas's cover snapshot.
6120
6714
  Prints the web link of every pushed canvas; refuses screens
6121
6715
  that still call go()
6122
6716
  pull [canvas\u2026] [--json] Take cloud changes into .design; a unit changed on both sides
6123
- [--theirs <unit>] goes to .design/.cache/cloud/incoming/<unit> (exit 2);
6124
- --theirs takes the cloud version and drops local changes
6717
+ [--theirs <unit>] is merged three ways (files changed on one side take it,
6718
+ text changed on both merges line by line); what does not
6719
+ merge waits for design merge. --theirs takes the cloud
6720
+ version and drops local changes
6721
+ merge [<unit|file>\u2026] [--here|--cloud] Conflicts a merge left: lists them (in a terminal, walks
6722
+ [--done] [--json] through them); --here / --cloud settles files or units;
6723
+ --done closes a merge once no markers are left
6125
6724
  url <canvas> [--json] The canvas in the web app, and its public link if published
6126
6725
  history <canvas|system> [--json] Stored revisions, newest first: when, who, screens, size
6127
6726
  rollback <canvas> <rev> Make an old revision current again in the cloud (a new
@@ -6141,7 +6740,7 @@ ${bold("Environment")}
6141
6740
  FOSS_DESIGN_CLOUD Cloud to sign in and link to (default https://app.fossdesign.dev)
6142
6741
  `;
6143
6742
  function projectPaths(rootOption, requireDesign = true) {
6144
- const found = rootOption ? path30.resolve(rootOption) : findProjectRoot();
6743
+ const found = rootOption ? path34.resolve(rootOption) : findProjectRoot();
6145
6744
  if (!found) {
6146
6745
  if (!requireDesign) return designPaths(process.cwd());
6147
6746
  throw new CliError("No .design folder here or above. Run `design init` in the project root first.");
@@ -6160,6 +6759,9 @@ async function main(argv) {
6160
6759
  title: { type: "string" },
6161
6760
  canvas: { type: "string" },
6162
6761
  remove: { type: "boolean" },
6762
+ here: { type: "boolean" },
6763
+ cloud: { type: "boolean" },
6764
+ done: { type: "boolean" },
6163
6765
  "no-gitignore": { type: "boolean" },
6164
6766
  empty: { type: "boolean" },
6165
6767
  open: { type: "boolean" },
@@ -6189,7 +6791,7 @@ async function main(argv) {
6189
6791
  if (values.theme && !theme2) throw new CliError("--theme is light or dark");
6190
6792
  switch (command) {
6191
6793
  case "init":
6192
- return runInit(values.root ? path30.resolve(values.root) : process.cwd(), {
6794
+ return runInit(values.root ? path34.resolve(values.root) : process.cwd(), {
6193
6795
  name: values.name,
6194
6796
  gitignore: !values["no-gitignore"]
6195
6797
  });
@@ -6203,6 +6805,16 @@ async function main(argv) {
6203
6805
  }
6204
6806
  case "icon":
6205
6807
  return runIcon(projectPaths(values.root), rest[0], { remove: !!values.remove });
6808
+ case "rename":
6809
+ if (!rest.length) throw new CliError("Usage: design rename <name>");
6810
+ return runRename(projectPaths(values.root), rest.join(" "));
6811
+ case "merge":
6812
+ return runMerge(projectPaths(values.root), rest, {
6813
+ here: !!values.here,
6814
+ cloud: !!values.cloud,
6815
+ done: !!values.done,
6816
+ json: !!values.json
6817
+ });
6206
6818
  case "import": {
6207
6819
  if (!rest[0]) throw new CliError("Usage: design import <folder> [--canvas <id>] [--title <title>]");
6208
6820
  return runImport(projectPaths(values.root), rest[0], { canvas: values.canvas, title: values.title });