@madebyseed/seed-cli-tools 3.0.2 → 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.
- package/CHANGELOG.md +57 -0
- package/__fixtures__/legacy-js-min/baseline/theme.dev.js +13 -0
- package/__fixtures__/legacy-js-min/baseline/theme.prod.js +1 -0
- package/__fixtures__/legacy-js-min/dist/assets/theme.js +13 -0
- package/__fixtures__/legacy-js-min/package.json +9 -0
- package/__fixtures__/legacy-js-min/src/scripts/components/sample.js +4 -0
- package/__fixtures__/legacy-js-min/src/scripts/theme.js +9 -0
- package/__fixtures__/legacy-js-min/src/scripts/vendor/lib.js +7 -0
- package/__fixtures__/legacy-js-min/src/scripts/vendor.js +3 -0
- package/__fixtures__/legacy-js-min/verify.sh +34 -0
- package/__fixtures__/module-js-min/dist/assets/components-greeter.js +6 -0
- package/__fixtures__/module-js-min/dist/assets/components-greeter.js.map +1 -0
- package/__fixtures__/module-js-min/dist/assets/components-items.js +5 -0
- package/__fixtures__/module-js-min/dist/assets/components-items.js.map +1 -0
- package/__fixtures__/module-js-min/dist/assets/header.js +20 -0
- package/__fixtures__/module-js-min/dist/assets/header.js.map +1 -0
- package/__fixtures__/module-js-min/dist/assets/sections-cart-items.js +8 -0
- package/__fixtures__/module-js-min/dist/assets/sections-cart-items.js.map +1 -0
- package/__fixtures__/module-js-min/dist/assets/vendor-lib.js +4 -0
- package/__fixtures__/module-js-min/dist/assets/vendor-lib.js.map +1 -0
- package/__fixtures__/module-js-min/package.json +9 -0
- package/__fixtures__/module-js-min/seed.project.json +15 -0
- package/__fixtures__/module-js-min/src/scripts/components/greeter.js +4 -0
- package/__fixtures__/module-js-min/src/scripts/components/items.js +3 -0
- package/__fixtures__/module-js-min/src/scripts/header.js +18 -0
- package/__fixtures__/module-js-min/src/scripts/sections/cart/items.js +6 -0
- package/__fixtures__/module-js-min/src/scripts/vendor/lib.js +2 -0
- package/__fixtures__/module-js-min/verify.sh +58 -0
- package/lib/commands/watch.js +55 -7
- package/lib/commands/watch.js.map +1 -1
- package/lib/shopify-bin.d.ts +14 -0
- package/lib/shopify-bin.js +18 -0
- package/lib/shopify-bin.js.map +1 -0
- package/lib/tasks/build-js-module.d.ts +2 -0
- package/lib/tasks/build-js-module.js +93 -0
- package/lib/tasks/build-js-module.js.map +1 -0
- package/lib/tasks/build-js.js +13 -1
- package/lib/tasks/build-js.js.map +1 -1
- package/lib/tasks/includes/config.d.ts +2 -0
- package/lib/tasks/includes/config.js +3 -0
- package/lib/tasks/includes/config.js.map +1 -1
- package/lib/tasks/includes/js-mode.d.ts +12 -0
- package/lib/tasks/includes/js-mode.js +42 -0
- package/lib/tasks/includes/js-mode.js.map +1 -0
- package/lib/tasks/watch-tui/App.d.ts +8 -0
- package/lib/tasks/watch-tui/App.js +337 -0
- package/lib/tasks/watch-tui/App.js.map +1 -0
- package/lib/tasks/watch-tui/classify.d.ts +39 -0
- package/lib/tasks/watch-tui/classify.js +84 -0
- package/lib/tasks/watch-tui/classify.js.map +1 -0
- package/lib/tasks/watch-tui/index.d.ts +30 -0
- package/lib/tasks/watch-tui/index.js +299 -0
- package/lib/tasks/watch-tui/index.js.map +1 -0
- package/lib/tasks/watch-tui/shopify-parse.d.ts +24 -0
- package/lib/tasks/watch-tui/shopify-parse.js +87 -0
- package/lib/tasks/watch-tui/shopify-parse.js.map +1 -0
- package/lib/tasks/watch-tui/state.d.ts +68 -0
- package/lib/tasks/watch-tui/state.js +61 -0
- package/lib/tasks/watch-tui/state.js.map +1 -0
- package/lib/tasks/watch-tui/theme-info.d.ts +10 -0
- package/lib/tasks/watch-tui/theme-info.js +79 -0
- package/lib/tasks/watch-tui/theme-info.js.map +1 -0
- package/lib/utils.js +63 -87
- package/lib/utils.js.map +1 -1
- package/package.json +11 -2
- package/src/commands/watch.ts +70 -20
- package/src/shopify-bin.ts +21 -0
- package/src/tasks/build-js-module.ts +92 -0
- package/src/tasks/build-js.ts +13 -1
- package/src/tasks/includes/config.ts +5 -0
- package/src/tasks/includes/js-mode.ts +48 -0
- package/src/tasks/watch-tui/App.tsx +486 -0
- package/src/tasks/watch-tui/classify.ts +120 -0
- package/src/tasks/watch-tui/index.ts +342 -0
- package/src/tasks/watch-tui/shopify-parse.ts +103 -0
- package/src/tasks/watch-tui/state.ts +113 -0
- package/src/tasks/watch-tui/theme-info.ts +109 -0
- package/src/types/declarations.d.ts +19 -1
- package/src/utils.ts +64 -93
- package/tsconfig.json +3 -2
- 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
|
+
}
|
package/src/tasks/build-js.ts
CHANGED
|
@@ -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
|
+
}
|