@glasshome/widget-cli 0.1.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.
@@ -0,0 +1,95 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ interface StoredAuth {
6
+ accessToken: string;
7
+ refreshToken: string;
8
+ expiresAt: number;
9
+ hubUrl: string;
10
+ scope: string;
11
+ }
12
+
13
+ const AUTH_DIR = join(homedir(), ".glasshome");
14
+ const AUTH_FILE = join(AUTH_DIR, "auth.json");
15
+
16
+ function ensureDir(): void {
17
+ if (!existsSync(AUTH_DIR)) {
18
+ mkdirSync(AUTH_DIR, { recursive: true, mode: 0o700 });
19
+ }
20
+ }
21
+
22
+ export function storeToken(data: StoredAuth): void {
23
+ ensureDir();
24
+ writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
25
+ }
26
+
27
+ export function getStoredAuth(): StoredAuth | null {
28
+ if (!existsSync(AUTH_FILE)) return null;
29
+ try {
30
+ return JSON.parse(readFileSync(AUTH_FILE, "utf-8"));
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export async function getToken(hubUrl: string): Promise<string | null> {
37
+ const stored = getStoredAuth();
38
+ if (!stored) return null;
39
+ if (stored.hubUrl !== hubUrl) return null;
40
+
41
+ // Check if token is still valid (with 60s buffer)
42
+ if (Date.now() < stored.expiresAt - 60_000) {
43
+ return stored.accessToken;
44
+ }
45
+
46
+ // Try to refresh
47
+ if (!stored.refreshToken) return null;
48
+
49
+ try {
50
+ const res = await fetch(`${hubUrl}/api/auth/oauth2/token`, {
51
+ method: "POST",
52
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
53
+ body: new URLSearchParams({
54
+ grant_type: "refresh_token",
55
+ refresh_token: stored.refreshToken,
56
+ client_id: "glasshome-widget-cli",
57
+ client_secret: "glasshome-cli-public",
58
+ }),
59
+ });
60
+
61
+ if (!res.ok) return null;
62
+
63
+ const data = (await res.json()) as {
64
+ access_token: string;
65
+ refresh_token?: string;
66
+ expires_in: number;
67
+ };
68
+
69
+ storeToken({
70
+ accessToken: data.access_token,
71
+ refreshToken: data.refresh_token ?? stored.refreshToken,
72
+ expiresAt: Date.now() + data.expires_in * 1000,
73
+ hubUrl: stored.hubUrl,
74
+ scope: stored.scope,
75
+ });
76
+
77
+ return data.access_token;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export function clearToken(): void {
84
+ if (existsSync(AUTH_FILE)) {
85
+ unlinkSync(AUTH_FILE);
86
+ }
87
+ }
88
+
89
+ export function getHubUrl(): string {
90
+ return getStoredAuth()?.hubUrl ?? "https://glasshome.app";
91
+ }
92
+
93
+ export function getScope(): string | null {
94
+ return getStoredAuth()?.scope ?? null;
95
+ }
@@ -0,0 +1,104 @@
1
+ interface PublishScope {
2
+ name: string;
3
+ type: "personal" | "organization";
4
+ displayName: string;
5
+ }
6
+
7
+ export async function fetchScopes(hubUrl: string, token: string): Promise<PublishScope[]> {
8
+ const res = await fetch(`${hubUrl}/api/widgets/scopes`, {
9
+ headers: { Authorization: `Bearer ${token}` },
10
+ });
11
+ if (!res.ok) {
12
+ throw Object.assign(new Error("Failed to fetch scopes"), { status: res.status });
13
+ }
14
+ const data = (await res.json()) as { scopes: PublishScope[] };
15
+ return data.scopes;
16
+ }
17
+
18
+ interface PublishRequestParams {
19
+ scope: string;
20
+ name: string;
21
+ displayName: string;
22
+ description?: string;
23
+ icon?: string;
24
+ minSize: { w: number; h: number };
25
+ maxSize: { w: number; h: number };
26
+ sdkVersion: string;
27
+ version: string;
28
+ bundleSize: number;
29
+ sha256Hash: string;
30
+ manifestJson: string;
31
+ }
32
+
33
+ interface PublishRequestResponse {
34
+ uploadUrl: string;
35
+ cdnUrl: string;
36
+ widgetId: string;
37
+ versionId: string;
38
+ }
39
+
40
+ interface PublishConfirmResponse {
41
+ success: boolean;
42
+ bundleUrl: string;
43
+ }
44
+
45
+ export async function requestPublish(
46
+ hubUrl: string,
47
+ token: string,
48
+ params: PublishRequestParams,
49
+ ): Promise<PublishRequestResponse> {
50
+ const res = await fetch(`${hubUrl}/api/widgets/publish`, {
51
+ method: "POST",
52
+ headers: {
53
+ "Content-Type": "application/json",
54
+ Authorization: `Bearer ${token}`,
55
+ },
56
+ body: JSON.stringify({ action: "request", ...params }),
57
+ });
58
+
59
+ if (res.status === 409) {
60
+ const data = (await res.json()) as { error: string };
61
+ throw Object.assign(new Error(data.error), { status: 409 });
62
+ }
63
+
64
+ if (!res.ok) {
65
+ const text = await res.text();
66
+ throw new Error(`Publish request failed (${res.status}): ${text}`);
67
+ }
68
+
69
+ return res.json() as Promise<PublishRequestResponse>;
70
+ }
71
+
72
+ export async function confirmPublish(
73
+ hubUrl: string,
74
+ token: string,
75
+ versionId: string,
76
+ ): Promise<PublishConfirmResponse> {
77
+ const res = await fetch(`${hubUrl}/api/widgets/publish`, {
78
+ method: "POST",
79
+ headers: {
80
+ "Content-Type": "application/json",
81
+ Authorization: `Bearer ${token}`,
82
+ },
83
+ body: JSON.stringify({ action: "confirm", versionId }),
84
+ });
85
+
86
+ if (!res.ok) {
87
+ const text = await res.text();
88
+ throw new Error(`Publish confirm failed (${res.status}): ${text}`);
89
+ }
90
+
91
+ return res.json() as Promise<PublishConfirmResponse>;
92
+ }
93
+
94
+ export async function uploadToR2(uploadUrl: string, bundleBuffer: Buffer): Promise<void> {
95
+ const res = await fetch(uploadUrl, {
96
+ method: "PUT",
97
+ headers: { "Content-Type": "application/javascript" },
98
+ body: bundleBuffer,
99
+ });
100
+
101
+ if (!res.ok) {
102
+ throw new Error(`R2 upload failed (${res.status}): ${await res.text()}`);
103
+ }
104
+ }
@@ -0,0 +1,126 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { gzipSync } from "node:zlib";
4
+
5
+ export interface WidgetManifest {
6
+ tag: string;
7
+ name: string;
8
+ minSize: { w: number; h: number };
9
+ maxSize: { w: number; h: number };
10
+ sdkVersion: string;
11
+ version?: string;
12
+ description?: string;
13
+ schema?: Record<string, unknown>;
14
+ defaultConfig?: Record<string, unknown>;
15
+ }
16
+
17
+ export interface Registry {
18
+ version: number;
19
+ generatedAt: string;
20
+ baseUrl: string;
21
+ widgets: Array<WidgetManifest & { bundleUrl: string }>;
22
+ }
23
+
24
+ export interface BundleInfo {
25
+ raw: number;
26
+ gzip: number;
27
+ text: string;
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Legacy single-widget helpers (kept for backward compat)
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Extract the widget manifest from the built dist/index.js bundle
36
+ * by dynamic-importing it and reading the registered custom element's metadata.
37
+ */
38
+ export async function extractManifest(cwd: string): Promise<WidgetManifest> {
39
+ const distPath = resolve(cwd, "dist/index.js");
40
+ if (!existsSync(distPath)) {
41
+ throw new Error(
42
+ "dist/index.js not found. Run `bun run build` first or let the command build for you.",
43
+ );
44
+ }
45
+
46
+ // Dynamic import the bundle — it self-registers a custom element via @glasshome/widget-sdk's defineWidget.
47
+ // The SDK's defineWidget stores manifest on the custom element class.
48
+ const mod = await import(distPath);
49
+
50
+ // The module default export or the manifest property contains widget metadata
51
+ const manifest: WidgetManifest | undefined = mod.manifest ?? mod.default?.manifest;
52
+
53
+ if (!manifest) {
54
+ throw new Error(
55
+ "Could not extract manifest from dist/index.js. Ensure your widget uses defineWidget() from @glasshome/widget-sdk.",
56
+ );
57
+ }
58
+
59
+ return manifest;
60
+ }
61
+
62
+ /**
63
+ * Read the raw bundle text and compute sizes.
64
+ */
65
+ export function getBundleInfo(cwd: string): BundleInfo {
66
+ const distPath = resolve(cwd, "dist/index.js");
67
+ const text = readFileSync(distPath, "utf-8");
68
+ const raw = Buffer.byteLength(text, "utf-8");
69
+ const gzip = gzipSync(text).byteLength;
70
+ return { raw, gzip, text };
71
+ }
72
+
73
+ export function formatBytes(bytes: number): string {
74
+ if (bytes < 1024) return `${bytes} B`;
75
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
76
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Multi-widget helpers
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /** Discover all widget directories in src/ that contain a manifest.json. */
84
+ export function discoverWidgets(cwd: string): string[] {
85
+ const srcDir = resolve(cwd, "src");
86
+ if (!existsSync(srcDir)) return [];
87
+
88
+ return readdirSync(srcDir).filter((entry) => {
89
+ const dir = join(srcDir, entry);
90
+ return statSync(dir).isDirectory() && existsSync(join(dir, "manifest.json"));
91
+ });
92
+ }
93
+
94
+ /** Read a single widget's manifest.json from its source directory. */
95
+ export function readManifest(cwd: string, widgetName: string): WidgetManifest {
96
+ const manifestPath = resolve(cwd, "src", widgetName, "manifest.json");
97
+ if (!existsSync(manifestPath)) {
98
+ throw new Error(`No manifest.json found at src/${widgetName}/manifest.json`);
99
+ }
100
+ return JSON.parse(readFileSync(manifestPath, "utf-8"));
101
+ }
102
+
103
+ /** Write a manifest.json to a widget's source directory. */
104
+ export function writeManifest(cwd: string, widgetName: string, manifest: WidgetManifest): void {
105
+ const manifestPath = resolve(cwd, "src", widgetName, "manifest.json");
106
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
107
+ }
108
+
109
+ /** Read dist/registry.json if it exists. */
110
+ export function readRegistry(cwd: string): Registry | null {
111
+ const registryPath = resolve(cwd, "dist", "registry.json");
112
+ if (!existsSync(registryPath)) return null;
113
+ return JSON.parse(readFileSync(registryPath, "utf-8"));
114
+ }
115
+
116
+ /** Get bundle info for a specific widget from the dist/ directory. */
117
+ export function getWidgetBundleInfo(cwd: string, widgetName: string): BundleInfo {
118
+ const distPath = resolve(cwd, "dist", `${widgetName}.js`);
119
+ if (!existsSync(distPath)) {
120
+ throw new Error(`dist/${widgetName}.js not found. Run \`bun run build\` first.`);
121
+ }
122
+ const text = readFileSync(distPath, "utf-8");
123
+ const raw = Buffer.byteLength(text, "utf-8");
124
+ const gzip = gzipSync(text).byteLength;
125
+ return { raw, gzip, text };
126
+ }
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.local
@@ -0,0 +1,3 @@
1
+ [tools]
2
+ node = "22"
3
+ bun = "1.3"
@@ -0,0 +1,90 @@
1
+ # PROJECT_NAME
2
+
3
+ PROJECT_DESCRIPTION
4
+
5
+ A multi-widget project for the GlassHome dashboard.
6
+
7
+ ## Getting started
8
+
9
+ ### Install dependencies
10
+
11
+ ```bash
12
+ bun install
13
+ ```
14
+
15
+ ### Build all widgets
16
+
17
+ ```bash
18
+ bun run build
19
+ ```
20
+
21
+ This produces `dist/{widget}.js` for each widget and generates `dist/registry.json`.
22
+
23
+ ## Adding widgets
24
+
25
+ Add a new widget to the project:
26
+
27
+ ```bash
28
+ bun widget add
29
+ ```
30
+
31
+ This creates `src/{widget-name}/index.tsx` and `src/{widget-name}/manifest.json`.
32
+
33
+ ## Testing in the dashboard
34
+
35
+ Connect your widgets to a running GlassHome instance:
36
+
37
+ ```bash
38
+ bun widget connect http://<dashboard-api-host>:3333
39
+ ```
40
+
41
+ This builds all widgets, serves them on your LAN IP, registers them with the dashboard, and watches for file changes. Widgets hot-reload as you edit.
42
+
43
+ ## Project structure
44
+
45
+ ```
46
+ PROJECT_NAME/
47
+ src/
48
+ {widget-name}/
49
+ index.tsx # defineWidget() + component
50
+ manifest.json # metadata for registry generation
51
+ dist/ # after build
52
+ {widget-name}.js
53
+ registry.json # auto-generated, lists all widgets
54
+ ```
55
+
56
+ Each widget subdirectory contains:
57
+
58
+ - **`index.tsx`** — SolidJS component + `defineWidget()` registration
59
+ - **`manifest.json`** — metadata (tag, name, type, size, version)
60
+
61
+ ## CLI commands
62
+
63
+ All commands are available via `bun widget`:
64
+
65
+ ```bash
66
+ bun widget add # Add a new widget to the project
67
+ bun widget connect <url> # Test in a running dashboard
68
+ bun widget validate [name] # Validate all or a specific widget
69
+ bun widget info [name] # Show widget metadata and bundle info
70
+ bun widget publish # Build and publish to GlassHome Hub
71
+ bun widget upgrade # Upgrade @glasshome/widget-sdk
72
+ ```
73
+
74
+ ## Publishing
75
+
76
+ ```bash
77
+ bun widget validate
78
+ bun widget publish
79
+ ```
80
+
81
+ ## SDK reference
82
+
83
+ ```tsx
84
+ import {
85
+ defineWidget, // Define and register a widget
86
+ getThemeToken, // Read a CSS variable (e.g. getThemeToken("color-primary"))
87
+ isDark, // Returns true if dark mode is active
88
+ createEntity, // Create a reactive entity signal (SolidJS)
89
+ } from "@glasshome/widget-sdk";
90
+ ```
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "PROJECT_NAME",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "PROJECT_DESCRIPTION",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "scripts": {
10
+ "build": "vite build",
11
+ "dev": "vite build --watch",
12
+ "widget": "glasshome-widget"
13
+ },
14
+ "peerDependencies": {
15
+ "solid-js": "^1.9.11",
16
+ "@glasshome/widget-sdk": "^0.2.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/bun": "latest",
20
+ "typescript": "^5.0.0",
21
+ "vite": "^7.3.1",
22
+ "vite-plugin-solid": "^2.11.10"
23
+ }
24
+ }
@@ -0,0 +1,49 @@
1
+ import { defineWidget } from "@glasshome/widget-sdk";
2
+ import { type Component, createSignal } from "solid-js";
3
+
4
+ interface WidgetConfig {
5
+ label: string;
6
+ }
7
+
8
+ const WIDGET_NAMEWidget: Component<{ config: WidgetConfig }> = (props) => {
9
+ const [count, setCount] = createSignal(0);
10
+
11
+ return (
12
+ <div class="flex h-full w-full flex-col items-center justify-center gap-3">
13
+ <h3 class="text-sm font-medium text-muted-foreground">
14
+ {props.config.label || "WIDGET_NAME"}
15
+ </h3>
16
+ <p class="text-4xl font-bold">{count()}</p>
17
+ <button
18
+ type="button"
19
+ onClick={() => setCount((c) => c + 1)}
20
+ class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
21
+ >
22
+ Increment
23
+ </button>
24
+ </div>
25
+ );
26
+ };
27
+
28
+ export default defineWidget<WidgetConfig>({
29
+ manifest: {
30
+ tag: "WIDGET_TAG",
31
+ name: "WIDGET_NAME",
32
+ minSize: { w: 1, h: 1 },
33
+ maxSize: { w: 4, h: 4 },
34
+ sdkVersion: "^0.2.0",
35
+ schema: {
36
+ type: "object",
37
+ properties: {
38
+ label: {
39
+ type: "string",
40
+ title: "Widget Label",
41
+ description: "Display label for the widget",
42
+ default: "WIDGET_NAME",
43
+ },
44
+ },
45
+ },
46
+ defaultConfig: { label: "WIDGET_NAME" },
47
+ },
48
+ component: WIDGET_NAMEWidget,
49
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "preserve",
7
+ "jsxImportSource": "solid-js",
8
+ "strict": true,
9
+ "verbatimModuleSyntax": true,
10
+ "skipLibCheck": true,
11
+ "noEmit": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vite";
2
+ import solid from "vite-plugin-solid";
3
+ import { glasshomeWidgets } from "@glasshome/widget-sdk/vite";
4
+
5
+ export default defineConfig({
6
+ plugins: [solid(), ...glasshomeWidgets()],
7
+ });