@canopy-iiif/app 0.7.14 → 0.7.17

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/lib/build/dev.js CHANGED
@@ -1,7 +1,7 @@
1
1
  const fs = require("fs");
2
2
  const fsp = fs.promises;
3
3
  const path = require("path");
4
- const { spawn } = require("child_process");
4
+ const { spawn, spawnSync } = require("child_process");
5
5
  const { build } = require("../build/build");
6
6
  const http = require("http");
7
7
  const url = require("url");
@@ -11,30 +11,17 @@ const {
11
11
  ASSETS_DIR,
12
12
  ensureDirSync,
13
13
  } = require("../common");
14
- const twHelper = (() => {
15
- try {
16
- return require("../../helpers/build-tailwind");
17
- } catch (_) {
18
- return null;
19
- }
20
- })();
21
14
  function resolveTailwindCli() {
22
- try {
23
- const cliJs = require.resolve("tailwindcss/lib/cli.js");
24
- return { cmd: process.execPath, args: [cliJs] };
25
- } catch (_) {}
26
- try {
27
- const bin = path.join(
28
- process.cwd(),
29
- "node_modules",
30
- ".bin",
31
- process.platform === "win32" ? "tailwindcss.cmd" : "tailwindcss"
32
- );
33
- if (fs.existsSync(bin)) return { cmd: bin, args: [] };
34
- } catch (_) {}
35
- return null;
15
+ const bin = path.join(
16
+ process.cwd(),
17
+ "node_modules",
18
+ ".bin",
19
+ process.platform === "win32" ? "tailwindcss.cmd" : "tailwindcss"
20
+ );
21
+ if (fs.existsSync(bin)) return { cmd: bin, args: [] };
22
+ return { cmd: 'tailwindcss', args: [] };
36
23
  }
37
- const PORT = Number(process.env.PORT || 3000);
24
+ const PORT = Number(process.env.PORT || 5001);
38
25
  let onBuildSuccess = () => {};
39
26
  let onBuildStart = () => {};
40
27
  let onCssChange = () => {};
@@ -609,381 +596,291 @@ async function dev() {
609
596
  "tailwind.config.mjs",
610
597
  "tailwind.config.ts",
611
598
  ].map((n) => path.join(appStylesDir, n));
