@glasshome/widget-cli 0.4.14 → 0.5.1

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.14",
3
+ "version": "0.5.1",
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.1.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();
@@ -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";
@@ -192,6 +192,17 @@ export async function runPublish(
192
192
 
193
193
  const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
194
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
+
195
206
  s.start(`Publishing ${manifest.name}@${version}...`);
196
207
 
197
208
  let publishData: Awaited<ReturnType<typeof requestPublish>>;
@@ -208,6 +219,9 @@ export async function runPublish(
208
219
  version,
209
220
  bundleSize: bundleBuffer.byteLength,
210
221
  sha256Hash,
222
+ ...(cssBuffer && cssSha256Hash
223
+ ? { cssSize: cssBuffer.byteLength, cssSha256Hash }
224
+ : {}),
211
225
  manifestJson: JSON.stringify(manifest),
212
226
  });
213
227
  } catch (err: any) {
@@ -227,11 +241,28 @@ export async function runPublish(
227
241
  process.exit(1);
228
242
  }
229
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
+
230
260
  s.message(`Confirming ${manifest.name}...`);
231
261
  try {
232
262
  const result = await confirmPublish(hubUrl, token!, publishData.versionId);
233
263
  s.stop(`Published ${manifest.name}@${version}`);
234
264
  log.info(`CDN: ${result.bundleUrl}`);
265
+ if (result.cssUrl) log.info(`CSS: ${result.cssUrl}`);
235
266
  } catch (err: any) {
236
267
  s.stop(`Confirmation failed: ${err.message}`);
237
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