@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.
- 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 -88
- 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 -94
- package/tsconfig.json +3 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const ANSI_RE = /\x1B\[[0-9;?]*[ -/]*[@-~]/g;
|
|
2
|
+
export const stripAnsi = (s: string): string => s.replace(ANSI_RE, "");
|
|
3
|
+
const GULP_TIME = /^\[\d{2}:\d{2}:\d{2}\]\s*/;
|
|
4
|
+
const CONT_PREFIX = /^›\s?/;
|
|
5
|
+
|
|
6
|
+
export type EventKind =
|
|
7
|
+
| "build-start"
|
|
8
|
+
| "build-done"
|
|
9
|
+
| "build-error"
|
|
10
|
+
| "file-change"
|
|
11
|
+
| "upload"
|
|
12
|
+
| "error"
|
|
13
|
+
| "warn"
|
|
14
|
+
| "info"
|
|
15
|
+
| "verbose";
|
|
16
|
+
|
|
17
|
+
export type Event =
|
|
18
|
+
| { kind: "build-start"; task: string }
|
|
19
|
+
| { kind: "build-done"; task: string; ms: number }
|
|
20
|
+
| { kind: "build-error"; task: string }
|
|
21
|
+
| {
|
|
22
|
+
kind: "file-change";
|
|
23
|
+
root: "src" | "dist";
|
|
24
|
+
dir: string;
|
|
25
|
+
file: string;
|
|
26
|
+
event: string;
|
|
27
|
+
}
|
|
28
|
+
| { kind: "upload"; message: string }
|
|
29
|
+
| { kind: "error"; message: string }
|
|
30
|
+
| { kind: "warn"; message: string }
|
|
31
|
+
| { kind: "info"; message: string }
|
|
32
|
+
| { kind: "verbose"; message: string };
|
|
33
|
+
|
|
34
|
+
const ERROR_PATTERNS: RegExp[] = [
|
|
35
|
+
/^Error\b/,
|
|
36
|
+
/^Error in plugin\b/i,
|
|
37
|
+
/^'[^']+' errored after/,
|
|
38
|
+
/^Failed\b/i,
|
|
39
|
+
/^Cannot\b/i,
|
|
40
|
+
/^(TypeError|SyntaxError|ReferenceError|RangeError):/,
|
|
41
|
+
/^\s+at\s+\S+\s+\(.+:\d+:\d+\)/,
|
|
42
|
+
/^[✖✗✘⨯×]/,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const WARN_PATTERNS: RegExp[] = [
|
|
46
|
+
/^Warning\b/i,
|
|
47
|
+
/^\[?warn(ing)?\]?[:\s]/i,
|
|
48
|
+
/^⚠/,
|
|
49
|
+
/^\(node:\d+\).*(Warning|deprecat)/i,
|
|
50
|
+
/\bdeprecat(ed|ion)\b/i,
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export function classify(raw: string): Event | null {
|
|
54
|
+
const stripped = stripAnsi(raw).trim();
|
|
55
|
+
if (!stripped) return null;
|
|
56
|
+
|
|
57
|
+
const withoutTime = stripped.replace(GULP_TIME, "").replace(CONT_PREFIX, "");
|
|
58
|
+
|
|
59
|
+
const startMatch = withoutTime.match(/^Starting '([^']+)'/);
|
|
60
|
+
if (startMatch) return { kind: "build-start", task: startMatch[1] };
|
|
61
|
+
|
|
62
|
+
const doneMatch = withoutTime.match(
|
|
63
|
+
/^Finished '([^']+)' after ([\d.]+)\s*(ms|s|μs)/,
|
|
64
|
+
);
|
|
65
|
+
if (doneMatch) {
|
|
66
|
+
const n = parseFloat(doneMatch[2]);
|
|
67
|
+
const unit = doneMatch[3];
|
|
68
|
+
const ms = unit === "s" ? n * 1000 : unit === "μs" ? n / 1000 : n;
|
|
69
|
+
return { kind: "build-done", task: doneMatch[1], ms };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const erroredMatch = withoutTime.match(/^'([^']+)' errored after/);
|
|
73
|
+
if (erroredMatch) return { kind: "build-error", task: erroredMatch[1] };
|
|
74
|
+
|
|
75
|
+
const fileChange = withoutTime.match(
|
|
76
|
+
/^(change in|updated dist)\s+(.+?)\s+-\s+(\w+)\s+(.+)$/,
|
|
77
|
+
);
|
|
78
|
+
if (fileChange) {
|
|
79
|
+
return {
|
|
80
|
+
kind: "file-change",
|
|
81
|
+
root: fileChange[1] === "updated dist" ? "dist" : "src",
|
|
82
|
+
dir: fileChange[2],
|
|
83
|
+
event: fileChange[3],
|
|
84
|
+
file: fileChange[4],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (WARN_PATTERNS.some((re) => re.test(withoutTime))) {
|
|
89
|
+
return { kind: "warn", message: withoutTime };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (ERROR_PATTERNS.some((re) => re.test(withoutTime))) {
|
|
93
|
+
return { kind: "error", message: withoutTime };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (/(Synced|Uploaded|Pushing|Uploading)/i.test(withoutTime)) {
|
|
97
|
+
return { kind: "upload", message: withoutTime };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (/^[-═─=]+$/.test(withoutTime)) {
|
|
101
|
+
return { kind: "verbose", message: withoutTime };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { kind: "info", message: withoutTime };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pick the most useful one-line summary from a multi-line error block.
|
|
109
|
+
*/
|
|
110
|
+
export function pickErrorSummary(lines: string[]): string {
|
|
111
|
+
const failed = lines.find((l) => /^Failed\s/i.test(l));
|
|
112
|
+
if (failed) return failed;
|
|
113
|
+
const errPrefix = lines.find((l) => /^Error:/.test(l));
|
|
114
|
+
if (errPrefix) return errPrefix.replace(/^Error:\s*/, "");
|
|
115
|
+
const pluginHeader = lines.find((l) => /^Error in plugin/i.test(l));
|
|
116
|
+
const meaningful = lines.find(
|
|
117
|
+
(l) => !/^(Message|Stack|Details|fileName|lineNumber):\s*$/i.test(l),
|
|
118
|
+
);
|
|
119
|
+
return pluginHeader || meaningful || lines[0];
|
|
120
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, Instance } from "ink";
|
|
3
|
+
import { classify, pickErrorSummary, stripAnsi } from "./classify";
|
|
4
|
+
import { ShopifyParser, ShopifySignal } from "./shopify-parse";
|
|
5
|
+
import { TuiStore } from "./state";
|
|
6
|
+
import { loadThemeInfo } from "./theme-info";
|
|
7
|
+
import { App } from "./App";
|
|
8
|
+
|
|
9
|
+
const ERROR_FLUSH_MS = 150;
|
|
10
|
+
/**
|
|
11
|
+
* Per-save builds bypass gulp's task system, so we can't pair their start
|
|
12
|
+
* and end events. Instead, on a src file-change we just flip to "building"
|
|
13
|
+
* for a short blink, then back to "watching". Pure visual feedback.
|
|
14
|
+
*/
|
|
15
|
+
const SAVE_BLINK_MS = 600;
|
|
16
|
+
/**
|
|
17
|
+
* Tail size for the raw-output ring buffer dumped on non-zero exit. Big enough
|
|
18
|
+
* to capture a typical gulp error block (header + stack + plugin context),
|
|
19
|
+
* small enough not to flood the terminal.
|
|
20
|
+
*/
|
|
21
|
+
const TAIL_BUFFER_SIZE = 60;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Tasks we don't want to clutter `lastBuild` with — meta steps and watchers.
|
|
25
|
+
* Their state transitions still go through normally; we just keep them out
|
|
26
|
+
* of the "last build: X" reading on the dashboard.
|
|
27
|
+
*/
|
|
28
|
+
const IGNORED_LAST_BUILD = /^(watch:|shopify:serve|output:errors$|clean$)/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* "Errors" that are really just downstream wrappers re-reporting an upstream
|
|
32
|
+
* failure. They're meaningless on their own (and crowd out the actual cause),
|
|
33
|
+
* so we keep them out of the structured `lastError`. They still flow into the
|
|
34
|
+
* logs and the tail-buffer dump.
|
|
35
|
+
*
|
|
36
|
+
* - `Error: exited with error code: N` — emitted by `end-of-stream` when a
|
|
37
|
+
* child process gulp is watching dies with a non-zero exit code. The real
|
|
38
|
+
* error happened earlier in stderr.
|
|
39
|
+
*/
|
|
40
|
+
const WRAPPER_ERROR_PATTERNS: RegExp[] = [
|
|
41
|
+
/^Error: (Command )?(failed|exited)( with (error )?code)?:?\s*\d+/i,
|
|
42
|
+
];
|
|
43
|
+
const ALT_SCREEN_ENTER = "\x1b[?1049h";
|
|
44
|
+
const ALT_SCREEN_LEAVE = "\x1b[?1049l";
|
|
45
|
+
const CLEAR_SCREEN = "\x1b[2J\x1b[H";
|
|
46
|
+
const CURSOR_HIDE = "\x1b[?25l";
|
|
47
|
+
const CURSOR_SHOW = "\x1b[?25h";
|
|
48
|
+
|
|
49
|
+
export interface TuiOptions {
|
|
50
|
+
verbose?: boolean;
|
|
51
|
+
sourceLabel?: string;
|
|
52
|
+
storeKey?: string;
|
|
53
|
+
cwd?: string;
|
|
54
|
+
onQuit?: () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class WatchTui {
|
|
58
|
+
private store: TuiStore;
|
|
59
|
+
private opts: TuiOptions;
|
|
60
|
+
private instance?: Instance;
|
|
61
|
+
private stopped = false;
|
|
62
|
+
private useAltScreen: boolean;
|
|
63
|
+
|
|
64
|
+
private errorBuffer: string[] = [];
|
|
65
|
+
private errorFlushTimer?: NodeJS.Timeout;
|
|
66
|
+
private shopify = new ShopifyParser();
|
|
67
|
+
private saveBlinkTimer?: NodeJS.Timeout;
|
|
68
|
+
// Raw output ring buffer — what we'd dump as a "tail" on unexpected exit.
|
|
69
|
+
// Both streams kept so the dump can flag which side a line came from.
|
|
70
|
+
private tailBuffer: { line: string; fromStderr: boolean }[] = [];
|
|
71
|
+
|
|
72
|
+
constructor(opts: TuiOptions = {}) {
|
|
73
|
+
this.opts = opts;
|
|
74
|
+
this.useAltScreen = Boolean(process.stdout.isTTY);
|
|
75
|
+
const themeInfo = loadThemeInfo(
|
|
76
|
+
opts.cwd || process.cwd(),
|
|
77
|
+
opts.storeKey || "main",
|
|
78
|
+
);
|
|
79
|
+
this.store = new TuiStore({
|
|
80
|
+
sourceLabel: opts.sourceLabel,
|
|
81
|
+
themeInfo,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
start(): void {
|
|
86
|
+
if (this.useAltScreen) {
|
|
87
|
+
process.stdout.write(ALT_SCREEN_ENTER);
|
|
88
|
+
process.stdout.write(CLEAR_SCREEN);
|
|
89
|
+
process.stdout.write(CURSOR_HIDE);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
this.instance = render(
|
|
93
|
+
React.createElement(App, {
|
|
94
|
+
store: this.store,
|
|
95
|
+
onQuit: () => this.requestQuit(),
|
|
96
|
+
}),
|
|
97
|
+
{
|
|
98
|
+
stdout: process.stdout,
|
|
99
|
+
stdin: process.stdin,
|
|
100
|
+
exitOnCtrlC: false,
|
|
101
|
+
patchConsole: false,
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
stop(exitCode?: number | null): void {
|
|
107
|
+
if (this.stopped) return;
|
|
108
|
+
|
|
109
|
+
if (this.saveBlinkTimer) clearTimeout(this.saveBlinkTimer);
|
|
110
|
+
this.flushErrorBuffer();
|
|
111
|
+
this.stopped = true;
|
|
112
|
+
|
|
113
|
+
if (this.instance) {
|
|
114
|
+
this.instance.unmount();
|
|
115
|
+
this.instance = undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (this.useAltScreen) {
|
|
119
|
+
process.stdout.write(CURSOR_SHOW);
|
|
120
|
+
process.stdout.write(ALT_SCREEN_LEAVE);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
this.printFinalSummary(exitCode);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
ingest(line: string, fromStderr: boolean): void {
|
|
127
|
+
// Always buffer the raw line — the safety net for "watch died and there's
|
|
128
|
+
// nothing in lastError to explain why." Trim leading/trailing whitespace
|
|
129
|
+
// but preserve the rest verbatim so stack traces stay readable on dump.
|
|
130
|
+
this.tailBuffer.push({ line, fromStderr });
|
|
131
|
+
if (this.tailBuffer.length > TAIL_BUFFER_SIZE) this.tailBuffer.shift();
|
|
132
|
+
|
|
133
|
+
this.applyShopifySignals(this.shopify.ingest(line));
|
|
134
|
+
|
|
135
|
+
const event = classify(line);
|
|
136
|
+
if (!event) return;
|
|
137
|
+
|
|
138
|
+
const inErrorWindow = this.errorFlushTimer !== undefined;
|
|
139
|
+
|
|
140
|
+
if (event.kind === "error") {
|
|
141
|
+
this.pushErrorLine(event.message);
|
|
142
|
+
this.store.pushLog("error", event.message);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (event.kind === "build-error") {
|
|
147
|
+
this.store.pushLog("build-error", `${event.task} failed`);
|
|
148
|
+
this.openErrorWindow();
|
|
149
|
+
this.store.setState("error");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (
|
|
154
|
+
inErrorWindow &&
|
|
155
|
+
fromStderr &&
|
|
156
|
+
(event.kind === "info" || event.kind === "verbose")
|
|
157
|
+
) {
|
|
158
|
+
this.pushErrorLine(event.message);
|
|
159
|
+
this.store.pushLog("error", event.message);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (inErrorWindow) this.flushErrorBuffer();
|
|
164
|
+
|
|
165
|
+
switch (event.kind) {
|
|
166
|
+
case "build-start":
|
|
167
|
+
this.store.pushLog("build-start", `building ${event.task}`);
|
|
168
|
+
// The long-running watchers (watch:src, watch:css, ...) emit
|
|
169
|
+
// Starting but never Finished — letting them set "building" pins
|
|
170
|
+
// the state forever if their Starting lands after the last Finished
|
|
171
|
+
// during the parallel-watcher race at startup.
|
|
172
|
+
if (!IGNORED_LAST_BUILD.test(event.task)) {
|
|
173
|
+
this.store.setState("building");
|
|
174
|
+
}
|
|
175
|
+
break;
|
|
176
|
+
case "build-done":
|
|
177
|
+
this.store.pushLog(
|
|
178
|
+
"build-done",
|
|
179
|
+
`built ${event.task} (${fmtMs(event.ms)})`,
|
|
180
|
+
);
|
|
181
|
+
if (!IGNORED_LAST_BUILD.test(event.task)) {
|
|
182
|
+
this.store.setState("watching");
|
|
183
|
+
this.store.setLastBuild({
|
|
184
|
+
task: event.task,
|
|
185
|
+
ms: event.ms,
|
|
186
|
+
at: Date.now(),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
case "file-change":
|
|
191
|
+
this.store.pushLog(
|
|
192
|
+
"file-change",
|
|
193
|
+
`${event.root}/${event.dir}/${event.file} (${event.event})`,
|
|
194
|
+
);
|
|
195
|
+
if (this.store.state === "starting") this.store.setState("watching");
|
|
196
|
+
// src save → brief "building…" blink so the dev sees feedback.
|
|
197
|
+
if (event.root === "src") this.blinkBuilding();
|
|
198
|
+
break;
|
|
199
|
+
case "upload":
|
|
200
|
+
this.store.pushLog("upload", event.message);
|
|
201
|
+
if (this.store.state === "starting") this.store.setState("watching");
|
|
202
|
+
break;
|
|
203
|
+
case "warn":
|
|
204
|
+
this.store.pushLog("warn", event.message);
|
|
205
|
+
break;
|
|
206
|
+
case "verbose":
|
|
207
|
+
if (this.opts.verbose) this.store.pushLog("verbose", event.message);
|
|
208
|
+
break;
|
|
209
|
+
case "info":
|
|
210
|
+
default:
|
|
211
|
+
this.store.pushLog("info", event.message);
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private blinkBuilding(): void {
|
|
217
|
+
this.store.setState("building");
|
|
218
|
+
if (this.saveBlinkTimer) clearTimeout(this.saveBlinkTimer);
|
|
219
|
+
this.saveBlinkTimer = setTimeout(() => {
|
|
220
|
+
this.saveBlinkTimer = undefined;
|
|
221
|
+
// Don't override an error state that came in during the blink.
|
|
222
|
+
if (this.store.state === "building") this.store.setState("watching");
|
|
223
|
+
}, SAVE_BLINK_MS);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private applyShopifySignals(signals: ShopifySignal[]): void {
|
|
227
|
+
for (const s of signals) {
|
|
228
|
+
if (s.kind === "auth-required") {
|
|
229
|
+
this.store.updateShopify({
|
|
230
|
+
authUrl: s.url,
|
|
231
|
+
authUserCode: s.userCode,
|
|
232
|
+
});
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (s.kind === "local-url") this.store.updateShopify({ localUrl: s.url });
|
|
237
|
+
else if (s.kind === "preview-url")
|
|
238
|
+
this.store.updateShopify({ previewUrl: s.url });
|
|
239
|
+
else if (s.kind === "editor-url")
|
|
240
|
+
this.store.updateShopify({ editorUrl: s.url });
|
|
241
|
+
|
|
242
|
+
// Any URL means auth has completed and the dev server is up — clear
|
|
243
|
+
// the pending auth banner.
|
|
244
|
+
if (this.store.shopify.authUrl) {
|
|
245
|
+
this.store.updateShopify({ authUrl: undefined, authUserCode: undefined });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (this.store.state === "starting") this.store.setState("watching");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private requestQuit(): void {
|
|
253
|
+
if (this.opts.onQuit) {
|
|
254
|
+
this.opts.onQuit();
|
|
255
|
+
} else {
|
|
256
|
+
this.stop(0);
|
|
257
|
+
process.kill(process.pid, "SIGINT");
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private pushErrorLine(message: string): void {
|
|
262
|
+
if (!message) return;
|
|
263
|
+
// Wrapper errors (downstream "child exited with code N" style) tell the
|
|
264
|
+
// user nothing on their own — keep them out of the structured error
|
|
265
|
+
// block. The actual cause sits in the tail buffer either way.
|
|
266
|
+
if (WRAPPER_ERROR_PATTERNS.some((re) => re.test(message))) return;
|
|
267
|
+
this.errorBuffer.push(message);
|
|
268
|
+
this.store.setState("error");
|
|
269
|
+
this.openErrorWindow();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private openErrorWindow(): void {
|
|
273
|
+
if (this.errorFlushTimer) clearTimeout(this.errorFlushTimer);
|
|
274
|
+
this.errorFlushTimer = setTimeout(
|
|
275
|
+
() => this.flushErrorBuffer(),
|
|
276
|
+
ERROR_FLUSH_MS,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private flushErrorBuffer(): void {
|
|
281
|
+
if (this.errorFlushTimer) {
|
|
282
|
+
clearTimeout(this.errorFlushTimer);
|
|
283
|
+
this.errorFlushTimer = undefined;
|
|
284
|
+
}
|
|
285
|
+
if (this.errorBuffer.length === 0) return;
|
|
286
|
+
const lines = dedupeConsecutive(this.errorBuffer);
|
|
287
|
+
this.errorBuffer = [];
|
|
288
|
+
|
|
289
|
+
const summary = pickErrorSummary(lines);
|
|
290
|
+
this.store.setLastError({ lines, summary, at: Date.now() });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private printFinalSummary(exitCode?: number | null): void {
|
|
294
|
+
const out = process.stdout;
|
|
295
|
+
const err = this.store.lastError;
|
|
296
|
+
const failed = typeof exitCode === "number" && exitCode !== 0;
|
|
297
|
+
|
|
298
|
+
if (err) {
|
|
299
|
+
// We captured a structured error block — show it.
|
|
300
|
+
out.write(`\n\x1b[31m⚠ Last error\x1b[0m\n`);
|
|
301
|
+
for (const line of err.lines) {
|
|
302
|
+
out.write(` \x1b[31m${stripAnsi(line)}\x1b[0m\n`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// On non-zero exit, also dump the raw output tail. Even when we have a
|
|
307
|
+
// structured `lastError`, the captured block is often just the tip of the
|
|
308
|
+
// failure — the actual cause might be earlier stderr lines that didn't
|
|
309
|
+
// match an ERROR_PATTERN. Always showing the tail means the user always
|
|
310
|
+
// has the full context.
|
|
311
|
+
if (failed && this.tailBuffer.length > 0) {
|
|
312
|
+
out.write(
|
|
313
|
+
`\n\x1b[31m⚠ Output tail (last ${this.tailBuffer.length} lines):\x1b[0m\n`,
|
|
314
|
+
);
|
|
315
|
+
for (const entry of this.tailBuffer) {
|
|
316
|
+
const tag = entry.fromStderr ? "\x1b[31merr\x1b[0m" : "\x1b[90mout\x1b[0m";
|
|
317
|
+
out.write(` ${tag} ${stripAnsi(entry.line)}\n`);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (failed) {
|
|
322
|
+
out.write(`\n\x1b[31m✗ watch exited (code ${exitCode})\x1b[0m\n`);
|
|
323
|
+
} else {
|
|
324
|
+
out.write(`\n\x1b[90m✓ watch exited\x1b[0m\n`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function dedupeConsecutive(lines: string[]): string[] {
|
|
330
|
+
const result: string[] = [];
|
|
331
|
+
for (const l of lines) {
|
|
332
|
+
if (result.length === 0 || result[result.length - 1] !== l) {
|
|
333
|
+
result.push(l);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function fmtMs(ms: number): string {
|
|
340
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
341
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
342
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { stripAnsi } from "./classify";
|
|
2
|
+
|
|
3
|
+
export type ShopifySignalKind =
|
|
4
|
+
| "local-url"
|
|
5
|
+
| "preview-url"
|
|
6
|
+
| "editor-url"
|
|
7
|
+
| "auth-required";
|
|
8
|
+
|
|
9
|
+
export interface ShopifySignal {
|
|
10
|
+
kind: ShopifySignalKind;
|
|
11
|
+
url: string;
|
|
12
|
+
/** Short user-facing code shopify shows to verify in the browser. */
|
|
13
|
+
userCode?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SHOPIFY_ELAPSED = /^\s*\d{2}:\d{2}:\d{2}\s+/;
|
|
17
|
+
const BRACKET_TIME = /^\[\d{2}:\d{2}:\d{2}\]\s*/;
|
|
18
|
+
const BOX_CHARS = /^[│╭╰─╮╯\s]+/;
|
|
19
|
+
const BOX_END = /[│╮╯─\s]+$/;
|
|
20
|
+
|
|
21
|
+
// Each shopify URL is self-identifying, so we pattern-match them directly
|
|
22
|
+
// instead of tracking the "Next steps" menu and [N] footnote slots.
|
|
23
|
+
const URL_RE = /https?:\/\/\S+/g;
|
|
24
|
+
const QUERY_FRAGMENT = /^[A-Za-z0-9_=&%.\-]+$/;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Stateful parser for the `shopify theme dev` console output.
|
|
28
|
+
*
|
|
29
|
+
* Extracts three URLs we care about (local / preview / editor) wherever they
|
|
30
|
+
* appear. Shopify wraps long URLs mid-query onto the next line, e.g.
|
|
31
|
+
*
|
|
32
|
+
* [2] https://store.myshopify.com/admin/themes/123/editor?
|
|
33
|
+
* hr=9292
|
|
34
|
+
*
|
|
35
|
+
* So if a URL ends in `?` or `&` we hold it and try to stitch the following
|
|
36
|
+
* line back on.
|
|
37
|
+
*/
|
|
38
|
+
export class ShopifyParser {
|
|
39
|
+
private pending?: string;
|
|
40
|
+
|
|
41
|
+
ingest(raw: string): ShopifySignal[] {
|
|
42
|
+
const line = normalize(raw);
|
|
43
|
+
if (!line) return [];
|
|
44
|
+
|
|
45
|
+
if (this.pending) {
|
|
46
|
+
if (QUERY_FRAGMENT.test(line)) {
|
|
47
|
+
const stitched = this.pending + line;
|
|
48
|
+
this.pending = undefined;
|
|
49
|
+
return this.extract(stitched);
|
|
50
|
+
}
|
|
51
|
+
// Not a continuation — abandon the incomplete url and parse this line.
|
|
52
|
+
this.pending = undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Device-code auth prompt:
|
|
56
|
+
// 👉 Open this link to start the auth process: https://accounts.shopify.com/activate-with-code?device_code%5Buser_code%5D=NCDB-HQVF
|
|
57
|
+
// We surface the URL and the user_code on the dashboard so the dev knows
|
|
58
|
+
// to click through. Don't return early — let URL extraction below
|
|
59
|
+
// capture the same URL into the auth-required signal.
|
|
60
|
+
const authMatch = line.match(
|
|
61
|
+
/Open this link to start the auth process:\s*(https?:\/\/\S+)/i,
|
|
62
|
+
);
|
|
63
|
+
if (authMatch) {
|
|
64
|
+
const url = authMatch[1];
|
|
65
|
+
const codeMatch = url.match(/user_code(?:%5D|\])=([A-Z0-9-]+)/i);
|
|
66
|
+
return [{ kind: "auth-required", url, userCode: codeMatch?.[1] }];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return this.extract(line);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private extract(line: string): ShopifySignal[] {
|
|
73
|
+
const signals: ShopifySignal[] = [];
|
|
74
|
+
URL_RE.lastIndex = 0;
|
|
75
|
+
let m: RegExpExecArray | null;
|
|
76
|
+
while ((m = URL_RE.exec(line)) !== null) {
|
|
77
|
+
const url = m[0];
|
|
78
|
+
if (url.endsWith("?") || url.endsWith("&")) {
|
|
79
|
+
this.pending = url;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const kind = classifyUrl(url);
|
|
83
|
+
if (kind) signals.push({ kind, url });
|
|
84
|
+
}
|
|
85
|
+
return signals;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function classifyUrl(url: string): ShopifySignalKind | null {
|
|
90
|
+
// Local dev server — reject subpath variants (e.g. the gift_cards preview).
|
|
91
|
+
if (/^http:\/\/127\.0\.0\.1:\d+\/?$/.test(url)) return "local-url";
|
|
92
|
+
if (/\/admin\/themes\/\d+\/editor/.test(url)) return "editor-url";
|
|
93
|
+
if (/[?&]preview_theme_id=\d+/.test(url)) return "preview-url";
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalize(raw: string): string {
|
|
98
|
+
let s = stripAnsi(raw);
|
|
99
|
+
s = s.replace(BRACKET_TIME, "");
|
|
100
|
+
s = s.replace(SHOPIFY_ELAPSED, "");
|
|
101
|
+
s = s.replace(BOX_CHARS, "").replace(BOX_END, "");
|
|
102
|
+
return s.trim();
|
|
103
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
import type { EventKind } from "./classify";
|
|
3
|
+
|
|
4
|
+
export type WatchState = "starting" | "watching" | "building" | "error";
|
|
5
|
+
|
|
6
|
+
export interface LastBuild {
|
|
7
|
+
task: string;
|
|
8
|
+
ms: number;
|
|
9
|
+
at: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LastError {
|
|
13
|
+
lines: string[];
|
|
14
|
+
summary: string;
|
|
15
|
+
at: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface LogEntry {
|
|
19
|
+
id: number;
|
|
20
|
+
ts: number;
|
|
21
|
+
kind: EventKind;
|
|
22
|
+
text: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ThemeInfo {
|
|
26
|
+
contextName?: string;
|
|
27
|
+
contextStatus?: string;
|
|
28
|
+
branch?: string;
|
|
29
|
+
baseBranch?: string;
|
|
30
|
+
storeDomain?: string;
|
|
31
|
+
previewThemeName?: string;
|
|
32
|
+
previewThemeId?: string;
|
|
33
|
+
previewUrl?: string;
|
|
34
|
+
customizerUrl?: string;
|
|
35
|
+
asanaTaskUrl?: string;
|
|
36
|
+
prUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ShopifyStatus {
|
|
40
|
+
localUrl?: string;
|
|
41
|
+
previewUrl?: string;
|
|
42
|
+
editorUrl?: string;
|
|
43
|
+
/** Set when shopify cli prompts for device-code auth; clears once any
|
|
44
|
+
* shopify URL arrives (auth completed and the dev server booted). */
|
|
45
|
+
authUrl?: string;
|
|
46
|
+
authUserCode?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const MAX_LOGS = 5000;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flag-based state machine — same model the original draft used. Any
|
|
53
|
+
* meaningful event flips the state directly; no in-flight counter to get
|
|
54
|
+
* stuck. The Outcome line in App.tsx is just a translation of (state,
|
|
55
|
+
* lastBuild, lastError) into one line.
|
|
56
|
+
*/
|
|
57
|
+
export class TuiStore extends EventEmitter {
|
|
58
|
+
state: WatchState = "starting";
|
|
59
|
+
lastBuild?: LastBuild;
|
|
60
|
+
lastError?: LastError;
|
|
61
|
+
logs: LogEntry[] = [];
|
|
62
|
+
themeInfo?: ThemeInfo;
|
|
63
|
+
shopify: ShopifyStatus = {};
|
|
64
|
+
sourceLabel?: string;
|
|
65
|
+
startedAt = Date.now();
|
|
66
|
+
private seq = 0;
|
|
67
|
+
|
|
68
|
+
constructor(opts: { sourceLabel?: string; themeInfo?: ThemeInfo } = {}) {
|
|
69
|
+
super();
|
|
70
|
+
this.sourceLabel = opts.sourceLabel;
|
|
71
|
+
this.themeInfo = opts.themeInfo;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
pushLog(kind: EventKind, text: string): LogEntry {
|
|
75
|
+
const entry: LogEntry = {
|
|
76
|
+
id: ++this.seq,
|
|
77
|
+
ts: Date.now(),
|
|
78
|
+
kind,
|
|
79
|
+
text,
|
|
80
|
+
};
|
|
81
|
+
this.logs.push(entry);
|
|
82
|
+
if (this.logs.length > MAX_LOGS) this.logs.shift();
|
|
83
|
+
this.emit("change");
|
|
84
|
+
return entry;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setState(next: WatchState): void {
|
|
88
|
+
if (this.state === next) return;
|
|
89
|
+
this.state = next;
|
|
90
|
+
this.emit("change");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setLastBuild(build: LastBuild): void {
|
|
94
|
+
this.lastBuild = build;
|
|
95
|
+
this.emit("change");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setLastError(err: LastError): void {
|
|
99
|
+
this.lastError = err;
|
|
100
|
+
this.state = "error";
|
|
101
|
+
this.emit("change");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
clearLastError(): void {
|
|
105
|
+
this.lastError = undefined;
|
|
106
|
+
this.emit("change");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
updateShopify(patch: Partial<ShopifyStatus>): void {
|
|
110
|
+
this.shopify = { ...this.shopify, ...patch };
|
|
111
|
+
this.emit("change");
|
|
112
|
+
}
|
|
113
|
+
}
|