@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,208 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { log, spinner } from "@clack/prompts";
4
+ import { buildWidgets } from "@glasshome/widget-sdk/vite";
5
+ import { trpcMutate, trpcQuery } from "../utils/api";
6
+
7
+ interface RegistryWidget {
8
+ tag: string;
9
+ name: string;
10
+ version: string;
11
+ bundleUrl: string;
12
+ sdkVersion: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface RegistryJson {
17
+ version: number;
18
+ widgets: RegistryWidget[];
19
+ }
20
+
21
+ /**
22
+ * Upload a single widget bundle to the API and register it.
23
+ */
24
+ async function uploadAndRegister(
25
+ apiUrl: string,
26
+ distDir: string,
27
+ widget: RegistryWidget,
28
+ ): Promise<void> {
29
+ const api = apiUrl.replace(/\/$/, "");
30
+
31
+ // Read the bundle file from dist/ (bundleUrl is relative like "./area.js")
32
+ const bundleFilename = widget.bundleUrl.replace(/^\.\//, "");
33
+ const bundlePath = resolve(distDir, bundleFilename);
34
+
35
+ if (!existsSync(bundlePath)) {
36
+ log.warn(`Bundle not found: ${bundlePath} — skipping ${widget.tag}`);
37
+ return;
38
+ }
39
+
40
+ const bundleContent = readFileSync(bundlePath, "utf-8");
41
+
42
+ // Upload bundle to API
43
+ const uploadRes = await fetch(`${api}/bundles/${widget.tag}`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/javascript" },
46
+ body: bundleContent,
47
+ });
48
+
49
+ if (!uploadRes.ok) {
50
+ throw new Error(`Failed to upload bundle for ${widget.tag}: HTTP ${uploadRes.status}`);
51
+ }
52
+
53
+ // Register the widget with a local bundle path (skips redundant download)
54
+ const manifest = { ...widget };
55
+ delete (manifest as Record<string, unknown>).bundleUrl;
56
+
57
+ await trpcMutate({
58
+ apiUrl: api,
59
+ path: "widget.register",
60
+ input: {
61
+ tag: widget.tag,
62
+ name: widget.name,
63
+ version: widget.version,
64
+ bundleUrl: `/bundles/${widget.tag}/bundle.js`,
65
+ manifestJson: JSON.stringify(manifest),
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Read registry.json and upload all widget bundles.
72
+ * @returns Array of widget tags that were registered
73
+ */
74
+ async function uploadAllWidgets(apiUrl: string, distDir: string): Promise<string[]> {
75
+ const registryPath = resolve(distDir, "registry.json");
76
+ const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
77
+ const tags: string[] = [];
78
+
79
+ for (const widget of registry.widgets) {
80
+ await uploadAndRegister(apiUrl, distDir, widget);
81
+ tags.push(widget.tag);
82
+ }
83
+
84
+ return tags;
85
+ }
86
+
87
+ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
88
+ const distDir = resolve(cwd, "dist");
89
+ const solid = (await import("vite-plugin-solid")).default;
90
+ const buildOpts = { srcDir: "src", outDir: "dist", plugins: [solid()] };
91
+
92
+ // Step 1: Initial build
93
+ const s = spinner();
94
+ s.start("Building widgets...");
95
+ try {
96
+ const origCwd = process.cwd();
97
+ process.chdir(cwd);
98
+ await buildWidgets(buildOpts);
99
+ process.chdir(origCwd);
100
+ } catch (err) {
101
+ s.stop("Build failed");
102
+ log.error(err instanceof Error ? err.message : String(err));
103
+ process.exit(1);
104
+ }
105
+ s.stop("Build complete");
106
+
107
+ // Step 2: Check registry was generated
108
+ const registryPath = resolve(distDir, "registry.json");
109
+ if (!existsSync(registryPath)) {
110
+ log.error("dist/registry.json not found after build. Check your vite.config.ts.");
111
+ process.exit(1);
112
+ }
113
+
114
+ // Step 3: Enable dev mode if needed
115
+ s.start("Registering widgets with dashboard...");
116
+ try {
117
+ const configData = await trpcQuery<{ devMode: boolean }>({
118
+ apiUrl: apiUrl.replace(/\/$/, ""),
119
+ path: "appConfig.get",
120
+ });
121
+
122
+ if (!configData.devMode) {
123
+ await trpcMutate({
124
+ apiUrl: apiUrl.replace(/\/$/, ""),
125
+ path: "appConfig.toggleDevMode",
126
+ input: {},
127
+ });
128
+ }
129
+ } catch {
130
+ // Non-fatal — dev mode may already be enabled
131
+ }
132
+
133
+ // Step 4: Upload bundles and register widgets
134
+ const registeredTags = await uploadAllWidgets(apiUrl, distDir);
135
+ s.stop("Widgets registered");
136
+
137
+ // Step 5: Watch src/ for changes and rebuild + re-upload only the changed widget
138
+ const srcDir = resolve(cwd, "src");
139
+ let rebuilding = false;
140
+ const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
141
+ if (rebuilding || !filename) return;
142
+ rebuilding = true;
143
+ try {
144
+ // Determine which widget changed from the file path (first path segment under src/)
145
+ const widgetName = filename.split(/[\\/]/)[0] ?? filename;
146
+ const origCwd = process.cwd();
147
+ process.chdir(cwd);
148
+ await buildWidgets({ ...buildOpts, only: [widgetName] });
149
+ process.chdir(origCwd);
150
+
151
+ // Upload only the changed widget
152
+ const registryPath = resolve(distDir, "registry.json");
153
+ const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
154
+ const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
155
+ if (widget) {
156
+ await uploadAndRegister(apiUrl, distDir, widget);
157
+ log.info(`Rebuilt & uploaded ${widget.tag}`);
158
+ } else {
159
+ log.warn(`No registry entry for ${widgetName} — skipping upload`);
160
+ }
161
+ } catch (err) {
162
+ log.warn(`Rebuild failed: ${err instanceof Error ? err.message : String(err)}`);
163
+ } finally {
164
+ rebuilding = false;
165
+ }
166
+ });
167
+
168
+ // Count widgets
169
+ const widgetCount = existsSync(resolve(cwd, "src"))
170
+ ? readdirSync(resolve(cwd, "src")).filter(
171
+ (d) =>
172
+ statSync(resolve(cwd, "src", d)).isDirectory() &&
173
+ existsSync(resolve(cwd, "src", d, "manifest.json")),
174
+ ).length
175
+ : 0;
176
+
177
+ log.success(`Connected! ${widgetCount} widget(s) are live.`);
178
+ log.info("Bundles uploaded to API. Widgets registered with local paths.");
179
+ log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
180
+ log.info("Press Ctrl+C to disconnect.");
181
+
182
+ // Step 6: Handle SIGINT — unregister and clean up
183
+ const cleanup = async () => {
184
+ log.info("\nDisconnecting...");
185
+ watcher.close();
186
+
187
+ for (const tag of registeredTags) {
188
+ try {
189
+ await trpcMutate({
190
+ apiUrl: apiUrl.replace(/\/$/, ""),
191
+ path: "widget.unregister",
192
+ input: { tag },
193
+ });
194
+ } catch {
195
+ // Non-fatal — API may be down
196
+ }
197
+ }
198
+ log.info("Widgets unregistered");
199
+
200
+ process.exit(0);
201
+ };
202
+
203
+ process.on("SIGINT", cleanup);
204
+ process.on("SIGTERM", cleanup);
205
+
206
+ // Keep alive
207
+ await new Promise(() => {});
208
+ }
@@ -0,0 +1,86 @@
1
+ import { log, outro, spinner } from "@clack/prompts";
2
+ import { trpcMutate, trpcQuery } from "../utils/api";
3
+
4
+ interface DevStatusResponse {
5
+ devMode: boolean;
6
+ registries: Array<{ url: string; name: string; isDefault: boolean }>;
7
+ }
8
+
9
+ export async function runAddDevRegistry(options: {
10
+ registryUrl?: string;
11
+ apiUrl?: string;
12
+ }): Promise<void> {
13
+ const registryUrl = options.registryUrl || "http://localhost:5173/__widgets/registry.json";
14
+ const apiUrl = options.apiUrl || "http://localhost:3333";
15
+
16
+ const s = spinner();
17
+
18
+ // Step 1: Check if dev mode is enabled
19
+ s.start("Checking dev mode status...");
20
+ let devEnabled = false;
21
+ try {
22
+ const status = await trpcQuery<DevStatusResponse>({
23
+ apiUrl,
24
+ path: "appConfig.get",
25
+ });
26
+ devEnabled = status.devMode;
27
+ } catch (err) {
28
+ s.stop("Failed to connect");
29
+ log.error(
30
+ `Could not reach dashboard API at ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
31
+ );
32
+ log.info("Make sure the dashboard API server is running and the URL is correct.");
33
+ process.exit(1);
34
+ }
35
+
36
+ // Step 2: Enable dev mode if needed
37
+ if (!devEnabled) {
38
+ s.message("Enabling dev mode...");
39
+ try {
40
+ const result = await trpcMutate<{ devMode: boolean }>({
41
+ apiUrl,
42
+ path: "appConfig.toggleDevMode",
43
+ input: {},
44
+ });
45
+ if (!result.devMode) {
46
+ s.stop("Failed");
47
+ log.error("Could not enable dev mode");
48
+ process.exit(1);
49
+ }
50
+ } catch (err) {
51
+ s.stop("Failed");
52
+ log.error(`Failed to enable dev mode: ${err instanceof Error ? err.message : String(err)}`);
53
+ process.exit(1);
54
+ }
55
+ }
56
+ s.stop("Dev mode enabled");
57
+
58
+ // Step 3: Add the registry via /dev/registries endpoint
59
+ s.start("Adding dev registry...");
60
+ try {
61
+ const res = await fetch(`${apiUrl.replace(/\/$/, "")}/dev/registries`, {
62
+ method: "POST",
63
+ headers: { "Content-Type": "application/json" },
64
+ body: JSON.stringify({ url: registryUrl, name: "Local Dev Server" }),
65
+ });
66
+
67
+ if (res.status === 409) {
68
+ s.stop("Registry already registered");
69
+ log.info(`Registry ${registryUrl} is already configured`);
70
+ } else if (res.ok) {
71
+ s.stop("Registry added");
72
+ log.success(`Added registry: ${registryUrl}`);
73
+ } else {
74
+ const err = (await res.json()) as { error: string };
75
+ s.stop("Failed");
76
+ log.error(`Failed to add registry: ${err.error}`);
77
+ process.exit(1);
78
+ }
79
+ } catch (err) {
80
+ s.stop("Failed");
81
+ log.error(`Failed to add registry: ${err instanceof Error ? err.message : String(err)}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ outro("Done! Refresh your dashboard to see dev widgets.");
86
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { log } from "@clack/prompts";
4
+ import {
5
+ discoverWidgets,
6
+ formatBytes,
7
+ getWidgetBundleInfo,
8
+ readManifest,
9
+ readRegistry,
10
+ } from "../utils/manifest";
11
+
12
+ export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
13
+ // Read package.json
14
+ const pkgPath = resolve(cwd, "package.json");
15
+ if (!existsSync(pkgPath)) {
16
+ log.error("No package.json found in current directory.");
17
+ process.exit(1);
18
+ }
19
+
20
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
21
+ log.info(`Project: ${pkg.name ?? "unknown"}`);
22
+ log.info(`Version: ${pkg.version ?? "0.0.0"}`);
23
+
24
+ const widgets = discoverWidgets(cwd);
25
+
26
+ if (widgets.length === 0) {
27
+ log.warn("No widgets found in src/. Run `bun widget add` to create one.");
28
+ return;
29
+ }
30
+
31
+ const toShow = widgetName ? widgets.filter((w) => w === widgetName) : widgets;
32
+
33
+ if (widgetName && toShow.length === 0) {
34
+ log.error(`Widget "${widgetName}" not found. Available: ${widgets.join(", ")}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ log.info(`\nWidgets: ${widgets.length} total`);
39
+
40
+ for (const name of toShow) {
41
+ log.info(`\n--- ${name} ---`);
42
+
43
+ try {
44
+ const manifest = readManifest(cwd, name);
45
+ log.info(` Tag: ${manifest.tag}`);
46
+ log.info(` Name: ${manifest.name}`);
47
+ log.info(` Type: ${manifest.type}`);
48
+ log.info(` Size: ${manifest.size}`);
49
+ log.info(` SDK Version: ${manifest.sdkVersion}`);
50
+ if (manifest.version) {
51
+ log.info(` Version: ${manifest.version}`);
52
+ }
53
+ if (manifest.description) {
54
+ log.info(` Description: ${manifest.description}`);
55
+ }
56
+
57
+ // Bundle size (if built)
58
+ try {
59
+ const info = getWidgetBundleInfo(cwd, name);
60
+ log.info(` Bundle: ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`);
61
+ } catch {
62
+ log.info(" Bundle: not built yet");
63
+ }
64
+ } catch (err) {
65
+ log.warn(` Could not read manifest: ${err instanceof Error ? err.message : String(err)}`);
66
+ }
67
+ }
68
+
69
+ // Registry summary
70
+ const registry = readRegistry(cwd);
71
+ if (registry) {
72
+ log.info(
73
+ `\nRegistry: ${registry.widgets.length} widget(s) (generated ${registry.generatedAt})`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,163 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3
+ import { log, spinner } from "@clack/prompts";
4
+ import open from "open";
5
+ import { getHubUrl, storeToken } from "../utils/auth";
6
+
7
+ const CLIENT_ID = "glasshome-widget-cli";
8
+ const CLIENT_SECRET = "glasshome-cli-public";
9
+ const REDIRECT_PORT = 9274;
10
+ const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
11
+ const LOGIN_TIMEOUT_MS = 120_000;
12
+
13
+ function base64url(buf: Buffer): string {
14
+ return buf.toString("base64url");
15
+ }
16
+
17
+ export async function runLogin(hubUrl?: string): Promise<void> {
18
+ const hub = hubUrl ?? getHubUrl();
19
+
20
+ // Generate PKCE parameters
21
+ const codeVerifier = base64url(randomBytes(32));
22
+ const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
23
+ const state = randomBytes(16).toString("hex");
24
+
25
+ const s = spinner();
26
+
27
+ // Start local callback server
28
+ const { code, receivedState } = await new Promise<{
29
+ code: string;
30
+ receivedState: string;
31
+ }>((resolve, reject) => {
32
+ const timeout = setTimeout(() => {
33
+ server.close();
34
+ reject(new Error("Login timed out — no response from browser within 120 seconds."));
35
+ }, LOGIN_TIMEOUT_MS);
36
+
37
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
38
+ const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`);
39
+
40
+ if (url.pathname !== "/callback") {
41
+ res.writeHead(404);
42
+ res.end("Not found");
43
+ return;
44
+ }
45
+
46
+ const code = url.searchParams.get("code");
47
+ const receivedState = url.searchParams.get("state");
48
+ const error = url.searchParams.get("error");
49
+
50
+ if (error) {
51
+ res.writeHead(200, { "Content-Type": "text/html" });
52
+ res.end("<html><head><script>window.close()</script></head><body></body></html>");
53
+ clearTimeout(timeout);
54
+ server.close();
55
+ reject(new Error(`OAuth error: ${error}`));
56
+ return;
57
+ }
58
+
59
+ if (!code || !receivedState) {
60
+ res.writeHead(400);
61
+ res.end("Missing code or state");
62
+ return;
63
+ }
64
+
65
+ res.writeHead(200, { "Content-Type": "text/html" });
66
+ res.end("<html><head><script>window.close()</script></head><body></body></html>");
67
+
68
+ clearTimeout(timeout);
69
+ server.close();
70
+ resolve({ code, receivedState });
71
+ });
72
+
73
+ server.listen(REDIRECT_PORT, () => {
74
+ const authUrl = new URL(`${hub}/api/auth/oauth2/authorize`);
75
+ authUrl.searchParams.set("client_id", CLIENT_ID);
76
+ authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
77
+ authUrl.searchParams.set("response_type", "code");
78
+ authUrl.searchParams.set("code_challenge", codeChallenge);
79
+ authUrl.searchParams.set("code_challenge_method", "S256");
80
+ authUrl.searchParams.set("state", state);
81
+ authUrl.searchParams.set("scope", "openid profile email");
82
+
83
+ log.info(`Opening browser for login at ${hub}`);
84
+ s.start("Waiting for browser login...");
85
+ open(authUrl.toString()).catch(() => {
86
+ log.warn(`Could not open browser. Visit this URL manually:\n ${authUrl.toString()}`);
87
+ });
88
+ });
89
+ });
90
+
91
+ s.stop("Login complete");
92
+
93
+ // Verify state
94
+ if (receivedState !== state) {
95
+ s.stop("Login failed");
96
+ log.error("State mismatch. Possible CSRF attack.");
97
+ process.exit(1);
98
+ }
99
+
100
+ // Exchange code for tokens
101
+ s.message("Exchanging code for tokens...");
102
+
103
+ const tokenRes = await fetch(`${hub}/api/auth/oauth2/token`, {
104
+ method: "POST",
105
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
106
+ body: new URLSearchParams({
107
+ grant_type: "authorization_code",
108
+ code,
109
+ redirect_uri: REDIRECT_URI,
110
+ code_verifier: codeVerifier,
111
+ client_id: CLIENT_ID,
112
+ client_secret: CLIENT_SECRET,
113
+ }),
114
+ });
115
+
116
+ if (!tokenRes.ok) {
117
+ s.stop("Login failed");
118
+ const text = await tokenRes.text();
119
+ log.error(`Token exchange failed: ${text}`);
120
+ process.exit(1);
121
+ }
122
+
123
+ const tokenData = (await tokenRes.json()) as {
124
+ access_token: string;
125
+ refresh_token: string;
126
+ expires_in: number;
127
+ };
128
+
129
+ // Fetch user info for scope
130
+ s.message("Fetching user info...");
131
+
132
+ const userRes = await fetch(`${hub}/api/auth/oauth2/userinfo`, {
133
+ headers: { Authorization: `Bearer ${tokenData.access_token}` },
134
+ });
135
+
136
+ if (!userRes.ok) {
137
+ s.stop("Login failed");
138
+ log.error("Could not fetch user info.");
139
+ process.exit(1);
140
+ }
141
+
142
+ const userInfo = (await userRes.json()) as {
143
+ name?: string;
144
+ email?: string;
145
+ sub?: string;
146
+ };
147
+ const displayName = userInfo.name || userInfo.email || userInfo.sub || "unknown";
148
+ const scopeBase = userInfo.name || userInfo.email?.split("@")[0] || userInfo.sub || "unknown";
149
+ const scope = scopeBase.toLowerCase().replace(/[^a-z0-9-]/g, "-");
150
+
151
+ // Store tokens
152
+ storeToken({
153
+ accessToken: tokenData.access_token,
154
+ refreshToken: tokenData.refresh_token,
155
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
156
+ hubUrl: hub,
157
+ scope,
158
+ });
159
+
160
+ s.stop("Logged in successfully");
161
+ log.success(`Authenticated as ${displayName} (scope: ${scope})`);
162
+ log.info(`Hub: ${hub}`);
163
+ }