@glasshome/widget-cli 0.5.4 → 0.7.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/bin/cli.ts CHANGED
@@ -31,6 +31,8 @@ Commands:
31
31
  login [hub-url] Authenticate with GlassHome Hub
32
32
  info [name] Show widget metadata and bundle info
33
33
  upgrade Upgrade @glasshome/widget-sdk to latest version
34
+ migrate config Rewrite widgets' raw-zod config to defineConfig + field.*
35
+ --name <widget> targets one; --dry previews without writing
34
36
  help Show this help message
35
37
 
36
38
  Examples:
@@ -62,6 +64,7 @@ const { values: flags, positionals } = parseArgs({
62
64
  bump: { type: "string" },
63
65
  scope: { type: "string" },
64
66
  "re-auth": { type: "boolean" },
67
+ dry: { type: "boolean" },
65
68
  help: { type: "boolean", short: "h" },
66
69
  },
67
70
  allowPositionals: true,
@@ -163,7 +166,7 @@ switch (effectiveCommand) {
163
166
  const widgetName = args[0];
164
167
  const { runInfo } = await import("../src/commands/info");
165
168
  await runInfo(resolveWidgetDir(), widgetName);
166
- outro("");
169
+ outro();
167
170
  break;
168
171
  }
169
172
 
@@ -174,6 +177,22 @@ switch (effectiveCommand) {
174
177
  break;
175
178
  }
176
179
 
180
+ case "migrate": {
181
+ const target = args[0];
182
+ if (!target) {
183
+ log.error("Missing target. Usage: glasshome-widget migrate config [--name <widget>] [--dry]");
184
+ process.exit(1);
185
+ }
186
+ const { runMigrate } = await import("../src/commands/migrate");
187
+ const ok = await runMigrate(resolveWidgetDir(), target, {
188
+ dry: flags.dry === true,
189
+ name: str(flags.name),
190
+ });
191
+ outro(ok ? "Done" : "Migration failed");
192
+ process.exit(ok ? 0 : 1);
193
+ break;
194
+ }
195
+
177
196
  default: {
178
197
  log.error(`Unknown command: ${command}`);
179
198
  console.log(HELP);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.5.4",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,11 @@
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^1.6.0",
24
24
  "@glasshome/widget-contract": "0.1.1",
25
- "@glasshome/widget-sdk": "1.3.0",
25
+ "@glasshome/widget-sdk": "1.4.0",
26
26
  "open": "^11.0.0",
27
+ "picocolors": "^1.1.1",
27
28
  "semver": "^7.6.0",
29
+ "ts-morph": "^28.0.0",
28
30
  "vite-plugin-solid": "^2.11.0"
29
31
  },
30
32
  "repository": {
@@ -1,5 +1,7 @@
1
1
  import { log, spinner } from "@clack/prompts";
2
2
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
3
+ import { lintAndReport } from "../utils/lint-source";
4
+ import { withQuietStdout } from "../utils/quiet";
3
5
 
4
6
  export async function runBuild(cwd: string): Promise<void> {
5
7
  const s = spinner();
@@ -8,12 +10,15 @@ export async function runBuild(cwd: string): Promise<void> {
8
10
  try {
9
11
  const solid = (await import("vite-plugin-solid")).default;
10
12
  process.chdir(cwd);
11
- await buildWidgets({
12
- srcDir: "src",
13
- outDir: "dist",
14
- plugins: [solid()],
15
- });
13
+ await withQuietStdout(() =>
14
+ buildWidgets({
15
+ srcDir: "src",
16
+ outDir: "dist",
17
+ plugins: [solid()],
18
+ }),
19
+ );
16
20
  s.stop("Build complete");
21
+ lintAndReport(cwd);
17
22
  } catch (err) {
18
23
  s.stop("Build failed");
19
24
  log.error(err instanceof Error ? err.message : String(err));
@@ -1,9 +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";
8
+ import { lintAndReport } from "../utils/lint-source";
9
+ import { withQuietStdout } from "../utils/quiet";
7
10
 
8
11
  interface RegistryWidget {
9
12
  name: string;
@@ -33,7 +36,7 @@ async function uploadAndRegister(
33
36
 
34
37
  const bundlePath = resolve(distDir, `${slug}.js`);
35
38
  if (!existsSync(bundlePath)) {
36
- log.warn(`Bundle not found: ${bundlePath} — skipping ${slug}`);
39
+ log.warn(`Bundle not found: ${bundlePath}, skipping ${slug}`);
37
40
  return;
38
41
  }
39
42
 
@@ -115,7 +118,7 @@ export async function runConnect(
115
118
  ): Promise<void> {
116
119
  const distDir = resolve(cwd, "dist");
117
120
  const solid = (await import("vite-plugin-solid")).default;
118
- // delegateEvents: false — widgets run in closed shadow roots where Solid's
121
+ // delegateEvents: false, widgets run in closed shadow roots where Solid's
119
122
  // document-level event delegation cannot see the target.
120
123
  const buildOpts = {
121
124
  srcDir: "src",
@@ -129,7 +132,7 @@ export async function runConnect(
129
132
  try {
130
133
  const origCwd = process.cwd();
131
134
  process.chdir(cwd);
132
- await buildWidgets(buildOpts);
135
+ await withQuietStdout(() => buildWidgets(buildOpts));
133
136
  process.chdir(origCwd);
134
137
  } catch (err) {
135
138
  s.stop("Build failed");
@@ -137,6 +140,7 @@ export async function runConnect(
137
140
  process.exit(1);
138
141
  }
139
142
  s.stop("Build complete");
143
+ lintAndReport(cwd);
140
144
 
141
145
  // Step 2: Check registry was generated
142
146
  const registryPath = resolve(distDir, "registry.json");
@@ -152,7 +156,7 @@ export async function runConnect(
152
156
 
153
157
  if (opts.reAuth) {
154
158
  clearHostToken(host);
155
- log.info("Discarded stored credentials (--re-auth).");
159
+ log.info("Discarded stored credentials (--re-auth)");
156
160
  }
157
161
 
158
162
  // Validate stored token before using it
@@ -166,17 +170,17 @@ export async function runConnect(
166
170
  const body = (await check.json()) as { session?: unknown } | null;
167
171
  if (body?.session) {
168
172
  token = existingToken;
169
- log.info("Using stored credentials for dashboard.");
173
+ log.info("Using stored credentials");
170
174
  } else {
171
175
  clearHostToken(host);
172
- log.warn("Stored credentials expired — re-authenticating.");
176
+ log.warn("Stored credentials expired, re-authenticating");
173
177
  }
174
178
  } else {
175
179
  clearHostToken(host);
176
- log.warn("Stored credentials expired — re-authenticating.");
180
+ log.warn("Stored credentials expired, re-authenticating");
177
181
  }
178
182
  } catch {
179
- log.warn("Could not validate stored credentials — re-authenticating.");
183
+ log.warn("Could not validate stored credentials, re-authenticating");
180
184
  }
181
185
  }
182
186
 
@@ -218,7 +222,7 @@ export async function runConnect(
218
222
  log.warn(
219
223
  `Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
220
224
  );
221
- // token stays empty — caught by guard below
225
+ // token stays empty, caught by guard below
222
226
  }
223
227
 
224
228
  if (deviceCode!) {
@@ -284,7 +288,7 @@ export async function runConnect(
284
288
  s.stop("Device code expired");
285
289
  break;
286
290
  }
287
- // Unexpected error — stop polling
291
+ // Unexpected error, stop polling
288
292
  s.stop(`Auth error: ${errData.error ?? res.status}`);
289
293
  break;
290
294
  } catch (err) {
@@ -303,7 +307,7 @@ export async function runConnect(
303
307
  }
304
308
 
305
309
  if (!token) {
306
- 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.");
307
311
  return;
308
312
  }
309
313
 
@@ -324,7 +328,7 @@ export async function runConnect(
324
328
  });
325
329
  }
326
330
  } catch {
327
- // Non-fatal — dev mode may already be enabled
331
+ // Non-fatal, dev mode may already be enabled
328
332
  }
329
333
 
330
334
  // Step 5: Upload bundles and register widgets
@@ -358,7 +362,7 @@ export async function runConnect(
358
362
  const origCwd = process.cwd();
359
363
  process.chdir(cwd);
360
364
  try {
361
- await buildWidgets({ ...buildOpts, only: widgets });
365
+ await withQuietStdout(() => buildWidgets({ ...buildOpts, only: widgets }));
362
366
  } finally {
363
367
  process.chdir(origCwd);
364
368
  }
@@ -371,7 +375,7 @@ export async function runConnect(
371
375
  await uploadAndRegister(apiUrl, distDir, widget, token);
372
376
  log.info(`Rebuilt & uploaded ${widgetName}`);
373
377
  } else {
374
- log.warn(`No registry entry for ${widgetName} — skipping upload`);
378
+ log.warn(`No registry entry for ${widgetName}, skipping upload`);
375
379
  }
376
380
  }
377
381
  } catch (err) {
@@ -402,14 +406,19 @@ export async function runConnect(
402
406
  ).length
403
407
  : 0;
404
408
 
405
- log.success(`Connected! ${widgetCount} widget(s) are live.`);
406
- log.info("Bundles uploaded to API. Widgets registered with local paths.");
407
- log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
408
- log.info("Press Ctrl+C to disconnect.");
409
-
410
- // 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
411
420
  const cleanup = async () => {
412
- log.info("\nDisconnecting...");
421
+ log.info("Disconnecting...");
413
422
  if (debounceTimer) clearTimeout(debounceTimer);
414
423
  watcher.close();
415
424
 
@@ -422,7 +431,7 @@ export async function runConnect(
422
431
  input: { scope: "local", name: slug },
423
432
  });
424
433
  } catch {
425
- // Non-fatal — API may be down
434
+ // Non-fatal, API may be down
426
435
  }
427
436
  }
428
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",
@@ -0,0 +1,93 @@
1
+ import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { log } from "@clack/prompts";
4
+ import { migrateConfigSource } from "../migrate/config-codemod";
5
+ import { discoverWidgets } from "../utils/manifest";
6
+
7
+ /** All `.ts`/`.tsx` source files under a widget dir. */
8
+ function widgetSourceFiles(cwd: string, widget: string): string[] {
9
+ const dir = resolve(cwd, "src", widget);
10
+ const out: string[] = [];
11
+ const walk = (d: string) => {
12
+ for (const entry of readdirSync(d)) {
13
+ const p = join(d, entry);
14
+ if (statSync(p).isDirectory()) walk(p);
15
+ else if ((entry.endsWith(".ts") || entry.endsWith(".tsx")) && !entry.endsWith(".d.ts"))
16
+ out.push(p);
17
+ }
18
+ };
19
+ walk(dir);
20
+ return out;
21
+ }
22
+
23
+ export interface MigrateOptions {
24
+ dry?: boolean;
25
+ name?: string;
26
+ }
27
+
28
+ /**
29
+ * `bun widget migrate config`, rewrite widgets' raw-zod config to `defineConfig` +
30
+ * `field.*`. Assistive: unmigratable fields stay raw zod and are reported as manual
31
+ * TODOs; validation is never dropped. Use `--dry` to preview without writing.
32
+ */
33
+ export async function runMigrate(
34
+ cwd: string,
35
+ target: string,
36
+ opts: MigrateOptions,
37
+ ): Promise<boolean> {
38
+ if (target !== "config") {
39
+ log.error(`Unknown migrate target "${target}". Supported: config`);
40
+ return false;
41
+ }
42
+
43
+ const all = discoverWidgets(cwd);
44
+ const widgets = opts.name ? all.filter((w) => w === opts.name) : all;
45
+ if (widgets.length === 0) {
46
+ log.error(
47
+ opts.name
48
+ ? `Widget "${opts.name}" not found. Available: ${all.join(", ")}`
49
+ : "No widgets found in src/.",
50
+ );
51
+ return false;
52
+ }
53
+
54
+ let migrated = 0;
55
+ let totalTodos = 0;
56
+ const skipped: string[] = [];
57
+
58
+ for (const widget of widgets) {
59
+ let widgetChanged = false;
60
+ for (const file of widgetSourceFiles(cwd, widget)) {
61
+ const original = readFileSync(file, "utf8");
62
+ const { code, changed, warnings } = migrateConfigSource(original, file);
63
+ if (!changed) continue;
64
+
65
+ widgetChanged = true;
66
+ migrated++;
67
+ if (!opts.dry) writeFileSync(file, code);
68
+
69
+ const rel = relative(cwd, file);
70
+ const verb = opts.dry ? "would migrate" : "migrated";
71
+ if (warnings.length === 0) {
72
+ log.success(`${widget}: ${verb} ${rel} (fully)`);
73
+ } else {
74
+ totalTodos += warnings.length;
75
+ log.warn(
76
+ `${widget}: ${verb} ${rel}, ${warnings.length} field(s) left as raw zod (manual TODO):`,
77
+ );
78
+ for (const w of warnings) {
79
+ log.message(` • ${w.field}: ${w.reason}`);
80
+ }
81
+ }
82
+ }
83
+ if (!widgetChanged) skipped.push(`${widget} (already migrated or no z.object config)`);
84
+ }
85
+
86
+ if (skipped.length > 0) log.info(`Skipped: ${skipped.join(", ")}`);
87
+ const prefix = opts.dry ? "Dry run: " : "";
88
+ log.info(`${prefix}${migrated} widget(s) migrated, ${totalTodos} field(s) need manual review.`);
89
+ if (!opts.dry && migrated > 0) {
90
+ log.info("Review the diffs, then rebuild (`bun widget build`) to verify the zod region drops.");
91
+ }
92
+ return true;
93
+ }
@@ -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;
@@ -0,0 +1,136 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { migrateConfigSource } from "./config-codemod";
3
+
4
+ function migrate(body: string) {
5
+ const code = `import { defineWidget, widgetFields, z } from "@glasshome/widget-sdk";\n\n${body}\n`;
6
+ return migrateConfigSource(code, "widget.tsx");
7
+ }
8
+
9
+ describe("migrateConfigSource, field kinds", () => {
10
+ test("widgetFields.* map to field.*", () => {
11
+ const r = migrate(
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});`,
13
+ );
14
+ expect(r.changed).toBe(true);
15
+ expect(r.warnings).toEqual([]);
16
+ expect(r.code).toContain("defineConfig({");
17
+ expect(r.code).toContain("title: field.title(),");
18
+ expect(r.code).toContain("area: field.area(),");
19
+ expect(r.code).toContain('lights: field.entities("light"),');
20
+ expect(r.code).toContain('th: field.entity("sensor", { deviceClass: "temperature" }),');
21
+ });
22
+
23
+ test("raw scalars → field.text/number/toggle/choice", () => {
24
+ const r = migrate(
25
+ `export const configSchema = z.object({\n name: z.string().optional().meta({ title: "Name" }),\n size: z.number().min(1).max(10).default(5).meta({ title: "Size" }),\n on: z.boolean().default(false).meta({ title: "On" }),\n style: z.enum(["a", "b"]).default("a").meta({ title: "Style" }),\n});`,
26
+ );
27
+ expect(r.warnings).toEqual([]);
28
+ expect(r.code).toContain('name: field.text({ title: "Name" }),');
29
+ expect(r.code).toContain('size: field.number({ title: "Size", min: 1, max: 10, default: 5 }),');
30
+ expect(r.code).toContain('on: field.toggle({ title: "On", default: false }),');
31
+ expect(r.code).toContain('style: field.choice(["a", "b"], { title: "Style", default: "a" }),');
32
+ });
33
+
34
+ test("entity arrays with domain/deviceClass/singleSelect + stringList", () => {
35
+ const r = migrate(
36
+ `export const configSchema = z.object({\n ids: z.array(z.string()).default([]).meta({ domain: "light", title: "Lights" }),\n one: z.array(z.string()).default([]).meta({ domain: "sensor", singleSelect: true, deviceClass: "power" }),\n tags: z.array(z.string()).default([]).meta({ title: "Tags" }),\n});`,
37
+ );
38
+ expect(r.warnings).toEqual([]);
39
+ expect(r.code).toContain('ids: field.entities("light", { title: "Lights" }),');
40
+ expect(r.code).toContain('one: field.entity("sensor", { deviceClass: "power" }),');
41
+ expect(r.code).toContain('tags: field.stringList({ title: "Tags" }),');
42
+ });
43
+
44
+ test("nested z.object → field.group (clock analogOptions)", () => {
45
+ const r = migrate(
46
+ `export const configSchema = z.object({\n analogOptions: z.object({\n border: z.boolean().default(false).meta({ title: "Show Border" }),\n ticks: z.enum(["none", "hour"]).default("hour").meta({ title: "Tick Marks" }),\n }).default({ border: false, ticks: "hour" }).meta({ title: "Analog Options" }),\n});`,
47
+ );
48
+ expect(r.warnings).toEqual([]);
49
+ expect(r.code).toContain("analogOptions: field.group({");
50
+ expect(r.code).toContain('border: field.toggle({ title: "Show Border", default: false }),');
51
+ expect(r.code).toContain('ticks: field.choice(["none", "hour"], { title: "Tick Marks", default: "hour" }),');
52
+ expect(r.code).toContain('}, { title: "Analog Options" }),');
53
+ });
54
+
55
+ test("z.infer → Infer", () => {
56
+ const r = migrate(
57
+ `export const configSchema = z.object({ title: widgetFields.title() });\nexport type Config = z.infer<typeof configSchema>;`,
58
+ );
59
+ expect(r.code).toContain("export type Config = Infer<typeof configSchema>;");
60
+ });
61
+ });
62
+
63
+ describe("migrateConfigSource, imports", () => {
64
+ test("adds defineConfig/field/Infer, drops widgetFields/z when unused", () => {
65
+ const r = migrate(
66
+ `export const configSchema = z.object({ title: widgetFields.title() });\nexport type Config = z.infer<typeof configSchema>;`,
67
+ );
68
+ const importLine = r.code.split("\n")[0];
69
+ expect(importLine).toContain("defineConfig");
70
+ expect(importLine).toContain("field");
71
+ expect(importLine).toContain("Infer");
72
+ // `Infer` is a type, must be type-only for verbatimModuleSyntax widgets.
73
+ expect(importLine).toContain("type Infer");
74
+ expect(importLine).not.toContain("widgetFields");
75
+ expect(importLine).not.toContain(" z,");
76
+ expect(importLine).not.toContain(" z ");
77
+ expect(importLine).toContain("defineWidget");
78
+ });
79
+
80
+ test("keeps z when a raw field survives (escape hatch)", () => {
81
+ const r = migrate(
82
+ `export const configSchema = z.object({\n title: widgetFields.title(),\n custom: z.string().url().meta({ title: "URL" }),\n});`,
83
+ );
84
+ expect(r.code.split("\n")[0]).toContain("z");
85
+ expect(r.warnings.map((w) => w.field)).toContain("custom");
86
+ });
87
+
88
+ test("reflows a multiline SDK import to one name per line (no inline append)", () => {
89
+ const code = `import {\n defineWidget,\n Widget,\n widgetFields,\n z,\n} from "@glasshome/widget-sdk";\n\nexport const configSchema = z.object({ title: widgetFields.title() });\nexport type Config = z.infer<typeof configSchema>;`;
90
+ const r = migrateConfigSource(code, "widget.tsx");
91
+ // No line should carry more than one named import (the inline-append smell).
92
+ expect(r.code).not.toMatch(/\bfield, defineConfig\b/);
93
+ expect(r.code).toContain("\n field,\n");
94
+ expect(r.code).toContain("\n defineConfig,\n");
95
+ expect(r.code).toContain("\n type Infer,\n");
96
+ expect(r.code).toContain('} from "@glasshome/widget-sdk";');
97
+ });
98
+
99
+ test("rewrites a bare zod import to the SDK import", () => {
100
+ const code = `import { defineWidget } from "@glasshome/widget-sdk";\nimport { z } from "zod";\n\nexport const configSchema = z.object({\n custom: z.string().refine((v) => !!v).meta({ title: "X" }),\n});`;
101
+ const r = migrateConfigSource(code, "widget.tsx");
102
+ expect(r.code).not.toContain('from "zod"');
103
+ expect(r.code).toContain('from "@glasshome/widget-sdk"');
104
+ expect(r.code.split("\n").find((l) => l.includes("widget-sdk"))).toContain("z");
105
+ });
106
+ });
107
+
108
+ describe("migrateConfigSource, report and skip (never drop validation)", () => {
109
+ test("factory-built + identifier fields are left raw and reported", () => {
110
+ const r = migrate(
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});`,
112
+ );
113
+ expect(r.changed).toBe(true);
114
+ expect(r.code).toContain("title: field.title(),");
115
+ // unmigratable values preserved verbatim
116
+ expect(r.code).toContain('solar: powerEntity("Solar"),');
117
+ expect(r.code).toContain("strategy: consumptionStrategy,");
118
+ const fields = r.warnings.map((w) => w.field).sort();
119
+ expect(fields).toEqual(["solar", "strategy"]);
120
+ });
121
+
122
+ test("unsupported modifiers (.refine) are reported, value kept", () => {
123
+ const r = migrate(
124
+ `export const configSchema = z.object({\n url: z.string().url().refine((v) => v.length > 0).meta({ title: "URL" }),\n});`,
125
+ );
126
+ expect(r.code).toContain("url: z.string().url().refine");
127
+ expect(r.warnings[0]?.field).toBe("url");
128
+ expect(r.warnings[0]?.reason).toContain("unsupported modifier");
129
+ });
130
+
131
+ test("no configSchema → unchanged", () => {
132
+ const r = migrateConfigSource(`export const x = 1;\n`, "widget.tsx");
133
+ expect(r.changed).toBe(false);
134
+ expect(r.code).toBe(`export const x = 1;\n`);
135
+ });
136
+ });
@@ -0,0 +1,342 @@
1
+ import { Node, type ObjectLiteralExpression, Project, SyntaxKind } from "ts-morph";
2
+
3
+ /**
4
+ * Codemod: rewrite a widget's raw-zod config to the SDK config API
5
+ * (`defineConfig` + `field.*`). Assistive, never destructive: any field pattern it
6
+ * does not recognize (custom refinements, unions, factory-built schemas, local
7
+ * identifiers) is left as raw zod inside `defineConfig` and reported as a manual TODO.
8
+ * `defineConfig` accepts raw `ZodType` values, so a partially-migrated schema still
9
+ * compiles and validates identically. Powered by `bun widget migrate config`.
10
+ */
11
+
12
+ export interface MigrationWarning {
13
+ field: string;
14
+ reason: string;
15
+ }
16
+
17
+ export interface MigrationResult {
18
+ code: string;
19
+ changed: boolean;
20
+ warnings: MigrationWarning[];
21
+ }
22
+
23
+ const KNOWN_MODIFIERS = new Set(["optional", "default", "min", "max", "meta"]);
24
+
25
+ const WIDGET_FIELDS_MAP: Record<string, string> = {
26
+ title: "field.title",
27
+ entityIds: "field.entities",
28
+ singleEntity: "field.entity",
29
+ areaId: "field.area",
30
+ };
31
+
32
+ interface ParsedChain {
33
+ baseObj: string; // "z" | "widgetFields"
34
+ baseMethod: string; // "object" | "enum" | ... | "title"
35
+ baseArgs: Node[];
36
+ optional: boolean;
37
+ defaultText?: string;
38
+ minText?: string;
39
+ maxText?: string;
40
+ meta?: ObjectLiteralExpression;
41
+ unknownMod?: string;
42
+ }
43
+
44
+ /** Walk a zod method chain from outside in; returns null for non-chain expressions. */
45
+ function parseChain(expr: Node): ParsedChain | null {
46
+ const mods: { name: string; args: Node[] }[] = [];
47
+ let cur: Node = expr;
48
+ while (Node.isCallExpression(cur)) {
49
+ const callee = cur.getExpression();
50
+ if (!Node.isPropertyAccessExpression(callee)) return null; // e.g. powerEntity(...)
51
+ const recv = callee.getExpression();
52
+ const name = callee.getName();
53
+ if (Node.isIdentifier(recv) && (recv.getText() === "z" || recv.getText() === "widgetFields")) {
54
+ // Reached the base constructor.
55
+ const parsed: ParsedChain = {
56
+ baseObj: recv.getText(),
57
+ baseMethod: name,
58
+ baseArgs: cur.getArguments(),
59
+ optional: false,
60
+ };
61
+ for (const m of mods) {
62
+ if (!KNOWN_MODIFIERS.has(m.name)) parsed.unknownMod = m.name;
63
+ if (m.name === "optional") parsed.optional = true;
64
+ else if (m.name === "default") parsed.defaultText = m.args[0]?.getText();
65
+ else if (m.name === "min") parsed.minText = m.args[0]?.getText();
66
+ else if (m.name === "max") parsed.maxText = m.args[0]?.getText();
67
+ else if (m.name === "meta") {
68
+ const arg = m.args[0];
69
+ if (arg && Node.isObjectLiteralExpression(arg)) parsed.meta = arg;
70
+ }
71
+ }
72
+ return parsed;
73
+ }
74
+ mods.push({ name, args: cur.getArguments() });
75
+ cur = recv;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /** Read an object-literal's string/boolean/simple props into name → source text. */
81
+ function readMeta(meta: ObjectLiteralExpression | undefined): Record<string, string> {
82
+ const out: Record<string, string> = {};
83
+ if (!meta) return out;
84
+ for (const prop of meta.getProperties()) {
85
+ if (Node.isPropertyAssignment(prop)) {
86
+ const init = prop.getInitializer();
87
+ if (init) out[prop.getName()] = init.getText();
88
+ }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ /** Emit `{ key: value, ... }` for the present entries, preserving source text. */
94
+ function optionsObject(entries: [string, string | undefined][]): string {
95
+ const parts = entries.filter(([, v]) => v !== undefined).map(([k, v]) => `${k}: ${v}`);
96
+ return `{ ${parts.join(", ")} }`;
97
+ }
98
+
99
+ /**
100
+ * Transform one field expression to its `field.*` form, or return a skip reason.
101
+ * `indent` is the leading whitespace for a nested `group` shape.
102
+ */
103
+ function transformField(expr: Node, indent: string): { text: string } | { skip: string } {
104
+ const c = parseChain(expr);
105
+ if (!c) return { skip: "not a zod chain (factory/identifier/custom expression)" };
106
+ if (c.unknownMod) return { skip: `unsupported modifier .${c.unknownMod}()` };
107
+
108
+ // widgetFields.* → field.*
109
+ if (c.baseObj === "widgetFields") {
110
+ const mapped = WIDGET_FIELDS_MAP[c.baseMethod];
111
+ if (!mapped) return { skip: `unknown widgetFields.${c.baseMethod}` };
112
+ const args = c.baseArgs.map((a) => a.getText()).join(", ");
113
+ return { text: `${mapped}(${args})` };
114
+ }
115
+
116
+ const m = readMeta(c.meta);
117
+ const title = m.title;
118
+ const description = m.description;
119
+
120
+ switch (c.baseMethod) {
121
+ case "string": {
122
+ if (m.formType === '"area-picker"' || m.formType === "'area-picker'") {
123
+ return {
124
+ text: `field.area(${title && title !== '"Area"' ? optionsObject([["title", title]]) : ""})`,
125
+ };
126
+ }
127
+ if (!title) return { skip: "z.string() without a title cannot map to field.text" };
128
+ return {
129
+ text: `field.text(${optionsObject([
130
+ ["title", title],
131
+ ["description", description],
132
+ ["default", c.defaultText],
133
+ ])})`,
134
+ };
135
+ }
136
+ case "number": {
137
+ if (!title) return { skip: "z.number() without a title" };
138
+ return {
139
+ text: `field.number(${optionsObject([
140
+ ["title", title],
141
+ ["description", description],
142
+ ["min", c.minText],
143
+ ["max", c.maxText],
144
+ ["default", c.defaultText],
145
+ ])})`,
146
+ };
147
+ }
148
+ case "boolean": {
149
+ if (!title) return { skip: "z.boolean() without a title" };
150
+ return {
151
+ text: `field.toggle(${optionsObject([
152
+ ["title", title],
153
+ ["description", description],
154
+ ["default", c.defaultText],
155
+ ])})`,
156
+ };
157
+ }
158
+ case "enum": {
159
+ if (!title) return { skip: "z.enum() without a title" };
160
+ const values = c.baseArgs[0]?.getText() ?? "[]";
161
+ return {
162
+ text: `field.choice(${values}, ${optionsObject([
163
+ ["title", title],
164
+ ["description", description],
165
+ ["default", c.defaultText],
166
+ ])})`,
167
+ };
168
+ }
169
+ case "array": {
170
+ const inner = c.baseArgs[0];
171
+ if (
172
+ !inner ||
173
+ !Node.isCallExpression(inner) ||
174
+ inner.getExpression().getText() !== "z.string"
175
+ ) {
176
+ return { skip: "z.array of a non-string element" };
177
+ }
178
+ if (m.domain) {
179
+ const domain = m.domain;
180
+ const opts = optionsObject([
181
+ ["title", title],
182
+ ["description", description],
183
+ ["deviceClass", m.deviceClass],
184
+ ]);
185
+ const optsArg = opts === "{ }" ? "" : `, ${opts}`;
186
+ const fn = m.singleSelect === "true" ? "field.entity" : "field.entities";
187
+ return { text: `${fn}(${domain}${optsArg})` };
188
+ }
189
+ if (!title) return { skip: "z.array(z.string()) without a domain or title" };
190
+ return {
191
+ text: `field.stringList(${optionsObject([
192
+ ["title", title],
193
+ ["description", description],
194
+ ])})`,
195
+ };
196
+ }
197
+ case "object": {
198
+ if (!title) return { skip: "nested z.object() without a title" };
199
+ const innerArg = c.baseArgs[0];
200
+ if (!innerArg || !Node.isObjectLiteralExpression(innerArg))
201
+ return { skip: "z.object() with a non-literal shape" };
202
+ const shape = buildShape(innerArg, `${indent} `);
203
+ return { text: `field.group(${shape.text}, ${optionsObject([["title", title]])})` };
204
+ }
205
+ default:
206
+ return { skip: `unsupported base z.${c.baseMethod}()` };
207
+ }
208
+ }
209
+
210
+ /** Build a `{ key: <field>, ... }` shape literal from an object of zod fields. */
211
+ function buildShape(
212
+ obj: ObjectLiteralExpression,
213
+ indent: string,
214
+ ): { text: string; warnings: MigrationWarning[] } {
215
+ const warnings: MigrationWarning[] = [];
216
+ const lines: string[] = [];
217
+ for (const prop of obj.getProperties()) {
218
+ if (!Node.isPropertyAssignment(prop)) {
219
+ // Spread, shorthand, method, leave the whole thing untouched conservatively.
220
+ lines.push(`${indent} ${prop.getText()}`);
221
+ warnings.push({
222
+ field: prop.getText().slice(0, 40),
223
+ reason: "non-standard property (spread/shorthand)",
224
+ });
225
+ continue;
226
+ }
227
+ const name = prop.getName();
228
+ const init = prop.getInitializer();
229
+ if (!init) continue;
230
+ const result = transformField(init, `${indent} `);
231
+ if ("text" in result) {
232
+ lines.push(`${indent} ${name}: ${result.text},`);
233
+ } else {
234
+ lines.push(`${indent} ${name}: ${init.getText()},`);
235
+ warnings.push({ field: name, reason: result.skip });
236
+ }
237
+ }
238
+ return { text: `{\n${lines.join("\n")}\n${indent}}`, warnings };
239
+ }
240
+
241
+ function rewriteNamedImport(
242
+ project: Project,
243
+ filePath: string,
244
+ moduleName: string,
245
+ add: { name: string; isTypeOnly?: boolean }[],
246
+ remove: string[],
247
+ ): void {
248
+ const sf = project.getSourceFileOrThrow(filePath);
249
+ const decl = sf.getImportDeclaration((d) => d.getModuleSpecifierValue() === moduleName);
250
+ if (!decl) return;
251
+ for (const named of decl.getNamedImports()) {
252
+ if (remove.includes(named.getName())) named.remove();
253
+ }
254
+ const existing = new Set(decl.getNamedImports().map((n) => n.getName()));
255
+ const toAdd = add.filter((n) => !existing.has(n.name));
256
+ // `Infer` is a type: import it type-only so `verbatimModuleSyntax` widgets compile.
257
+ if (toAdd.length > 0) decl.addNamedImports(toAdd);
258
+ // Drop an import that has no remaining named/default/namespace bindings.
259
+ if (
260
+ decl.getNamedImports().length === 0 &&
261
+ !decl.getDefaultImport() &&
262
+ !decl.getNamespaceImport()
263
+ ) {
264
+ decl.remove();
265
+ return;
266
+ }
267
+ // ts-morph appends new names inline onto the last line of a multiline import. Put
268
+ // each name back on its own line (order preserved, so no churn to existing imports);
269
+ // leave single-line imports as-is.
270
+ if (decl.getText().includes("\n")) {
271
+ const names = decl
272
+ .getNamedImports()
273
+ .map((n) => ` ${n.isTypeOnly() ? "type " : ""}${n.getName()},`)
274
+ .join("\n");
275
+ decl.replaceWithText(`import {\n${names}\n} from "${decl.getModuleSpecifierValue()}";`);
276
+ }
277
+ }
278
+
279
+ export function migrateConfigSource(code: string, filePath = "widget.tsx"): MigrationResult {
280
+ const project = new Project({ useInMemoryFileSystem: true });
281
+ const sf = project.createSourceFile(filePath, code);
282
+
283
+ const decl = sf.getVariableDeclaration("configSchema");
284
+ const initializer = decl?.getInitializer();
285
+ const warnings: MigrationWarning[] = [];
286
+ let changed = false;
287
+
288
+ if (
289
+ initializer &&
290
+ Node.isCallExpression(initializer) &&
291
+ initializer.getExpression().getText() === "z.object"
292
+ ) {
293
+ const shapeArg = initializer.getArguments()[0];
294
+ if (shapeArg && Node.isObjectLiteralExpression(shapeArg)) {
295
+ const built = buildShape(shapeArg, "");
296
+ warnings.push(...built.warnings);
297
+ initializer.replaceWithText(`defineConfig(${built.text})`);
298
+ changed = true;
299
+ }
300
+ }
301
+
302
+ // z.infer<...> → Infer<...>
303
+ for (const ref of sf.getDescendantsOfKind(SyntaxKind.TypeReference)) {
304
+ if (ref.getText().startsWith("z.infer")) {
305
+ ref.getTypeName().replaceWithText("Infer");
306
+ changed = true;
307
+ }
308
+ }
309
+
310
+ if (!changed) return { code, changed: false, warnings };
311
+
312
+ // Recompute which SDK symbols the migrated body needs (ignore import statements).
313
+ const body = sf
314
+ .getStatements()
315
+ .filter((s) => !Node.isImportDeclaration(s))
316
+ .map((s) => s.getText())
317
+ .join("\n");
318
+ const needField = /\bfield\./.test(body);
319
+ const needDefineConfig = /\bdefineConfig\(/.test(body);
320
+ const needInfer = /\bInfer\s*</.test(body);
321
+ const needZ = /\bz\./.test(body);
322
+ const usesWidgetFields = /\bwidgetFields\./.test(body);
323
+
324
+ const add: { name: string; isTypeOnly?: boolean }[] = [];
325
+ if (needField) add.push({ name: "field" });
326
+ if (needDefineConfig) add.push({ name: "defineConfig" });
327
+ if (needInfer) add.push({ name: "Infer", isTypeOnly: true });
328
+ const remove: string[] = [];
329
+ if (!usesWidgetFields) remove.push("widgetFields");
330
+ if (!needZ) remove.push("z");
331
+
332
+ // Move any surviving `z` need onto the SDK import; drop the bare "zod" import.
333
+ const zodDecl = sf.getImportDeclaration((d) => d.getModuleSpecifierValue() === "zod");
334
+ if (zodDecl) {
335
+ zodDecl.remove();
336
+ if (needZ) add.push({ name: "z" });
337
+ }
338
+
339
+ rewriteNamedImport(project, filePath, "@glasshome/widget-sdk", add, remove);
340
+
341
+ return { code: sf.getFullText(), changed, warnings };
342
+ }
@@ -0,0 +1,84 @@
1
+ import { readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { log } from "@clack/prompts";
4
+ import { deprecations, formatDeprecation } from "@glasshome/widget-sdk/deprecations";
5
+ import { discoverWidgets } from "./manifest";
6
+
7
+ /**
8
+ * Source-level deprecation lint. Scans widget `.ts`/`.tsx` for deprecated API usage
9
+ * (driven by the SDK deprecation registry) and direct `zod` imports, printing the
10
+ * removal timeline. Warnings only: never blocks a build until the deprecated path is
11
+ * actually removed (v2). Runs on `bun widget build` and `connect`.
12
+ */
13
+
14
+ export interface SourceLintFinding {
15
+ widget: string;
16
+ file: string;
17
+ line: number;
18
+ message: string;
19
+ }
20
+
21
+ const SOURCE_EXTS = [".ts", ".tsx"];
22
+ const ZOD_IMPORT = /\bfrom\s+["']zod["']/;
23
+ const ZOD_IMPORT_MESSAGE =
24
+ 'importing from "zod" directly couples your widget to the SDK\'s zod version. Prefer field.*/defineConfig; for advanced schemas import { z } from "@glasshome/widget-sdk".';
25
+
26
+ function widgetSourceFiles(cwd: string, widget: string): string[] {
27
+ const dir = resolve(cwd, "src", widget);
28
+ const out: string[] = [];
29
+ const walk = (d: string) => {
30
+ for (const entry of readdirSync(d)) {
31
+ const p = join(d, entry);
32
+ if (statSync(p).isDirectory()) walk(p);
33
+ else if (SOURCE_EXTS.some((e) => entry.endsWith(e))) out.push(p);
34
+ }
35
+ };
36
+ walk(dir);
37
+ return out;
38
+ }
39
+
40
+ const registryMatchers = deprecations
41
+ .filter((d) => d.sourcePattern)
42
+ .map((d) => ({ entry: d, re: new RegExp(d.sourcePattern as string) }));
43
+
44
+ /** Scan one widget's source and return deprecation findings. */
45
+ export function lintWidgetSource(cwd: string, widget: string): SourceLintFinding[] {
46
+ const findings: SourceLintFinding[] = [];
47
+ for (const file of widgetSourceFiles(cwd, widget)) {
48
+ const rel = relative(cwd, file);
49
+ const lines = readFileSync(file, "utf8").split("\n");
50
+ lines.forEach((text, i) => {
51
+ const line = i + 1;
52
+ for (const { entry, re } of registryMatchers) {
53
+ if (re.test(text)) {
54
+ findings.push({ widget, file: rel, line, message: formatDeprecation(entry) });
55
+ }
56
+ }
57
+ if (ZOD_IMPORT.test(text)) {
58
+ findings.push({
59
+ widget,
60
+ file: rel,
61
+ line,
62
+ message: `[@glasshome/widget-sdk] ${ZOD_IMPORT_MESSAGE}`,
63
+ });
64
+ }
65
+ });
66
+ }
67
+ return findings;
68
+ }
69
+
70
+ /**
71
+ * Lint the given widgets (or all discovered widgets) and print findings as warnings.
72
+ * Returns the findings so callers can act on them; never throws or blocks the build.
73
+ */
74
+ export function lintAndReport(cwd: string, widgets?: string[]): SourceLintFinding[] {
75
+ const targets = widgets ?? discoverWidgets(cwd);
76
+ const findings = targets.flatMap((w) => lintWidgetSource(cwd, w));
77
+ if (findings.length === 0) return findings;
78
+
79
+ log.warn(`${findings.length} deprecated config usage(s) found (non-blocking until SDK 2.0):`);
80
+ for (const f of findings) {
81
+ log.message(` ${f.file}:${f.line} ${f.message}`);
82
+ }
83
+ return findings;
84
+ }
@@ -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
+ }