@madebyseed/seed-cli-tools 3.0.0 → 4.0.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/__fixtures__/legacy-js-min/baseline/theme.dev.js +13 -0
  3. package/__fixtures__/legacy-js-min/baseline/theme.prod.js +1 -0
  4. package/__fixtures__/legacy-js-min/dist/assets/theme.js +13 -0
  5. package/__fixtures__/legacy-js-min/package.json +9 -0
  6. package/__fixtures__/legacy-js-min/src/scripts/components/sample.js +4 -0
  7. package/__fixtures__/legacy-js-min/src/scripts/theme.js +9 -0
  8. package/__fixtures__/legacy-js-min/src/scripts/vendor/lib.js +7 -0
  9. package/__fixtures__/legacy-js-min/src/scripts/vendor.js +3 -0
  10. package/__fixtures__/legacy-js-min/verify.sh +34 -0
  11. package/__fixtures__/module-js-min/dist/assets/components-greeter.js +6 -0
  12. package/__fixtures__/module-js-min/dist/assets/components-greeter.js.map +1 -0
  13. package/__fixtures__/module-js-min/dist/assets/components-items.js +5 -0
  14. package/__fixtures__/module-js-min/dist/assets/components-items.js.map +1 -0
  15. package/__fixtures__/module-js-min/dist/assets/header.js +20 -0
  16. package/__fixtures__/module-js-min/dist/assets/header.js.map +1 -0
  17. package/__fixtures__/module-js-min/dist/assets/sections-cart-items.js +8 -0
  18. package/__fixtures__/module-js-min/dist/assets/sections-cart-items.js.map +1 -0
  19. package/__fixtures__/module-js-min/dist/assets/vendor-lib.js +4 -0
  20. package/__fixtures__/module-js-min/dist/assets/vendor-lib.js.map +1 -0
  21. package/__fixtures__/module-js-min/package.json +9 -0
  22. package/__fixtures__/module-js-min/seed.project.json +15 -0
  23. package/__fixtures__/module-js-min/src/scripts/components/greeter.js +4 -0
  24. package/__fixtures__/module-js-min/src/scripts/components/items.js +3 -0
  25. package/__fixtures__/module-js-min/src/scripts/header.js +18 -0
  26. package/__fixtures__/module-js-min/src/scripts/sections/cart/items.js +6 -0
  27. package/__fixtures__/module-js-min/src/scripts/vendor/lib.js +2 -0
  28. package/__fixtures__/module-js-min/verify.sh +58 -0
  29. package/lib/commands/watch.js +55 -7
  30. package/lib/commands/watch.js.map +1 -1
  31. package/lib/shopify-bin.d.ts +14 -0
  32. package/lib/shopify-bin.js +18 -0
  33. package/lib/shopify-bin.js.map +1 -0
  34. package/lib/tasks/build-js-module.d.ts +2 -0
  35. package/lib/tasks/build-js-module.js +93 -0
  36. package/lib/tasks/build-js-module.js.map +1 -0
  37. package/lib/tasks/build-js.js +13 -1
  38. package/lib/tasks/build-js.js.map +1 -1
  39. package/lib/tasks/includes/config.d.ts +2 -0
  40. package/lib/tasks/includes/config.js +3 -0
  41. package/lib/tasks/includes/config.js.map +1 -1
  42. package/lib/tasks/includes/js-mode.d.ts +12 -0
  43. package/lib/tasks/includes/js-mode.js +42 -0
  44. package/lib/tasks/includes/js-mode.js.map +1 -0
  45. package/lib/tasks/watch-tui/App.d.ts +8 -0
  46. package/lib/tasks/watch-tui/App.js +337 -0
  47. package/lib/tasks/watch-tui/App.js.map +1 -0
  48. package/lib/tasks/watch-tui/classify.d.ts +39 -0
  49. package/lib/tasks/watch-tui/classify.js +84 -0
  50. package/lib/tasks/watch-tui/classify.js.map +1 -0
  51. package/lib/tasks/watch-tui/index.d.ts +30 -0
  52. package/lib/tasks/watch-tui/index.js +299 -0
  53. package/lib/tasks/watch-tui/index.js.map +1 -0
  54. package/lib/tasks/watch-tui/shopify-parse.d.ts +24 -0
  55. package/lib/tasks/watch-tui/shopify-parse.js +87 -0
  56. package/lib/tasks/watch-tui/shopify-parse.js.map +1 -0
  57. package/lib/tasks/watch-tui/state.d.ts +68 -0
  58. package/lib/tasks/watch-tui/state.js +61 -0
  59. package/lib/tasks/watch-tui/state.js.map +1 -0
  60. package/lib/tasks/watch-tui/theme-info.d.ts +10 -0
  61. package/lib/tasks/watch-tui/theme-info.js +79 -0
  62. package/lib/tasks/watch-tui/theme-info.js.map +1 -0
  63. package/lib/utils.js +63 -88
  64. package/lib/utils.js.map +1 -1
  65. package/package.json +11 -2
  66. package/src/commands/watch.ts +70 -20
  67. package/src/shopify-bin.ts +21 -0
  68. package/src/tasks/build-js-module.ts +92 -0
  69. package/src/tasks/build-js.ts +13 -1
  70. package/src/tasks/includes/config.ts +5 -0
  71. package/src/tasks/includes/js-mode.ts +48 -0
  72. package/src/tasks/watch-tui/App.tsx +486 -0
  73. package/src/tasks/watch-tui/classify.ts +120 -0
  74. package/src/tasks/watch-tui/index.ts +342 -0
  75. package/src/tasks/watch-tui/shopify-parse.ts +103 -0
  76. package/src/tasks/watch-tui/state.ts +113 -0
  77. package/src/tasks/watch-tui/theme-info.ts +109 -0
  78. package/src/types/declarations.d.ts +19 -1
  79. package/src/utils.ts +64 -94
  80. package/tsconfig.json +3 -2
  81. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,92 @@
