@glasshome/widget-cli 0.9.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.9.0",
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": [
@@ -19,7 +20,8 @@
19
20
  "@iconify/types": "^2.0.0",
20
21
  "@types/bun": "latest",
21
22
  "@types/semver": "^7.0.0",
22
- "playwright": "^1.40.0"
23
+ "playwright": "^1.40.0",
24
+ "typescript": "^5"
23
25
  },
24
26
  "dependencies": {
25
27
  "@clack/prompts": "^1.6.0",
@@ -28,7 +30,7 @@
28
30
  "@fontsource-variable/geist-mono": "^5.2.8",
29
31
  "@glasshome/sync-layer": "0.4.0",
30
32
  "@glasshome/widget-contract": "0.2.0",
31
- "@glasshome/widget-sdk": "1.7.0",
33
+ "@glasshome/widget-sdk": "1.8.1",
32
34
  "@iconify-json/lucide": "^1.2.0",
33
35
  "@iconify-json/mdi": "^1.2.3",
34
36
  "@tailwindcss/vite": "^4.1.13",
@@ -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
@@ -43,46 +43,55 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
43
43
  process.exit(1);
44
44
  }
45
45
 
46
- log.success(`Captured ${summary.shots} shot(s) across ${summary.widgets} widget(s)`);
47
-
48
- if (summary.skipped.length) {
49
- log.info(`No examples (skipped): ${summary.skipped.join(", ")}`);
50
- }
51
-
52
- const network = summary.failures.filter((f) => f.kind === "network");
53
46
  const hangs = summary.failures.filter((f) => f.kind === "hang");
54
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;
55
52
 
