@glasshome/widget-cli 0.6.0 → 0.7.1

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/bin/cli.ts CHANGED
@@ -166,7 +166,7 @@ switch (effectiveCommand) {
166
166
  const widgetName = args[0];
167
167
  const { runInfo } = await import("../src/commands/info");
168
168
  await runInfo(resolveWidgetDir(), widgetName);
169
- outro("");
169
+ outro();
170
170
  break;
171
171
  }
172
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,8 +22,9 @@
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.6.0",
24
24
  "@glasshome/widget-contract": "0.1.1",
25
- "@glasshome/widget-sdk": "1.4.0",
25
+ "@glasshome/widget-sdk": "1.4.1",
26
26
  "open": "^11.0.0",
27
+ "picocolors": "^1.1.1",
27
28
  "semver": "^7.6.0",
28
29
  "ts-morph": "^28.0.0",
29
30
  "vite-plugin-solid": "^2.11.0"
@@ -1,6 +1,7 @@
1
1
  import { log, spinner } from "@clack/prompts";
2
2
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
3
3
  import { lintAndReport } from "../utils/lint-source";
4
+ import { withQuietStdout } from "../utils/quiet";
4
5
 
5
6
  export async function runBuild(cwd: string): Promise<void> {
6
7
  const s = spinner();
@@ -9,11 +10,13 @@ export async function runBuild(cwd: string): Promise<void> {
9
10
  try {
10
11
  const solid = (await import("vite-plugin-solid")).default;
11
12
  process.chdir(cwd);
12
- await buildWidgets({
13
- srcDir: "src",
14
- outDir: "dist",
15
- plugins: [solid()],
16
- });
13
+ await withQuietStdout(() =>
14
+ buildWidgets({
15
+ srcDir: "src",
16
+ outDir: "dist",
17
+ plugins: [solid()],
18
+ }),
19
+ );
17
20
  s.stop("Build complete");
18
21
  lintAndReport(cwd);
19
22
  } catch (err) {
@@ -1,10 +1,12 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { log, spinner } from "@clack/prompts";
3
+ import { log, note, spinner } from "@clack/prompts";
4
+ import color from "picocolors";
4
5
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
5
6
  import { trpcMutate, trpcQuery } from "../utils/api";
6
7
  import { clearHostToken, extractHost, getHostToken, storeHostToken } from "../utils/auth";
7
8
  import { lintAndReport } from "../utils/lint-source";
9
+ import { withQuietStdout } from "../utils/quiet";
8
10
 
9
11
  interface RegistryWidget {
10
12
  name: string;
@@ -34,7 +36,7 @@ async function uploadAndRegister(
34
36
 
35
37
  const bundlePath = resolve(distDir, `${slug}.js`);
36
38
  if (!existsSync(bundlePath)) {
37
- log.warn(`Bundle not found: ${bundlePath} — skipping ${slug}`);
39
+ log.warn(`Bundle not found: ${bundlePath}, skipping ${slug}`);
38
40
  return;
39
41
  }
40
42
 
@@ -116,7 +118,7 @@ export async function runConnect(
116
118
  ): Promise<void> {
117
119
  const distDir = resolve(cwd, "dist");
118
120
  const solid = (await import("vite-plugin-solid")).default;
119
- // delegateEvents: false — widgets run in closed shadow roots where Solid's
121
+ // delegateEvents: false, widgets run in closed shadow roots where Solid's
120
122
  // document-level event delegation cannot see the target.
121
123
  const buildOpts = {
122
124
  srcDir: "src",
@@ -130,7 +132,7 @@ export async function runConnect(
130
132
  try {
131
133
  const origCwd = process.cwd();
132
134
  process.chdir(cwd);
133
- await buildWidgets(buildOpts);
135
+ await withQuietStdout(() => buildWidgets(buildOpts));
134
136
  process.chdir(origCwd);
135
137
  } catch (err) {
136
138
  s.stop("Build failed");
@@ -154,7 +156,7 @@ export async function runConnect(
154
156
 
155
157
  if (opts.reAuth) {
156
158
  clearHostToken(host);
157
- log.info("Discarded stored credentials (--re-auth).");
159
+ log.info("Discarded stored credentials (--re-auth)");
158
160
  }
159
161
 
160
162
  // Validate stored token before using it
@@ -168,17 +170,17 @@ export async function runConnect(
168
170
  const body = (await check.json()) as { session?: unknown } | null;
169
171
  if (body?.session) {
170
172
  token = existingToken;
171
- log.info("Using stored credentials for dashboard.");
173
+ log.info("Using stored credentials");
172
174
  } else {
173
175
  clearHostToken(host);
174
- log.warn("Stored credentials expired — re-authenticating.");
176
+ log.warn("Stored credentials expired, re-authenticating");
175
177
  }
176
178
  } else {
177
179
  clearHostToken(host);
178
- log.warn("Stored credentials expired — re-authenticating.");
180
+ log.warn("Stored credentials expired, re-authenticating");
179
181
  }
180
182
  } catch {
181
- log.warn("Could not validate stored credentials — re-authenticating.");
183
+ log.warn("Could not validate stored credentials, re-authenticating");
182
184
  }
183
185
  }
184
186
 
@@ -220,7 +222,7 @@ export async function runConnect(
220
222
  log.warn(
221
223
  `Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
222
224
  );
223
- // token stays empty — caught by guard below
225
+ // token stays empty, caught by guard below
224
226
  }
225
227
 
226
228
  if (deviceCode!) {
@@ -286,7 +288,7 @@ export async function runConnect(
286
288
  s.stop("Device code expired");
287
289
  break;
288
290
  }
289
- // Unexpected error — stop polling
291
+ // Unexpected error, stop polling
290
292
  s.stop(`Auth error: ${errData.error ?? res.status}`);
291
293
  break;
292
294
  } catch (err) {
@@ -305,7 +307,7 @@ export async function runConnect(
305
307
  }
306
308
 
307
309
  if (!token) {
308
- log.warn("Authentication failed — widgets won't be connected. Log in at the dashboard first, then restart.");
310
+ log.warn("Authentication failed, widgets won't be connected. Log in at the dashboard first, then restart.");
309
311
  return;
310
312
  }
311
313
 
@@ -326,7 +328,7 @@ export async function runConnect(
326
328
  });
327
329
  }
328
330
  } catch {
329
- // Non-fatal — dev mode may already be enabled
331
+ // Non-fatal, dev mode may already be enabled
330
332
  }
331
333
 
332
334
  // Step 5: Upload bundles and register widgets
@@ -360,7 +362,7 @@ export async function runConnect(
360
362
  const origCwd = process.cwd();
361
363
  process.chdir(cwd);
362
364
  try {
363
- await buildWidgets({ ...buildOpts, only: widgets });
365
+ await withQuietStdout(() => buildWidgets({ ...buildOpts, only: widgets }));
364
366
  } finally {
365
367
  process.chdir(origCwd);
366
368
  }
@@ -373,7 +375,7 @@ export async function runConnect(
373
375
  await uploadAndRegister(apiUrl, distDir, widget, token);
374
376
  log.info(`Rebuilt & uploaded ${widgetName}`);
375
377
  } else {
376
- log.warn(`No registry entry for ${widgetName} — skipping upload`);
378
+ log.warn(`No registry entry for ${widgetName}, skipping upload`);
377
379
  }
378
380
  }
379
381
  } catch (err) {
@@ -404,14 +406,19 @@ export async function runConnect(
404
406
  ).length
405
407
  : 0;
406
408
 
407
- log.success(`Connected! ${widgetCount} widget(s) are live.`);
408
- log.info("Bundles uploaded to API. Widgets registered with local paths.");
409
- log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
410
- log.info("Press Ctrl+C to disconnect.");
411
-
412
- // Step 7: Handle SIGINT — unregister and clean up
409
+ log.success(color.green(`Connected: ${widgetCount} widget(s) live`));
410
+ note(
411
+ [
412
+ `${color.cyan("watching")} src/ (edits auto-rebuild & re-upload)`,
413
+ `${color.cyan("add")} new widgets with ${color.bold("bun widget add")}`,
414
+ `${color.cyan("stop")} press ${color.bold("Ctrl+C")} to disconnect`,
415
+ ].join("\n"),
416
+ "Live testing",
417
+ );
418
+
419
+ // Step 7: Handle SIGINT, unregister and clean up
413
420
  const cleanup = async () => {
414
- log.info("\nDisconnecting...");
421
+ log.info("Disconnecting...");
415
422
  if (debounceTimer) clearTimeout(debounceTimer);
416
423
  watcher.close();
417
424
 
@@ -424,7 +431,7 @@ export async function runConnect(
424
431
  input: { scope: "local", name: slug },
425
432
  });
426
433
  } catch {
427
- // Non-fatal — API may be down
434
+ // Non-fatal, API may be down
428
435
  }
429
436
  }
430
437
  log.info("Widgets unregistered");
@@ -9,7 +9,8 @@ import {
9
9
  writeFileSync,
10
10
  } from "node:fs";
11
11
  import { dirname, join, resolve } from "node:path";
12
- import { cancel, confirm, isCancel, log, spinner, text } from "@clack/prompts";
12
+ import { cancel, confirm, isCancel, log, note, spinner, text } from "@clack/prompts";
13
+ import color from "picocolors";
13
14
  import { getCliVersion } from "../utils/version";
14
15
  import { promptWidgetDetails, scaffoldWidget } from "./add";
15
16
 
@@ -157,8 +158,8 @@ export async function runCreate() {
157
158
  } else {
158
159
  installSpinner.stop(
159
160
  monorepoRoot
160
- ? "Install failed — run `bun install` from the monorepo root"
161
- : "Install failed — you can run `bun install` manually",
161
+ ? "Install failed, run `bun install` from the monorepo root"
162
+ : "Install failed, you can run `bun install` manually",
162
163
  );
163
164
  }
164
165
  }
@@ -167,7 +168,7 @@ export async function runCreate() {
167
168
  const inGitRepo = isInsideGitRepo(process.cwd());
168
169
 
169
170
  if (inGitRepo) {
170
- log.info("Skipping git init — already in a git repository");
171
+ log.info("Skipping git init, already in a git repository");
171
172
  } else {
172
173
  const shouldGit = await confirm({
173
174
  message: "Initialize git repository?",
@@ -183,30 +184,29 @@ export async function runCreate() {
183
184
  Bun.spawnSync(["git", "commit", "-m", "Initial widget scaffold"], { cwd: targetDir });
184
185
  gitSpinner.stop("Git repository initialized");
185
186
  } else {
186
- gitSpinner.stop("Git init failed — you can run `git init` manually");
187
+ gitSpinner.stop("Git init failed, you can run `git init` manually");
187
188
  }
188
189
  }
189
190
  }
190
191
 
191
192
  // Next-steps guide
192
- log.info("");
193
- log.step("Next steps:");
193
+ const cmd = (c: string, desc: string) => `${color.cyan(c.padEnd(30))} ${color.dim(desc)}`;
194
+ const steps: string[] = [];
194
195
  if (monorepoRoot) {
195
- log.info(" bun install Run from monorepo root to link workspace packages");
196
- log.info(` cd ${projectName}`);
196
+ steps.push(cmd("bun install", "from monorepo root, links workspace packages"));
197
+ steps.push(color.cyan(`cd ${projectName}`));
197
198
  } else {
198
- log.info(` cd ${projectName}`);
199
- if (isCancel(shouldInstall) || !shouldInstall) {
200
- log.info(" bun install");
201
- }
199
+ steps.push(color.cyan(`cd ${projectName}`));
200
+ if (isCancel(shouldInstall) || !shouldInstall) steps.push(color.cyan("bun install"));
202
201
  }
203
- log.info(" glasshome-widget connect <url> Connect to a running dashboard for live testing");
204
- log.info(" glasshome-widget add Add another widget to the project");
205
- log.info(" glasshome-widget build Build all widgets");
206
- log.info(" glasshome-widget validate Validate all widgets");
207
- log.info(" glasshome-widget publish Build & publish to GlassHome Hub");
208
- log.info(" glasshome-widget info Show widget metadata");
209
- log.info(" glasshome-widget upgrade Upgrade @glasshome/widget-sdk");
202
+ steps.push(cmd("bun widget connect <url>", "connect to a dashboard for live testing"));
203
+ steps.push(cmd("bun widget add", "add another widget"));
204
+ steps.push(cmd("bun widget build", "build all widgets"));
205
+ steps.push(cmd("bun widget validate", "validate all widgets"));
206
+ steps.push(cmd("bun widget publish", "build & publish to GlassHome Hub"));
207
+ steps.push(cmd("bun widget info", "show widget metadata"));
208
+ steps.push(cmd("bun widget upgrade", "upgrade @glasshome/widget-sdk"));
209
+ note(steps.join("\n"), "Next steps");
210
210
  } catch (err) {
211
211
  s.stop("Failed to scaffold widget project.");
212
212
  rmSync(targetDir, { recursive: true, force: true });
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- import { log } from "@clack/prompts";
3
+ import { log, note } from "@clack/prompts";
4
+ import color from "picocolors";
4
5
  import {
5
6
  discoverWidgets,
6
7
  formatBytes,
@@ -18,8 +19,6 @@ export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
18
19
  }
19
20
 
20
21
  const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
21
- log.info(`Project: ${pkg.name ?? "unknown"}`);
22
- log.info(`Version: ${pkg.version ?? "0.0.0"}`);
23
22
 
24
23
  const widgets = discoverWidgets(cwd);
25
24
 
@@ -35,44 +34,40 @@ export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
35
34
  process.exit(1);
36
35
  }
37
36
 
38
- log.info(`\nWidgets: ${widgets.length} total`);
37
+ log.step(
38
+ `${color.bold(pkg.name ?? "unknown")} ${color.dim(`v${pkg.version ?? "0.0.0"}`)} ${color.dim("·")} ${widgets.length} widget(s)`,
39
+ );
39
40
 
40
- for (const name of toShow) {
41
- log.info(`\n--- ${name} ---`);
41
+ // Right-pad labels so values line up inside the note box.
42
+ const field = (label: string, value: string) => `${color.dim(label.padEnd(9))} ${value}`;
42
43
 
44
+ for (const name of toShow) {
43
45
  try {
44
46
  const manifest = readManifest(cwd, name);
45
- log.info(` Name: ${manifest.name}`);
46
- log.info(` Min Size: ${manifest.minSize.w}×${manifest.minSize.h}`);
47
- log.info(` Max Size: ${manifest.maxSize.w}×${manifest.maxSize.h}`);
48
- if (manifest.defaultSize) {
49
- log.info(` Default: ${manifest.defaultSize.w}×${manifest.defaultSize.h}`);
50
- }
51
- log.info(` SDK Version: ${manifest.sdkVersion}`);
52
- if (manifest.version) {
53
- log.info(` Version: ${manifest.version}`);
54
- }
55
- if (manifest.description) {
56
- log.info(` Description: ${manifest.description}`);
57
- }
47
+ const lines: string[] = [];
48
+
49
+ const size = (s: { w: number; h: number }) => `${s.w}×${s.h}`;
50
+ lines.push(field("Size", `${size(manifest.minSize)} → ${size(manifest.maxSize)}`));
51
+ if (manifest.defaultSize) lines.push(field("Default", size(manifest.defaultSize)));
52
+ lines.push(field("SDK", manifest.sdkVersion));
53
+ if (manifest.version) lines.push(field("Version", manifest.version));
54
+ if (manifest.description) lines.push(field("About", color.dim(manifest.description)));
58
55
 
59
- // Bundle size (if built)
60
56
  try {
61
57
  const info = getWidgetBundleInfo(cwd, name);
62
- log.info(` Bundle: ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`);
58
+ lines.push(field("Bundle", `${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`));
63
59
  } catch {
64
- log.info(" Bundle: not built yet");
60
+ lines.push(field("Bundle", color.dim("not built yet")));
65
61
  }
62
+
63
+ note(lines.join("\n"), `${manifest.name} ${color.dim(`(${name})`)}`);
66
64
  } catch (err) {
67
- log.warn(` Could not read manifest: ${err instanceof Error ? err.message : String(err)}`);
65
+ log.warn(`${name}: could not read manifest: ${err instanceof Error ? err.message : String(err)}`);
68
66
  }
69
67
  }
70
68
 
71
- // Registry summary
72
69
  const registry = readRegistry(cwd);
73
70
  if (registry) {
74
- log.info(
75
- `\nRegistry: ${registry.widgets.length} widget(s) (generated ${registry.generatedAt})`,
76
- );
71
+ log.info(`Registry: ${registry.widgets.length} widget(s) (generated ${registry.generatedAt})`);
77
72
  }
78
73
  }
@@ -39,7 +39,7 @@ export async function runLogin(hubUrl?: string): Promise<void> {
39
39
  }>((resolve, reject) => {
40
40
  const timeout = setTimeout(() => {
41
41
  server.close();
42
- reject(new Error("Login timed out — no response from browser within 120 seconds."));
42
+ reject(new Error("Login timed out, no response from browser within 120 seconds."));
43
43
  }, LOGIN_TIMEOUT_MS);
44
44
 
45
45
  const server = createServer((req: IncomingMessage, res: ServerResponse) => {
@@ -96,17 +96,16 @@ export async function runLogin(hubUrl?: string): Promise<void> {
96
96
  });
97
97
  });
98
98
 
99
- s.stop("Login complete");
99
+ s.stop("Browser login complete");
100
100
 
101
- // Verify state
101
+ // Verify state (spinner already stopped, use log, not s.stop)
102
102
  if (receivedState !== state) {
103
- s.stop("Login failed");
104
103
  log.error("State mismatch. Possible CSRF attack.");
105
104
  process.exit(1);
106
105
  }
107
106
 
108
107
  // Exchange code for tokens
109
- s.message("Exchanging code for tokens...");
108
+ s.start("Exchanging code for tokens...");
110
109
 
111
110
  const tokenRes = await fetch(`${hub}/api/auth/oauth2/token`, {
112
111
  method: "POST",
@@ -26,7 +26,7 @@ export interface MigrateOptions {
26
26
  }
27
27
 
28
28
  /**
29
- * `bun widget migrate config` — rewrite widgets' raw-zod config to `defineConfig` +
29
+ * `bun widget migrate config`, rewrite widgets' raw-zod config to `defineConfig` +
30
30
  * `field.*`. Assistive: unmigratable fields stay raw zod and are reported as manual
31
31
  * TODOs; validation is never dropped. Use `--dry` to preview without writing.
32
32
  */
@@ -73,7 +73,7 @@ export async function runMigrate(
73
73
  } else {
74
74
  totalTodos += warnings.length;
75
75
  log.warn(
76
- `${widget}: ${verb} ${rel} — ${warnings.length} field(s) left as raw zod (manual TODO):`,
76
+ `${widget}: ${verb} ${rel}, ${warnings.length} field(s) left as raw zod (manual TODO):`,
77
77
  );
78
78
  for (const w of warnings) {
79
79
  log.message(` • ${w.field}: ${w.reason}`);
@@ -113,7 +113,7 @@ export async function runPublish(
113
113
  let sizeInfo = "";
114
114
  try {
115
115
  const info = getWidgetBundleInfo(cwd, name);
116
- sizeInfo = ` — ${formatBytes(info.raw)}`;
116
+ sizeInfo = `, ${formatBytes(info.raw)}`;
117
117
  } catch {}
118
118
  return {
119
119
  value: name,
@@ -226,7 +226,7 @@ export async function runPublish(
226
226
  });
227
227
  } catch (err: any) {
228
228
  if (err.status === 409) {
229
- s.stop(`${manifest.name}@${version} already published — bump version to republish`);
229
+ s.stop(`${manifest.name}@${version} already published, bump version to republish`);
230
230
  process.exit(0);
231
231
  }
232
232
  s.stop(`Publish failed: ${err.message}`);
@@ -244,7 +244,7 @@ export async function runPublish(
244
244
  if (cssBuffer) {
245
245
  if (!publishData.cssUploadUrl) {
246
246
  s.stop(
247
- `dist/${widgetName}.css exists but the Hub did not return a css upload URL — update the Hub or remove the stylesheet`,
247
+ `dist/${widgetName}.css exists but the Hub did not return a css upload URL, update the Hub or remove the stylesheet`,
248
248
  );
249
249
  process.exit(1);
250
250
  }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
- import { log, spinner } from "@clack/prompts";
3
+ import { log, note, spinner } from "@clack/prompts";
4
4
  import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
5
5
  import { runValidate } from "./validate";
6
6
 
@@ -124,11 +124,14 @@ export async function runUpgrade(cwd: string): Promise<void> {
124
124
  } else {
125
125
  // Standalone mode: can't fetch from npm (private package), guide user
126
126
  log.info("@glasshome/widget-sdk is a workspace package and is not published to npm.");
127
- log.info("");
128
- log.step("To upgrade:");
129
- log.info(" 1. Update the @glasshome/widget-sdk version in your package.json peerDependencies");
130
- log.info(" 2. Run `bun install` to install the new version");
131
- log.info(" 3. Run `bun widget upgrade` again to sync manifest files");
132
- log.info(" 4. Run `bun widget validate` to check compatibility");
127
+ note(
128
+ [
129
+ "1. Bump @glasshome/widget-sdk in package.json peerDependencies",
130
+ "2. bun install",
131
+ "3. bun widget upgrade (sync manifest files)",
132
+ "4. bun widget validate (check compatibility)",
133
+ ].join("\n"),
134
+ "To upgrade",
135
+ );
133
136
  }
134
137
  }
@@ -40,18 +40,18 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
40
40
  const capabilities = (manifest as { capabilities?: unknown }).capabilities;
41
41
  if (manifest.sdkVersion && requiresCapabilities(manifest.sdkVersion) && capabilities === undefined) {
42
42
  errors.push(
43
- `sdkVersion "${manifest.sdkVersion}" only admits SDK >= 1.0.0, so manifest.json must declare "capabilities" — use [] if the widget never reads or controls Home Assistant`,
43
+ `sdkVersion "${manifest.sdkVersion}" only admits SDK >= 1.0.0, so manifest.json must declare "capabilities", use [] if the widget never reads or controls Home Assistant`,
44
44
  );
45
45
  }
46
46
  }
47
47
 
48
- // INS-05: "*" sdkVersion is rejected at publish time — authors must declare
48
+ // INS-05: "*" sdkVersion is rejected at publish time, authors must declare
49
49
  // a real range (e.g. "^0.3.0"). Installed widgets that already ship "*" are
50
50
  // still tolerated by the loader with a warn-and-mount, but new publishes
51
51
  // are blocked here.
52
52
  if (manifest.sdkVersion === "*") {
53
53
  errors.push(
54
- `sdkVersion "*" is not allowed — declare a semver range (e.g. "^0.3.0") to opt into host compatibility checks`,
54
+ `sdkVersion "*" is not allowed, declare a semver range (e.g. "^0.3.0") to opt into host compatibility checks`,
55
55
  );
56
56
  }
57
57
 
@@ -156,7 +156,7 @@ export async function runValidate(
156
156
  const failed: string[] = [];
157
157
 
158
158
  for (const name of toValidate) {
159
- if (!quiet) log.info(`\nValidating widget: ${name}`);
159
+ if (!quiet) log.step(name);
160
160
 
161
161
  // Read and validate manifest.json
162
162
  let manifest: WidgetManifest;
@@ -211,7 +211,7 @@ export async function runValidate(
211
211
  // Validate registry.json
212
212
  const registry = readRegistry(cwd);
213
213
  if (!registry) {
214
- if (!quiet) log.error("\ndist/registry.json not found");
214
+ if (!quiet) log.error("dist/registry.json not found");
215
215
  allPassed = false;
216
216
  } else {
217
217
  const registryTags = new Set(registry.widgets.map((w) => w.bundleUrl));
@@ -221,21 +221,17 @@ export async function runValidate(
221
221
  allPassed = false;
222
222
  }
223
223
  }
224
- if (!quiet) log.info(`\nRegistry: ${registry.widgets.length} widget(s)`);
224
+ if (!quiet) log.info(`Registry: ${registry.widgets.length} widget(s)`);
225
225
  }
226
226
 
227
+ // Quiet callers (publish) get a one-line summary; interactive runs let the
228
+ // caller's outro own the final banner so it isn't printed twice.
227
229
  if (quiet) {
228
230
  if (allPassed) {
229
231
  log.success(`Validated ${toValidate.length} widget(s)`);
230
232
  } else {
231
233
  log.error(`Validation failed for: ${failed.join(", ")}`);
232
234
  }
233
- } else {
234
- if (allPassed) {
235
- log.success("\nAll checks passed");
236
- } else {
237
- log.error("\nValidation failed");
238
- }
239
235
  }
240
236
 
241
237
  return allPassed;
@@ -6,7 +6,7 @@ function migrate(body: string) {
6
6
  return migrateConfigSource(code, "widget.tsx");
7
7
  }
8
8
 
9
- describe("migrateConfigSource — field kinds", () => {
9
+ describe("migrateConfigSource, field kinds", () => {
10
10
  test("widgetFields.* map to field.*", () => {
11
11
  const r = migrate(
12
12
  `export const configSchema = z.object({\n title: widgetFields.title(),\n area: widgetFields.areaId(),\n lights: widgetFields.entityIds("light"),\n th: widgetFields.singleEntity("sensor", { deviceClass: "temperature" }),\n});`,
@@ -60,7 +60,7 @@ describe("migrateConfigSource — field kinds", () => {
60
60
  });
61
61
  });
62
62
 
63
- describe("migrateConfigSource — imports", () => {
63
+ describe("migrateConfigSource, imports", () => {
64
64
  test("adds defineConfig/field/Infer, drops widgetFields/z when unused", () => {
65
65
  const r = migrate(
66
66
  `export const configSchema = z.object({ title: widgetFields.title() });\nexport type Config = z.infer<typeof configSchema>;`,
@@ -69,7 +69,7 @@ describe("migrateConfigSource — imports", () => {
69
69
  expect(importLine).toContain("defineConfig");
70
70
  expect(importLine).toContain("field");
71
71
  expect(importLine).toContain("Infer");
72
- // `Infer` is a type — must be type-only for verbatimModuleSyntax widgets.
72
+ // `Infer` is a type, must be type-only for verbatimModuleSyntax widgets.
73
73
  expect(importLine).toContain("type Infer");
74
74
  expect(importLine).not.toContain("widgetFields");
75
75
  expect(importLine).not.toContain(" z,");
@@ -105,7 +105,7 @@ describe("migrateConfigSource — imports", () => {
105
105
  });
106
106
  });
107
107
 
108
- describe("migrateConfigSource — report and skip (never drop validation)", () => {
108
+ describe("migrateConfigSource, report and skip (never drop validation)", () => {
109
109
  test("factory-built + identifier fields are left raw and reported", () => {
110
110
  const r = migrate(
111
111
  `const powerEntity = (l: string) => z.array(z.string()).default([]).meta({ domain: "sensor", title: l });\nexport const configSchema = z.object({\n title: widgetFields.title(),\n solar: powerEntity("Solar"),\n strategy: consumptionStrategy,\n});`,
@@ -216,7 +216,7 @@ function buildShape(
216
216
  const lines: string[] = [];
217
217
  for (const prop of obj.getProperties()) {
218
218
  if (!Node.isPropertyAssignment(prop)) {
219
- // Spread, shorthand, method — leave the whole thing untouched conservatively.
219
+ // Spread, shorthand, method, leave the whole thing untouched conservatively.
220
220
  lines.push(`${indent} ${prop.getText()}`);
221
221
  warnings.push({
222
222
  field: prop.getText().slice(0, 40),
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Run `fn` with stdout writes swallowed. `buildWidgets()` (SDK) console.logs a
3
+ * "[registry] Generated..." line mid-build; when the caller drives a clack
4
+ * spinner that raw write lands on the spinner's line and garbles it. Stderr is
5
+ * left alone so real warnings/errors still surface.
6
+ */
7
+ export async function withQuietStdout<T>(fn: () => Promise<T>): Promise<T> {
8
+ const originalWrite = process.stdout.write.bind(process.stdout);
9
+ const originalLog = console.log;
10
+ // Bun's console.log writes to fd 1 natively and bypasses a process.stdout.write
11
+ // override, so both paths are silenced.
12
+ process.stdout.write = (() => true) as typeof process.stdout.write;
13
+ console.log = () => {};
14
+ try {
15
+ return await fn();
16
+ } finally {
17
+ process.stdout.write = originalWrite;
18
+ console.log = originalLog;
19
+ }
20
+ }