1
+ import gulp from "gulp";
2
+ import gulpif from "gulp-if";
3
+ import plumber from "gulp-plumber";
4
+ import rename from "gulp-rename";
5
+ import sourcemaps from "gulp-sourcemaps";
6
+ import terser from "gulp-terser";
7
+ import chokidar from "chokidar";
8
+ import { utimesSync } from "fs";
9
+ import { glob } from "glob";
10
+ import utils from "./includes/utilities";
11
+ import config from "./includes/config";
12
+ import messages from "./includes/messages";
13
+
14
+ /**
15
+ * Module-mode JS pipeline (seed.project.json: build.js.type = "module").
16
+ *
17
+ * Each `src/scripts/**\/*.js` file ships as its own ES module to
18
+ * `dist/assets/`. No bundling, no concatenation. Filenames are flattened with
19
+ * the parent folder as a prefix to dodge name collisions:
20
+ *
21
+ * src/scripts/header.js → dist/assets/header.js
22
+ * src/scripts/components/whatever.js → dist/assets/components-whatever.js
23
+ * src/scripts/sections/cart/items.js → dist/assets/sections-cart-items.js
24
+ * src/scripts/vendor/swiper.js → dist/assets/vendor-swiper.js
25
+ *
26
+ * In dev (no `--optimize`) we emit inline sourcemaps and skip minification so
27
+ * stack traces map cleanly to source. In prod we minify with terser (which
28
+ * supports modern syntax — uglify-js used by the legacy pipeline does not).
29
+ */
30
+
31
+ const SCRIPTS_GLOB = "src/scripts/**/*.js";
32
+
33
+ function flattenWithParent(): NodeJS.ReadWriteStream {
34
+ return rename((file) => {
35
+ if (file.dirname && file.dirname !== "." && file.dirname !== "") {
36
+ const segments = file.dirname.split(/[\\/]/).filter(Boolean);
37
+ file.basename = segments.join("-") + "-" + file.basename;
38
+ file.dirname = "";
39
+ }
40
+ });
41
+ }
42
+
43
+ function buildOne(srcPath: string): NodeJS.ReadWriteStream {
44
+ return gulp
45
+ .src(srcPath, { allowEmpty: true, base: "src/scripts" })
46
+ .pipe(plumber(utils.errorHandler))
47
+ .pipe(gulpif(!config.optimize, sourcemaps.init()))
48
+ .pipe(gulpif(config.optimize, terser()))
49
+ .pipe(gulpif(!config.optimize, sourcemaps.write(".")))
50
+ .pipe(flattenWithParent())
51
+ .pipe(gulp.dest(config.dist.assets));
52
+ }
53
+
54
+ function processModuleJs(): NodeJS.ReadWriteStream {
55
+ messages.logProcessFiles(`build:js (module mode)`);
56
+ return buildOne(SCRIPTS_GLOB);
57
+ }
58
+
59
+ /**
60
+ * Touch JS files to update their modification time so Shopify's watcher picks
61
+ * the change up — same trick the legacy pipeline uses.
62
+ */
63
+ function touchJsFiles(): void {
64
+ try {
65
+ const jsFiles = glob.sync(`${config.dist.assets}*.js`);
66
+ jsFiles.forEach((file) => {
67
+ const now = new Date();
68
+ utimesSync(file, now, now);
69
+ messages.logFileEvent("touch", file);
70
+ });
71
+ } catch (error) {
72
+ console.error("Error touching JS files:", error);
73
+ }
74
+ }
75
+
76
+ export function moduleBuildTask(): NodeJS.ReadWriteStream {
77
+ return processModuleJs();
78
+ }
79
+
80
+ export function moduleWatchTask(): void {
81
+ chokidar
82
+ .watch(SCRIPTS_GLOB, { ignoreInitial: true })
83
+ .on("all", (event, path) => {
84
+ messages.logFileEvent(event, path);
85
+ // Per-file rebuild — only the changed file flows through. Cheaper and
86
+ // simpler than re-globbing the world on every save.
87
+ const stream = buildOne(path);
88
+ stream.on("end", () => {
89
+ setTimeout(() => touchJsFiles(), 500);
90
+ });
91
+ });
92
+ }
@@ -10,11 +10,16 @@ import { utimesSync } from "fs";
10
10
  import utils from "./includes/utilities";
