@flashdsm/genome 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  GENOME_CONFIG_FILENAME,
4
4
  GenomeConfigSchema,
5
+ GenomeOutputsSchema,
5
6
  LEGACY_CONFIG_FILENAME,
6
7
  TokenCategorySchema,
7
8
  TokenSchema,
@@ -13,7 +14,7 @@ import {
13
14
  parseCategoryInput,
14
15
  parseFlashAtomicDocument,
15
16
  resolveTokenFilePath
16
- } from "./chunk-PHEWKKKF.js";
17
+ } from "./chunk-YA26VZWF.js";
17
18
 
18
19
  // src/index.ts
19
20
  import { resolve as resolve13 } from "path";
@@ -24,7 +25,7 @@ import {
24
25
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
26
  import { z as z3 } from "zod";
26
27
  import { readFileSync, realpathSync } from "fs";
27
- import { readFile as readFile16 } from "fs/promises";
28
+ import { readFile as readFile17 } from "fs/promises";
28
29
 
29
30
  // ../core/dist/change-plan.js
30
31
  import { z } from "zod";
@@ -509,7 +510,11 @@ function classify(name, value) {
509
510
  if (/^#[0-9a-fA-F]{3,8}$/.test(v))
510
511
  return { category: "colors", type: "color" };
511
512
  if (/^-?\d*\.?\d+(px|rem|em|%)$/.test(v)) {
512
- return /radius/i.test(name) ? { category: "radius", type: "radius" } : { category: "spacing", type: "size" };
513
+ if (/radius/i.test(name))
514
+ return { category: "radius", type: "radius" };
515
+ if (/(?:^|-)(?:text|fs)-(?:\d?x+s|sm|md|base|lg|\d?x+l|\d{1,3}|display|hero|title|caption)$/i.test(name))
516
+ return { category: "typography", type: "font" };
517
+ return { category: "spacing", type: "size" };
513
518
  }
514
519
  return null;
515
520
  }
@@ -628,9 +633,29 @@ function projectNameFromPackageJson(text) {
628
633
  return null;
629
634
  }
630
635
  }
636
+ async function pulledFiles(source) {
637
+ const clean2 = (p) => p.replace(/\\/g, "/").replace(/^\.\//, "");
638
+ const byPath = new Map(source.paths.map((p) => [clean2(p), p]));
639
+ const configPath = byPath.get(GENOME_CONFIG_FILENAME);
640
+ if (!configPath)
641
+ return () => false;
642
+ try {
643
+ const config = JSON.parse(await source.read(configPath) ?? "");
644
+ const { genomeDir } = GenomeOutputsSchema.parse(config?.outputs ?? {});
645
+ const lockPath = byPath.get(`${clean2(genomeDir).replace(/\/?$/, "/")}genome.lock`);
646
+ if (!lockPath)
647
+ return () => false;
648
+ const lock = JSON.parse(await source.read(lockPath) ?? "");
649
+ const files = new Set((Array.isArray(lock?.files) ? lock.files : []).filter((f) => typeof f === "string").map(clean2));
650
+ return (posix2) => files.has(clean2(posix2));
651
+ } catch {
652
+ return () => false;
653
+ }
654
+ }
631
655
  async function buildWorkspaceScan(source) {
632
656
  const root = source.workspaceRoot;
633
- const paths = source.paths;
657
+ const pulled = await pulledFiles(source);
658
+ const paths = source.paths.filter((p) => !pulled(p));
634
659
  const tokens = {};
635
660
  const files = [];
636
661
  const fileSources = [];
@@ -2680,7 +2705,7 @@ function tokenHashes(tokens) {
2680
2705
  }
2681
2706
 
2682
2707
  // ../codegen/dist/from-atomic-document.js
2683
- function atomicTokenToDesign(t, value = t.value) {
2708
+ function atomicTokenToDesign(t, value = t.value, ref = t.referencedTokenId) {
2684
2709
  return {
2685
2710
  name: t.name,
2686
2711
  value,
@@ -2688,7 +2713,7 @@ function atomicTokenToDesign(t, value = t.value) {
2688
2713
  category: t.category,
2689
2714
  ...t.description ? { description: t.description } : {},
2690
2715
  ...t.layer ? { layer: t.layer } : {},
2691
- ...t.referencedTokenId ? { referencedTokenId: t.referencedTokenId } : {},
2716
+ ...ref ? { referencedTokenId: ref } : {},
2692
2717
  ...t.colorGroup ? { colorGroup: t.colorGroup } : {},
2693
2718
  ...t.fallbackValue ? { fallbackValue: t.fallbackValue } : {},
2694
2719
  ...t.metadata ? { metadata: t.metadata } : {}
@@ -2702,6 +2727,10 @@ function atomicThemeValues(t) {
2702
2727
  return [t.value, t.value];
2703
2728
  return [dark ? String(dark) : null, light ? String(light) : null];
2704
2729
  }
2730
+ function atomicThemeRef(t, theme) {
2731
+ const ref = t.metadata?.[`${theme}Ref`];
2732
+ return typeof ref === "string" && ref ? ref : t.referencedTokenId;
2733
+ }
2705
2734
  function fromAtomicDocument(doc) {
2706
2735
  const f = doc.foundations;
2707
2736
  const colors = [];
@@ -2713,9 +2742,9 @@ function fromAtomicDocument(doc) {
2713
2742
  for (const t of palette) {
2714
2743
  const [dark, light] = atomicThemeValues(t);
2715
2744
  if (dark !== null)
2716
- colors.push(atomicTokenToDesign(t, dark));
2745
+ colors.push(atomicTokenToDesign(t, dark, atomicThemeRef(t, "dark")));
2717
2746
  if (light !== null)
2718
- lightColors.push(atomicTokenToDesign(t, light));
2747
+ lightColors.push(atomicTokenToDesign(t, light, atomicThemeRef(t, "light")));
2719
2748
  }
2720
2749
  return {
2721
2750
  colors,
@@ -2881,6 +2910,52 @@ ${FLASH_BLOCK_END}
2881
2910
  `;
2882
2911
  }
2883
2912
 
2913
+ // ../codegen/dist/shared/codeNames.js
2914
+ function keepInTheme(tokens, codeNames) {
2915
+ let out = tokens;
2916
+ for (const [code, role] of Object.entries(codeNames)) {
2917
+ if (!code || !role || code === role)
2918
+ continue;
2919
+ const target = out.find((t) => t.name === role);
2920
+ if (!target)
2921
+ continue;
2922
+ const held = out.find((t) => t.name === code);
2923
+ const kept = {
2924
+ ...held ?? { type: target.type, category: target.category },
2925
+ name: code,
2926
+ value: "",
2927
+ layer: "alias",
2928
+ referencedTokenId: role,
2929
+ description: held?.description ?? `From your code: ${code}`
2930
+ };
2931
+ out = out.map((t) => {
2932
+ if (t === held)
2933
+ return kept;
2934
+ if (t.referencedTokenId !== code)
2935
+ return t;
2936
+ return held?.referencedTokenId ? { ...t, referencedTokenId: held.referencedTokenId } : {
2937
+ ...t,
2938
+ layer: "primitive",
2939
+ referencedTokenId: void 0,
2940
+ value: held?.value ?? t.value
2941
+ };
2942
+ });
2943
+ if (!held)
2944
+ out = [...out, kept];
2945
+ }
2946
+ return out;
2947
+ }
2948
+ function keepCodeNames(tokens, codeNames, cssPrefix) {
2949
+ if (!cssPrefix || !codeNames || Object.keys(codeNames).length === 0) {
2950
+ return tokens;
2951
+ }
2952
+ return {
2953
+ ...tokens,
2954
+ colors: keepInTheme(tokens.colors, codeNames),
2955
+ lightColors: keepInTheme(tokens.lightColors, codeNames)
2956
+ };
2957
+ }
2958
+
2884
2959
  // ../codegen/dist/shared/cssPrefix.js
2885
2960
  function normalizeCssPrefix(raw) {
2886
2961
  const cleaned = (raw ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3744,6 +3819,9 @@ function pruneUnusedMotionTokens(tokens, components = []) {
3744
3819
  }
3745
3820
 
3746
3821
  // ../codegen/dist/repo-token-diff.js
3822
+ function repoEditValue(edit) {
3823
+ return edit.reference ? `var(--${tokensToCssVarName(edit.reference)})` : edit.after;
3824
+ }
3747
3825
  var GROUP_SECTIONS = {
3748
3826
  spacing: "spacing",
3749
3827
  typography: "typography",
@@ -3822,22 +3900,116 @@ function diffRepoTokens(doc, repoDtcg, responsive) {
3822
3900
  });
3823
3901
  return { edits, findings };
3824
3902
  }
3825
- const compare = (path, section, name, before, after, theme) => {
3826
- if (before === void 0 || after === void 0)
3903
+ const palette = singleThemePalette(tokens);
3904
+ const defaultTheme = palette === tokens.colors ? "dark" : "light";
3905
+ const names = {
3906
+ dark: new Map(tokens.colors.map((t) => [tokensToCssVarName(t.name), t])),
3907
+ light: new Map(tokens.lightColors.map((t) => [tokensToCssVarName(t.name), t]))
3908
+ };
3909
+ const hasTheme = {
3910
+ dark: tokens.colors.length > 0,
3911
+ light: tokens.lightColors.length > 0
3912
+ };
3913
+ const addColour = (path, name, theme, value) => {
3914
+ if (!hasTheme[theme]) {
3915
+ findings.push({
3916
+ path,
3917
+ code: "missing-theme",
3918
+ after: value,
3919
+ reason: `The file gives a ${theme} value, and this design system has no ${theme} theme yet. Add the theme in Flash, then push again.`
3920
+ });
3827
3921
  return;
3828
- if (same(before, after))
3922
+ }
3923
+ push({
3924
+ path,
3925
+ section: BUNDLE_SECTION[theme === "dark" ? "colors" : "lightColors"],
3926
+ name,
3927
+ theme,
3928
+ kind: "add",
3929
+ before: "",
3930
+ after: value
3931
+ });
3932
+ };
3933
+ const add = (group, key, entry) => {
3934
+ if (!entry || typeof entry !== "object")
3935
+ return false;
3936
+ if (group === "color") {
3937
+ const modes = modesOf(entry);
3938
+ const given = ["dark", "light"].filter((theme) => typeof modes?.[theme] === "string");
3939
+ if (given.length) {
3940
+ for (const theme of given) {
3941
+ addColour(`color.${key}`, key, theme, String(modes[theme]));
3942
+ }
3943
+ return true;
3944
+ }
3945
+ if (typeof entry.$value !== "string")
3946
+ return false;
3947
+ const themes = ["dark", "light"].filter((t) => hasTheme[t]);
3948
+ for (const theme of themes.length ? themes : [defaultTheme]) {
3949
+ addColour(`color.${key}`, key, theme, entry.$value);
3950
+ }
3951
+ return true;
3952
+ }
3953
+ const field = GROUP_SECTIONS[group];
3954
+ if (!field || typeof entry.$value !== "string" || ladderOfEntry(entry)) {
3955
+ return false;
3956
+ }
3957
+ push({
3958
+ path: `${group}.${key}`,
3959
+ section: BUNDLE_SECTION[field],
3960
+ name: key,
3961
+ kind: "add",
3962
+ before: "",
3963
+ after: entry.$value
3964
+ });
3965
+ return true;
3966
+ };
3967
+ const referenced = (value) => {
3968
+ const inner = value.trim().slice(1, -1);
3969
+ const dot = inner.indexOf(".");
3970
+ if (dot < 0)
3971
+ return void 0;
3972
+ const group = inner.slice(0, dot);
3973
+ const key = inner.slice(dot + 1);
3974
+ if (group === "color") {
3975
+ const held = names.dark.get(key) ?? names.light.get(key);
3976
+ if (held)
3977
+ return held.name;
3978
+ } else if (GROUP_SECTIONS[group]) {
3979
+ const held = tokens[GROUP_SECTIONS[group]].find((t) => groupKey(group, t.name) === key);
3980
+ if (held)
3981
+ return held.name;
3982
+ } else {
3983
+ return void 0;
3984
+ }
3985
+ return repo[group]?.[key] ? key : void 0;
3986
+ };
3987
+ const push = (edit) => {
3988
+ if (!isReference(edit.after)) {
3989
+ edits.push(edit);
3829
3990
  return;
3830
- if (isReference(before) || isReference(after)) {
3991
+ }
3992
+ const target = referenced(edit.after);
3993
+ if (!target) {
3994
+ if (findings.some((f) => f.path === edit.path && f.after === edit.after))
3995
+ return;
3831
3996
  findings.push({
3832
- path,
3997
+ path: edit.path,
3833
3998
  code: "alias",
3834
- before,
3835
- after,
3836
- reason: "This token is an alias, or became one. Re-point aliases in Flash; a push only carries literal values."
3999
+ ...edit.before ? { before: edit.before } : {},
4000
+ after: edit.after,
4001
+ reason: "This points at a token that is neither in the design system nor in the file. Add that token, or point at one that exists."
3837
4002
  });
3838
4003
  return;
3839
4004
  }
3840
- edits.push({
4005
+ edits.push({ ...edit, reference: target });
4006
+ };
4007
+ const compare = (path, section, name, before, after, theme) => {
4008
+ if (before === void 0 || after === void 0)
4009
+ return;
4010
+ if (same(before, after))
4011
+ return;
4012
+ push({
3841
4013
  path,
3842
4014
  section,
3843
4015
  name,
@@ -3879,7 +4051,7 @@ function diffRepoTokens(doc, repoDtcg, responsive) {
3879
4051
  code: "alias",
3880
4052
  before: narrowest.value,
3881
4053
  after: repoNarrowest.value,
3882
- reason: "This token is an alias, or became one. Re-point aliases in Flash; a push only carries literal values."
4054
+ reason: "This token changes with the viewport, and its narrowest size became an alias. Point it in Flash; a push carries one literal size here."
3883
4055
  });
3884
4056
  } else {
3885
4057
  edits.push({
@@ -3914,11 +4086,13 @@ function diffRepoTokens(doc, repoDtcg, responsive) {
3914
4086
  for (const key of Object.keys(repoGroup)) {
3915
4087
  if (key.startsWith("$") || key in webGroup)
3916
4088
  continue;
3917
- findings.push({
3918
- path: `${group}.${key}`,
3919
- code: "added-in-repo",
3920
- reason: "Not in the design system. Add it in Flash, then pull; a push does not create tokens."
3921
- });
4089
+ if (!add(group, key, repoGroup[key])) {
4090
+ findings.push({
4091
+ path: `${group}.${key}`,
4092
+ code: "added-in-repo",
4093
+ reason: "Not in the design system, and not a single value a push can add. Add it in Flash, then pull."
4094
+ });
4095
+ }
3922
4096
  }
3923
4097
  for (const key of Object.keys(webGroup)) {
3924
4098
  if (key in repoGroup)
@@ -3932,20 +4106,20 @@ function diffRepoTokens(doc, repoDtcg, responsive) {
3932
4106
  };
3933
4107
  const webColors = web.color ?? {};
3934
4108
  presence("color", webColors);
3935
- const palette = singleThemePalette(tokens);
3936
- const defaultTheme = palette === tokens.colors ? "dark" : "light";
3937
- const names = {
3938
- dark: new Map(tokens.colors.map((t) => [tokensToCssVarName(t.name), t])),
3939
- light: new Map(tokens.lightColors.map((t) => [tokensToCssVarName(t.name), t]))
3940
- };
3941
4109
  for (const [key, webEntry] of Object.entries(webColors)) {
3942
4110
  const repoEntry = repo.color?.[key];
3943
4111
  if (!repoEntry)
3944
4112
  continue;
3945
4113
  for (const theme of ["dark", "light"]) {
3946
4114
  const token = names[theme].get(key);
3947
- if (!token)
4115
+ if (!token) {
4116
+ const own = modesOf(repoEntry)?.[theme];
4117
+ if (typeof own === "string" && own.trim()) {
4118
+ const other = names[theme === "dark" ? "light" : "dark"].get(key);
4119
+ addColour(`color.${key}`, other.name, theme, own);
4120
+ }
3948
4121
  continue;
4122
+ }
3949
4123
  compare(`color.${key}`, BUNDLE_SECTION[theme === "dark" ? "colors" : "lightColors"], token.name, themeValue(webEntry, theme, defaultTheme), themeValue(repoEntry, theme, defaultTheme, webEntry), theme);
3950
4124
  }
3951
4125
  }
@@ -4004,7 +4178,7 @@ function documentToRepoBundle(doc, options = {}) {
4004
4178
  ...options.paths
4005
4179
  };
4006
4180
  const components = atomicComponentsToExportSpecs(doc);
4007
- const tokens = pruneUnusedMotionTokens(withResponsiveLayer(fromAtomicDocument(doc), options.responsive), components);
4181
+ const tokens = pruneUnusedMotionTokens(keepCodeNames(withResponsiveLayer(fromAtomicDocument(doc), options.responsive), options.codeNames, options.cssPrefix), components);
4008
4182
  const projectName = options.projectName ?? doc.name;
4009
4183
  const files = [];
4010
4184
  if (paths.tokensDtcg) {
@@ -4197,10 +4371,20 @@ function responsiveOf(envelope) {
4197
4371
  function exportSettingsOf(envelope) {
4198
4372
  const raw = envelope.export_settings;
4199
4373
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
4200
- const { css_prefix: cssPrefix, css_selector: cssSelector } = raw;
4374
+ const {
4375
+ css_prefix: cssPrefix,
4376
+ css_selector: cssSelector,
4377
+ code_names: rawCodeNames
4378
+ } = raw;
4379
+ const codeNames = rawCodeNames && typeof rawCodeNames === "object" && !Array.isArray(rawCodeNames) ? Object.fromEntries(
4380
+ Object.entries(rawCodeNames).filter(
4381
+ (e) => typeof e[1] === "string"
4382
+ )
4383
+ ) : {};
4201
4384
  return {
4202
4385
  ...typeof cssPrefix === "string" ? { cssPrefix } : {},
4203
- ...typeof cssSelector === "string" ? { cssSelector } : {}
4386
+ ...typeof cssSelector === "string" ? { cssSelector } : {},
4387
+ ...Object.keys(codeNames).length ? { codeNames } : {}
4204
4388
  };
4205
4389
  }
4206
4390
  function editPath(edit) {
@@ -4662,19 +4846,43 @@ async function detectDrift(opts) {
4662
4846
  };
4663
4847
  }
4664
4848
 
4665
- // src/tools/push-to-web.ts
4666
- function findingEntry(finding) {
4667
- return {
4668
- action: "flag",
4669
- targetKind: "token",
4670
- path: finding.path,
4671
- before: finding.before ?? null,
4672
- after: finding.after ?? null,
4673
- code: finding.code,
4674
- reason: finding.reason
4675
- };
4849
+ // src/tools/scan-workspace.ts
4850
+ import { readFile as readFile6, readdir, stat } from "fs/promises";
4851
+ import { join as join5, resolve as resolve7 } from "path";
4852
+ async function walk(root, dir, out) {
4853
+ const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
4854
+ for (const e of entries) {
4855
+ const full = join5(dir, e.name);
4856
+ if (e.isDirectory()) {
4857
+ if (!isSkippedScanDir(e.name)) await walk(root, full, out);
4858
+ } else if (e.isFile()) out.push(full.slice(root.length + 1));
4859
+ }
4676
4860
  }
4677
- async function pushToWeb(opts) {
4861
+ async function readSmallFile(path) {
4862
+ const info = await stat(path).catch(() => null);
4863
+ if (!info || !info.isFile() || info.size > MAX_SCAN_READ_BYTES) return null;
4864
+ return readFile6(path, "utf8").catch(() => null);
4865
+ }
4866
+ async function scanWorkspace(workspaceRoot, opts = {}) {
4867
+ const root = resolve7(workspaceRoot);
4868
+ const paths = [];
4869
+ await walk(root, root, paths);
4870
+ return buildWorkspaceScan({
4871
+ workspaceRoot: root,
4872
+ paths: opts.skip ? paths.filter((p) => !opts.skip(p)) : paths,
4873
+ read: (rel) => readSmallFile(join5(root, rel))
4874
+ });
4875
+ }
4876
+
4877
+ // src/tools/code-changes.ts
4878
+ function scanIsEmpty(scan) {
4879
+ const tokens = Object.values(scan.tokens ?? {}).reduce(
4880
+ (n, list2) => n + list2.length,
4881
+ 0
4882
+ );
4883
+ return tokens === 0 && (scan.files ?? []).length === 0;
4884
+ }
4885
+ async function collectCodeChanges(opts) {
4678
4886
  const state = await readRepoState(opts);
4679
4887
  const { envelope, lock } = state;
4680
4888
  if (!lock) {
@@ -4689,75 +4897,145 @@ async function pushToWeb(opts) {
4689
4897
  `Flash has published a new version since this repo pulled (published ${lock.publishedVersionId ?? "unknown"}, now ${envelope.published_version_id ?? "none"}). A push now could undo those changes. Keep a copy of your edits, pull, re-apply them, then push.`
4690
4898
  );
4691
4899
  }
4692
- if (state.tokensText === null) {
4693
- throw new SyncRefusal(
4694
- "missing-tokens-file",
4695
- `${state.tokensPath} is missing, so there is nothing to push. Pull again to restore it.`
4696
- );
4697
- }
4698
- const { edits, findings } = diffRepoTokens(
4699
- state.doc,
4700
- state.tokensText,
4701
- state.responsive
4900
+ const { edits, findings } = state.tokensText === null ? {
4901
+ edits: [],
4902
+ findings: [
4903
+ {
4904
+ path: state.tokensPath,
4905
+ code: "unreadable",
4906
+ reason: `${state.tokensPath} is missing, so only the stylesheets were read. Pull again to restore it.`
4907
+ }
4908
+ ]
4909
+ } : diffRepoTokens(state.doc, state.tokensText, state.responsive);
4910
+ const pulled = new Set(
4911
+ lock.files.map((f) => f.replace(/\\/g, "/").replace(/^\.\//, ""))
4912
+ );
4913
+ const scan = await scanWorkspace(opts.workspaceRoot, {
4914
+ skip: (rel) => pulled.has(rel.replace(/\\/g, "/"))
4915
+ });
4916
+ return {
4917
+ state,
4918
+ scan,
4919
+ edits,
4920
+ findings,
4921
+ request: {
4922
+ scan,
4923
+ changes: edits.map((edit) => ({
4924
+ section: edit.section,
4925
+ name: edit.name,
4926
+ value: repoEditValue(edit)
4927
+ })),
4928
+ base_revision: envelope.revision
4929
+ }
4930
+ };
4931
+ }
4932
+
4933
+ // src/tools/push-to-web.ts
4934
+ var NOT_TAKEN = /* @__PURE__ */ new Set(["unresolved", "unusable"]);
4935
+ var THEME_OF = {
4936
+ "design-tokens-dark-colors": "dark",
4937
+ "design-tokens-light-colors": "light"
4938
+ };
4939
+ function rowPath(section, name) {
4940
+ const theme = section ? THEME_OF[section] : void 0;
4941
+ return theme ? `${name} (${theme})` : name;
4942
+ }
4943
+ function findingEntry(finding) {
4944
+ return {
4945
+ action: "flag",
4946
+ targetKind: "token",
4947
+ path: finding.path,
4948
+ before: finding.before ?? null,
4949
+ after: finding.after ?? null,
4950
+ code: finding.code,
4951
+ reason: finding.reason
4952
+ };
4953
+ }
4954
+ function planFrom(response, edits) {
4955
+ const notes = response.notes ?? [];
4956
+ const noteFor = (section, name) => notes.find(
4957
+ (n) => !NOT_TAKEN.has(n.kind) && n.name === name && n.section === section
4702
4958
  );
4703
- const flagged = findings.map(findingEntry);
4959
+ const rows = (response.diff ?? []).map((row) => {
4960
+ const note = noteFor(row.section, row.name);
4961
+ const edit = edits.find(
4962
+ (e) => e.section === row.section && e.name === row.name
4963
+ );
4964
+ return {
4965
+ ...tokenChangeToPlanEntry(row),
4966
+ path: edit ? editPath(edit) : rowPath(row.section, row.name),
4967
+ ...edit && edit.kind !== "add" ? { before: edit.before } : {},
4968
+ ...edit && !edit.reference ? { after: edit.after } : {},
4969
+ ...note ? { code: note.kind, reason: note.reason } : {}
4970
+ };
4971
+ });
4972
+ const refused = notes.filter((n) => NOT_TAKEN.has(n.kind)).map((n) => ({
4973
+ action: "flag",
4974
+ targetKind: "token",
4975
+ path: rowPath(n.section, n.name),
4976
+ code: n.kind,
4977
+ reason: n.reason
4978
+ }));
4979
+ return [...rows, ...refused];
4980
+ }
4981
+ async function pushToWeb(opts) {
4982
+ const collected = await collectCodeChanges(opts);
4983
+ const { envelope } = collected.state;
4984
+ const flagged = collected.findings.map(findingEntry);
4704
4985
  const base = {
4705
4986
  genomeId: envelope.id,
4706
4987
  revision: envelope.revision,
4707
- findings
4988
+ findings: collected.findings
4708
4989
  };
4709
- if (!edits.length) {
4990
+ if (!collected.request.changes.length && scanIsEmpty(collected.scan)) {
4710
4991
  return {
4711
4992
  ...makeChangePlan(flagged, true),
4712
4993
  ...base,
4713
4994
  status: "no_changes",
4714
4995
  changeSetId: null,
4715
- message: findings.length ? "No token value changed. The flagged entries are things a push does not carry." : "No token value changed since the last pull."
4996
+ message: collected.findings.length ? "Nothing in the code to send. The flagged entries are things a push does not carry." : "Nothing in the code to send: no stylesheet defines a token, and the tokens file matches the last pull."
4716
4997
  };
4717
4998
  }
4718
- const planned = edits.map((edit) => ({
4719
- action: "update",
4720
- targetKind: "token",
4721
- path: editPath(edit),
4722
- before: edit.before,
4723
- after: edit.after
4724
- }));
4725
- if (opts.confirm !== true) {
4999
+ const dryRun = opts.confirm !== true;
5000
+ const codebase = opts.config.codebase?.name ?? opts.config.name;
5001
+ const response = await opts.client.callTool(
5002
+ "propose_code_scan",
5003
+ {
5004
+ ...collected.request,
5005
+ scan: {
5006
+ ...collected.request.scan,
5007
+ // The review card says where the code came from.
5008
+ workspaceRoot: codebase
5009
+ },
5010
+ ...dryRun ? { dry_run: true } : {}
5011
+ }
5012
+ );
5013
+ const entries = [...planFrom(response, collected.edits), ...flagged];
5014
+ const changed = (response.diff ?? []).length;
5015
+ if (response.no_changes) {
5016
+ return {
5017
+ ...makeChangePlan(entries, dryRun),
5018
+ ...base,
5019
+ status: "no_changes",
5020
+ changeSetId: null,
5021
+ message: "Flash already has what the code holds, or the same proposal is waiting for review."
5022
+ };
5023
+ }
5024
+ if (dryRun) {
4726
5025
  return {
4727
- ...makeChangePlan([...planned, ...flagged], true),
5026
+ ...makeChangePlan(entries, true),
4728
5027
  ...base,
4729
5028
  status: "dry_run",
4730
5029
  changeSetId: null,
4731
- message: `${edits.length} token value${edits.length === 1 ? "" : "s"} to propose for review. Run again with confirm to open the change set in Flash.`
5030
+ message: `${changed} change${changed === 1 ? "" : "s"} to propose for review. Run again with confirm to open the change set in Flash.`
4732
5031
  };
4733
5032
  }
4734
- const codebase = opts.config.codebase?.name ?? opts.config.name;
4735
- const response = await opts.client.callTool(
4736
- "propose_change_set",
4737
- {
4738
- base_revision: envelope.revision,
4739
- source_label: `Genome \xB7 ${codebase}`,
4740
- changes: edits.map((edit) => ({
4741
- section: edit.section,
4742
- name: edit.name,
4743
- value: edit.after
4744
- }))
4745
- }
4746
- );
4747
- const skipped = (response.skipped ?? []).map((s) => ({
4748
- action: "flag",
4749
- targetKind: "token",
4750
- path: `${s.section}/${s.name}`,
4751
- code: "skipped-by-server",
4752
- reason: s.reason
4753
- }));
4754
- const proposed = (response.diff ?? []).map(tokenChangeToPlanEntry);
4755
5033
  return {
4756
- ...makeChangePlan([...proposed, ...skipped, ...flagged], false),
5034
+ ...makeChangePlan(entries, false),
4757
5035
  ...base,
4758
- status: response.no_changes ? "no_changes" : "proposed",
5036
+ status: "proposed",
4759
5037
  changeSetId: response.change_set_id ?? null,
4760
- message: response.message ?? (response.no_changes ? "Flash already has these values, or the same proposal is waiting for review." : "Proposed for review. Open Sync in Flash to apply or discard it.")
5038
+ message: "Proposed for review. Open Sync in Flash to apply or discard it."
4761
5039
  };
4762
5040
  }
4763
5041
 
@@ -4861,8 +5139,8 @@ function ensureTrailingNewline(text) {
4861
5139
  }
4862
5140
 
4863
5141
  // src/workspace.ts
4864
- import { readFile as readFile6 } from "fs/promises";
4865
- import { resolve as resolve7 } from "path";
5142
+ import { readFile as readFile7 } from "fs/promises";
5143
+ import { resolve as resolve8 } from "path";
4866
5144
 
4867
5145
  // src/rpc.ts
4868
5146
  var TOOL_ERROR = -32e3;
@@ -4967,7 +5245,7 @@ function genomeToken(explicit) {
4967
5245
  async function readGenomeConfig(workspaceRoot, configPath) {
4968
5246
  let legacy = false;
4969
5247
  for (const path of configCandidates(configPath, workspaceRoot)) {
4970
- const raw = await readFile6(path, "utf8").catch(() => null);
5248
+ const raw = await readFile7(path, "utf8").catch(() => null);
4971
5249
  if (!raw) continue;
4972
5250
  if (path.endsWith(LEGACY_CONFIG_FILENAME)) legacy = true;
4973
5251
  let parsed;
@@ -4990,7 +5268,7 @@ async function readGenomeConfig(workspaceRoot, configPath) {
4990
5268
  );
4991
5269
  }
4992
5270
  async function workspaceContext(opts) {
4993
- const workspaceRoot = resolve7(process.cwd(), opts.workspace ?? ".");
5271
+ const workspaceRoot = resolve8(process.cwd(), opts.workspace ?? ".");
4994
5272
  const config = await readGenomeConfig(workspaceRoot, opts.configPath);
4995
5273
  const client = createRpcClient({
4996
5274
  // Already resolved by the caller in the documented order: the flag, then
@@ -5005,8 +5283,8 @@ async function workspaceContext(opts) {
5005
5283
 
5006
5284
  // src/tools/link.ts
5007
5285
  import { randomBytes } from "crypto";
5008
- import { readFile as readFile7, writeFile as writeFile2 } from "fs/promises";
5009
- import { basename, join as join5, resolve as resolve8 } from "path";
5286
+ import { readFile as readFile8, writeFile as writeFile2 } from "fs/promises";
5287
+ import { basename, join as join6, resolve as resolve9 } from "path";
5010
5288
  var DEFAULT_INPUTS = {
5011
5289
  tokens: ["design-tokens.json", "tokens.css"],
5012
5290
  components: []
@@ -5025,9 +5303,9 @@ async function linkGenome(opts) {
5025
5303
  "The server did not say which design system this token belongs to."
5026
5304
  );
5027
5305
  }
5028
- const root = resolve8(opts.workspaceRoot);
5029
- const path = join5(root, GENOME_CONFIG_FILENAME);
5030
- const before = await readFile7(path, "utf8").catch(() => null);
5306
+ const root = resolve9(opts.workspaceRoot);
5307
+ const path = join6(root, GENOME_CONFIG_FILENAME);
5308
+ const before = await readFile8(path, "utf8").catch(() => null);
5031
5309
  let previous = null;
5032
5310
  let unreadable = false;
5033
5311
  if (before) {
@@ -5093,13 +5371,13 @@ function describeLink(result) {
5093
5371
  }
5094
5372
 
5095
5373
  // src/tools/apply-token-patch.ts
5096
- import { readFile as readFile8, writeFile as writeFile3 } from "fs/promises";
5374
+ import { readFile as readFile9, writeFile as writeFile3 } from "fs/promises";
5097
5375
  function isRecord2(value) {
5098
5376
  return typeof value === "object" && value !== null && !Array.isArray(value);
5099
5377
  }
5100
5378
  async function applyTokenPatch(tokenFile, patch, options = {}) {
5101
5379
  const confirm = options.confirm === true;
5102
- const text = await readFile8(tokenFile, "utf8");
5380
+ const text = await readFile9(tokenFile, "utf8");
5103
5381
  const raw = JSON.parse(text);
5104
5382
  const { tokens, name } = normalizeTokenDocument(raw);
5105
5383
  const beforeCount = tokens.length;
@@ -5161,30 +5439,30 @@ async function applyTokenPatch(tokenFile, patch, options = {}) {
5161
5439
  }
5162
5440
 
5163
5441
  // src/tools/brand-dna.ts
5164
- import { readFile as readFile9 } from "fs/promises";
5165
- import { dirname as dirname3, resolve as resolve9 } from "path";
5442
+ import { readFile as readFile10 } from "fs/promises";
5443
+ import { dirname as dirname3, resolve as resolve10 } from "path";
5166
5444
  function isRecord3(value) {
5167
5445
  return typeof value === "object" && value !== null && !Array.isArray(value);
5168
5446
  }
5169
5447
  async function getBrandDna(options) {
5170
5448
  if (options.brandDnaFile) {
5171
- const text2 = await readFile9(options.brandDnaFile, "utf8");
5449
+ const text2 = await readFile10(options.brandDnaFile, "utf8");
5172
5450
  const raw2 = JSON.parse(text2);
5173
5451
  if (!isRecord3(raw2)) {
5174
5452
  throw new Error("brand-dna file must be a JSON object");
5175
5453
  }
5176
5454
  return { source: options.brandDnaFile, brandDna: raw2 };
5177
5455
  }
5178
- const sibling = resolve9(dirname3(options.tokenFile), "brand-dna.json");
5456
+ const sibling = resolve10(dirname3(options.tokenFile), "brand-dna.json");
5179
5457
  try {
5180
- const text2 = await readFile9(sibling, "utf8");
5458
+ const text2 = await readFile10(sibling, "utf8");
5181
5459
  const raw2 = JSON.parse(text2);
5182
5460
  if (isRecord3(raw2)) {
5183
5461
  return { source: sibling, brandDna: raw2 };
5184
5462
  }
5185
5463
  } catch {
5186
5464
  }
5187
- const text = await readFile9(options.tokenFile, "utf8");
5465
+ const text = await readFile10(options.tokenFile, "utf8");
5188
5466
  const raw = JSON.parse(text);
5189
5467
  if (!isRecord3(raw)) {
5190
5468
  throw new Error(
@@ -5259,7 +5537,7 @@ async function generateComponentPrompt(componentsFile, tokenFile, name) {
5259
5537
  }
5260
5538
 
5261
5539
  // src/tools/list-violations.ts
5262
- import { readFile as readFile10 } from "fs/promises";
5540
+ import { readFile as readFile11 } from "fs/promises";
5263
5541
  var HEX_RE = /#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/g;
5264
5542
  var ARBITRARY_TW_RE = /\b(?:bg|text|border|from|to|via|ring)-\[#[0-9a-fA-F]{3,8}\]/g;
5265
5543
  var ARBITRARY_PX_RE = /\b(?:p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|ml|mr|gap|space-[xy])-\[(\d+)px\]/g;
@@ -5300,7 +5578,7 @@ function lineCol(source, index) {
5300
5578
  }
5301
5579
  async function listViolations(tokenFile, filePath) {
5302
5580
  const loaded = await loadTokensFromFile(tokenFile);
5303
- const source = await readFile10(filePath, "utf8");
5581
+ const source = await readFile11(filePath, "utf8");
5304
5582
  const colors = colorTokenSet(loaded.tokens);
5305
5583
  const spacing = spacingTokenSet(loaded.tokens);
5306
5584
  const violations = [];
@@ -5369,11 +5647,11 @@ import { spawn } from "child_process";
5369
5647
  import { createServer } from "http";
5370
5648
 
5371
5649
  // src/oauth/credentials.ts
5372
- import { chmod, mkdir as mkdir2, readFile as readFile11, writeFile as writeFile4 } from "fs/promises";
5650
+ import { chmod, mkdir as mkdir2, readFile as readFile12, writeFile as writeFile4 } from "fs/promises";
5373
5651
  import { homedir } from "os";
5374
- import { dirname as dirname4, join as join6 } from "path";
5652
+ import { dirname as dirname4, join as join7 } from "path";
5375
5653
  function credentialPath() {
5376
- return process.env.FLASH_GENOME_CREDENTIALS || join6(homedir(), ".config", "flash-dsm", "credentials.json");
5654
+ return process.env.FLASH_GENOME_CREDENTIALS || join7(homedir(), ".config", "flash-dsm", "credentials.json");
5377
5655
  }
5378
5656
  async function writeCredential(cred) {
5379
5657
  const path = credentialPath();
@@ -5385,7 +5663,7 @@ async function writeCredential(cred) {
5385
5663
  async function readCredential() {
5386
5664
  try {
5387
5665
  const raw = JSON.parse(
5388
- await readFile11(credentialPath(), "utf8")
5666
+ await readFile12(credentialPath(), "utf8")
5389
5667
  );
5390
5668
  if (!raw.accessToken || !raw.apiUrl) return null;
5391
5669
  if (raw.kind === "token") return raw;
@@ -5554,34 +5832,6 @@ async function loginWithOauth(opts) {
5554
5832
  return { path };
5555
5833
  }
5556
5834
 
5557
- // src/tools/scan-workspace.ts
5558
- import { readFile as readFile12, readdir, stat } from "fs/promises";
5559
- import { join as join7, resolve as resolve10 } from "path";
5560
- async function walk(root, dir, out) {
5561
- const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
5562
- for (const e of entries) {
5563
- const full = join7(dir, e.name);
5564
- if (e.isDirectory()) {
5565
- if (!isSkippedScanDir(e.name)) await walk(root, full, out);
5566
- } else if (e.isFile()) out.push(full.slice(root.length + 1));
5567
- }
5568
- }
5569
- async function readSmallFile(path) {
5570
- const info = await stat(path).catch(() => null);
5571
- if (!info || !info.isFile() || info.size > MAX_SCAN_READ_BYTES) return null;
5572
- return readFile12(path, "utf8").catch(() => null);
5573
- }
5574
- async function scanWorkspace(workspaceRoot) {
5575
- const root = resolve10(workspaceRoot);
5576
- const paths = [];
5577
- await walk(root, root, paths);
5578
- return buildWorkspaceScan({
5579
- workspaceRoot: root,
5580
- paths,
5581
- read: (rel) => readSmallFile(join7(root, rel))
5582
- });
5583
- }
5584
-
5585
5835
  // src/tools/init.ts
5586
5836
  import { readFile as readFile13 } from "fs/promises";
5587
5837
  import { join as join8 } from "path";
@@ -5711,8 +5961,8 @@ function describeInit(result) {
5711
5961
 
5712
5962
  // src/tools/connect.ts
5713
5963
  import { spawnSync } from "child_process";
5714
- import { readFile as readFile14 } from "fs/promises";
5715
- import { join as join9 } from "path";
5964
+ import { readFile as readFile15 } from "fs/promises";
5965
+ import { join as join10 } from "path";
5716
5966
 
5717
5967
  // src/cli-sync.ts
5718
5968
  import { resolve as resolve11 } from "path";
@@ -5916,6 +6166,475 @@ async function runSyncCommand(opts, deps = {}) {
5916
6166
  }
5917
6167
  }
5918
6168
 
6169
+ // src/tools/live-preview.ts
6170
+ import { spawn as spawn2 } from "child_process";
6171
+ import { mkdir as mkdir3, readFile as readFile14, rm, writeFile as writeFile5 } from "fs/promises";
6172
+ import http from "http";
6173
+ import net from "net";
6174
+ import { homedir as homedir2 } from "os";
6175
+ import { dirname as dirname5, join as join9 } from "path";
6176
+ var LIVE_PREVIEW_PORTS = [4747, 4748, 4749, 4750];
6177
+ var LIVE_PATH = "/__flash/";
6178
+ var COMMON_DEV_PORTS = [
6179
+ 5173,
6180
+ 5174,
6181
+ 3e3,
6182
+ 3001,
6183
+ 4321,
6184
+ 4200,
6185
+ 8080,
6186
+ 8e3,
6187
+ 5e3,
6188
+ 1234
6189
+ ];
6190
+ function livePreviewStatePath() {
6191
+ return process.env.FLASH_GENOME_PREVIEW_STATE || join9(homedir2(), ".config", "flash-dsm", "live-preview.json");
6192
+ }
6193
+ function httpOrigin(url) {
6194
+ if (!url) return null;
6195
+ try {
6196
+ const parsed = new URL(url);
6197
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
6198
+ } catch {
6199
+ return null;
6200
+ }
6201
+ }
6202
+ async function lastLivePreviewOrigin(apiUrl) {
6203
+ try {
6204
+ const state = JSON.parse(
6205
+ await readFile14(livePreviewStatePath(), "utf8")
6206
+ );
6207
+ if (typeof state.origin !== "string" || typeof state.apiOrigin !== "string")
6208
+ return null;
6209
+ if (state.apiOrigin !== httpOrigin(apiUrl)) return null;
6210
+ return httpOrigin(state.origin) === state.origin ? state.origin : null;
6211
+ } catch {
6212
+ return null;
6213
+ }
6214
+ }
6215
+ async function portsNamedByProject(root) {
6216
+ const found = [];
6217
+ const add = (raw) => {
6218
+ const port = Number(raw);
6219
+ if (Number.isInteger(port) && port > 0 && port < 65536 && !found.includes(port)) {
6220
+ found.push(port);
6221
+ }
6222
+ };
6223
+ try {
6224
+ const pkg = JSON.parse(
6225
+ await readFile14(join9(root, "package.json"), "utf8")
6226
+ );
6227
+ for (const script of Object.values(pkg.scripts ?? {})) {
6228
+ for (const m of script.matchAll(/(?:--port|-p)[=\s]+(\d{2,5})/g))
6229
+ add(m[1]);
6230
+ for (const m of script.matchAll(/\bPORT=(\d{2,5})/g)) add(m[1]);
6231
+ }
6232
+ } catch {
6233
+ }
6234
+ for (const name of [
6235
+ "vite.config.ts",
6236
+ "vite.config.js",
6237
+ "vite.config.mts",
6238
+ "vite.config.mjs"
6239
+ ]) {
6240
+ try {
6241
+ const text = await readFile14(join9(root, name), "utf8");
6242
+ for (const m of text.matchAll(/\bport\s*:\s*(\d{2,5})/g)) add(m[1]);
6243
+ } catch {
6244
+ }
6245
+ }
6246
+ return found;
6247
+ }
6248
+ async function devServerCommand(root) {
6249
+ let scripts = {};
6250
+ try {
6251
+ scripts = JSON.parse(await readFile14(join9(root, "package.json"), "utf8")).scripts ?? {};
6252
+ } catch {
6253
+ return null;
6254
+ }
6255
+ const script = ["dev", "start", "serve"].find((name) => scripts[name]);
6256
+ if (!script) return null;
6257
+ const has = async (name) => readFile14(join9(root, name)).then(
6258
+ () => true,
6259
+ () => false
6260
+ );
6261
+ const pm = await has("pnpm-lock.yaml") ? "pnpm" : await has("yarn.lock") ? "yarn" : await has("bun.lock") || await has("bun.lockb") ? "bun" : "npm";
6262
+ if (pm === "pnpm" || pm === "yarn") return `${pm} ${script}`;
6263
+ if (pm === "npm" && script === "start") return "npm start";
6264
+ return `${pm} run ${script}`;
6265
+ }
6266
+ async function answersWithPage(url, fetchImpl) {
6267
+ const controller = new AbortController();
6268
+ const timer = setTimeout(() => controller.abort(), 900);
6269
+ try {
6270
+ const res = await fetchImpl(url, {
6271
+ signal: controller.signal,
6272
+ headers: { accept: "text/html" },
6273
+ redirect: "manual"
6274
+ });
6275
+ const type = res.headers.get("content-type") ?? "";
6276
+ return type.includes("text/html") || res.status >= 300 && res.status < 400;
6277
+ } catch {
6278
+ return false;
6279
+ } finally {
6280
+ clearTimeout(timer);
6281
+ }
6282
+ }
6283
+ async function findDevServer(root, fetchImpl = fetch) {
6284
+ const ports = [
6285
+ ...await portsNamedByProject(root),
6286
+ ...COMMON_DEV_PORTS
6287
+ ].filter(
6288
+ (port, i, all) => all.indexOf(port) === i && !LIVE_PREVIEW_PORTS.includes(port)
6289
+ );
6290
+ for (const port of ports) {
6291
+ const url = `http://localhost:${port}`;
6292
+ if (await answersWithPage(`${url}/`, fetchImpl)) return url;
6293
+ }
6294
+ return null;
6295
+ }
6296
+ function liveCss(entries) {
6297
+ return entries.map(([name, value]) => {
6298
+ const inline = '[style*="' + name + ':"]';
6299
+ return ":root, :host, *:not(" + inline + "):not(" + inline + " *) { " + name + ": " + value + " !important; }";
6300
+ }).join("\n");
6301
+ }
6302
+ function liveScript(origin) {
6303
+ return `(() => {
6304
+ const ORIGIN = ${JSON.stringify(origin)};
6305
+ const NAME = /^--[A-Za-z0-9_-]{1,80}$/;
6306
+ const VALUE = /^[^;{}<>]{1,200}$/;
6307
+ const liveCss = ${liveCss.toString()};
6308
+ let css = "";
6309
+ const roots = new Set([document]);
6310
+ const sheetFor = (root) => {
6311
+ const host = root === document ? document.head || document.documentElement : root;
6312
+ let el = host.querySelector(":scope > style[data-flash-live]");
6313
+ if (!el) {
6314
+ el = document.createElement("style");
6315
+ el.setAttribute("data-flash-live", "");
6316
+ host.appendChild(el);
6317
+ }
6318
+ return el;
6319
+ };
6320
+ const collect = (node) => {
6321
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT);
6322
+ for (let el = walker.currentNode; el; el = walker.nextNode()) {
6323
+ if (el.shadowRoot && !roots.has(el.shadowRoot)) {
6324
+ roots.add(el.shadowRoot);
6325
+ observe(el.shadowRoot);
6326
+ collect(el.shadowRoot);
6327
+ }
6328
+ }
6329
+ };
6330
+ const paint = () => {
6331
+ for (const root of roots) {
6332
+ const el = sheetFor(root);
6333
+ if (el.textContent !== css) el.textContent = css;
6334
+ }
6335
+ };
6336
+ let queued = false;
6337
+ const observer = new MutationObserver(() => {
6338
+ if (queued) return;
6339
+ queued = true;
6340
+ requestAnimationFrame(() => {
6341
+ queued = false;
6342
+ collect(document.documentElement);
6343
+ paint();
6344
+ });
6345
+ });
6346
+ const observe = (root) =>
6347
+ observer.observe(root, { childList: true, subtree: true });
6348
+ window.addEventListener("message", (event) => {
6349
+ if (event.origin !== ORIGIN || !event.data) return;
6350
+ const { type, vars } = event.data;
6351
+ if (type === "flash:reset") css = "";
6352
+ else if (type === "flash:vars" && vars && typeof vars === "object") {
6353
+ css = liveCss(
6354
+ Object.entries(vars).filter(
6355
+ ([name, value]) =>
6356
+ NAME.test(name) && typeof value === "string" && VALUE.test(value),
6357
+ ),
6358
+ );
6359
+ } else return;
6360
+ collect(document.documentElement);
6361
+ paint();
6362
+ });
6363
+ const start = () => {
6364
+ observe(document.documentElement);
6365
+ collect(document.documentElement);
6366
+ paint();
6367
+ if (window.parent !== window) {
6368
+ window.parent.postMessage({ type: "flash:ready" }, ORIGIN);
6369
+ }
6370
+ };
6371
+ if (document.readyState === "loading") {
6372
+ document.addEventListener("DOMContentLoaded", start, { once: true });
6373
+ } else start();
6374
+ })();
6375
+ `;
6376
+ }
6377
+ function cleanHeaders(headers, injected, origin) {
6378
+ const out = {};
6379
+ let csp = "";
6380
+ for (const [key, value] of Object.entries(headers)) {
6381
+ const name = key.toLowerCase();
6382
+ if (name === "x-frame-options") continue;
6383
+ if (name === "content-security-policy") {
6384
+ csp = (Array.isArray(value) ? value.join(";") : String(value ?? "")).split(";").filter((part) => part.trim() && !/^\s*frame-ancestors\b/i.test(part)).join(";").trim();
6385
+ continue;
6386
+ }
6387
+ if (injected && (name === "content-length" || name === "etag")) continue;
6388
+ out[key] = value;
6389
+ }
6390
+ out["content-security-policy"] = [csp, `frame-ancestors ${origin}`].filter(Boolean).join("; ");
6391
+ return out;
6392
+ }
6393
+ function injectScript(html) {
6394
+ const tag = `<script src="${LIVE_PATH}live.js"></script>`;
6395
+ if (html.includes(tag)) return html;
6396
+ const head = html.match(/<head[^>]*>/i);
6397
+ if (head?.index !== void 0) {
6398
+ const at = head.index + head[0].length;
6399
+ return html.slice(0, at) + tag + html.slice(at);
6400
+ }
6401
+ return tag + html;
6402
+ }
6403
+ async function serveLivePreview(opts) {
6404
+ const target = new URL(opts.devUrl);
6405
+ const targetPort = Number(target.port || 80);
6406
+ const origin = opts.allowedOrigin.replace(/\/$/, "");
6407
+ const script = liveScript(origin);
6408
+ let info = null;
6409
+ const cors = (res) => {
6410
+ res.setHeader("Access-Control-Allow-Origin", origin);
6411
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
6412
+ res.setHeader("Access-Control-Allow-Headers", "*");
6413
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
6414
+ res.setHeader("Vary", "Origin");
6415
+ };
6416
+ const own = (req, res) => {
6417
+ const path = (req.url ?? "").split("?")[0];
6418
+ if (req.method === "OPTIONS") {
6419
+ cors(res);
6420
+ res.writeHead(204).end();
6421
+ return;
6422
+ }
6423
+ if (path === `${LIVE_PATH}hello`) {
6424
+ cors(res);
6425
+ res.writeHead(200, {
6426
+ "content-type": "application/json",
6427
+ "cache-control": "no-store"
6428
+ });
6429
+ res.end(JSON.stringify(info));
6430
+ return;
6431
+ }
6432
+ if (path === `${LIVE_PATH}live.js`) {
6433
+ res.writeHead(200, {
6434
+ "content-type": "text/javascript; charset=utf-8",
6435
+ "cache-control": "no-store"
6436
+ });
6437
+ res.end(script);
6438
+ return;
6439
+ }
6440
+ res.writeHead(404).end();
6441
+ };
6442
+ const proxy = (req, res) => {
6443
+ const headers = { ...req.headers, host: target.host };
6444
+ headers["accept-encoding"] = "identity";
6445
+ const upstream = http.request(
6446
+ {
6447
+ hostname: target.hostname,
6448
+ port: targetPort,
6449
+ path: req.url,
6450
+ method: req.method,
6451
+ headers
6452
+ },
6453
+ (up) => {
6454
+ const type = String(up.headers["content-type"] ?? "");
6455
+ const encoded = up.headers["content-encoding"];
6456
+ if (type.includes("text/html") && (!encoded || encoded === "identity")) {
6457
+ const chunks = [];
6458
+ up.on("data", (c) => chunks.push(c));
6459
+ up.on("end", () => {
6460
+ const body = injectScript(Buffer.concat(chunks).toString("utf8"));
6461
+ res.writeHead(
6462
+ up.statusCode ?? 200,
6463
+ cleanHeaders(up.headers, true, origin)
6464
+ );
6465
+ res.end(body);
6466
+ });
6467
+ return;
6468
+ }
6469
+ res.writeHead(
6470
+ up.statusCode ?? 200,
6471
+ cleanHeaders(up.headers, false, origin)
6472
+ );
6473
+ up.pipe(res);
6474
+ }
6475
+ );
6476
+ upstream.on("error", () => {
6477
+ if (res.headersSent) {
6478
+ res.end();
6479
+ return;
6480
+ }
6481
+ res.writeHead(502, { "content-type": "text/html; charset=utf-8" });
6482
+ res.end(
6483
+ `<!doctype html><meta charset="utf-8"><body style="font:14px system-ui;padding:32px;color:#555"><p>Your app isn't answering on ${target.host}.</p><p>Start its dev server and this page shows it.</p></body>`
6484
+ );
6485
+ });
6486
+ req.pipe(upstream);
6487
+ };
6488
+ const fromThisMachine = (req) => {
6489
+ const address = server.address();
6490
+ const port = address && typeof address === "object" ? address.port : null;
6491
+ const host = /^(\[[^\]]+\]|[^:]+)(?::(\d+))?$/.exec(
6492
+ String(req.headers.host ?? "").toLowerCase()
6493
+ );
6494
+ return !!host && ["localhost", "127.0.0.1", "[::1]"].includes(host[1]) && (!host[2] || Number(host[2]) === port);
6495
+ };
6496
+ const server = http.createServer((req, res) => {
6497
+ if (!fromThisMachine(req)) {
6498
+ res.writeHead(403, { "content-type": "text/plain" }).end("Forbidden");
6499
+ return;
6500
+ }
6501
+ if ((req.url ?? "").startsWith(LIVE_PATH) || req.method === "OPTIONS") {
6502
+ own(req, res);
6503
+ } else {
6504
+ proxy(req, res);
6505
+ }
6506
+ });
6507
+ server.on("upgrade", (req, socket, head) => {
6508
+ if (!fromThisMachine(req)) {
6509
+ socket.destroy();
6510
+ return;
6511
+ }
6512
+ const upstream = net.connect(targetPort, target.hostname, () => {
6513
+ const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`];
6514
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
6515
+ const name = req.rawHeaders[i];
6516
+ const value = name.toLowerCase() === "host" ? target.host : req.rawHeaders[i + 1];
6517
+ lines.push(`${name}: ${value}`);
6518
+ }
6519
+ upstream.write(lines.join("\r\n") + "\r\n\r\n");
6520
+ if (head.length) upstream.write(head);
6521
+ socket.pipe(upstream).pipe(socket);
6522
+ });
6523
+ upstream.on("error", () => socket.destroy());
6524
+ socket.on("error", () => upstream.destroy());
6525
+ });
6526
+ for (const port of opts.ports ?? LIVE_PREVIEW_PORTS) {
6527
+ const ok = await new Promise((resolve14) => {
6528
+ const onError = () => resolve14(false);
6529
+ server.once("error", onError);
6530
+ server.listen(port, "127.0.0.1", () => {
6531
+ server.off("error", onError);
6532
+ resolve14(true);
6533
+ });
6534
+ });
6535
+ if (ok) {
6536
+ info = {
6537
+ designSystemId: opts.designSystemId,
6538
+ devUrl: opts.devUrl,
6539
+ project: opts.project ?? null,
6540
+ port,
6541
+ version: opts.version ?? "0.0.0",
6542
+ pid: process.pid
6543
+ };
6544
+ return { server, info };
6545
+ }
6546
+ }
6547
+ throw new Error(
6548
+ `Ports ${(opts.ports ?? LIVE_PREVIEW_PORTS).join(", ")} are all taken; the live preview can't start.`
6549
+ );
6550
+ }
6551
+ async function askLivePreview(port, fetchImpl = fetch) {
6552
+ const controller = new AbortController();
6553
+ const timer = setTimeout(() => controller.abort(), 700);
6554
+ try {
6555
+ const res = await fetchImpl(`http://127.0.0.1:${port}${LIVE_PATH}hello`, {
6556
+ signal: controller.signal
6557
+ });
6558
+ if (!res.ok) return null;
6559
+ const body = await res.json();
6560
+ return body && typeof body.designSystemId === "number" ? body : null;
6561
+ } catch {
6562
+ return null;
6563
+ } finally {
6564
+ clearTimeout(timer);
6565
+ }
6566
+ }
6567
+ async function stopLivePreview(fetchImpl = fetch) {
6568
+ const path = livePreviewStatePath();
6569
+ let state;
6570
+ try {
6571
+ state = JSON.parse(await readFile14(path, "utf8"));
6572
+ } catch {
6573
+ return false;
6574
+ }
6575
+ const { pid, origin, apiOrigin } = state;
6576
+ if (origin && apiOrigin) {
6577
+ await writeFile5(path, JSON.stringify({ origin, apiOrigin }), {
6578
+ mode: 384
6579
+ });
6580
+ } else {
6581
+ await rm(path, { force: true });
6582
+ }
6583
+ if (typeof pid !== "number") return false;
6584
+ for (const port of LIVE_PREVIEW_PORTS) {
6585
+ const info = await askLivePreview(port, fetchImpl);
6586
+ if (info?.pid === pid) {
6587
+ try {
6588
+ process.kill(pid);
6589
+ } catch {
6590
+ }
6591
+ return true;
6592
+ }
6593
+ }
6594
+ return false;
6595
+ }
6596
+ async function startLivePreviewInBackground(opts) {
6597
+ await stopLivePreview();
6598
+ const child = spawn2(
6599
+ process.execPath,
6600
+ [
6601
+ opts.bin,
6602
+ "preview",
6603
+ "--serve",
6604
+ "--dev-url",
6605
+ opts.devUrl,
6606
+ "--system",
6607
+ String(opts.designSystemId),
6608
+ "--origin",
6609
+ opts.allowedOrigin,
6610
+ ...opts.project ? ["--name", opts.project] : []
6611
+ ],
6612
+ { detached: true, stdio: "ignore" }
6613
+ );
6614
+ child.unref();
6615
+ const path = livePreviewStatePath();
6616
+ await mkdir3(dirname5(path), { recursive: true });
6617
+ await writeFile5(
6618
+ path,
6619
+ JSON.stringify({
6620
+ pid: child.pid,
6621
+ devUrl: opts.devUrl,
6622
+ designSystemId: opts.designSystemId,
6623
+ origin: opts.allowedOrigin.replace(/\/$/, ""),
6624
+ apiOrigin: httpOrigin(opts.apiUrl)
6625
+ }),
6626
+ { mode: 384 }
6627
+ );
6628
+ for (let i = 0; i < 20; i++) {
6629
+ await new Promise((r) => setTimeout(r, 150));
6630
+ for (const port of LIVE_PREVIEW_PORTS) {
6631
+ const info = await askLivePreview(port, opts.fetchImpl);
6632
+ if (info?.designSystemId === opts.designSystemId) return info;
6633
+ }
6634
+ }
6635
+ return null;
6636
+ }
6637
+
5919
6638
  // src/tools/connect.ts
5920
6639
  var GENOME_SERVER_KEY = "flash-genome";
5921
6640
  var GENOME_LATEST = "@flashdsm/genome@latest";
@@ -5929,7 +6648,7 @@ var ConnectError = class extends Error {
5929
6648
  async function packageName2(root) {
5930
6649
  try {
5931
6650
  return projectNameFromPackageJson(
5932
- await readFile14(join9(root, "package.json"), "utf8")
6651
+ await readFile15(join10(root, "package.json"), "utf8")
5933
6652
  );
5934
6653
  } catch {
5935
6654
  return null;
@@ -6080,6 +6799,30 @@ async function connectFromBrowser(opts) {
6080
6799
  token
6081
6800
  })))(collected.token) : 1;
6082
6801
  const mcp = (opts.addMcp ?? addMcpForClaudeCode)(scan.workspaceRoot);
6802
+ let livePreview = null;
6803
+ let noDevServer = false;
6804
+ let livePreviewFailed;
6805
+ let devCommand = null;
6806
+ if (opts.livePreview !== false) {
6807
+ const devUrl = await (opts.findDevServer ?? findDevServer)(scan.workspaceRoot);
6808
+ if (devUrl) {
6809
+ const start = opts.livePreview ?? ((args) => startLivePreviewInBackground({ ...args, bin: process.argv[1], apiUrl }));
6810
+ try {
6811
+ livePreview = await start({
6812
+ devUrl,
6813
+ designSystemId: collected.id,
6814
+ allowedOrigin: httpOrigin(collected.editor_url) ?? new URL(apiUrl).origin,
6815
+ project: collected.name
6816
+ });
6817
+ } catch {
6818
+ livePreview = null;
6819
+ }
6820
+ if (!livePreview) livePreviewFailed = devUrl;
6821
+ } else {
6822
+ noDevServer = true;
6823
+ devCommand = await devServerCommand(scan.workspaceRoot);
6824
+ }
6825
+ }
6083
6826
  return {
6084
6827
  system: {
6085
6828
  id: collected.id,
@@ -6091,7 +6834,11 @@ async function connectFromBrowser(opts) {
6091
6834
  link,
6092
6835
  linkError,
6093
6836
  pullExit,
6094
- mcp
6837
+ mcp,
6838
+ livePreview,
6839
+ noDevServer,
6840
+ livePreviewFailed,
6841
+ devCommand
6095
6842
  };
6096
6843
  }
6097
6844
  function describeConnect(result) {
@@ -6118,6 +6865,20 @@ function describeConnect(result) {
6118
6865
  `So your editor reads the system while you work, add Genome as an MCP server. In Claude Code: ${result.mcp.command}`
6119
6866
  );
6120
6867
  }
6868
+ if (result.livePreview) {
6869
+ lines.push(
6870
+ `Your app (${result.livePreview.devUrl}) shows live in Flash's preview and recolours as you edit. It runs in the background; stop it with \`npx ${GENOME_LATEST} preview --stop\`.`
6871
+ );
6872
+ } else if (result.noDevServer) {
6873
+ const start = result.devCommand ? ` (\`${result.devCommand}\`)` : "";
6874
+ lines.push(
6875
+ `Your app's dev server isn't running, so Flash's preview shows a sample screen for now. To see your app there instead, start the dev server${start}, then run \`npx ${GENOME_LATEST} preview\` in this folder. The editor switches to your app by itself.`
6876
+ );
6877
+ } else if (result.livePreviewFailed) {
6878
+ lines.push(
6879
+ `Your app answers on ${result.livePreviewFailed}, but the live preview didn't start, so Flash's preview shows a sample screen. Run \`npx ${GENOME_LATEST} preview\` in this folder to try again.`
6880
+ );
6881
+ }
6121
6882
  lines.push(
6122
6883
  `Genome's sign-in is stored at ${result.credentialPath}. No token goes in any config.`
6123
6884
  );
@@ -6295,8 +7056,8 @@ async function captureProjectShell(opts) {
6295
7056
  }
6296
7057
 
6297
7058
  // src/tools/scan-usage.ts
6298
- import { readFile as readFile15, readdir as readdir2 } from "fs/promises";
6299
- import { basename as basename2, dirname as dirname5, join as join10, posix, resolve as resolve12 } from "path";
7059
+ import { readFile as readFile16, readdir as readdir2 } from "fs/promises";
7060
+ import { basename as basename2, dirname as dirname6, join as join11, posix, resolve as resolve12 } from "path";
6300
7061
  var PRODUCT_USAGE_VERSION = 1;
6301
7062
  var USAGE_LIMITS = {
6302
7063
  components: 400,
@@ -6320,11 +7081,11 @@ async function walk2(root, dir, out) {
6320
7081
  for (const entry of entries) {
6321
7082
  if (entry.isDirectory()) {
6322
7083
  if (!SKIP_DIR.test(entry.name)) {
6323
- await walk2(root, join10(dir, entry.name), out);
7084
+ await walk2(root, join11(dir, entry.name), out);
6324
7085
  }
6325
7086
  } else {
6326
7087
  out.push(
6327
- join10(dir, entry.name).slice(root.length + 1).replace(/\\/g, "/")
7088
+ join11(dir, entry.name).slice(root.length + 1).replace(/\\/g, "/")
6328
7089
  );
6329
7090
  }
6330
7091
  }
@@ -6388,7 +7149,7 @@ function tagUses(source, local) {
6388
7149
  return (source.match(new RegExp(`<${escapeRe(local)}(?=[\\s/>.])`, "g")) ?? []).length;
6389
7150
  }
6390
7151
  async function repositoryName(root) {
6391
- const gitConfig = await readFile15(join10(root, ".git", "config"), "utf8").catch(
7152
+ const gitConfig = await readFile16(join11(root, ".git", "config"), "utf8").catch(
6392
7153
  () => ""
6393
7154
  );
6394
7155
  const remote = gitConfig.match(
@@ -6398,7 +7159,7 @@ async function repositoryName(root) {
6398
7159
  if (slug) return slug;
6399
7160
  try {
6400
7161
  const pkg = JSON.parse(
6401
- await readFile15(join10(root, "package.json"), "utf8")
7162
+ await readFile16(join11(root, "package.json"), "utf8")
6402
7163
  );
6403
7164
  if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim();
6404
7165
  } catch {
@@ -6421,7 +7182,7 @@ async function scanProductUsage(workspaceRoot, opts = {}) {
6421
7182
  for (const rel of paths) {
6422
7183
  if (basename2(rel) !== "package.json") continue;
6423
7184
  try {
6424
- const pkg = JSON.parse(await readFile15(join10(root, rel), "utf8"));
7185
+ const pkg = JSON.parse(await readFile16(join11(root, rel), "utf8"));
6425
7186
  if (typeof pkg.name === "string") ownPackages.add(pkg.name);
6426
7187
  } catch {
6427
7188
  }
@@ -6431,13 +7192,13 @@ async function scanProductUsage(workspaceRoot, opts = {}) {
6431
7192
  );
6432
7193
  const found = /* @__PURE__ */ new Map();
6433
7194
  for (const rel of sources) {
6434
- const text = await readFile15(join10(root, rel), "utf8").catch(() => null);
7195
+ const text = await readFile16(join11(root, rel), "utf8").catch(() => null);
6435
7196
  if (!text || !text.includes("import")) continue;
6436
7197
  for (const name of importedNames(text)) {
6437
7198
  if (!definedAt.has(name.imported)) continue;
6438
7199
  if (!isOwnSource(name.source, ownPackages)) continue;
6439
7200
  const home = definedAt.get(name.imported);
6440
- if (home && (home === rel || dirname5(home) === dirname5(rel) && /(^|\/)index\.[a-z]+$/i.test(rel))) {
7201
+ if (home && (home === rel || dirname6(home) === dirname6(rel) && /(^|\/)index\.[a-z]+$/i.test(rel))) {
6441
7202
  continue;
6442
7203
  }
6443
7204
  const uses = tagUses(text, name.local);
@@ -6657,6 +7418,11 @@ function parseCliArgs(argv) {
6657
7418
  let confirm = false;
6658
7419
  let json = false;
6659
7420
  let noOpen = false;
7421
+ let serve = false;
7422
+ let stop = false;
7423
+ let devUrl;
7424
+ let system;
7425
+ let origin;
6660
7426
  const VALUE_FLAGS = /* @__PURE__ */ new Set([
6661
7427
  "--tokens",
6662
7428
  "-t",
@@ -6668,7 +7434,10 @@ function parseCliArgs(argv) {
6668
7434
  "--token",
6669
7435
  "--cwd",
6670
7436
  "--code",
6671
- "--name"
7437
+ "--name",
7438
+ "--dev-url",
7439
+ "--system",
7440
+ "--origin"
6672
7441
  ]);
6673
7442
  let command;
6674
7443
  for (let i = 0; i < argv.length; i++) {
@@ -6732,6 +7501,27 @@ function parseCliArgs(argv) {
6732
7501
  noOpen = true;
6733
7502
  continue;
6734
7503
  }
7504
+ if (arg === "--serve") {
7505
+ serve = true;
7506
+ continue;
7507
+ }
7508
+ if (arg === "--stop") {
7509
+ stop = true;
7510
+ continue;
7511
+ }
7512
+ if (arg === "--dev-url") {
7513
+ devUrl = argv[++i];
7514
+ continue;
7515
+ }
7516
+ if (arg === "--system") {
7517
+ const n = Number(argv[++i]);
7518
+ system = Number.isInteger(n) ? n : void 0;
7519
+ continue;
7520
+ }
7521
+ if (arg === "--origin") {
7522
+ origin = argv[++i];
7523
+ continue;
7524
+ }
6735
7525
  if (arg === "--help" || arg === "-h") {
6736
7526
  console.error(`Usage: flash-genome [command] [options]
6737
7527
 
@@ -6743,12 +7533,15 @@ Commands:
6743
7533
  scans, opens Flash to confirm, then links the repo,
6744
7534
  writes the system into it and (in Claude Code) adds
6745
7535
  Genome as the project's MCP server
6746
- init --code CODE Same, with a setup code instead of the browser:
7536
+ preview Show this app, running on its dev server, inside Flash
7537
+ and recolour it as you edit (connect starts it; this
7538
+ starts it again). --stop ends it
7539
+ init --code CODE Same as connect, with a setup code instead of the browser:
6747
7540
  scans, creates it in Flash, stores the sign-in, links
6748
7541
  the repo. CODE comes from Flash: Import > From your code
6749
7542
  link Bind this repo to the design system you signed in to
6750
7543
  pull Write the design system into this repo (dry run unless --confirm)
6751
- push Propose this repo's token edits to Flash (dry run unless --confirm)
7544
+ push Propose what this repo's code changed to Flash (dry run unless --confirm)
6752
7545
  drift Has the repo moved, has Flash, which tokens (read-only)
6753
7546
  login Open Flash in the browser and store the sign-in locally
6754
7547
 
@@ -6795,7 +7588,12 @@ Exit codes (pull, push, drift):
6795
7588
  name,
6796
7589
  confirm,
6797
7590
  json,
6798
- noOpen
7591
+ noOpen,
7592
+ serve,
7593
+ stop,
7594
+ devUrl,
7595
+ system,
7596
+ origin
6799
7597
  };
6800
7598
  }
6801
7599
  function textResult(data, isError = false) {
@@ -7154,7 +7952,7 @@ async function createServer2(opts) {
7154
7952
  );
7155
7953
  server.tool(
7156
7954
  "push_to_web",
7157
- "Propose the token values this repo changed to Flash, as a change set a person reviews on the Sync page. Nothing is applied. Returns the plan; proposes only with confirm. Refused while Flash is ahead of the last pull.",
7955
+ "Propose what this repo's code changed to Flash, as one change set a person reviews on the Sync page: the app's own stylesheets (new tokens, light or dark values Flash lacks, aliases re-pointed with var(--step)) and edits to the pulled design-tokens.json. Files the pull wrote are not read as code. Nothing is applied. Returns the plan; proposes only with confirm. Refused while Flash is ahead of the last pull.",
7158
7956
  workspaceArgs,
7159
7957
  async (args) => {
7160
7958
  try {
@@ -7253,7 +8051,7 @@ async function resolveApiUrl(cli, cwd) {
7253
8051
  const fromEnv = process.env.FLASH_GENOME_API_URL || process.env.FLASH_DS_API_URL;
7254
8052
  if (fromEnv) return fromEnv;
7255
8053
  for (const path of configCandidates(cli.configPath, cwd)) {
7256
- const raw = await readFile16(path, "utf8").catch(() => null);
8054
+ const raw = await readFile17(path, "utf8").catch(() => null);
7257
8055
  if (!raw) continue;
7258
8056
  try {
7259
8057
  const parsed = GenomeConfigSchema.safeParse(JSON.parse(raw));
@@ -7320,6 +8118,64 @@ async function runConnect(cli, apiUrl) {
7320
8118
  process.exit(1);
7321
8119
  }
7322
8120
  }
8121
+ async function readLinkedSystem(root) {
8122
+ try {
8123
+ const config = await readGenomeConfig(root);
8124
+ return { id: config.genomeId, name: config.name ?? null };
8125
+ } catch {
8126
+ return null;
8127
+ }
8128
+ }
8129
+ async function runPreview(cli, apiUrl) {
8130
+ if (cli.stop) {
8131
+ const stopped = await stopLivePreview();
8132
+ console.log(stopped ? "Stopped the live preview." : "No live preview was running.");
8133
+ return;
8134
+ }
8135
+ const origin = (cli.origin ?? await lastLivePreviewOrigin(apiUrl) ?? new URL(apiUrl).origin).replace(/\/$/, "");
8136
+ if (cli.serve) {
8137
+ if (!cli.devUrl || cli.system === void 0) {
8138
+ console.error("preview --serve needs --dev-url and --system.");
8139
+ process.exit(2);
8140
+ }
8141
+ const { info: info2 } = await serveLivePreview({
8142
+ devUrl: cli.devUrl,
8143
+ designSystemId: cli.system,
8144
+ allowedOrigin: origin,
8145
+ project: cli.name ?? null,
8146
+ version: packageVersion()
8147
+ });
8148
+ console.error(`[flash-genome] live preview on http://localhost:${info2.port} -> ${info2.devUrl}`);
8149
+ return;
8150
+ }
8151
+ const root = resolve13(cli.cwd ?? process.cwd());
8152
+ const linked = await readLinkedSystem(root);
8153
+ if (!linked) {
8154
+ console.error(
8155
+ "This folder isn't linked to a Flash system yet. Run `npx @flashdsm/genome@latest connect` first."
8156
+ );
8157
+ process.exit(2);
8158
+ }
8159
+ const devUrl = cli.devUrl ?? await findDevServer(root);
8160
+ if (!devUrl) {
8161
+ const start = await devServerCommand(root) ?? "npm run dev";
8162
+ console.error(
8163
+ `Couldn't find your app's dev server on localhost, so Flash still shows its sample screen. Start it (${start}), then run this again, or pass --dev-url http://localhost:PORT.`
8164
+ );
8165
+ process.exit(1);
8166
+ }
8167
+ const info = await startLivePreviewInBackground({
8168
+ bin: process.argv[1],
8169
+ apiUrl,
8170
+ devUrl,
8171
+ designSystemId: linked.id,
8172
+ allowedOrigin: origin,
8173
+ project: linked.name
8174
+ });
8175
+ console.log(
8176
+ info ? `Flash shows ${devUrl} live on its preview (through localhost:${info.port}). Stop it with \`npx @flashdsm/genome preview --stop\`.` : "The live preview didn't start. Run `npx @flashdsm/genome preview --serve --dev-url " + devUrl + " --system " + linked.id + "` to see why."
8177
+ );
8178
+ }
7323
8179
  async function main() {
7324
8180
  const cli = parseCliArgs(process.argv.slice(2));
7325
8181
  const apiUrl = await resolveApiUrl(cli, resolve13(cli.cwd ?? process.cwd()));
@@ -7348,6 +8204,7 @@ async function main() {
7348
8204
  if (cli.command === "link") return runLink(cli);
7349
8205
  if (cli.command === "init") return runInit(cli, apiUrl);
7350
8206
  if (cli.command === "connect") return runConnect(cli, apiUrl);
8207
+ if (cli.command === "preview") return runPreview(cli, apiUrl);
7351
8208
  if (cli.command) {
7352
8209
  console.error(
7353
8210
  `Unknown command "${cli.command}". Run flash-genome --help for the list.`