@glasshome/widget-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GlassHome Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # @glasshome/widget-cli
2
+
3
+ CLI for creating and managing GlassHome widgets.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ bun add -g @glasshome/widget-cli
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ bunx @glasshome/widget-cli create my-widget
15
+ cd my-widget
16
+ bun install
17
+ bun run dev
18
+ ```
19
+
20
+ ## Commands
21
+
22
+ | Command | Description |
23
+ |---------|-------------|
24
+ | `create <name>` | Scaffold a new widget project |
25
+ | `validate` | Validate widget manifest and bundle |
26
+ | `info` | Show widget metadata |
27
+ | `publish` | Publish widget to the registry |
28
+
29
+ ## Documentation
30
+
31
+ Full docs at [glasshome.app/docs](https://glasshome.app/docs)
32
+
33
+ ## License
34
+
35
+ MIT
package/bin/cli.ts ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bun
2
+ import { existsSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { intro, log, outro } from "@clack/prompts";
5
+
6
+ function resolveWidgetDir(): string {
7
+ if (explicitDir) {
8
+ const dir = resolve(process.cwd(), explicitDir);
9
+ if (existsSync(resolve(dir, "src"))) return dir;
10
+ log.error(`No src/ directory found in ${dir}`);
11
+ process.exit(1);
12
+ }
13
+ const cwd = process.cwd();
14
+ if (existsSync(resolve(cwd, "src"))) return cwd;
15
+ const sibling = resolve(cwd, "../widgets");
16
+ if (existsSync(resolve(sibling, "src"))) return sibling;
17
+ return cwd;
18
+ }
19
+
20
+ const HELP = `
21
+ glasshome-widget <command> [--dir <path>] [options]
22
+
23
+ Commands:
24
+ add Add a new widget to the project
25
+ build Build all widgets (per-widget, self-contained bundles)
26
+ connect <url> Connect to a running dashboard for live testing
27
+ validate [name] Validate all widgets or a specific one
28
+ publish Build all widgets and publish to Hub
29
+ login [hub-url] Authenticate with GlassHome Hub
30
+ info [name] Show widget metadata and bundle info
31
+ upgrade Upgrade @glasshome/widget-sdk to latest version
32
+ help Show this help message
33
+
34
+ Examples:
35
+ glasshome-widget add
36
+ glasshome-widget build
37
+ glasshome-widget connect http://localhost:3333
38
+ glasshome-widget validate
39
+ glasshome-widget validate clock
40
+ glasshome-widget info
41
+ glasshome-widget info clock
42
+ glasshome-widget publish
43
+ glasshome-widget login
44
+ glasshome-widget login https://my-hub.example.com
45
+ glasshome-widget upgrade
46
+ glasshome-widget publish --dir packages/widgets
47
+ `.trim();
48
+
49
+ const rawArgs = process.argv.slice(2);
50
+ const dirFlagIdx = rawArgs.indexOf("--dir");
51
+ let explicitDir: string | undefined;
52
+ if (dirFlagIdx !== -1) {
53
+ explicitDir = rawArgs[dirFlagIdx + 1];
54
+ rawArgs.splice(dirFlagIdx, 2);
55
+ }
56
+ const [command, ...args] = rawArgs;
57
+
58
+ if (!command || command === "help" || command === "--help" || command === "-h") {
59
+ console.log(HELP);
60
+ process.exit(0);
61
+ }
62
+
63
+ intro(`glasshome-widget ${command}`);
64
+
65
+ switch (command) {
66
+ case "add": {
67
+ const { runAdd } = await import("../src/commands/add");
68
+ await runAdd(resolveWidgetDir());
69
+ outro("Done");
70
+ break;
71
+ }
72
+
73
+ case "build": {
74
+ const { runBuild } = await import("../src/commands/build");
75
+ await runBuild(resolveWidgetDir());
76
+ outro("Done");
77
+ break;
78
+ }
79
+
80
+ case "connect": {
81
+ const url = args[0];
82
+ if (!url) {
83
+ log.error("Missing required argument: <url>");
84
+ log.info("Usage: glasshome-widget connect <dashboard-api-url>");
85
+ process.exit(1);
86
+ }
87
+ const { runConnect } = await import("../src/commands/connect");
88
+ await runConnect(url, resolveWidgetDir());
89
+ break;
90
+ }
91
+
92
+ case "validate": {
93
+ const widgetName = args[0];
94
+ const { runValidate } = await import("../src/commands/validate");
95
+ const passed = await runValidate(resolveWidgetDir(), widgetName);
96
+ outro(passed ? "All checks passed" : "Validation failed");
97
+ process.exit(passed ? 0 : 1);
98
+ break;
99
+ }
100
+
101
+ case "login": {
102
+ const loginHubUrl = args[0];
103
+ const { runLogin } = await import("../src/commands/login");
104
+ try {
105
+ await runLogin(loginHubUrl);
106
+ outro("Done");
107
+ } catch (err) {
108
+ log.error(err instanceof Error ? err.message : "Login failed.");
109
+ process.exit(1);
110
+ }
111
+ break;
112
+ }
113
+
114
+ case "publish": {
115
+ const { runPublish } = await import("../src/commands/publish");
116
+ await runPublish(resolveWidgetDir());
117
+ outro("Done");
118
+ break;
119
+ }
120
+
121
+ case "info": {
122
+ const widgetName = args[0];
123
+ const { runInfo } = await import("../src/commands/info");
124
+ await runInfo(resolveWidgetDir(), widgetName);
125
+ outro("");
126
+ break;
127
+ }
128
+
129
+ case "upgrade": {
130
+ const { runUpgrade } = await import("../src/commands/upgrade");
131
+ await runUpgrade(resolveWidgetDir());
132
+ outro("Done");
133
+ break;
134
+ }
135
+
136
+ default: {
137
+ log.error(`Unknown command: ${command}`);
138
+ console.log(HELP);
139
+ process.exit(1);
140
+ }
141
+ }
package/bin/create.ts ADDED
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env bun
2
+ import {
3
+ cpSync,
4
+ existsSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ renameSync,
8
+ rmSync,
9
+ statSync,
10
+ writeFileSync,
11
+ } from "node:fs";
12
+ import { dirname, join, resolve } from "node:path";
13
+ import { cancel, confirm, intro, isCancel, log, outro, spinner, text } from "@clack/prompts";
14
+ import { promptWidgetDetails, scaffoldWidget } from "../src/commands/add";
15
+
16
+ const templateDir = resolve(import.meta.dir, "../template");
17
+
18
+ /** Walk up from cwd looking for a package.json with a `workspaces` field. */
19
+ function findMonorepoRoot(from: string): string | null {
20
+ let dir = resolve(from);
21
+ const root = (dir.match(/^[A-Za-z]:\\/) ?? ["/"])[0];
22
+ while (true) {
23
+ const pkgPath = join(dir, "package.json");
24
+ if (existsSync(pkgPath)) {
25
+ try {
26
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
27
+ if (pkg.workspaces) return dir;
28
+ } catch {}
29
+ }
30
+ const parent = dirname(dir);
31
+ if (parent === dir || parent === root) break;
32
+ dir = parent;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ /** Check if cwd is inside an existing git repository. */
38
+ function isInsideGitRepo(from: string): boolean {
39
+ const result = Bun.spawnSync(["git", "rev-parse", "--is-inside-work-tree"], {
40
+ cwd: from,
41
+ stderr: "pipe",
42
+ });
43
+ return result.exitCode === 0;
44
+ }
45
+
46
+ intro("Create GlassHome Widget Project");
47
+
48
+ // ---- Project-level prompts ----
49
+
50
+ const projectName = await text({
51
+ message: "Project name",
52
+ placeholder: "my-widgets",
53
+ defaultValue: "my-widgets",
54
+ validate(value) {
55
+ if (!value) return "Name is required";
56
+ if (/\s/.test(value)) return "Name cannot contain spaces";
57
+ if (!/^[a-z]/.test(value)) return "Name must start with a lowercase letter";
58
+ if (!/^[a-z][a-z0-9-]*$/.test(value))
59
+ return "Name must be lowercase alphanumeric with optional hyphens";
60
+ },
61
+ });
62
+
63
+ if (isCancel(projectName)) {
64
+ cancel("Operation cancelled.");
65
+ process.exit(0);
66
+ }
67
+
68
+ const projectDescription = await text({
69
+ message: "Project description (optional)",
70
+ placeholder: "My GlassHome widget collection",
71
+ defaultValue: "",
72
+ });
73
+
74
+ if (isCancel(projectDescription)) {
75
+ cancel("Operation cancelled.");
76
+ process.exit(0);
77
+ }
78
+
79
+ // ---- First widget prompts ----
80
+
81
+ log.info("Let's set up your first widget:");
82
+
83
+ const widgetDetails = await promptWidgetDetails();
84
+ if (!widgetDetails) process.exit(0);
85
+
86
+ const targetDir = resolve(process.cwd(), projectName as string);
87
+
88
+ if (existsSync(targetDir)) {
89
+ cancel(`Directory "${projectName}" already exists.`);
90
+ process.exit(1);
91
+ }
92
+
93
+ const s = spinner();
94
+ s.start("Scaffolding widget project...");
95
+
96
+ try {
97
+ // Copy template to target
98
+ cpSync(templateDir, targetDir, { recursive: true });
99
+
100
+ // Strip .template suffixes (used to prevent IDE type-checking of template files)
101
+ function processDir(dir: string) {
102
+ for (const entry of readdirSync(dir)) {
103
+ const fullPath = join(dir, entry);
104
+ if (statSync(fullPath).isDirectory()) {
105
+ processDir(fullPath);
106
+ continue;
107
+ }
108
+ if (entry.endsWith(".template")) {
109
+ renameSync(fullPath, join(dir, entry.replace(/\.template$/, "")));
110
+ }
111
+ }
112
+ }
113
+ processDir(targetDir);
114
+
115
+ // Remove the template's src/index.tsx (it will be placed into a widget subdir)
116
+ const oldSrcIndex = join(targetDir, "src/index.tsx");
117
+ if (existsSync(oldSrcIndex)) {
118
+ rmSync(oldSrcIndex);
119
+ }
120
+
121
+ const projectDesc =
122
+ (projectDescription as string) || `A GlassHome widget project: ${projectName}`;
123
+
124
+ // Replace project-level placeholders in package.json
125
+ const pkgPath = join(targetDir, "package.json");
126
+ let pkgContent = readFileSync(pkgPath, "utf-8");
127
+ pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName as string);
128
+ pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
129
+ writeFileSync(pkgPath, pkgContent);
130
+
131
+ // Replace project-level placeholders in README.md
132
+ const readmePath = join(targetDir, "README.md");
133
+ let readmeContent = readFileSync(readmePath, "utf-8");
134
+ readmeContent = readmeContent.replace(/PROJECT_NAME/g, projectName as string);
135
+ readmeContent = readmeContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
136
+ writeFileSync(readmePath, readmeContent);
137
+
138
+ // Create the first widget in src/{widgetName}/
139
+ scaffoldWidget(targetDir, widgetDetails);
140
+
141
+ s.stop("Widget project created!");
142
+
143
+ // Post-scaffold: auto-install
144
+ const monorepoRoot = findMonorepoRoot(process.cwd());
145
+ const installCwd = monorepoRoot ?? targetDir;
146
+ const installHint = monorepoRoot ? " (from monorepo root)" : "";
147
+
148
+ const shouldInstall = await confirm({
149
+ message: `Install dependencies?${installHint}`,
150
+ initialValue: true,
151
+ });
152
+
153
+ if (!isCancel(shouldInstall) && shouldInstall) {
154
+ const installSpinner = spinner();
155
+ installSpinner.start("Installing dependencies...");
156
+ const installProc = Bun.spawnSync(["bun", "install"], { cwd: installCwd });
157
+ if (installProc.exitCode === 0) {
158
+ installSpinner.stop("Dependencies installed");
159
+ } else {
160
+ installSpinner.stop(
161
+ monorepoRoot
162
+ ? "Install failed — run `bun install` from the monorepo root"
163
+ : "Install failed — you can run `bun install` manually",
164
+ );
165
+ }
166
+ }
167
+
168
+ // Post-scaffold: git init (skip when already inside a git repo)
169
+ const inGitRepo = isInsideGitRepo(process.cwd());
170
+
171
+ if (inGitRepo) {
172
+ log.info("Skipping git init — already in a git repository");
173
+ } else {
174
+ const shouldGit = await confirm({
175
+ message: "Initialize git repository?",
176
+ initialValue: true,
177
+ });
178
+
179
+ if (!isCancel(shouldGit) && shouldGit) {
180
+ const gitSpinner = spinner();
181
+ gitSpinner.start("Initializing git repository...");
182
+ const initProc = Bun.spawnSync(["git", "init"], { cwd: targetDir });
183
+ if (initProc.exitCode === 0) {
184
+ Bun.spawnSync(["git", "add", "-A"], { cwd: targetDir });
185
+ Bun.spawnSync(["git", "commit", "-m", "Initial widget scaffold"], { cwd: targetDir });
186
+ gitSpinner.stop("Git repository initialized");
187
+ } else {
188
+ gitSpinner.stop("Git init failed — you can run `git init` manually");
189
+ }
190
+ }
191
+ }
192
+
193
+ // Enhanced outro with next-steps guide
194
+ log.info("");
195
+ log.step("Next steps:");
196
+ if (monorepoRoot) {
197
+ log.info(" bun install Run from monorepo root to link workspace packages");
198
+ log.info(` cd ${projectName}`);
199
+ } else {
200
+ log.info(` cd ${projectName}`);
201
+ if (isCancel(shouldInstall) || !shouldInstall) {
202
+ log.info(" bun install");
203
+ }
204
+ }
205
+ log.info(" bun widget connect <url> Connect to a running dashboard for live testing");
206
+ log.info(" bun widget add Add another widget to the project");
207
+ log.info(" bun run build Build all widgets + generate registry.json");
208
+ log.info(" bun widget validate Validate all widgets");
209
+ log.info(" bun widget publish Build & publish to GlassHome Hub");
210
+ log.info(" bun widget info Show widget metadata");
211
+ log.info(" bun widget upgrade Upgrade @glasshome/widget-sdk");
212
+
213
+ outro("Happy building!");
214
+ } catch (err) {
215
+ s.stop("Failed to scaffold widget project.");
216
+ // Rollback: remove partially created directory
217
+ rmSync(targetDir, { recursive: true, force: true });
218
+ cancel(`Error: ${err instanceof Error ? err.message : String(err)}`);
219
+ process.exit(1);
220
+ }
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@glasshome/widget-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for creating and managing GlassHome widgets",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-glasshome-widget": "bin/create.ts",
8
+ "glasshome-widget": "bin/cli.ts"
9
+ },
10
+ "scripts": {
11
+ "create": "bun bin/create.ts",
12
+ "cli": "bun bin/cli.ts"
13
+ },
14
+ "files": [
15
+ "bin",
16
+ "src",
17
+ "template"
18
+ ],
19
+ "dependencies": {
20
+ "@clack/prompts": "^0.10.0",
21
+ "@glasshome/widget-sdk": "^0.1.0",
22
+ "open": "^11.0.0",
23
+ "semver": "^7.6.0"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/glasshome/widget-cli.git"
28
+ },
29
+ "keywords": [
30
+ "glasshome",
31
+ "widget",
32
+ "cli",
33
+ "scaffold",
34
+ "home-assistant",
35
+ "dashboard",
36
+ "smart-home"
37
+ ],
38
+ "homepage": "https://github.com/glasshome/widget-cli#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/glasshome/widget-cli/issues"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
44
+ },
45
+ "engines": {
46
+ "bun": ">=1.0.0"
47
+ },
48
+ "author": "GlassHome Labs",
49
+ "license": "MIT"
50
+ }
@@ -0,0 +1,136 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { cancel, isCancel, log, text } from "@clack/prompts";
4
+
5
+ /** Capitalize kebab-case into PascalCase for component names. */
6
+ function capitalize(str: string): string {
7
+ return str
8
+ .split("-")
9
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
10
+ .join("");
11
+ }
12
+
13
+ /** Create the widget source files in src/{widgetName}/. */
14
+ export function scaffoldWidget(
15
+ cwd: string,
16
+ opts: {
17
+ widgetName: string;
18
+ tag: string;
19
+ description: string;
20
+ },
21
+ ): void {
22
+ const { widgetName, tag, description } = opts;
23
+ const widgetDir = resolve(cwd, "src", widgetName);
24
+
25
+ if (existsSync(widgetDir)) {
26
+ throw new Error(`src/${widgetName}/ already exists`);
27
+ }
28
+
29
+ mkdirSync(widgetDir, { recursive: true });
30
+
31
+ const displayName = capitalize(widgetName);
32
+ const widgetDescription = description || `A GlassHome dashboard widget: ${widgetName}`;
33
+
34
+ // Read index.tsx template from the template directory
35
+ const templateDir = resolve(import.meta.dir, "../../template");
36
+ let srcContent = readFileSync(resolve(templateDir, "src/index.tsx.template"), "utf-8");
37
+ srcContent = srcContent.replace(/WIDGET_TAG/g, tag);
38
+ srcContent = srcContent.replace(/WIDGET_NAME/g, displayName);
39
+ writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
40
+
41
+ // Generate manifest.json
42
+ const manifest = {
43
+ tag,
44
+ name: displayName,
45
+ description: widgetDescription,
46
+ minSize: { w: 1, h: 1 },
47
+ maxSize: { w: 4, h: 4 },
48
+ sdkVersion: "^0.2.0",
49
+ version: "0.1.0",
50
+ };
51
+ writeFileSync(resolve(widgetDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
52
+ }
53
+
54
+ /** Interactive prompts for widget details. Returns null if cancelled. */
55
+ export async function promptWidgetDetails(defaults?: { widgetName?: string }): Promise<{
56
+ widgetName: string;
57
+ tag: string;
58
+ description: string;
59
+ } | null> {
60
+ const widgetName = await text({
61
+ message: "Widget name",
62
+ placeholder: "clock",
63
+ defaultValue: defaults?.widgetName,
64
+ validate(value) {
65
+ if (!value) return "Name is required";
66
+ if (/\s/.test(value)) return "Name cannot contain spaces";
67
+ if (!/^[a-z]/.test(value)) return "Name must start with a lowercase letter";
68
+ if (!/^[a-z][a-z0-9-]*$/.test(value))
69
+ return "Name must be lowercase alphanumeric with optional hyphens";
70
+ },
71
+ });
72
+ if (isCancel(widgetName)) {
73
+ cancel("Operation cancelled.");
74
+ return null;
75
+ }
76
+
77
+ const tag = await text({
78
+ message: "Custom element tag",
79
+ placeholder: `glasshome-${widgetName}`,
80
+ defaultValue: `glasshome-${widgetName}`,
81
+ validate(value) {
82
+ if (!value) return "Tag is required";
83
+ if (!/^[a-z]/.test(value)) return "Tag must start with a lowercase letter";
84
+ if (!value.includes("-")) return "Custom element tags must contain a hyphen";
85
+ if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(value))
86
+ return "Tag must be lowercase alphanumeric with hyphens (e.g. glasshome-my-widget)";
87
+ },
88
+ });
89
+ if (isCancel(tag)) {
90
+ cancel("Operation cancelled.");
91
+ return null;
92
+ }
93
+
94
+ const description = await text({
95
+ message: "Description (optional)",
96
+ placeholder: "A GlassHome dashboard widget",
97
+ defaultValue: "",
98
+ });
99
+ if (isCancel(description)) {
100
+ cancel("Operation cancelled.");
101
+ return null;
102
+ }
103
+
104
+ return {
105
+ widgetName: widgetName as string,
106
+ tag: tag as string,
107
+ description: description as string,
108
+ };
109
+ }
110
+
111
+ export async function runAdd(cwd: string): Promise<void> {
112
+ // Verify project structure
113
+ const srcDir = resolve(cwd, "src");
114
+ if (!existsSync(srcDir)) {
115
+ log.error("No src/ directory found. Are you in a GlassHome widget project?");
116
+ process.exit(1);
117
+ }
118
+
119
+ const details = await promptWidgetDetails();
120
+ if (!details) process.exit(0);
121
+
122
+ const widgetDir = resolve(srcDir, details.widgetName);
123
+ if (existsSync(widgetDir)) {
124
+ log.error(`src/${details.widgetName}/ already exists.`);
125
+ process.exit(1);
126
+ }
127
+
128
+ try {
129
+ scaffoldWidget(cwd, details);
130
+ log.success(`Widget "${details.widgetName}" added at src/${details.widgetName}/`);
131
+ log.info("Run `bun run build` to include it in the registry.");
132
+ } catch (err) {
133
+ log.error(`Failed to add widget: ${err instanceof Error ? err.message : String(err)}`);
134
+ process.exit(1);
135
+ }
136
+ }
@@ -0,0 +1,22 @@
1
+ import { log, spinner } from "@clack/prompts";
2
+ import { buildWidgets } from "@glasshome/widget-sdk/vite";
3
+
4
+ export async function runBuild(cwd: string): Promise<void> {
5
+ const s = spinner();
6
+ s.start("Building widgets...");
7
+
8
+ try {
9
+ const solid = (await import("vite-plugin-solid")).default;
10
+ process.chdir(cwd);
11
+ await buildWidgets({
12
+ srcDir: "src",
13
+ outDir: "dist",
14
+ plugins: [solid()],
15
+ });
16
+ s.stop("Build complete");
17
+ } catch (err) {
18
+ s.stop("Build failed");
19
+ log.error(err instanceof Error ? err.message : String(err));
20
+ process.exit(1);
21
+ }
22
+ }