@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 +20 -1
- package/package.json +4 -2
- package/src/commands/build.ts +10 -5
- package/src/commands/connect.ts +32 -23
- package/src/commands/create.ts +20 -20
- package/src/commands/info.ts +22 -27
- package/src/commands/login.ts +4 -5
- package/src/commands/migrate.ts +93 -0
- package/src/commands/publish.ts +3 -3
- package/src/commands/upgrade.ts +10 -7
- package/src/commands/validate.ts +8 -12
- package/src/migrate/config-codemod.test.ts +136 -0
- package/src/migrate/config-codemod.ts +342 -0
- package/src/utils/lint-source.ts +84 -0
- package/src/utils/quiet.ts +20 -0
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.
|
|
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.
|
|
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": {
|
package/src/commands/build.ts
CHANGED
|
@@ -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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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));
|
package/src/commands/connect.ts
CHANGED
|
@@ -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}
|
|
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
|
|
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
|
|
173
|
+
log.info("Using stored credentials");
|
|
170
174
|
} else {
|
|
171
175
|
clearHostToken(host);
|
|
172
|
-
log.warn("Stored credentials expired
|
|
176
|
+
log.warn("Stored credentials expired, re-authenticating");
|
|
173
177
|
}
|
|
174
178
|
} else {
|
|
175
179
|
clearHostToken(host);
|
|
176
|
-
log.warn("Stored credentials expired
|
|
180
|
+
log.warn("Stored credentials expired, re-authenticating");
|
|
177
181
|
}
|
|
178
182
|
} catch {
|
|
179
|
-
log.warn("Could not validate stored credentials
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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}
|
|
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
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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("
|
|
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
|
|
434
|
+
// Non-fatal, API may be down
|
|
426
435
|
}
|
|
427
436
|
}
|
|
428
437
|
log.info("Widgets unregistered");
|
package/src/commands/create.ts
CHANGED
|
@@ -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
|
|
161
|
-
: "Install failed
|
|
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
|
|
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
|
|
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
|
-
|
|
193
|
-
|
|
193
|
+
const cmd = (c: string, desc: string) => `${color.cyan(c.padEnd(30))} ${color.dim(desc)}`;
|
|
194
|
+
const steps: string[] = [];
|
|
194
195
|
if (monorepoRoot) {
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
steps.push(cmd("bun install", "from monorepo root, links workspace packages"));
|
|
197
|
+
steps.push(color.cyan(`cd ${projectName}`));
|
|
197
198
|
} else {
|
|
198
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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 });
|
package/src/commands/info.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if (manifest.
|
|
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
|
-
|
|
58
|
+
lines.push(field("Bundle", `${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`));
|
|
63
59
|
} catch {
|
|
64
|
-
|
|
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(
|
|
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
|
}
|
package/src/commands/login.ts
CHANGED
|
@@ -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
|
|
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("
|
|
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.
|
|
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
|
+
}
|
package/src/commands/publish.ts
CHANGED
|
@@ -113,7 +113,7 @@ export async function runPublish(
|
|
|
113
113
|
let sizeInfo = "";
|
|
114
114
|
try {
|
|
115
115
|
const info = getWidgetBundleInfo(cwd, name);
|
|
116
|
-
sizeInfo =
|
|
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
|
|
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
|
|
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
|
}
|
package/src/commands/upgrade.ts
CHANGED
|
@@ -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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
}
|
package/src/commands/validate.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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
|
|
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.
|
|
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("
|
|
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(
|
|
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
|
+
}
|