56
- // Nothing can actually leave — DNS is blackholed. These are blocked attempts,
57
- // i.e. what each widget wanted from the network and must degrade without.
58
- if (network.length) {
59
- log.warn(
60
- `NETWORK: ${network.length} blocked request(s) — all denied, none left the machine\n` +
61
- network.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
62
- );
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}`);
63
58
  } else {
64
- log.success("NETWORK: no widget attempted to reach the network");
59
+ log.success(`Rendered ${summary.shots} previews (light + dark) into ${out}`);
65
60
  }
66
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.
67
68
  if (hangs.length) {
68
- log.error(
69
- `RENDER: ${hangs.length} render(s) failed or timed out\n` +
70
- hangs.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
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"),
71
72
  );
72
- } else {
73
- log.success("RENDER: every render settled inside the timeout");
74
73
  }
75
74
 
75
+ // Should never happen: a bundle's bytes changed mid-run. This is a real
76
+ // problem, not a slow render.
76
77
  if (integrity.length) {
77
78
  log.error(
78
- `INTEGRITY: ${integrity.length} widget(s) changed after pinning\n` +
79
- integrity.map((f) => ` ${f.widget} ${f.detail}`).join("\n"),
79
+ `A bundle changed while rendering (report this):\n` +
80
+ integrity.map((f) => ` · ${f.widget}`).join("\n"),
80
81
  );
81
- } else {
82
- log.success("INTEGRITY: every bundle matched its pinned hash");
83
82
  }
84
83
 
85
- log.info(`Previews written to ${color.cyan("preview/")}`);
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
+ }
86
95
 
87
96
  // Vite's dev server leaves live handles behind (file watchers, keep-alive
88
97
  // sockets from browsers that crashed mid-render), so the process would sit
@@ -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)) {
@@ -202,8 +202,8 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
202
202
  for (const a of lock.attempts) widgetAttempts.add(a);
203
203
  };
204
204
 
205
- try {
206
- await withRenderTimeout(`${widget} / ${label} / ${theme}`, async () => {
205
+ const attempt = () =>
206
+ withRenderTimeout(`${widget} / ${label} / ${theme}`, async () => {
207
207
  if (shared) {
208
208
  const page = await shared.newPage();
209
209
  try {
@@ -220,17 +220,22 @@ export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary>
220
220
  await withFreshBrowser(shoot);
221
221
  }
222
222
  });
223
- shot++;
224
- } catch (err) {
225
- failures.push({
226
- widget,
227
- kind: "hang",
228
- detail: `${label}/${theme}: ${err instanceof Error ? err.message : String(err)}`,
229
- });
230
- // A timed-out render leaves the browser wedged; drop it so the next
231
- // shot starts clean instead of cascading into more timeouts.
232
- await shared?.recycle().catch(() => {});
223
+
224
+ // Two tries. A render that crosses 30s is usually a heavy widget
225
+ // flaking under load, not a real failure: recycle (the wedged
226
+ // browser is why it stalled) and give it one clean retry before
227
+ // recording a miss.
228
+ let ok = false;
229
+ for (let tries = 0; tries < 2 && !ok; tries++) {
230
+ try {
231
+ await attempt();
232
+ ok = true;
233
+ } catch {
234
+ await shared?.recycle().catch(() => {});
235
+ }
233
236
  }
237
+ if (ok) shot++;
238
+ else failures.push({ widget, kind: "hang", detail: `${label} (${theme})` });
234
239
  }
235
240
  }
236
241
 
@@ -155,7 +155,6 @@ async function main(): Promise<void> {
155
155
  stage.style.height = `${height}px`;
156
156
 
157
157
  const ctx: ReactiveWidgetContext = {
158
- isEditMode: () => false,
159
158
  updateConfig: () => {},
160
159
  dimensions: () => ({ width, height }),
161
160
  };
@@ -0,0 +1,22 @@
1
+ import { homedir } from "node:os";
2
+ import { join, resolve, sep } from "node:path";
3
+
4
+ /**
5
+ * Whether a resolved module path is bun's global install cache rather than a
6
+ * real install.
7
+ *
8
+ * With a package absent, bun still resolves its bare name, from
9
+ * `~/.bun/install/cache/typescript@7.0.2@@@1/`. Tools that shell out to what
10
+ * they resolved would then run a version the project never installed.
11
+ *
12
+ * Testing for a `node_modules` segment instead looks equivalent and is not: in
13
+ * a workspace, `@glasshome/widget-sdk` resolves through a symlink to its real
14
+ * source directory, which has no such segment, so that test rejects the very
15
+ * install it should accept and the guard silently does nothing.
16
+ */
17
+ export function isBunGlobalCache(modulePath: string): boolean {
18
+ const cacheDir =
19
+ process.env.BUN_INSTALL_CACHE_DIR ??
20
+ join(process.env.BUN_INSTALL ?? join(homedir(), ".bun"), "install", "cache");
21
+ return resolve(modulePath).startsWith(resolve(cacheDir) + sep);
22
+ }
@@ -0,0 +1,85 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { isBunGlobalCache } from "./resolution";
6
+ import { bareVersion, defaultSdkRange, getInstalledSdkVersion } from "./sdk-version";
7
+
8
+ const tmpDir = mkdtempSync(join(tmpdir(), "glasshome-sdk-version-"));
9
+
10
+ afterAll(() => {
11
+ rmSync(tmpDir, { recursive: true, force: true });
12
+ });
13
+
14
+ let fixtureCount = 0;
15
+
16
+ /** A fake installed SDK, either under node_modules or as a workspace symlink
17
+ to a source tree, which is how a monorepo actually resolves it. */
18
+ function project(opts: { version?: string; asWorkspaceLink?: boolean }): string {
19
+ const dir = join(tmpDir, `project-${fixtureCount++}`);
20
+ mkdirSync(dir, { recursive: true });
21
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture" }));
22
+
23
+ if (opts.version) {
24
+ const target = opts.asWorkspaceLink
25
+ ? join(dir, "packages", "widget-sdk")
26
+ : join(dir, "node_modules", "@glasshome", "widget-sdk");
27
+ mkdirSync(target, { recursive: true });
28
+ writeFileSync(
29
+ join(target, "package.json"),
30
+ JSON.stringify({ name: "@glasshome/widget-sdk", version: opts.version }),
31
+ );
32
+ if (opts.asWorkspaceLink) {
33
+ mkdirSync(join(dir, "node_modules", "@glasshome"), { recursive: true });
34
+ symlinkSync(target, join(dir, "node_modules", "@glasshome", "widget-sdk"), "dir");
35
+ }
36
+ }
37
+ return dir;
38
+ }
39
+
40
+ describe("getInstalledSdkVersion", () => {
41
+ test("reads the version from a normal install", () => {
42
+ expect(getInstalledSdkVersion(project({ version: "1.8.1" }))).toBe("1.8.1");
43
+ });
44
+
45
+ test("reads it through a workspace symlink to a source tree", () => {
46
+ // Regression: an earlier version required a `node_modules` segment in the
47
+ // resolved path. A workspace symlink resolves to the SDK's real source
48
+ // directory, which has none, so the check returned null and every guard
49
+ // built on it silently did nothing in the monorepo.
50
+ expect(getInstalledSdkVersion(project({ version: "1.7.0", asWorkspaceLink: true }))).toBe(
51
+ "1.7.0",
52
+ );
53
+ });
54
+
55
+ test("returns null when no SDK is installed", () => {
56
+ // Must not fall back to bun's global cache copy.
57
+ expect(getInstalledSdkVersion(project({}))).toBeNull();
58
+ });
59
+ });
60
+
61
+ describe("defaultSdkRange", () => {
62
+ test("carets the installed version", () => {
63
+ expect(defaultSdkRange(project({ version: "1.8.1" }))).toBe("^1.8.1");
64
+ });
65
+
66
+ test("falls back to the SDK this CLI ships against", () => {
67
+ // No project SDK, so it must still produce a 1.x range rather than nothing:
68
+ // a pre-1.0 range would excuse the widget from declaring capabilities.
69
+ const range = defaultSdkRange(project({}));
70
+ expect(range).toMatch(/^\^\d+\.\d+\.\d+/);
71
+ expect(bareVersion(range as string).startsWith("0.")).toBe(false);
72
+ });
73
+ });
74
+
75
+ describe("isBunGlobalCache", () => {
76
+ test("recognises the cache directory", () => {
77
+ const cache = join(tmpDir, "cache");
78
+ process.env.BUN_INSTALL_CACHE_DIR = cache;
79
+ expect(isBunGlobalCache(join(cache, "typescript@7.0.2@@@1", "package.json"))).toBe(true);
80
+ expect(isBunGlobalCache(join(tmpDir, "node_modules", "typescript", "package.json"))).toBe(
81
+ false,
82
+ );
83
+ delete process.env.BUN_INSTALL_CACHE_DIR;
84
+ });
85
+ });
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { join, resolve } from "node:path";
4
+ import { isBunGlobalCache } from "./resolution";
5
+
6
+ /**
7
+ * The SDK a widget actually builds against.
8
+ *
9
+ * `@glasshome/widget-sdk` is host-provided (`HOST_PROVIDED_MODULES`), so the
10
+ * build externalizes it and the published bundle runs against whatever SDK the
11
+ * host serves. That is why `manifest.sdkVersion` exists: it is the range the
12
+ * host checks before mounting. It must be derived from, and checked against,
13
+ * the SDK really resolved here — comparing it to the range declared in
14
+ * package.json only ever compares a claim with itself.
15
+ */
16
+
17
+ const SDK_PKG = "@glasshome/widget-sdk";
18
+
19
+ /**
20
+ * Last resort when neither the project nor this CLI can be read. Deliberately a
21
+ * 1.x range: anything below 1.0.0 excuses a widget from declaring capabilities,
22
+ * so a wrong-but-modern default fails loudly instead of silently opting out of
23
+ * the security contract.
24
+ */
25
+ export const FALLBACK_SDK_RANGE = "^1.0.0";
26
+
27
+ /**
28
+ * Version of the installed SDK, or null when none is installed.
29
+ *
30
+ * An ancestor's `node_modules` is fine and expected (hoisted installs put it
31
+ * there), as is a workspace symlink resolving to the SDK's own source tree.
32
+ * Bun's global install cache is not: with nothing installed it still resolves
33
+ * the bare name, and that is not what the build would use.
34
+ */
35
+ export function getInstalledSdkVersion(cwd: string): string | null {
36
+ try {
37
+ const require = createRequire(join(cwd, "package.json"));
38
+ const pkgPath = resolve(require.resolve(`${SDK_PKG}/package.json`));
39
+ if (isBunGlobalCache(pkgPath)) return null;
40
+ const version = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
41
+ return typeof version === "string" ? version : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The SDK this CLI itself was built against, used when scaffolding a project
49
+ * that has nothing installed yet.
50
+ */
51
+ export function getCliSdkVersion(): string | null {
52
+ try {
53
+ const pkgPath = resolve(import.meta.dir, "../../package.json");
54
+ if (!existsSync(pkgPath)) return null;
55
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
56
+ const range = pkg.dependencies?.[SDK_PKG] ?? pkg.peerDependencies?.[SDK_PKG];
57
+ return typeof range === "string" ? range.replace(/^[\^~]/, "") : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The `^x.y.z` range to write when scaffolding: what the project has installed,
65
+ * else what this CLI ships against. A caret, so patch and minor SDK releases do
66
+ * not invalidate every published widget.
67
+ */
68
+ export function defaultSdkRange(cwd?: string): string | null {
69
+ const version = (cwd ? getInstalledSdkVersion(cwd) : null) ?? getCliSdkVersion();
70
+ return version ? `^${version}` : null;
71
+ }
72
+
73
+ /** Strip a leading caret/tilde so a range can be compared as a version. */
74
+ export function bareVersion(range: string): string {
75
+ return range.replace(/^[\^~]/, "");
76
+ }
@@ -0,0 +1,128 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { cpSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import { tmpdir } from "node:os";
5
+ import { dirname, join, sep } from "node:path";
6
+ import { runTypecheck } from "./typecheck";
7
+
8
+ // Inside the package so fixtures can resolve typescript from its node_modules,
9
+ // which is what a hoisted or workspace install looks like.
10
+ const tmpDir = mkdtempSync(join(import.meta.dir, ".typecheck-test-"));
11
+ // Outside it, for the one case that must find no typescript anywhere above it.
12
+ const isolatedDir = mkdtempSync(join(tmpdir(), "glasshome-typecheck-"));
13
+
14
+ afterAll(() => {
15
+ rmSync(tmpDir, { recursive: true, force: true });
16
+ rmSync(isolatedDir, { recursive: true, force: true });
17
+ });
18
+
19
+ /**
20
+ * A real installed typescript to link into fixtures, or null.
21
+ *
22
+ * Resolved lazily and allowed to be absent: this package does not declare
23
+ * typescript, so in the monorepo it comes from the root's node_modules and in a
24
+ * standalone checkout there may be none at all. A hard resolve at module load
25
+ * would take the whole file down there. The `node_modules` check keeps bun's
26
+ * global-cache fallback out, which would not be a real install.
27
+ */
28
+ function findTypescript(): string | null {
29
+ try {
30
+ const pkg = createRequire(import.meta.url).resolve("typescript/package.json");
31
+ return pkg.split(sep).includes("node_modules") ? dirname(pkg) : null;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const typescriptDir = findTypescript();
38
+ const noTypescript = typescriptDir === null;
39
+
40
+ let fixtureCount = 0;
41
+
42
+ interface ProjectOptions {
43
+ source?: string;
44
+ tsconfig?: boolean;
45
+ typescript?: boolean;
46
+ /** Somewhere with no node_modules above it, so nothing can be inherited. */
47
+ isolated?: boolean;
48
+ }
49
+
50
+ function project({
51
+ source,
52
+ tsconfig = true,
53
+ typescript = true,
54
+ isolated = false,
55
+ }: ProjectOptions): string {
56
+ const dir = join(isolated ? isolatedDir : tmpDir, `project-${fixtureCount++}`);
57
+ mkdirSync(join(dir, "src"), { recursive: true });
58
+ writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "fixture", type: "module" }));
59
+ if (tsconfig) {
60
+ writeFileSync(
61
+ join(dir, "tsconfig.json"),
62
+ JSON.stringify({
63
+ compilerOptions: {
64
+ strict: true,
65
+ noEmit: true,
66
+ module: "ESNext",
67
+ target: "ESNext",
68
+ moduleResolution: "bundler",
69
+ // Hermetic: without this tsc auto-loads every @types package it finds
70
+ // walking up to the monorepo root, which is both irrelevant to what
71
+ // is under test and slow enough to blow the test timeout.
72
+ types: [],
73
+ },
74
+ include: ["src"],
75
+ }),
76
+ );
77
+ }
78
+ if (typescript && typescriptDir) {
79
+ mkdirSync(join(dir, "node_modules"), { recursive: true });
80
+ symlinkSync(typescriptDir, join(dir, "node_modules", "typescript"), "dir");
81
+ }
82
+ writeFileSync(join(dir, "src", "index.ts"), source ?? "export const n: number = 1;\n");
83
+ return dir;
84
+ }
85
+
86
+ describe("runTypecheck", () => {
87
+ test.skipIf(noTypescript)("passes a project whose types are sound", () => {
88
+ expect(runTypecheck(project({})).status).toBe("ok");
89
+ });
90
+
91
+ test.skipIf(noTypescript)("fails on a type error and reports the file and message", () => {
92
+ const result = runTypecheck(project({ source: `export const n: number = "nope";\n` }));
93
+ expect(result.status).toBe("failed");
94
+ if (result.status !== "failed") return;
95
+ expect(result.output).toContain("src/index.ts");
96
+ expect(result.output).toContain("TS2322");
97
+ });
98
+
99
+ test("skips a project with no tsconfig rather than failing it", () => {
100
+ // A JS-only or custom-setup project is not broken, it is just not ours to check.
101
+ const result = runTypecheck(project({ tsconfig: false }));
102
+ expect(result.status).toBe("skipped");
103
+ if (result.status !== "skipped") return;
104
+ expect(result.reason).toContain("tsconfig");
105
+ });
106
+
107
+ test("skips when typescript is not installed in the project", () => {
108
+ // Load-bearing: with nothing installed, bun still resolves a bare
109
+ // "typescript" from its global install cache, so without the node_modules
110
+ // check this would silently check against whatever major is cached there.
111
+ const result = runTypecheck(project({ typescript: false, isolated: true }));
112
+ expect(result.status).toBe("skipped");
113
+ if (result.status !== "skipped") return;
114
+ expect(result.reason).toContain("not installed");
115
+ });
116
+
117
+ test.skipIf(noTypescript)("uses an ancestor's typescript, as hoisted and workspace installs give", () => {
118
+ // Requiring a copy inside the project itself would skip every monorepo.
119
+ const parent = project({ typescript: true });
120
+ const child = join(parent, "nested");
121
+ mkdirSync(join(child, "src"), { recursive: true });
122
+ cpSync(join(parent, "package.json"), join(child, "package.json"));
123
+ cpSync(join(parent, "tsconfig.json"), join(child, "tsconfig.json"));
124
+ writeFileSync(join(child, "src", "index.ts"), `export const n: number = "nope";\n`);
125
+
126
+ expect(runTypecheck(child).status).toBe("failed");
127
+ });
128
+ });
@@ -0,0 +1,84 @@
1
+ import { existsSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { log } from "@clack/prompts";
5
+ import { isBunGlobalCache } from "./resolution";
6
+
7
+ /**
8
+ * Typecheck a widget project before building it.
9
+ *
10
+ * Scaffolded projects build with a bare `vite build`, which strips types without
11
+ * checking them, so a widget could bundle and publish with its config type and
12
+ * its `configSchema` disagreeing — including `examples` whose config the widget
13
+ * would never accept. tsc is the only thing that catches that at authoring time.
14
+ *
15
+ * Skips (loudly) rather than failing when the project has no tsconfig or no
16
+ * local typescript: a guard that cannot run has to say so.
17
+ */
18
+
19
+ export type TypecheckResult =
20
+ | { status: "ok" }
21
+ | { status: "failed"; output: string }
22
+ | { status: "skipped"; reason: string };
23
+
24
+ /**
25
+ * An installed typescript, never bun's global cache.
26
+ *
27
+ * Load-bearing: when a project has no typescript installed, bun still resolves
28
+ * the bare name, from `~/.bun/install/cache/typescript@7.0.2@@@1/`, so the CLI
29
+ * would silently check against whatever major happens to be cached there.
30
+ * Resolving to an *ancestor's* `node_modules` is fine and deliberate: hoisted
31
+ * and workspace installs put it there, so requiring a copy inside the project
32
+ * would skip every monorepo.
33
+ */
34
+ function resolveTsc(cwd: string): string | null {
35
+ try {
36
+ const require = createRequire(join(cwd, "package.json"));
37
+ const pkg = resolve(require.resolve("typescript/package.json"));
38
+ if (isBunGlobalCache(pkg)) return null;
39
+ const bin = join(dirname(pkg), "bin", "tsc");
40
+ return existsSync(bin) ? bin : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ export function runTypecheck(cwd: string): TypecheckResult {
47
+ const tsconfig = join(cwd, "tsconfig.json");
48
+ if (!existsSync(tsconfig)) return { status: "skipped", reason: "no tsconfig.json" };
49
+
50
+ const tsc = resolveTsc(cwd);
51
+ if (!tsc) {
52
+ return { status: "skipped", reason: "typescript is not installed in this project" };
53
+ }
54
+
55
+ const proc = Bun.spawnSync([process.execPath, tsc, "--noEmit", "-p", tsconfig], {
56
+ cwd,
57
+ stdout: "pipe",
58
+ stderr: "pipe",
59
+ });
60
+ if (proc.exitCode === 0) return { status: "ok" };
61
+
62
+ const output = [proc.stdout.toString(), proc.stderr.toString()].join("").trim();
63
+ return { status: "failed", output: output || `tsc exited ${proc.exitCode}` };
64
+ }
65
+
66
+ /**
67
+ * Typecheck and report. Returns false when the build should stop.
68
+ *
69
+ * Blocking on purpose: a type error here means the bundle that reaches the Hub
70
+ * is built from code the author never checked.
71
+ */
72
+ export function typecheckAndReport(cwd: string): boolean {
73
+ const result = runTypecheck(cwd);
74
+ if (result.status === "ok") return true;
75
+
76
+ if (result.status === "skipped") {
77
+ log.warn(`Types were NOT checked: ${result.reason}.`);
78
+ return true;
79
+ }
80
+
81
+ log.error("Typecheck failed:");
82
+ log.message(result.output);
83
+ return false;
84
+ }
@@ -18,7 +18,12 @@ bun install
18
18
  bun run build
19
19
  ```