11
11
  import config from "./includes/config";
12
12
  import messages from "./includes/messages";
13
+ import { moduleBuildTask, moduleWatchTask } from "./build-js-module";
13
14
  // Using Console type from Node.js types
14
15
  import type { Console } from "console";
15
16
 
16
17
  const minify = composer(uglifyjs, console as Console);
17
18
 
19
+ // Surface the active JS pipeline mode once at task wire-up so it shows up in
20
+ // `seed build` / `seed watch` output without every task spamming it.
21
+ messages.logProcessFiles(`js mode: ${config.jsMode}`);
22
+
18
23
  interface MinifyOptions {
19
24
  mangle: boolean;
20
25
  compress: boolean;
@@ -65,10 +70,16 @@ function touchJsFiles(): void {
65
70
  }
66
71
 
67
72
  gulp.task("build:js", () => {
73
+ if (config.jsMode === "module") return moduleBuildTask();
68
74
  return processThemeJs();
69
75
  });
70
76
 
71
- gulp.task("watch:js", () => {
77
+ gulp.task("watch:js", (done) => {
78
+ if (config.jsMode === "module") {
79
+ moduleWatchTask();
80
+ done();
81
+ return;
82
+ }
72
83
  chokidar
73
84
  .watch(
74
85
  [config.src.js, `!${config.roots.vendorJs}`, `!${config.src.vendorJs}`],
@@ -84,6 +95,7 @@ gulp.task("watch:js", () => {
84
95
  touchJsFiles();
85
96
  }, 500);
86
97
  });
98
+ done();
87
99
  });
88
100
 
89
101
  gulp.task("build:vendor-js", () => {
@@ -9,6 +9,7 @@ import minimist from "minimist";
9
9
  import { existsSync } from "fs";
10
10
  import autoprefixer from "autoprefixer";
11
11
  import postcssImport from "postcss-import";
12
+ import { resolveJsMode, type JsMode } from "./js-mode";
12
13
 
13
14
  const logger = debug("seed-tools");
14
15
  const argv = minimist(process.argv.slice(2));
@@ -28,6 +29,8 @@ try {
28
29
  logger(err);
29
30
  }
30
31
 
32
+ const jsMode: JsMode = resolveJsMode(themeRoot);
33
+
31
34
  interface PathConfig {
32
35
  root: string;
33
36
  js: string;
@@ -89,6 +92,7 @@ interface BuildConfig {
89
92
  tailwindConfig: string;
90
93
  usesTailwind: boolean;
91
94
  usesModuleBundler: boolean;
95
+ jsMode: JsMode;
92
96
  seedConfig: string;
93
97
  shopifyIgnore: string;
94
98
  src: PathConfig;
@@ -116,6 +120,7 @@ const config: BuildConfig = {
116
120
  tailwindConfig,
117
121
  usesTailwind: existsSync(join(themeRoot, tailwindConfig)),
118
122
  usesModuleBundler: !!pkg["bundle-js"],
123
+ jsMode,
119
124
  seedConfig: "seed.config.js",
120
125
  shopifyIgnore: join(themeRoot, ".shopifyignore"),
121
126
 
@@ -0,0 +1,48 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import debug from "debug";
4
+
5
+ const logger = debug("seed-tools:js-mode");
6
+
7
+ export type JsMode = "legacy" | "module";
8
+
9
+ interface ProjectJsonShape {
10
+ build?: {
11
+ js?: {
12
+ type?: unknown;
13
+ };
14
+ };
15
+ }
16
+
17
+ /**
18
+ * Resolve the active JS pipeline mode from `seed.project.json` → `build.js.type`.
19
+ *
20
+ * Defaults to `legacy` for: missing file, missing field, unknown value. This is
21
+ * intentional — backwards compatibility means an existing theme with no
22
+ * `build.js` config keeps the gulp-include + uglify pipeline it had before.
23
+ *
24
+ * `bundle` is reserved for a future bundled mode and throws here so the field
25
+ * remains forward-compatible without silently falling back to legacy.
26
+ */
27
+ export function resolveJsMode(themeRoot: string): JsMode {
28
+ const file = join(themeRoot, "seed.project.json");
29
+ if (!existsSync(file)) return "legacy";
30
+
31
+ let parsed: ProjectJsonShape = {};
32
+ try {
33
+ parsed = JSON.parse(readFileSync(file, "utf-8"));
34
+ } catch (err) {
35
+ logger("failed to parse seed.project.json:", err);
36
+ return "legacy";
37
+ }
38
+
39
+ const type = parsed.build?.js?.type;
40
+ if (type === "module") return "module";
41
+ if (type === "bundle") {
42
+ throw new Error(
43
+ 'seed.project.json: build.js.type="bundle" is reserved for a future ' +
44
+ 'bundled mode and not yet implemented. Use "legacy" or "module".',
45
+ );
46
+ }
47
+ return "legacy";
48
+ }
@@ -0,0 +1,486 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { Box, Text, useInput, useStdout } from "ink";
3
+ import type { TuiStore, LogEntry } from "./state";
4
+
5
+ type Tab = "dashboard" | "logs" | "errors";
6
+
7
+ interface AppProps {
8
+ store: TuiStore;
9
+ onQuit: () => void;
10
+ }
11
+
12
+ function useStoreTick(store: TuiStore): number {
13
+ const [tick, setTick] = useState(0);
14
+ useEffect(() => {
15
+ const h = (): void => setTick((t) => t + 1);
16
+ store.on("change", h);
17
+ const interval = setInterval(h, 1000);
18
+ return () => {
19
+ store.off("change", h);
20
+ clearInterval(interval);
21
+ };
22
+ }, [store]);
23
+ return tick;
24
+ }
25
+
26
+ export function App({ store, onQuit }: AppProps): React.ReactElement {
27
+ useStoreTick(store);
28
+ const [tab, setTab] = useState<Tab>("dashboard");
29
+ const [logOffset, setLogOffset] = useState(0); // 0 = tail
30
+ const { stdout } = useStdout();
31
+ const rows = stdout?.rows ?? 24;
32
+ const cols = stdout?.columns ?? 80;
33
+
34
+ useInput((input, key) => {
35
+ // Ink swallows Ctrl+C while in raw mode (we set exitOnCtrlC: false so we
36
+ // can run our own teardown), so handle it explicitly here.
37
+ if (input === "q" || (key.ctrl && input === "c")) {
38
+ onQuit();
39
+ return;
40
+ }
41
+ if (input === "d") {
42
+ setTab("dashboard");
43
+ return;
44
+ }
45
+ if (input === "l") {
46
+ setTab("logs");
47
+ setLogOffset(0);
48
+ return;
49
+ }
50
+ if (input === "e") {
51
+ setTab("errors");
52
+ return;
53
+ }
54
+
55
+ if (tab === "logs") {
56
+ if (key.upArrow || input === "k") {
57
+ setLogOffset((o) => o + 1);
58
+ } else if (key.downArrow || input === "j") {
59
+ setLogOffset((o) => Math.max(0, o - 1));
60
+ } else if (key.pageUp) {
61
+ setLogOffset((o) => o + 10);
62
+ } else if (key.pageDown) {
63
+ setLogOffset((o) => Math.max(0, o - 10));
64
+ } else if (input === "g") {
65
+ setLogOffset(store.logs.length);
66
+ } else if (input === "G") {
67
+ setLogOffset(0);
68
+ }
69
+ }
70
+ });
71
+
72
+ return (
73
+ <Box flexDirection="column" width={cols}>
74
+ <Header store={store} />
75
+ <Box flexDirection="column" flexGrow={1} paddingX={1} paddingY={1}>
76
+ {tab === "dashboard" && <Dashboard store={store} />}
77
+ {tab === "logs" && (
78
+ <Logs store={store} rows={rows - 6} offset={logOffset} cols={cols} />
79
+ )}
80
+ {tab === "errors" && <Errors store={store} cols={cols} />}
81
+ </Box>
82
+ <Footer tab={tab} store={store} />
83
+ </Box>
84
+ );
85
+ }
86
+
87
+ function Header({ store }: { store: TuiStore }): React.ReactElement {
88
+ const info = store.themeInfo;
89
+ const subtitle = [
90
+ info?.contextName,
91
+ info?.storeDomain || store.sourceLabel,
92
+ info?.branch,
93
+ ]
94
+ .filter(Boolean)
95
+ .join(" · ");
96
+
97
+ return (
98
+ <Box
99
+ borderStyle="round"
100
+ borderColor="gray"
101
+ paddingX={1}
102
+ flexDirection="column"
103
+ >
104
+ <Box justifyContent="space-between">
105
+ <Box>
106
+ <Text bold color="cyan">
107
+ seed watch
108
+ </Text>
109
+ {subtitle && <Text color="gray">{" " + subtitle}</Text>}
110
+ </Box>
111
+ <Outcome store={store} />
112
+ </Box>
113
+ </Box>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Single status line — direct translation of (state, lastBuild, lastError).
119
+ * Same flag-based state model as the original draft, just rendered as an
120
+ * outcome line instead of a colored badge:
121
+ * ⏳ building… state=building
122
+ * ✗ build failed · time [e] state=error (lastError set)
123
+ * ✓ task · ms · time state=watching with lastBuild
124
+ * ● watching state=watching, no lastBuild yet
125
+ * ◌ starting… state=starting
126
+ */
127
+ function Outcome({ store }: { store: TuiStore }): React.ReactElement | null {
128
+ if (store.state === "building") {
129
+ return (
130
+ <Text color="cyan">
131
+ ⏳ <Text color="gray">building…</Text>
132
+ </Text>
133
+ );
134
+ }
135
+ if (store.state === "error" && store.lastError) {
136
+ const time = new Date(store.lastError.at).toTimeString().slice(0, 8);
137
+ return (
138
+ <Text>
139
+ <Text color="red" bold>
140
+ ✗ build failed
141
+ </Text>
142
+ <Text color="gray">{" · " + time + " "}</Text>
143
+ <Text color="gray">[</Text>
144
+ <Text color="yellow">e</Text>
145
+ <Text color="gray">]</Text>
146
+ </Text>
147
+ );
148
+ }
149
+ if (store.state === "watching" && store.lastBuild) {
150
+ const time = new Date(store.lastBuild.at).toTimeString().slice(0, 8);
151
+ return (
152
+ <Text>
153
+ <Text color="green">✓ </Text>
154
+ <Text>{store.lastBuild.task}</Text>
155
+ <Text color="gray">{" · " + fmtMs(store.lastBuild.ms)}</Text>
156
+ <Text color="gray">{" · " + time}</Text>
157
+ </Text>
158
+ );
159
+ }
160
+ if (store.state === "watching") {
161
+ return (
162
+ <Text color="green">
163
+ ● <Text color="gray">watching</Text>
164
+ </Text>
165
+ );
166
+ }
167
+ return (
168
+ <Text color="yellow">
169
+ ◌ <Text color="gray">starting…</Text>
170
+ </Text>
171
+ );
172
+ }
173
+
174
+ function Dashboard({ store }: { store: TuiStore }): React.ReactElement {
175
+ const info = store.themeInfo;
176
+ const shopify = store.shopify;
177
+
178
+ // Prefer live shopify-cli URLs over cached context URLs when available.
179
+ const previewUrl = shopify.previewUrl || info?.previewUrl;
180
+ const editorUrl = shopify.editorUrl || info?.customizerUrl;
181
+
182
+ // Every row is always rendered. Shopify URLs show "loading…" until the
183
+ // dev server prints them; context fields show "N/A" when not configured.
184
+ const rows: Array<[string, React.ReactNode]> = [
185
+ [
186
+ "Context",
187
+ info?.contextName ? (
188
+ <Text>
189
+ {info.contextName}
190
+ {info.contextStatus && (
191
+ <Text color="gray">{" (" + info.contextStatus + ")"}</Text>
192
+ )}
193
+ </Text>
194
+ ) : (
195
+ <NA />
196
+ ),
197
+ ],
198
+ [
199
+ "Branch",
200
+ info?.branch ? (
201
+ <Text>
202
+ {info.branch}
203
+ {info.baseBranch && (
204
+ <Text color="gray">{" → " + info.baseBranch}</Text>
205
+ )}
206
+ </Text>
207
+ ) : (
208
+ <NA />
209
+ ),
210
+ ],
211
+ [
212
+ "Store",
213
+ info?.storeDomain || store.sourceLabel ? (
214
+ <Text>{info?.storeDomain || store.sourceLabel}</Text>
215
+ ) : (
216
+ <NA />
217
+ ),
218
+ ],
219
+ [
220
+ "Theme",
221
+ info?.previewThemeName || info?.previewThemeId ? (
222
+ <Text>
223
+ {info.previewThemeName ?? String(info.previewThemeId)}
224
+ {info.previewThemeName && info.previewThemeId && (
225
+ <Text color="gray">{" (" + info.previewThemeId + ")"}</Text>
226
+ )}
227
+ </Text>
228
+ ) : (
229
+ <NA />
230
+ ),
231
+ ],
232
+ ["Local", shopify.localUrl ? <Url value={shopify.localUrl} /> : <Loading />],
233
+ ["Preview", previewUrl ? <Url value={previewUrl} /> : <Loading />],
234
+ ["Editor", editorUrl ? <Url value={editorUrl} /> : <Loading />],
235
+ [
236
+ "Task",
237
+ info?.asanaTaskUrl ? <Url value={info.asanaTaskUrl} /> : <NA />,
238
+ ],
239
+ ["PR", info?.prUrl ? <Url value={info.prUrl} /> : <NA />],
240
+ ];
241
+
242
+ return (
243
+ <Box flexDirection="column">
244
+ {shopify.authUrl && <AuthBanner store={store} />}
245
+ {rows.map(([label, value], i) => (
246
+ <Box key={i}>
247
+ <Box width={10}>
248
+ <Text color="gray">{label}</Text>
249
+ </Box>
250
+ {value}
251
+ </Box>
252
+ ))}
253
+ </Box>
254
+ );
255
+ }
256
+
257
+ function AuthBanner({ store }: { store: TuiStore }): React.ReactElement {
258
+ const { authUrl, authUserCode } = store.shopify;
259
+ return (
260
+ <Box
261
+ borderStyle="round"
262
+ borderColor="yellow"
263
+ paddingX={1}
264
+ flexDirection="column"
265
+ marginBottom={1}
266
+ >
267
+ <Text color="yellow" bold>
268
+ ⚠ Shopify CLI needs you to log in
269
+ </Text>
270
+ <Box marginTop={1} flexDirection="column">
271
+ <Text>
272
+ <Text color="gray">Open: </Text>
273
+ <Text color="blue">{authUrl}</Text>
274
+ </Text>
275
+ {authUserCode && (
276
+ <Text>
277
+ <Text color="gray">Verify code: </Text>
278
+ <Text color="yellow" bold>
279
+ {authUserCode}
280
+ </Text>
281
+ </Text>
282
+ )}
283
+ </Box>
284
+ </Box>
285
+ );
286
+ }
287
+
288
+ function Url({ value }: { value: string }): React.ReactElement {
289
+ return <Text color="blue">{value}</Text>;
290
+ }
291
+
292
+ function Loading(): React.ReactElement {
293
+ return <Text color="yellow">loading…</Text>;
294
+ }
295
+
296
+ function NA(): React.ReactElement {
297
+ return <Text color="gray">N/A</Text>;
298
+ }
299
+
300
+ function Logs({
301
+ store,
302
+ rows,
303
+ offset,
304
+ cols,
305
+ }: {
306
+ store: TuiStore;
307
+ rows: number;
308
+ offset: number;
309
+ cols: number;
310
+ }): React.ReactElement {
311
+ const visibleRows = Math.max(3, rows);
312
+ const logs = store.logs;
313
+ const total = logs.length;
314
+ const end = Math.max(0, total - offset);
315
+ const start = Math.max(0, end - visibleRows);
316
+ const slice = logs.slice(start, end);
317
+
318
+ const following = offset === 0;
319
+ const scrollHint = following
320
+ ? `following (${total} lines)`
321
+ : `${start + 1}-${end} of ${total} · [j/k] scroll · [G] bottom`;
322
+
323
+ return (
324
+ <Box flexDirection="column">
325
+ <Text color="gray">{scrollHint}</Text>
326
+ <Box flexDirection="column" marginTop={1}>
327
+ {slice.length === 0 ? (
328
+ <Text color="gray">No output yet.</Text>
329
+ ) : (
330
+ slice.map((entry) => <LogLine key={entry.id} entry={entry} cols={cols} />)
331
+ )}
332
+ </Box>
333
+ </Box>
334
+ );
335
+ }
336
+
337
+ function LogLine({
338
+ entry,
339
+ cols,
340
+ }: {
341
+ entry: LogEntry;
342
+ cols: number;
343
+ }): React.ReactElement {
344
+ const color = colorForKind(entry.kind);
345
+ const time = new Date(entry.ts).toTimeString().slice(0, 8);
346
+ const budget = Math.max(20, cols - time.length - 3);
347
+ const text = truncate(entry.text, budget);
348
+ return (
349
+ <Text>
350
+ <Text color="gray">{time + " "}</Text>
351
+ <Text color={color}>{text}</Text>
352
+ </Text>
353
+ );
354
+ }
355
+
356
+ function Errors({
357
+ store,
358
+ cols,
359
+ }: {
360
+ store: TuiStore;
361
+ cols: number;
362
+ }): React.ReactElement {
363
+ const err = store.lastError;
364
+ if (!err) {
365
+ return <Text color="gray">No errors recorded.</Text>;
366
+ }
367
+ const ago = fmtAgo(Date.now() - err.at);
368
+ return (
369
+ <Box flexDirection="column">
370
+ <Text color="red" bold>
371
+ ⚠ Last error <Text color="gray">({ago} ago)</Text>
372
+ </Text>
373
+ <Box marginTop={1} flexDirection="column">
374
+ {err.lines.map((line, i) => (
375
+ <Text key={i} color="red">
376
+ {truncate(line, Math.max(20, cols - 2))}
377
+ </Text>
378
+ ))}
379
+ </Box>
380
+ </Box>
381
+ );
382
+ }
383
+
384
+ function Footer({
385
+ tab,
386
+ store,
387
+ }: {
388
+ tab: Tab;
389
+ store: TuiStore;
390
+ }): React.ReactElement {
391
+ const hasError = Boolean(store.lastError);
392
+ return (
393
+ <Box
394
+ borderStyle="round"
395
+ borderColor="gray"
396
+ paddingX={1}
397
+ justifyContent="space-between"
398
+ >
399
+ <Box>
400
+ <TabLabel active={tab === "dashboard"} key_="d" label="dashboard" />
401
+ <Text color="gray">{" "}</Text>
402
+ <TabLabel
403
+ active={tab === "logs"}
404
+ key_="l"
405
+ label={`logs (${store.logs.length})`}
406
+ />
407
+ <Text color="gray">{" "}</Text>
408
+ <TabLabel
409
+ active={tab === "errors"}
410
+ key_="e"
411
+ label="errors"
412
+ marker={hasError ? "!" : undefined}
413
+ />
414
+ </Box>
415
+ <Text color="gray">
416
+ [<Text color="yellow">q</Text>] quit
417
+ </Text>
418
+ </Box>
419
+ );
420
+ }
421
+
422
+ function TabLabel({
423
+ active,
424
+ key_,
425
+ label,
426
+ marker,
427
+ }: {
428
+ active: boolean;
429
+ key_: string;
430
+ label: string;
431
+ marker?: string;
432
+ }): React.ReactElement {
433
+ return (
434
+ <Text>
435
+ <Text color="gray">[</Text>
436
+ <Text color="yellow">{key_}</Text>
437
+ <Text color="gray">] </Text>
438
+ <Text color={active ? "cyan" : "white"} bold={active}>
439
+ {label}
440
+ </Text>
441
+ {marker && <Text color="red">{" " + marker}</Text>}
442
+ </Text>
443
+ );
444
+ }
445
+
446
+ function colorForKind(kind: LogEntry["kind"]): string {
447
+ switch (kind) {
448
+ case "error":
449
+ case "build-error":
450
+ return "red";
451
+ case "warn":
452
+ return "yellow";
453
+ case "build-start":
454
+ case "build-done":
455
+ return "cyan";
456
+ case "file-change":
457
+ return "magenta";
458
+ case "upload":
459
+ return "blue";
460
+ case "verbose":
461
+ return "gray";
462
+ default:
463
+ return "white";
464
+ }
465
+ }
466
+
467
+ function fmtMs(ms: number): string {
468
+ if (ms < 1000) return `${Math.round(ms)}ms`;
469
+ return `${(ms / 1000).toFixed(1)}s`;
470
+ }
471
+
472
+ function fmtAgo(ms: number): string {
473
+ if (ms < 1000) return "just now";
474
+ if (ms < 60 * 1000) return `${Math.round(ms / 1000)}s`;
475
+ if (ms < 60 * 60 * 1000) {
476
+ const m = Math.floor(ms / 60000);
477
+ const s = Math.round((ms % 60000) / 1000);
478
+ return s > 0 && m < 10 ? `${m}m ${s}s` : `${m}m`;
479
+ }
480
+ return `${Math.round(ms / 3600000)}h`;
481
+ }
482
+
483
+ function truncate(s: string, max: number): string {
484
+ if (s.length <= max) return s;
485
+ return s.slice(0, Math.max(0, max - 1)) + "…";
486
+ }