@glasshome/widget-cli 0.5.4 → 0.6.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,
@@ -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.6.0",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,9 +22,10 @@
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
27
  "semver": "^7.6.0",
28
+ "ts-morph": "^28.0.0",
28
29
  "vite-plugin-solid": "^2.11.0"
29
30
  },
30
31
  "repository": {
@@ -1,5 +1,6 @@
1
1
  import { log, spinner } from "@clack/prompts";
2
2
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
3
+ import { lintAndReport } from "../utils/lint-source";
3
4
 
4
5
  export async function runBuild(cwd: string): Promise<void> {
5
6
  const s = spinner();
@@ -14,6 +15,7 @@ export async function runBuild(cwd: string): Promise<void> {
14
15
  plugins: [solid()],
15
16
  });
16
17
  s.stop("Build complete");
18
+ lintAndReport(cwd);
17
19
  } catch (err) {
18
20
  s.stop("Build failed");
19
21
  log.error(err instanceof Error ? err.message : String(err));
@@ -4,6 +4,7 @@ import { log, spinner } from "@clack/prompts";
4
4
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
5
5
  import { trpcMutate, trpcQuery } from "../utils/api";
6
6
  import { clearHostToken, extractHost, getHostToken, storeHostToken } from "../utils/auth";
7
+ import { lintAndReport } from "../utils/lint-source";
7
8
 
8
9
  interface RegistryWidget {
9
10
  name: string;
@@ -137,6 +138,7 @@ export async function runConnect(
137
138
  process.exit(1);
138
139
  }
139
140
  s.stop("Build complete");
141
+ lintAndReport(cwd);
140
142
 
141
143
  // Step 2: Check registry was generated
142
144
  const registryPath = resolve(distDir, "registry.json");
@@ -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
+ }
@@ -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
+ }