@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 +3 -2
- package/src/commands/connect.ts +28 -1
- package/src/commands/create.ts +6 -1
- package/src/commands/login.ts +6 -0
- package/src/commands/publish.ts +37 -1
- package/src/commands/validate.ts +17 -10
- package/src/utils/hub-api.ts +11 -2
- package/src/utils/version.ts +66 -0
- package/template/package.json.template +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.
|
|
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-
|
|
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"
|
package/src/commands/connect.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|
package/src/commands/create.ts
CHANGED
|
@@ -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
|
package/src/commands/login.ts
CHANGED
|
@@ -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());
|
package/src/commands/publish.ts
CHANGED
|
@@ -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);
|
package/src/commands/validate.ts
CHANGED
|
@@ -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
|
-
//
|
|
32
|
-
|
|
33
|
-
if (!
|
|
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")
|
package/src/utils/hub-api.ts
CHANGED
|
@@ -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(
|
|
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":
|
|
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
|
+
}
|