@glasshome/widget-cli 0.8.4 → 0.10.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
@@ -27,6 +27,8 @@ Commands:
27
27
  build Build all widgets (per-widget, self-contained bundles)
28
28
  connect <url> Connect to a running dashboard for live testing
29
29
  --re-auth discards stored credentials and re-runs device approval
30
+ preview [name...] Screenshot widgets' examples (light + dark) into preview/
31
+ --isolate gives every render its own browser process
30
32
  validate [name] Validate all widgets or a specific one
31
33
  publish [hub-url] Build and publish a widget to Hub
32
34
  login [hub-url] Authenticate with GlassHome Hub
@@ -42,6 +44,8 @@ Examples:
42
44
  glasshome-widget add
43
45
  glasshome-widget build
44
46
  glasshome-widget connect http://localhost:3333
47
+ glasshome-widget preview
48
+ glasshome-widget preview clock
45
49
  glasshome-widget validate
46
50
  glasshome-widget validate clock
47
51
  glasshome-widget info
@@ -65,6 +69,7 @@ const { values: flags, positionals } = parseArgs({
65
69
  bump: { type: "string" },
66
70
  scope: { type: "string" },
67
71
  "re-auth": { type: "boolean" },
72
+ isolate: { type: "boolean" },
68
73
  dry: { type: "boolean" },
69
74
  help: { type: "boolean", short: "h" },
70
75
  },
@@ -134,6 +139,14 @@ switch (effectiveCommand) {
134
139
  break;
135
140
  }
136
141
 
142
+ case "preview": {
143
+ const widgetDir = resolveWidgetDir();
144
+ await notifySdkUpdate(widgetDir);
145
+ const { runPreview } = await import("../src/commands/preview");
146
+ await runPreview(widgetDir, args, flags.isolate === true);
147
+ break;
148
+ }
149
+
137
150
  case "validate": {
138
151
  const widgetName = args[0];
139
152
  const widgetDir = resolveWidgetDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.8.4",
3
+ "version": "0.10.0",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "cli": "bun bin/cli.ts",
11
+ "test": "bun test",
11
12
  "typecheck": "tsc --noEmit"
12
13
  },
13
14
  "files": [
@@ -16,19 +17,37 @@
16
17
  "template"
17
18
  ],
18
19
  "devDependencies": {
20
+ "@iconify/types": "^2.0.0",
19
21
  "@types/bun": "latest",
20
- "@types/semver": "^7.0.0"
22
+ "@types/semver": "^7.0.0",
23
+ "playwright": "^1.40.0",
24
+ "typescript": "^5"
21
25
  },
22
26
  "dependencies": {
23
27
  "@clack/prompts": "^1.6.0",
28
+ "@fontsource-variable/caveat": "^5.2.8",
29
+ "@fontsource-variable/geist": "^5.2.9",
30
+ "@fontsource-variable/geist-mono": "^5.2.8",
31
+ "@glasshome/sync-layer": "0.4.0",
24
32
  "@glasshome/widget-contract": "0.2.0",
25
- "@glasshome/widget-sdk": "1.6.1",
33
+ "@glasshome/widget-sdk": "1.8.1",
34
+ "@iconify-json/lucide": "^1.2.0",
35
+ "@iconify-json/mdi": "^1.2.3",
36
+ "@tailwindcss/vite": "^4.1.13",
26
37
  "open": "^11.0.0",
27
38
  "picocolors": "^1.1.1",
28
39
  "semver": "^7.6.0",
29
40
  "ts-morph": "^28.0.0",
30
41
  "vite-plugin-solid": "^2.11.0"
31
42
  },
43
+ "peerDependencies": {
44
+ "playwright": "^1.40.0"
45
+ },
46
+ "peerDependenciesMeta": {
47
+ "playwright": {
48
+ "optional": true
49
+ }
50
+ },
32
51
  "repository": {
33
52
  "type": "git",
34
53
  "url": "https://github.com/glasshome/widget-cli.git"
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { cancel, isCancel, log, text } from "@clack/prompts";
4
+ import { defaultSdkRange, FALLBACK_SDK_RANGE } from "../utils/sdk-version";
4
5
 
5
6
  /** Capitalize kebab-case into PascalCase for component names. */
6
7
  function capitalize(str: string): string {
@@ -30,20 +31,30 @@ export function scaffoldWidget(
30
31
  const displayName = capitalize(widgetName);
31
32
  const widgetDescription = description || `A GlassHome dashboard widget: ${widgetName}`;
32
33
 
34
+ // The range the host checks before mounting, derived from the SDK this
35
+ // project actually builds against. Hardcoding it produced widgets that
36
+ // declared a 0.2-era range while running on 1.x, which reads as "pre-1.0" to
37
+ // `requiresCapabilities` and quietly excused them from declaring capabilities.
38
+ const sdkRange = defaultSdkRange(cwd) ?? FALLBACK_SDK_RANGE;
39
+
33
40
  // Read index.tsx template from the template directory
34
41
  const templateDir = resolve(import.meta.dir, "../../template");
35
42
  let srcContent = readFileSync(resolve(templateDir, "src/index.tsx.template"), "utf-8");
36
43
  srcContent = srcContent.replace(/WIDGET_NAME/g, displayName);
44
+ srcContent = srcContent.replace(/SDK_RANGE/g, sdkRange);
37
45
  writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
38
46
 
39
- // Generate manifest.json
47
+ // Generate manifest.json. `capabilities` is required for any SDK >= 1.0.0
48
+ // range and an empty array is the honest default: a fresh widget reads
49
+ // nothing from Home Assistant, and the declaration is what consent shows.
40
50
  const manifest = {
41
51
  name: displayName,
42
52
  description: widgetDescription,
43
53
  minSize: { w: 1, h: 1 },
44
54
  maxSize: { w: 4, h: 4 },
45
55
  defaultSize: { w: 2, h: 2 },
46
- sdkVersion: "^0.2.0",
56
+ sdkVersion: sdkRange,
57
+ capabilities: [],
47
58
  version: "0.1.0",
48
59
  };
49
60
  writeFileSync(resolve(widgetDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
@@ -2,8 +2,15 @@ import { log, spinner } from "@clack/prompts";
2
2
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
3
3
  import { lintAndReport } from "../utils/lint-source";
4
4
  import { withQuietStdout } from "../utils/quiet";
5
+ import { typecheckAndReport } from "../utils/typecheck";
5
6
 
6
7
  export async function runBuild(cwd: string): Promise<void> {
8
+ const check = spinner();
9
+ check.start("Checking types...");
10
+ const typesOk = typecheckAndReport(cwd);
11
+ check.stop(typesOk ? "Types checked" : "Typecheck failed");
12
+ if (!typesOk) process.exit(1);
13
+
7
14
  const s = spinner();
8
15
  s.start("Building widgets...");
9
16
 
@@ -11,6 +11,7 @@ import {
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { cancel, confirm, isCancel, log, note, spinner, text } from "@clack/prompts";
13
13
  import color from "picocolors";
14
+ import { defaultSdkRange, FALLBACK_SDK_RANGE } from "../utils/sdk-version";
14
15
  import { getCliVersion } from "../utils/version";
15
16
  import { promptWidgetDetails, scaffoldWidget } from "./add";
16
17
 
@@ -125,6 +126,9 @@ export async function runCreate() {
125
126
  pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName as string);
126
127
  pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
127
128
  pkgContent = pkgContent.replace(/CLI_VERSION/g, `^${getCliVersion()}`);
129
+ // Nothing is installed yet, so this comes from the SDK the CLI ships
130
+ // against. `bun widget upgrade` re-syncs it and every manifest later.
131
+ pkgContent = pkgContent.replace(/SDK_RANGE/g, defaultSdkRange() ?? FALLBACK_SDK_RANGE);
128
132
  writeFileSync(pkgPath, pkgContent);
129
133
 
130
134
  // Replace project-level placeholders in README.md
@@ -0,0 +1,100 @@
1
+ import { log } from "@clack/prompts";
2
+ import color from "picocolors";
3
+ import { runPreview as capturePreview } from "../preview/capture";
4
+ import { withQuietStdout } from "../utils/quiet";
5
+
6
+ /**
7
+ * Screenshot every widget's authored examples (light + dark) into
8
+ * `<project>/preview/`, rendered through the same constraints as the hub's
9
+ * render worker: frozen clock, DNS blackhole, per-render timeout, hash pin.
10
+ *
11
+ * Playwright is an OPTIONAL peer — the CLI stays Chromium-free by default — so
12
+ * detect it first and give an actionable install hint rather than a raw
13
+ * module-not-found when it is absent.
14
+ */
15
+ export async function runPreview(cwd: string, names: string[], isolate: boolean): Promise<void> {
16
+ try {
17
+ await import("playwright");
18
+ } catch {
19
+ log.error("Preview needs Playwright + a Chromium build, which are not installed.");
20
+ log.info(`Install them with:\n ${color.bold("bun add -d playwright && bunx playwright install chromium")}`);
21
+ process.exit(1);
22
+ }
23
+
24
+ // Capture the REAL stdout writer before withQuietStdout no-ops
25
+ // process.stdout.write. A clack spinner writes through that same override and
26
+ // would freeze inside the quiet block, so progress goes out this bound
27
+ // reference (which the override cannot intercept) as plain lines. Build/vite
28
+ // noise stays silenced; only these phase lines get through.
29
+ const realWrite = process.stdout.write.bind(process.stdout);
30
+ const progress = (m: string) => {
31
+ realWrite(`${color.gray("│")} ${color.dim(m)}\n`);
32
+ };
33
+
34
+ log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
35
+
36
+ let summary: Awaited<ReturnType<typeof capturePreview>>;
37
+ try {
38
+ summary = await withQuietStdout(() =>
39
+ capturePreview({ projectDir: cwd, only: names, isolate, onProgress: progress }),
40
+ );
41
+ } catch (err) {
42
+ log.error(err instanceof Error ? err.message : String(err));
43
+ process.exit(1);
44
+ }
45
+
46
+ const hangs = summary.failures.filter((f) => f.kind === "hang");
47
+ const integrity = summary.failures.filter((f) => f.kind === "integrity");
48
+ const networkWidgets = [
49
+ ...new Set(summary.failures.filter((f) => f.kind === "network").map((f) => f.widget)),
50
+ ];
51
+ const attempted = summary.shots + hangs.length;
52
+
53
+ // Headline: plain count of what landed in preview/. A miss only matters if a
54
+ // render could not complete (hangs) or a bundle changed under us (integrity).
55
+ const out = color.cyan("preview/");
56
+ if (hangs.length || integrity.length) {
57
+ log.warn(`Rendered ${summary.shots} of ${attempted} previews into ${out}`);
58
+ } else {
59
+ log.success(`Rendered ${summary.shots} previews (light + dark) into ${out}`);
60
+ }
61
+
62
+ if (summary.skipped.length) {
63
+ log.message(color.dim(`No examples to render: ${summary.skipped.join(", ")}`));
64
+ }
65
+
66
+ // Real miss: a render still too slow after one retry. Its slot is left without
67
+ // a PNG; everything else rendered.
68
+ if (hangs.length) {
69
+ log.warn(
70
+ `Too slow to render, even after a retry (no image written):\n` +
71
+ hangs.map((f) => ` · ${f.widget} ${f.detail}`).join("\n"),
72
+ );
73
+ }
74
+
75
+ // Should never happen: a bundle's bytes changed mid-run. This is a real
76
+ // problem, not a slow render.
77
+ if (integrity.length) {
78
+ log.error(
79
+ `A bundle changed while rendering (report this):\n` +
80
+ integrity.map((f) => ` · ${f.widget}`).join("\n"),
81
+ );
82
+ }
83
+
84
+ // Informational, not a failure: some widgets have no offline data and reach
85
+ // for the network. The request is blocked and nothing leaves the machine; the
86
+ // widget renders a placeholder. Expected for camera; worth a glance if another
87
+ // widget shows up here.
88
+ if (networkWidgets.length) {
89
+ log.message(
90
+ color.dim(
91
+ `Used a placeholder (no offline data; network blocked, nothing left the machine): ${networkWidgets.join(", ")}`,
92
+ ),
93
+ );
94
+ }
95
+
96
+ // Vite's dev server leaves live handles behind (file watchers, keep-alive
97
+ // sockets from browsers that crashed mid-render), so the process would sit
98
+ // idle forever after the verdict instead of exiting. Leave deliberately.
99
+ process.exit(hangs.length || integrity.length ? 1 : 0);
100
+ }
@@ -12,6 +12,7 @@ import {
12
12
  readManifest,
13
13
  writeManifest,
14
14
  } from "../utils/manifest";
15
+ import { typecheckAndReport } from "../utils/typecheck";
15
16
  import { enforceCliVersion } from "../utils/version";
16
17
  import { runLogin } from "./login";
17
18
 
@@ -28,13 +29,7 @@ export async function runPublish(
28
29
  ): Promise<void> {
29
30
  const s = spinner();
30
31
 
31
- // Step 1: Validate (quiet)
32
32
  const { runValidate } = await import("./validate");
33
- const valid = await runValidate(cwd, undefined, { quiet: true });
34
- if (!valid) {
35
- log.error("Fix validation errors before publishing.");
36
- process.exit(1);
37
- }
38
33
 
39
34
  // Step 2: Authenticate
40
35
  const hubUrl = hubUrlOverride ?? getHubUrl();
@@ -167,7 +162,17 @@ export async function runPublish(
167
162
  writeManifest(cwd, widgetName, manifest);
168
163
  }
169
164
 
170
- // Step 6: Build (async so spinner animates)
165
+ // Step 6: Typecheck, then build (async so spinner animates).
166
+ //
167
+ // Checked here rather than left to the project's own build script: a project
168
+ // scaffolded before the template ran tsc builds with a bare `vite build`, and
169
+ // publishing is the irreversible step. A project whose build already
170
+ // typechecks just pays it twice, which is cheap next to a bad publish.
171
+ s.start("Checking types...");
172
+ const typesOk = typecheckAndReport(cwd);
173
+ s.stop(typesOk ? "Types checked" : "Typecheck failed");
174
+ if (!typesOk) process.exit(1);
175
+
171
176
  s.start("Building widget...");
172
177
  const buildProc = Bun.spawn(["bun", "run", "build"], { cwd, stdout: "pipe", stderr: "pipe" });
173
178
  const buildExit = await buildProc.exited;
@@ -179,6 +184,15 @@ export async function runPublish(
179
184
  }
180
185
  s.stop("Build complete");
181
186
 
187
+ // Validate AFTER the build, not before: the build is what produces the
188
+ // manifest and bundle that get published, so validating beforehand checks a
189
+ // different document than the one that ships.
190
+ const valid = await runValidate(cwd, undefined, { quiet: true });
191
+ if (!valid) {
192
+ log.error("Fix validation errors before publishing.");
193
+ process.exit(1);
194
+ }
195
+
182
196
  // Step 7: Publish
183
197
  const distPath = resolve(cwd, "dist", `${widgetName}.js`);
184
198
 
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import { log, note, spinner } from "@clack/prompts";
4
4
  import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
5
+ import { getInstalledSdkVersion } from "../utils/sdk-version";
5
6
  import { runValidate } from "./validate";
6
7
 
7
8
  /** Walk up from cwd looking for a package.json with a `workspaces` field. */
@@ -121,16 +122,30 @@ export async function runUpgrade(cwd: string): Promise<void> {
121
122
  } else {
122
123
  log.warn(`Synced to ${sdkVersion} but validation has warnings/errors. Check above.`);
123
124
  }
125
+ return;
126
+ }
127
+
128
+ // Standalone project. Bumping the dependency is still the author's call, but
129
+ // syncing the manifests to whatever is installed is not: a manifest range
130
+ // that excludes the installed SDK fails validation, and this is the command
131
+ // that error tells people to run.
132
+ const installed = getInstalledSdkVersion(cwd);
133
+ if (installed) {
134
+ syncManifestSdkVersions(cwd, installed);
135
+ const valid = await runValidate(cwd);
136
+ if (!valid) log.warn(`Synced manifests to ${installed}, but validation found problems above.`);
124
137
  } else {
125
- log.info("Not inside the widget workspace. Upgrade @glasshome/widget-sdk manually:");
126
- note(
127
- [
128
- "1. Bump @glasshome/widget-sdk in package.json peerDependencies",
129
- "2. bun install",
130
- "3. bun widget upgrade (sync manifest files)",
131
- "4. bun widget validate (check compatibility)",
132
- ].join("\n"),
133
- "To upgrade",
134
- );
138
+ log.warn("No @glasshome/widget-sdk installed here, so manifests were left alone.");
135
139
  }
140
+
141
+ log.info("To move to a different SDK version:");
142
+ note(
143
+ [
144
+ "1. Bump @glasshome/widget-sdk in package.json peerDependencies",
145
+ "2. bun install",
146
+ "3. bun widget upgrade (sync manifest files)",
147
+ "4. bun widget validate (check compatibility)",
148
+ ].join("\n"),
149
+ "To upgrade",
150
+ );
136
151
  }
@@ -5,6 +5,7 @@ import {
5
5
  formatSchemaError,
6
6
  publishManifestSchema,
7
7
  requiresCapabilities,
8
+ satisfiesSdk,
8
9
  } from "@glasshome/widget-contract";
9
10
  import semver from "semver";
10
11
  import {
@@ -15,7 +16,7 @@ import {
15
16
  readRegistry,
16
17
  type WidgetManifest,
17
18
  } from "../utils/manifest";
18
- import { getProjectSdkVersion } from "../utils/version";
19
+ import { getInstalledSdkVersion } from "../utils/sdk-version";
19
20
 
20
21
  interface ValidationResult {
21
22
  passed: boolean;
@@ -23,7 +24,7 @@ interface ValidationResult {
23
24
  warnings: string[];
24
25
  }
25
26
 
26
- function validateManifest(manifest: WidgetManifest): ValidationResult {
27
+ function validateManifest(manifest: WidgetManifest, installedSdk: string | null): ValidationResult {
27
28
  const errors: string[] = [];
28
29
  const warnings: string[] = [];
29
30
  const hasValidMinSize =
@@ -47,6 +48,23 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
47
48
  }
48
49
  }
49
50
 
51
+ // The range must admit the SDK actually resolved in node_modules. Comparing
52
+ // it against the range declared in package.json only compares a claim with
53
+ // itself: both said "^0.2.0" while the build ran on 1.7.0, and it passed.
54
+ // An error, not a warning: a pre-1.0 range also excuses the widget from
55
+ // declaring capabilities, so a stale one silently leaves the 1.x contract.
56
+ if (
57
+ installedSdk &&
58
+ manifest.sdkVersion &&
59
+ manifest.sdkVersion !== "*" &&
60
+ semver.validRange(manifest.sdkVersion) &&
61
+ !satisfiesSdk(installedSdk, manifest.sdkVersion)
62
+ ) {
63
+ errors.push(
64
+ `sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${installedSdk}, so it claims a compatibility it was not built for; run \`bun widget upgrade\``,
65
+ );
66
+ }
67
+
50
68
  // INS-05: "*" sdkVersion is rejected at publish time, authors must declare
51
69
  // a real range (e.g. "^0.3.0"). Installed widgets that already ship "*" are
52
70
  // still tolerated by the loader with a warn-and-mount, but new publishes
@@ -157,8 +175,7 @@ export async function runValidate(
157
175
 
158
176
  const failed: string[] = [];
159
177
 
160
- // Manifest sdkVersion range must admit the pinned SDK, else it lies about compatibility.
161
- const projectSdk = getProjectSdkVersion(cwd);
178
+ const installedSdk = getInstalledSdkVersion(cwd);
162
179
 
163
180
  for (const name of toValidate) {
164
181
  if (!quiet) log.step(name);
@@ -175,7 +192,7 @@ export async function runValidate(
175
192
  continue;
176
193
  }
177
194
 
178
- const result = validateManifest(manifest);
195
+ const result = validateManifest(manifest, installedSdk);
179
196
 
180
197
  if (result.errors.length > 0) {
181
198
  for (const err of result.errors) {
@@ -189,19 +206,6 @@ export async function runValidate(
189
206
  }
190
207
  }
191
208
 
192
- if (
193
- !quiet &&
194
- projectSdk &&
195
- manifest.sdkVersion &&
196
- manifest.sdkVersion !== "*" &&
197
- semver.validRange(manifest.sdkVersion) &&
198
- !semver.satisfies(projectSdk, manifest.sdkVersion)
199
- ) {
200
- log.warn(
201
- ` - manifest sdkVersion "${manifest.sdkVersion}" excludes the installed SDK ${projectSdk}; run \`bun widget upgrade\``,
202
- );
203
- }
204
-
205
209
  // Check bundle exists
206
210
  const bundlePath = resolve(cwd, "dist", `${name}.js`);
207
211
  if (!existsSync(bundlePath)) {