612
- let configPath = [...twConfigsApp, ...twConfigsRoot].find((p) => {
599
+ const configPath = [...twConfigsApp, ...twConfigsRoot].find((p) => {
613
600
  try {
614
601
  return fs.existsSync(p);
615
602
  } catch (_) {
616
603
  return false;
617
604
  }
618
605
  });
606
+ if (!configPath) {
607
+ throw new Error(
608
+ "[tailwind] Missing Tailwind config. Expected app/styles/tailwind.config.{js,cjs,mjs,ts} or a root-level Tailwind config."
609
+ );
610
+ }
619
611
  const inputCandidates = [
620
612
  path.join(appStylesDir, "index.css"),
621
613
  path.join(CONTENT_DIR, "_styles.css"),
622
614
  ];
623
- let inputCss = inputCandidates.find((p) => {
615
+ const inputCss = inputCandidates.find((p) => {
624
616
  try {
625
617
  return fs.existsSync(p);
626
618
  } catch (_) {
627
619
  return false;
628
620
  }
629
621
  });
630
- // Generate fallback config and input if missing
631
- if (!configPath) {
632
- try {
633
- const { CACHE_DIR } = require("./common");
634
- const genDir = path.join(CACHE_DIR, "tailwind");
635
- ensureDirSync(genDir);
636
- const genCfg = path.join(genDir, "tailwind.config.js");
637
- const cfg = `module.exports = {\n presets: [require('@canopy-iiif/app/ui/canopy-iiif-preset')],\n content: [\n './content/**/*.{mdx,html}',\n './site/**/*.html',\n './site/**/*.js',\n './packages/app/ui/**/*.{js,jsx,ts,tsx}',\n './packages/app/lib/iiif/components/**/*.{js,jsx}',\n ],\n theme: { extend: {} },\n plugins: [require('@canopy-iiif/app/ui/canopy-iiif-plugin')],\n};\n`;
638
- fs.writeFileSync(genCfg, cfg, "utf8");
639
- configPath = genCfg;
640
- } catch (_) {
641
- configPath = null;
642
- }
643
- }
644
622
  if (!inputCss) {
645
- try {
646
- const { CACHE_DIR } = require("./common");
647
- const genDir = path.join(CACHE_DIR, "tailwind");
648
- ensureDirSync(genDir);
649
- const genCss = path.join(genDir, "index.css");
650
- fs.writeFileSync(
651
- genCss,
652
- `@tailwind base;\n@tailwind components;\n@tailwind utilities;\n`,
653
- "utf8"
654
- );
655
- inputCss = genCss;
656
- } catch (_) {
657
- inputCss = null;
658
- }
623
+ throw new Error(
624
+ "[tailwind] Missing Tailwind entry stylesheet. Create app/styles/index.css (or content/_styles.css)."
625
+ );
659
626
  }
660
627
  const outputCss = path.join(OUT_DIR, "styles", "styles.css");
661
- if (configPath && inputCss) {
662
- // Ensure output dir exists and start watcher
663
- ensureDirSync(path.dirname(outputCss));
664
- let child = null;
665
- // Ensure output file exists (fallback minimal CSS if CLI/compile fails)
666
- function writeFallbackCssIfMissing() {
667
- try {
668
- if (!fs.existsSync(outputCss)) {
669
- const base = `:root{--max-w:760px;--muted:#6b7280}*{box-sizing:border-box}body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,Helvetica,Arial,sans-serif;max-width:var(--max-w);margin:2rem auto;padding:0 1rem;line-height:1.6}a{color:#2563eb;text-decoration:none}a:hover{text-decoration:underline}`;
670
- ensureDirSync(path.dirname(outputCss));
671
- fs.writeFileSync(outputCss, base + "\n", "utf8");
672
- console.log(
673
- "[tailwind] wrote fallback CSS to",
674
- prettyPath(outputCss)
675
- );
676
- }
677
- } catch (_) {}
628
+ ensureDirSync(path.dirname(outputCss));
629
+
630
+ const cli = resolveTailwindCli();
631
+ if (!cli) {
632
+ throw new Error(
633
+ "[tailwind] Tailwind CLI not found. Install the 'tailwindcss' package in the workspace."
634
+ );
635
+ }
636
+
637
+ const fileSizeKb = (p) => {
638
+ try {
639
+ const st = fs.statSync(p);
640
+ return st && st.size ? (st.size / 1024).toFixed(1) : "0.0";
641
+ } catch (_) {
642
+ return "0.0";
678
643
  }
679
- function fileSizeKb(p) {
680
- try {
681
- const st = fs.statSync(p);
682
- return st && st.size ? (st.size / 1024).toFixed(1) : "0.0";
683
- } catch (_) {
684
- return "0.0";
685
- }
644
+ };
645
+
646
+ const baseArgs = [
647
+ "-i",
648
+ inputCss,
649
+ "-o",
650
+ outputCss,
651
+ "-c",
652
+ configPath,
653
+ "--minify",
654
+ ];
655
+
656
+ const initial = spawnSync(cli.cmd, [...cli.args, ...baseArgs], {
657
+ stdio: ["ignore", "pipe", "pipe"],
658
+ env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
659
+ });
660
+ if (!initial || initial.status !== 0) {
661
+ if (initial && initial.stderr) {
662
+ try { process.stderr.write(initial.stderr); } catch (_) {}
686
663
  }
687
- // Initial one-off compile so the CSS exists before watcher starts
664
+ throw new Error("[tailwind] Initial Tailwind build failed.");
665
+ }
666
+ console.log(
667
+ `[tailwind] initial build ok (${fileSizeKb(outputCss)} KB) →`,
668
+ prettyPath(outputCss)
669
+ );
670
+
671
+ const watchArgs = [
672
+ "-i",
673
+ inputCss,
674
+ "-o",
675
+ outputCss,
676
+ "--watch",
677
+ "-c",
678
+ configPath,
679
+ "--minify",
680
+ ];
681
+ let child = null;
682
+ let unmuted = false;
683
+ let cssWatcherAttached = false;
684
+
685
+ function attachCssWatcherOnce() {
686
+ if (cssWatcherAttached) return;
687
+ cssWatcherAttached = true;
688
688
  try {
689
- const cliOnce = resolveTailwindCli();
690
- if (cliOnce) {
691
- const { spawnSync } = require("child_process");
692
- const argsOnce = [
693
- "-i",
694
- inputCss,
695
- "-o",
696
- outputCss,
697
- "-c",
698
- configPath,
699
- "--minify",
700
- ];
701
- const res = spawnSync(cliOnce.cmd, [...cliOnce.args, ...argsOnce], {
702
- stdio: ["ignore", "pipe", "pipe"],
703
- env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
704
- });
705
- if (res && res.status === 0) {
689
+ fs.watch(outputCss, { persistent: false }, () => {
690
+ if (!unmuted) {
691
+ unmuted = true;
706
692
  console.log(
707
- `[tailwind] initial build ok (${fileSizeKb(outputCss)} KB) →`,
708
- prettyPath(outputCss)
693
+ `[tailwind] watching ${prettyPath(
694
+ inputCss
695
+ )} — compiled (${fileSizeKb(outputCss)} KB)`
709
696
  );
710
- } else {
711
- console.warn("[tailwind] initial build failed; using fallback CSS");
712
- try {
713
- if (res && res.stderr) process.stderr.write(res.stderr);
714
- } catch (_) {}
715
- writeFallbackCssIfMissing();
716
697
  }
717
- } else {
718
- console.warn("[tailwind] CLI not found; using fallback CSS");
719
- writeFallbackCssIfMissing();
720
- }
721
- } catch (_) {}
722
- // Prefer direct CLI spawn so we can mute initial rebuild logs
723
- const cli = resolveTailwindCli();
724
- if (cli) {
725
- const args = [
726
- "-i",
727
- inputCss,
728
- "-o",
729
- outputCss,
730
- "--watch",
731
- "-c",
732
- configPath,
733
- "--minify",
734
- ];
735
- let unmuted = false;
736
- let cssWatcherAttached = false;
737
- function attachCssWatcherOnce() {
738
- if (cssWatcherAttached) return;
739
- cssWatcherAttached = true;
740
698
  try {
741
- fs.watch(outputCss, { persistent: false }, () => {
742
- if (!unmuted) {
743
- unmuted = true;
744
- console.log(
745
- `[tailwind] watching ${prettyPath(
746
- inputCss
747
- )} — compiled (${fileSizeKb(outputCss)} KB)`
748
- );
749
- }
750
- try {
751
- onCssChange();
752
- } catch (_) {}
753
- });
699
+ onCssChange();
754
700
  } catch (_) {}
701
+ });
702
+ } catch (_) {}
703
+ }
704
+
705
+ function compileTailwindOnce() {
706
+ const res = spawnSync(cli.cmd, [...cli.args, ...baseArgs], {
707
+ stdio: ["ignore", "pipe", "pipe"],
708
+ env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
709
+ });
710
+ if (!res || res.status !== 0) {
711
+ if (res && res.stderr) {
712
+ try { process.stderr.write(res.stderr); } catch (_) {}
755
713
  }
756
- function compileTailwindOnce() {
757
- try {
758
- const { spawnSync } = require("child_process");
759
- const res = spawnSync(
760
- cli.cmd,
761
- [
762
- ...cli.args,
763
- "-i",
764
- inputCss,
765
- "-o",
766
- outputCss,
767
- "-c",
768
- configPath,
769
- "--minify",
770
- ],
771
- {
772
- stdio: ["ignore", "pipe", "pipe"],
773
- env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
774
- }
775
- );
776
- if (res && res.status === 0) {
777
- console.log(
778
- `[tailwind] compiled (${fileSizeKb(outputCss)} KB) →`,
779
- prettyPath(outputCss)
780
- );
781
- try {
782
- onCssChange();
783
- } catch (_) {}
784
- } else {
785
- console.warn("[tailwind] on-demand compile failed");
786
- try {
787
- if (res && res.stderr) process.stderr.write(res.stderr);
788
- } catch (_) {}
714
+ throw new Error("[tailwind] On-demand Tailwind compile failed.");
715
+ }
716
+ console.log(
717
+ `[tailwind] compiled (${fileSizeKb(outputCss)} KB) →`,
718
+ prettyPath(outputCss)
719
+ );
720
+ try {
721
+ onCssChange();
722
+ } catch (_) {}
723
+ }
724
+
725
+ function startTailwindWatcher() {
726
+ unmuted = false;
727
+ const proc = spawn(cli.cmd, [...cli.args, ...watchArgs], {
728
+ stdio: ["ignore", "pipe", "pipe"],
729
+ env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
730
+ });
731
+ if (proc.stdout)
732
+ proc.stdout.on("data", (d) => {
733
+ const s = d ? String(d) : "";
734
+ if (!unmuted) {
735
+ if (/error/i.test(s)) {
736
+ try { process.stdout.write("[tailwind] " + s); } catch (_) {}
789
737
  }
790
- } catch (_) {}
791
- }
792
- function startTailwindWatcher() {
793
- unmuted = false;
794
- const proc = spawn(cli.cmd, [...cli.args, ...args], {
795
- stdio: ["ignore", "pipe", "pipe"],
796
- env: { ...process.env, BROWSERSLIST_IGNORE_OLD_DATA: "1" },
797
- });
798
- if (proc.stdout)
799
- proc.stdout.on("data", (d) => {
800
- const s = d ? String(d) : "";
801
- if (!unmuted) {
802
- if (/error/i.test(s)) {
803
- try {
804
- process.stdout.write("[tailwind] " + s);
805
- } catch (_) {}
806
- }
807
- } else {
808
- try {
809
- process.stdout.write(s);
810
- } catch (_) {}
811
- }
812
- });
813
- if (proc.stderr)
814
- proc.stderr.on("data", (d) => {
815
- const s = d ? String(d) : "";
816
- if (!unmuted) {
817
- if (s.trim()) {
818
- try {
819
- process.stderr.write("[tailwind] " + s);
820
- } catch (_) {}
821
- }
822
- } else {
823
- try {
824
- process.stderr.write(s);
825
- } catch (_) {}
826
- }
827
- });
828
- proc.on("exit", (code) => {
829
- // Ignore null exits (expected when we intentionally restart the watcher)
830
- if (code !== 0 && code !== null) {
831
- console.error("[tailwind] watcher exited with code", code);
738
+ } else {
739
+ try { process.stdout.write(s); } catch (_) {}
740
+ }
741
+ });
742
+ if (proc.stderr)
743
+ proc.stderr.on("data", (d) => {
744
+ const s = d ? String(d) : "";
745
+ if (!unmuted) {
746
+ if (s.trim()) {
747
+ try { process.stderr.write("[tailwind] " + s); } catch (_) {}
832
748
  }
833
- });
834
- attachCssWatcherOnce();
835
- return proc;
749
+ } else {
750
+ try { process.stderr.write(s); } catch (_) {}
751
+ }
752
+ });
753
+ proc.on("exit", (code) => {
754
+ if (code !== null && code !== 0) {
755
+ console.error("[tailwind] watcher exited with code", code);
756
+ process.exit(typeof code === "number" ? code : 1);
836
757
  }
837
- child = startTailwindWatcher();
838
- // Unmute Tailwind logs after the first successful CSS write
839
- // Watch UI Tailwind plugin/preset files and restart Tailwind to pick up code changes
758
+ });
759
+ attachCssWatcherOnce();
760
+ return proc;
761
+ }
762
+
763
+ const safeCompile = (label) => {
764
+ try {
765
+ compileTailwindOnce();
766
+ } catch (err) {
767
+ console.error(label ? `${label}: ${err.message || err}` : err);
768
+ process.exit(1);
769
+ }
770
+ };
771
+
772
+ child = startTailwindWatcher();
773
+
774
+ const uiPlugin = path.join(
775
+ __dirname,
776
+ "../ui",
777
+ "tailwind-canopy-iiif-plugin.js"
778
+ );
779
+ const uiPreset = path.join(
780
+ __dirname,
781
+ "../ui",
782
+ "tailwind-canopy-iiif-preset.js"
783
+ );
784
+ const uiStylesDir = path.join(__dirname, "../ui", "styles");
785
+ const files = [uiPlugin, uiPreset].filter((p) => {
786
+ try {
787
+ return fs.existsSync(p);
788
+ } catch (_) {
789
+ return false;
790
+ }
791
+ });
792
+ let restartTimer = null;
793
+ const restart = () => {
794
+ clearTimeout(restartTimer);
795
+ restartTimer = setTimeout(() => {
796
+ console.log(
797
+ "[tailwind] detected UI plugin/preset change — restarting Tailwind"
798
+ );
840
799
  try {
841
- const uiPlugin = path.join(
842
- __dirname,
843
- "../ui",
844
- "tailwind-canopy-iiif-plugin.js"
845
- );
846
- const uiPreset = path.join(
847
- __dirname,
848
- "../ui",
849
- "tailwind-canopy-iiif-preset.js"
850
- );
851
- const uiStylesDir = path.join(__dirname, "../ui", "styles");
852
- const files = [uiPlugin, uiPreset].filter((p) => {
853
- try {
854
- return fs.existsSync(p);
855
- } catch (_) {
856
- return false;
857
- }
858
- });
859
- let restartTimer = null;
860
- const restart = () => {
861
- clearTimeout(restartTimer);
862
- restartTimer = setTimeout(() => {
863
- console.log(
864
- "[tailwind] detected UI plugin/preset change — restarting Tailwind"
865
- );
866
- try {
867
- if (child && !child.killed) child.kill();
868
- } catch (_) {}
869
- // Force a compile immediately so new CSS lands before reload
870
- compileTailwindOnce();
871
- child = startTailwindWatcher();
872
- // Notify clients that a rebuild is in progress; CSS watcher will trigger reload on write
873
- try {
874
- onBuildStart();
875
- } catch (_) {}
876
- }, 50);
877
- };
878
- for (const f of files) {
800
+ if (child && !child.killed) child.kill();
801
+ } catch (_) {}
802
+ safeCompile("[tailwind] compile after plugin change failed");
803
+ child = startTailwindWatcher();
804
+ try { onBuildStart(); } catch (_) {}
805
+ }, 50);
806
+ };
807
+ for (const f of files) {
808
+ try {
809
+ fs.watch(f, { persistent: false }, restart);
810
+ } catch (_) {}
811
+ }
812
+ if (fs.existsSync(uiStylesDir)) {
813
+ try {
814
+ fs.watch(
815
+ uiStylesDir,
816
+ { persistent: false, recursive: true },
817
+ (evt, fn) => {
879
818
  try {
880
- fs.watch(f, { persistent: false }, restart);
819
+ if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
881
820
  } catch (_) {}
882
821
  }
883
- // Watch UI styles directory (Sass partials used by the plugin); restart Tailwind on Sass changes
822
+ );
823
+ } catch (_) {
824
+ const watchers = new Map();
825
+ const watchDir = (dir) => {
826
+ if (watchers.has(dir)) return;
884
827
  try {
885
- if (fs.existsSync(uiStylesDir)) {
886
- try {
887
- fs.watch(
888
- uiStylesDir,
889
- { persistent: false, recursive: true },
890
- (evt, fn) => {
891
- try {
892
- if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
893
- } catch (_) {}
894
- }
895
- );
896
- } catch (_) {
897
- // Fallback: per-dir watch without recursion
898
- const watchers = new Map();
899
- const watchDir = (dir) => {
900
- if (watchers.has(dir)) return;
901
- try {
902
- const w = fs.watch(
903
- dir,
904
- { persistent: false },
905
- (evt, fn) => {
906
- try {
907
- if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
908
- } catch (_) {}
909
- }
910
- );
911
- watchers.set(dir, w);
912
- } catch (_) {}
913
- };
914
- const scan = (dir) => {
915
- try {
916
- const entries = fs.readdirSync(dir, {
917
- withFileTypes: true,
918
- });
919
- for (const e of entries) {
920
- const p = path.join(dir, e.name);
921
- if (e.isDirectory()) {
922
- watchDir(p);
923
- scan(p);
924
- }
925
- }
926
- } catch (_) {}
927
- };
928
- watchDir(uiStylesDir);
929
- scan(uiStylesDir);
828
+ const w = fs.watch(
829
+ dir,
830
+ { persistent: false },
831
+ (evt, fn) => {
832
+ try {
833
+ if (fn && /\.s[ac]ss$/i.test(String(fn))) restart();
834
+ } catch (_) {}
930
835
  }
931
- }
932
- } catch (_) {}
933
- // Also watch the app Tailwind config; restart Tailwind when it changes
934
- try {
935
- if (configPath && fs.existsSync(configPath))
936
- fs.watch(configPath, { persistent: false }, () => {
937
- console.log(
938
- "[tailwind] tailwind.config change — restarting Tailwind"
939
- );
940
- restart();
941
- });
942
- } catch (_) {}
943
- // If the input CSS lives under app/styles, watch the directory for direct edits to CSS/partials
944
- try {
945
- const stylesDir = path.dirname(inputCss || "");
946
- if (stylesDir && stylesDir.includes(path.join("app", "styles"))) {
947
- let cssDebounce = null;
948
- fs.watch(stylesDir, { persistent: false }, (evt, fn) => {
949
- clearTimeout(cssDebounce);
950
- cssDebounce = setTimeout(() => {
951
- try {
952
- onBuildStart();
953
- } catch (_) {}
954
- // Force a compile so changes in index.css or partials are reflected immediately
955
- try {
956
- compileTailwindOnce();
957
- } catch (_) {}
958
- try {
959
- onCssChange();
960
- } catch (_) {}
961
- }, 50);
962
- });
963
- }
836
+ );
837
+ watchers.set(dir, w);
964
838
  } catch (_) {}
965
- } catch (_) {}
966
- } else if (twHelper && typeof twHelper.watchTailwind === "function") {
967
- // Fallback to helper (cannot mute its initial logs)
968
- child = twHelper.watchTailwind({
969
- input: inputCss,
970
- output: outputCss,
971
- config: configPath,
972
- minify: false,
973
- });
974
- if (child) {
975
- console.log("[tailwind] watching", prettyPath(inputCss));
839
+ };
840
+ const scan = (dir) => {
976
841
  try {
977
- fs.watch(outputCss, { persistent: false }, () => {
978
- try {
979
- onCssChange();
980
- } catch (_) {}
842
+ const entries = fs.readdirSync(dir, {
843
+ withFileTypes: true,
981
844
  });
845
+ for (const e of entries) {
846
+ const p = path.join(dir, e.name);
847
+ if (e.isDirectory()) {
848
+ watchDir(p);
849
+ scan(p);
850
+ }
851
+ }
982
852
  } catch (_) {}
983
- }
853
+ };
854
+ watchDir(uiStylesDir);
855
+ scan(uiStylesDir);
984
856
  }
985
857
  }
986
- } catch (_) {}
858
+ if (fs.existsSync(configPath)) {
859
+ try {
860
+ fs.watch(configPath, { persistent: false }, () => {
861
+ console.log("[tailwind] tailwind.config change — restarting Tailwind");
862
+ restart();
863
+ });
864
+ } catch (_) {}
865
+ }
866
+ const stylesDir = path.dirname(inputCss);
867
+ if (stylesDir && stylesDir.includes(path.join("app", "styles"))) {
868
+ let cssDebounce = null;
869
+ try {
870
+ fs.watch(stylesDir, { persistent: false }, (evt, fn) => {
871
+ clearTimeout(cssDebounce);
872
+ cssDebounce = setTimeout(() => {
873
+ try { onBuildStart(); } catch (_) {}
874
+ safeCompile("[tailwind] compile after CSS change failed");
875
+ try { onCssChange(); } catch (_) {}
876
+ }, 50);
877
+ });
878
+ } catch (_) {}
879
+ }
880
+ } catch (err) {
881
+ console.error("[tailwind] setup failed:", err && err.message ? err.message : err);
882
+ process.exit(1);
883
+ }
987
884
  console.log("[Watching]", prettyPath(CONTENT_DIR), "(Ctrl+C to stop)");
988
885
  const rw = tryRecursiveWatch();
989
886
  if (!rw) watchPerDir();