@glasshome/widget-cli 0.4.13 → 0.5.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.4.13",
3
+ "version": "0.5.0",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^0.10.0",
24
- "@glasshome/widget-sdk": "^0.5.0",
24
+ "@glasshome/widget-contract": "0.1.0",
25
+ "@glasshome/widget-sdk": "1.0.0",
25
26
  "open": "^11.0.0",
26
27
  "semver": "^7.6.0",
27
28
  "vite-plugin-solid": "^2.11.0"
@@ -52,8 +52,28 @@ async function uploadAndRegister(
52
52
  throw new Error(`Failed to upload bundle for ${slug}: HTTP ${uploadRes.status}`);
53
53
  }
54
54
 
55
+ // Widgets render in shadow roots and need their own stylesheet uploaded
56
+ // alongside the bundle.
57
+ const cssPath = resolve(distDir, `${slug}.css`);
58
+ let cssUrl: string | undefined;
59
+ if (existsSync(cssPath)) {
60
+ const cssRes = await fetch(`${api}/bundles/local/local/${slug}`, {
61
+ method: "POST",
62
+ headers: {
63
+ "Content-Type": "text/css",
64
+ Authorization: `Bearer ${token}`,
65
+ },
66
+ body: readFileSync(cssPath, "utf-8"),
67
+ });
68
+ if (!cssRes.ok) {
69
+ throw new Error(`Failed to upload stylesheet for ${slug}: HTTP ${cssRes.status}`);
70
+ }
71
+ cssUrl = `/bundles/local/local/${slug}/bundle.css`;
72
+ }
73
+
55
74
  const manifest = { ...widget };
56
75
  delete (manifest as Record<string, unknown>).bundleUrl;
76
+ delete (manifest as Record<string, unknown>).cssUrl;
57
77
 
58
78
  await trpcMutate({
59
79
  apiUrl: api,
@@ -64,6 +84,7 @@ async function uploadAndRegister(
64
84
  name: slug,
65
85
  version: widget.version,
66
86
  bundleUrl: `/bundles/local/local/${slug}/bundle.js`,
87
+ ...(cssUrl ? { cssUrl } : {}),
67
88
  manifestJson: JSON.stringify(manifest),
68
89
  },
69
90
  });