20
20
 
21
- This produces `dist/{widget}.js` for each widget and generates `dist/registry.json`.
21
+ This typechecks the project, produces `dist/{widget}.js` for each widget and
22
+ generates `dist/registry.json`. To check types on their own:
23
+
24
+ ```bash
25
+ bun run typecheck
26
+ ```
22
27
 
23
28
  ## Adding widgets
24
29
 
@@ -7,13 +7,14 @@
7
7
  "dist"
8
8
  ],
9
9
  "scripts": {
10
- "build": "vite build",
10
+ "build": "tsc --noEmit && vite build",
11
+ "typecheck": "tsc --noEmit",
11
12
  "dev": "vite build --watch",
12
13
  "widget": "glasshome-widget"
13
14
  },
14
15
  "peerDependencies": {
15
16
  "solid-js": "^1.9.11",
16
- "@glasshome/widget-sdk": "^0.2.0"
17
+ "@glasshome/widget-sdk": "SDK_RANGE"
17
18
  },
18
19
  "devDependencies": {
19
20
  "@glasshome/widget-cli": "CLI_VERSION",
@@ -31,7 +31,7 @@ export default defineWidget<WidgetConfig>({
31
31
  minSize: { w: 1, h: 1 },
32
32
  maxSize: { w: 4, h: 4 },
33
33
  defaultSize: { w: 2, h: 2 },
34
- sdkVersion: "^0.2.0",
34
+ sdkVersion: "SDK_RANGE",
35
35
  schema: {
36
36
  type: "object",
37
37
  properties: {