@@ -94,7 +115,13 @@ export async function runConnect(
94
115
  ): Promise<void> {
95
116
  const distDir = resolve(cwd, "dist");
96
117
  const solid = (await import("vite-plugin-solid")).default;
97
- const buildOpts = { srcDir: "src", outDir: "dist", plugins: [solid()] };
118
+ // delegateEvents: false — widgets run in closed shadow roots where Solid's
119
+ // document-level event delegation cannot see the target.
120
+ const buildOpts = {
121
+ srcDir: "src",
122
+ outDir: "dist",
123
+ plugins: [solid({ solid: { delegateEvents: false } })],
124
+ };
98
125
 
99
126
  // Step 1: Initial build
100
127
  const s = spinner();
@@ -10,6 +10,7 @@ import {
10
10
  } from "node:fs";
11
11
  import { dirname, join, resolve } from "node:path";
12
12
  import { cancel, confirm, isCancel, log, spinner, text } from "@clack/prompts";
13
+ import { getCliVersion } from "../utils/version";
13
14
  import { promptWidgetDetails, scaffoldWidget } from "./add";
14
15
 
15
16
  const templateDir = resolve(import.meta.dir, "../../template");
@@ -114,11 +115,15 @@ export async function runCreate() {
114
115
  const projectDesc =
115
116
  (projectDescription as string) || `A GlassHome widget project: ${projectName}`;
116
117
 
117
- // Replace project-level placeholders in package.json
118
+ // Replace project-level placeholders in package.json. Pin the CLI to the
119
+ // version that scaffolded the project (caret) so `bun widget` resolves the
120
+ // project-local binary and `bun update` keeps it current, instead of a
121
+ // stale global/bunx copy.
118
122
  const pkgPath = join(targetDir, "package.json");
119
123
  let pkgContent = readFileSync(pkgPath, "utf-8");
120
124
  pkgContent = pkgContent.replace(/PROJECT_NAME/g, projectName as string);
121
125
  pkgContent = pkgContent.replace(/PROJECT_DESCRIPTION/g, projectDesc);
126
+ pkgContent = pkgContent.replace(/CLI_VERSION/g, `^${getCliVersion()}`);
122
127
  writeFileSync(pkgPath, pkgContent);
123
128
 
124
129
  // Replace project-level placeholders in README.md
@@ -3,6 +3,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
3
3
  import { log, spinner } from "@clack/prompts";
4
4
  import open from "open";
5
5
  import { getHubUrl, storeToken } from "../utils/auth";
6
+ import { enforceCliVersion, notifyCliUpdate } from "../utils/version";
6
7
 
7
8
  // Public client: PKCE authenticates it, no client secret. The redirect is an
8
9
  // IP-loopback URI (RFC 8252) registered on the hub.
@@ -19,6 +20,11 @@ function base64url(buf: Buffer): string {
19
20
  export async function runLogin(hubUrl?: string): Promise<void> {
20
21
  const hub = hubUrl ?? getHubUrl();
21
22
 
23
+ // Refuse to start the OAuth dance if the hub no longer supports this CLI;
24
+ // otherwise a stale client fails mid-flow with an opaque redirect error.
25
+ await enforceCliVersion(hub);
26
+ await notifyCliUpdate();
27
+
22
28
  // Generate PKCE parameters
23
29
  const codeVerifier = base64url(randomBytes(32));
24
30
  const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { readFileSync } from "node:fs";
2
+ import { existsSync, readFileSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { cancel, isCancel, log, select, spinner } from "@clack/prompts";
5
5
  import semver from "semver";
@@ -12,6 +12,7 @@ import {
12
12
  readManifest,
13
13
  writeManifest,
14
14
  } from "../utils/manifest";
15
+ import { enforceCliVersion } from "../utils/version";
15
16
  import { runLogin } from "./login";
16
17
 
17
18
  interface PublishOptions {
@@ -38,6 +39,10 @@ export async function runPublish(
38
39
  // Step 2: Authenticate
39
40
  const hubUrl = hubUrlOverride ?? getHubUrl();
40
41
  if (hubUrlOverride) log.info(`Hub: ${hubUrl}`);
42
+
43
+ // Stop an unsupported CLI before it uploads against a protocol it predates.
44
+ await enforceCliVersion(hubUrl);
45
+
41
46
  let token = await getToken(hubUrl);
42
47
 
43
48
  if (!token) {
@@ -187,6 +192,17 @@ export async function runPublish(
187
192
 
188
193
  const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
189
194
 
195
+ // Optional stylesheet emitted next to the bundle; published alongside it
196
+ // so the Hub can serve and integrity-check both.
197
+ const cssPath = resolve(cwd, "dist", `${widgetName}.css`);
198
+ let cssBuffer: Buffer | null = null;
199
+ if (existsSync(cssPath)) {
200
+ cssBuffer = Buffer.from(readFileSync(cssPath));
201
+ }
202
+ const cssSha256Hash = cssBuffer
203
+ ? createHash("sha256").update(cssBuffer).digest("hex")
204
+ : undefined;
205
+
190
206
  s.start(`Publishing ${manifest.name}@${version}...`);
191
207
 
192
208
  let publishData: Awaited<ReturnType<typeof requestPublish>>;
@@ -203,6 +219,9 @@ export async function runPublish(
203
219
  version,
204
220
  bundleSize: bundleBuffer.byteLength,
205
221
  sha256Hash,
222
+ ...(cssBuffer && cssSha256Hash
223
+ ? { cssSize: cssBuffer.byteLength, cssSha256Hash }
224
+ : {}),
206
225
  manifestJson: JSON.stringify(manifest),
207
226
  });
208
227
  } catch (err: any) {
@@ -222,11 +241,28 @@ export async function runPublish(
222
241
  process.exit(1);
223
242
  }
224
243
 
244
+ if (cssBuffer) {
245
+ if (!publishData.cssUploadUrl) {
246
+ s.stop(
247
+ `dist/${widgetName}.css exists but the Hub did not return a css upload URL — update the Hub or remove the stylesheet`,
248
+ );
249
+ process.exit(1);
250
+ }
251
+ s.message(`Uploading ${manifest.name} styles to CDN...`);
252
+ try {
253
+ await uploadToR2(publishData.cssUploadUrl, cssBuffer, "text/css");
254
+ } catch (err: any) {
255
+ s.stop(`CSS upload failed: ${err.message}`);
256
+ process.exit(1);
257
+ }
258
+ }
259
+
225
260
  s.message(`Confirming ${manifest.name}...`);
226
261
  try {
227
262
  const result = await confirmPublish(hubUrl, token!, publishData.versionId);
228
263
  s.stop(`Published ${manifest.name}@${version}`);
229
264
  log.info(`CDN: ${result.bundleUrl}`);
265
+ if (result.cssUrl) log.info(`CSS: ${result.cssUrl}`);
230
266
  } catch (err: any) {
231
267
  s.stop(`Confirmation failed: ${err.message}`);
232
268
  process.exit(1);
@@ -1,6 +1,11 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { log, spinner } from "@clack/prompts";
4
+ import {
5
+ formatSchemaError,
6
+ publishManifestSchema,
7
+ requiresCapabilities,
8
+ } from "@glasshome/widget-contract";
4
9
  import {
5
10
  discoverWidgets,
6
11
  formatBytes,
@@ -28,9 +33,17 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
28
33
  typeof manifest.maxSize.w === "number" &&
29
34
  typeof manifest.maxSize.h === "number";
30
35
 
31
- // Required fields
32
- if (!manifest.name) errors.push("Missing required field: name");
33
- if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
36
+ // Canonical publish-time validation shared with the Hub.
37
+ const schemaResult = publishManifestSchema.safeParse(manifest);
38
+ if (!schemaResult.success) {
39
+ errors.push(`Manifest schema: ${formatSchemaError(schemaResult.error)}`);
40
+ const capabilities = (manifest as { capabilities?: unknown }).capabilities;
41
+ if (manifest.sdkVersion && requiresCapabilities(manifest.sdkVersion) && capabilities === undefined) {
42
+ errors.push(
43
+ `sdkVersion "${manifest.sdkVersion}" only admits SDK >= 1.0.0, so manifest.json must declare "capabilities" — use [] if the widget never reads or controls Home Assistant`,
44
+ );
45
+ }
46
+ }
34
47
 
35
48
  // INS-05: "*" sdkVersion is rejected at publish time — authors must declare
36
49
  // a real range (e.g. "^0.3.0"). Installed widgets that already ship "*" are
@@ -42,13 +55,7 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
42
55
  );
43
56
  }
44
57
 
45
- // Size validation
46
- if (!hasValidMinSize) {
47
- errors.push("minSize must be an object with numeric w and h properties");
48
- }
49
- if (!hasValidMaxSize) {
50
- errors.push("maxSize must be an object with numeric w and h properties");
51
- }
58
+ // Size validation (cross-field checks the schema does not cover)
52
59
  if (
53
60
  manifest.defaultSize &&
54
61
  (typeof manifest.defaultSize.w !== "number" || typeof manifest.defaultSize.h !== "number")
@@ -29,6 +29,8 @@ interface PublishRequestParams {
29
29
  version: string;
30
30
  bundleSize: number;
31
31
  sha256Hash: string;
32
+ cssSize?: number;
33
+ cssSha256Hash?: string;
32
34
  manifestJson: string;
33
35
  }
34
36
 
@@ -37,11 +39,14 @@ interface PublishRequestResponse {
37
39
  cdnUrl: string;
38
40
  widgetId: string;
39
41
  versionId: string;
42
+ cssUploadUrl?: string;
43
+ cssUrl?: string;
40
44
  }
41
45
 
42
46
  interface PublishConfirmResponse {
43
47
  success: boolean;
44
48
  bundleUrl: string;
49
+ cssUrl?: string;
45
50
  }
46
51
 
47
52
  export async function requestPublish(
@@ -107,10 +112,14 @@ export async function confirmPublish(
107
112
  return res.json() as Promise<PublishConfirmResponse>;
108
113
  }
109
114
 
110
- export async function uploadToR2(uploadUrl: string, bundleBuffer: Uint8Array): Promise<void> {
115
+ export async function uploadToR2(
116
+ uploadUrl: string,
117
+ bundleBuffer: Uint8Array,
118
+ contentType: string = "application/javascript",
119
+ ): Promise<void> {
111
120
  const res = await fetch(uploadUrl, {
112
121
  method: "PUT",
113
- headers: { "Content-Type": "application/javascript" },
122
+ headers: { "Content-Type": contentType },
114
123
  body: bundleBuffer as unknown as BodyInit,
115
124
  });
116
125
 
@@ -0,0 +1,66 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { log } from "@clack/prompts";
4
+ import semver from "semver";
5
+
6
+ /** This CLI's own version, read from the package.json it ships with. */
7
+ export function getCliVersion(): string {
8
+ try {
9
+ const pkgPath = resolve(import.meta.dir, "../../package.json");
10
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")) as { version?: string };
11
+ return pkg.version ?? "0.0.0";
12
+ } catch {
13
+ return "0.0.0";
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Ask the hub for the minimum CLI version its protocol still accepts and stop
19
+ * with an actionable message when this CLI is below it. Older hub-facing breaks
20
+ * (e.g. the 0.4.11 OAuth redirect change) surfaced as opaque errors; this turns
21
+ * them into "update your CLI". Network/parse failures are non-fatal: the check
22
+ * never blocks publishing when the hub can't be reached.
23
+ */
24
+ export async function enforceCliVersion(hubUrl: string): Promise<void> {
25
+ const current = getCliVersion();
26
+
27
+ let minSupported: string | undefined;
28
+ try {
29
+ const res = await fetch(`${hubUrl}/api/widgets/cli-version`);
30
+ if (res.ok) {
31
+ const data = (await res.json()) as { minSupported?: string };
32
+ minSupported = data.minSupported;
33
+ }
34
+ } catch {
35
+ return;
36
+ }
37
+
38
+ if (minSupported && semver.valid(minSupported) && semver.lt(current, minSupported)) {
39
+ throw new Error(
40
+ `This widget CLI (${current}) is no longer supported by ${hubUrl} (minimum ${minSupported}).\n` +
41
+ "Update it: bun add -g @glasshome/widget-cli@latest\n" +
42
+ "Or run once without installing: bunx @glasshome/widget-cli@latest <command>",
43
+ );
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Best-effort nudge: warn when a newer CLI is published on npm. Never blocks;
49
+ * any failure (offline, registry hiccup) is swallowed.
50
+ */
51
+ export async function notifyCliUpdate(): Promise<void> {
52
+ const current = getCliVersion();
53
+ try {
54
+ const res = await fetch("https://registry.npmjs.org/@glasshome/widget-cli/latest");
55
+ if (!res.ok) return;
56
+ const data = (await res.json()) as { version?: string };
57
+ const latest = data.version;
58
+ if (latest && semver.valid(latest) && semver.gt(latest, current)) {
59
+ log.warn(
60
+ `A newer widget CLI is available (${current} → ${latest}). Update: bun add -g @glasshome/widget-cli@latest`,
61
+ );
62
+ }
63
+ } catch {
64
+ // ignore
65
+ }
66
+ }
@@ -16,6 +16,7 @@
16
16
  "@glasshome/widget-sdk": "^0.2.0"
17
17
  },
18
18
  "devDependencies": {
19
+ "@glasshome/widget-cli": "CLI_VERSION",
19
20
  "@types/bun": "latest",
20
21
  "typescript": "^5.0.0",
21
22
  "vite": "^7.3.1",