@glasshome/widget-cli 0.3.2 → 0.4.2

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,13 +1,14 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.2",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glasshome-widget": "bin/cli.ts"
8
8
  },
9
9
  "scripts": {
10
- "cli": "bun bin/cli.ts"
10
+ "cli": "bun bin/cli.ts",
11
+ "typecheck": "tsc --noEmit"
11
12
  },
12
13
  "files": [
13
14
  "bin",
@@ -16,7 +17,7 @@
16
17
  ],
17
18
  "dependencies": {
18
19
  "@clack/prompts": "^0.10.0",
19
- "@glasshome/widget-sdk": "^0.1.0",
20
+ "@glasshome/widget-sdk": "0.2.3",
20
21
  "open": "^11.0.0",
21
22
  "semver": "^7.6.0"
22
23
  },
@@ -45,6 +45,7 @@ export function scaffoldWidget(
45
45
  description: widgetDescription,
46
46
  minSize: { w: 1, h: 1 },
47
47
  maxSize: { w: 4, h: 4 },
48
+ defaultSize: { w: 2, h: 2 },
48
49
  sdkVersion: "^0.2.0",
49
50
  version: "0.1.0",
50
51
  };
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
3
3
  import { log, spinner } from "@clack/prompts";
4
4
  import { buildWidgets } from "@glasshome/widget-sdk/vite";
5
5
  import { trpcMutate, trpcQuery } from "../utils/api";
6
+ import { extractHost, getHostToken, storeHostToken } from "../utils/auth";
6
7
 
7
8
  interface RegistryWidget {
8
9
  tag: string;
@@ -25,6 +26,7 @@ async function uploadAndRegister(
25
26
  apiUrl: string,
26
27
  distDir: string,
27
28
  widget: RegistryWidget,
29
+ token: string,
28
30
  ): Promise<void> {
29
31
  const api = apiUrl.replace(/\/$/, "");
30
32
 
@@ -39,10 +41,13 @@ async function uploadAndRegister(
39
41
 
40
42
  const bundleContent = readFileSync(bundlePath, "utf-8");
41
43
 
42
- // Upload bundle to API
43
- const uploadRes = await fetch(`${api}/bundles/${widget.tag}`, {
44
+ // Upload bundle to API (local registry namespace)
45
+ const uploadRes = await fetch(`${api}/bundles/local/${widget.tag}`, {
44
46
  method: "POST",
45
- headers: { "Content-Type": "application/javascript" },
47
+ headers: {
48
+ "Content-Type": "application/javascript",
49
+ Authorization: `Bearer ${token}`,
50
+ },
46
51
  body: bundleContent,
47
52
  });
48
53
 
@@ -57,11 +62,12 @@ async function uploadAndRegister(
57
62
  await trpcMutate({
58
63
  apiUrl: api,
59
64
  path: "widget.register",
65
+ token,
60
66
  input: {
61
67
  tag: widget.tag,
62
68
  name: widget.name,
63
69
  version: widget.version,
64
- bundleUrl: `/bundles/${widget.tag}/bundle.js`,
70
+ bundleUrl: `/bundles/local/${widget.tag}/bundle.js`,
65
71
  manifestJson: JSON.stringify(manifest),
66
72
  },
67
73
  });
@@ -71,13 +77,13 @@ async function uploadAndRegister(
71
77
  * Read registry.json and upload all widget bundles.
72
78
  * @returns Array of widget tags that were registered
73
79
  */
74
- async function uploadAllWidgets(apiUrl: string, distDir: string): Promise<string[]> {
80
+ async function uploadAllWidgets(apiUrl: string, distDir: string, token: string): Promise<string[]> {
75
81
  const registryPath = resolve(distDir, "registry.json");
76
82
  const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
77
83
  const tags: string[] = [];
78
84
 
79
85
  for (const widget of registry.widgets) {
80
- await uploadAndRegister(apiUrl, distDir, widget);
86
+ await uploadAndRegister(apiUrl, distDir, widget, token);
81
87
  tags.push(widget.tag);
82
88
  }
83
89
 
@@ -111,18 +117,146 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
111
117
  process.exit(1);
112
118
  }
113
119
 
114
- // Step 3: Enable dev mode if needed
120
+ // Step 3: Obtain bearer token via device authorization flow
121
+ const api = apiUrl.replace(/\/$/, "");
122
+ const host = extractHost(api);
123
+ let token = "";
124
+
125
+ const existingToken = getHostToken(host);
126
+ if (existingToken) {
127
+ token = existingToken;
128
+ log.info("Using stored credentials for dashboard.");
129
+ } else {
130
+ // Request a device code
131
+ s.start("Requesting authorization code...");
132
+ let deviceCode: string;
133
+ let userCode: string;
134
+ let verificationUriComplete: string;
135
+ let expiresIn: number;
136
+ let interval: number;
137
+
138
+ try {
139
+ const res = await fetch(`${api}/api/auth/device/code`, {
140
+ method: "POST",
141
+ headers: { "Content-Type": "application/json" },
142
+ body: JSON.stringify({ client_id: "glasshome-widget-cli" }),
143
+ });
144
+ if (!res.ok) {
145
+ throw new Error(`HTTP ${res.status}`);
146
+ }
147
+ const data = (await res.json()) as {
148
+ device_code: string;
149
+ user_code: string;
150
+ verification_uri_complete: string;
151
+ expires_in: number;
152
+ interval: number;
153
+ };
154
+ deviceCode = data.device_code;
155
+ userCode = data.user_code;
156
+ // Build verification URL from the dashboard URL the user provided,
157
+ // not the API response (which uses the API server's origin)
158
+ verificationUriComplete = `${api}/device?user_code=${data.user_code}`;
159
+ expiresIn = data.expires_in;
160
+ interval = data.interval ?? 5;
161
+ } catch (err) {
162
+ s.stop("Failed to request device code");
163
+ log.error(
164
+ `Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
165
+ );
166
+ process.exit(1);
167
+ }
168
+ s.stop("Authorization code ready");
169
+
170
+ // Prompt user to visit the verification URL
171
+ log.info(`Open in browser: ${verificationUriComplete}`);
172
+ log.info(`Device code: ${userCode}`);
173
+
174
+ // Try to open browser automatically
175
+ await import("open")
176
+ .then((m) => m.default(verificationUriComplete))
177
+ .catch(() => {});
178
+
179
+ // Poll for approval
180
+ s.start("Waiting for authorization (approve in your browser)...");
181
+ const deadline = Date.now() + expiresIn * 1000;
182
+ let approved = false;
183
+
184
+ while (Date.now() < deadline) {
185
+ await new Promise((r) => setTimeout(r, interval * 1000));
186
+
187
+ try {
188
+ const res = await fetch(`${api}/api/auth/device/token`, {
189
+ method: "POST",
190
+ headers: { "Content-Type": "application/json" },
191
+ body: JSON.stringify({
192
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
193
+ device_code: deviceCode,
194
+ client_id: "glasshome-widget-cli",
195
+ }),
196
+ });
197
+
198
+ if (res.ok) {
199
+ const data = (await res.json()) as {
200
+ access_token: string;
201
+ expires_in: number;
202
+ };
203
+ token = data.access_token;
204
+ const tokenExpiresAt = Date.now() + data.expires_in * 1000;
205
+ storeHostToken(host, token, tokenExpiresAt);
206
+ approved = true;
207
+ break;
208
+ }
209
+
210
+ const errData = (await res.json()) as { error?: string };
211
+ if (errData.error === "authorization_pending" || errData.error === "slow_down") {
212
+ // Continue polling
213
+ if (errData.error === "slow_down") {
214
+ interval = Math.min(interval + 5, 30);
215
+ }
216
+ continue;
217
+ }
218
+ if (errData.error === "access_denied") {
219
+ s.stop("Authorization denied");
220
+ log.error("You denied access. Run `bun widget connect` again to retry.");
221
+ process.exit(1);
222
+ }
223
+ if (errData.error === "expired_token") {
224
+ s.stop("Device code expired");
225
+ log.error("The device code expired before you approved. Run `bun widget connect` again.");
226
+ process.exit(1);
227
+ }
228
+ // Unexpected error
229
+ throw new Error(errData.error ?? `HTTP ${res.status}`);
230
+ } catch (err) {
231
+ s.stop("Authorization failed");
232
+ log.error(
233
+ `Error polling for token: ${err instanceof Error ? err.message : String(err)}`,
234
+ );
235
+ process.exit(1);
236
+ }
237
+ }
238
+
239
+ if (!approved) {
240
+ s.stop("Timed out");
241
+ log.error("Authorization timed out. Run `bun widget connect` again.");
242
+ process.exit(1);
243
+ }
244
+ s.stop("Authorized");
245
+ }
246
+
247
+ // Step 4: Enable dev mode if needed
115
248
  s.start("Registering widgets with dashboard...");
116
249
  try {
117
250
  const configData = await trpcQuery<{ devMode: boolean }>({
118
- apiUrl: apiUrl.replace(/\/$/, ""),
251
+ apiUrl: api,
119
252
  path: "appConfig.get",
120
253
  });
121
254
 
122
255
  if (!configData.devMode) {
123
256
  await trpcMutate({
124
- apiUrl: apiUrl.replace(/\/$/, ""),
257
+ apiUrl: api,
125
258
  path: "appConfig.toggleDevMode",
259
+ token,
126
260
  input: {},
127
261
  });
128
262
  }
@@ -130,11 +264,11 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
130
264
  // Non-fatal — dev mode may already be enabled
131
265
  }
132
266
 
133
- // Step 4: Upload bundles and register widgets
134
- const registeredTags = await uploadAllWidgets(apiUrl, distDir);
267
+ // Step 5: Upload bundles and register widgets
268
+ const registeredTags = await uploadAllWidgets(apiUrl, distDir, token);
135
269
  s.stop("Widgets registered");
136
270
 
137
- // Step 5: Watch src/ for changes and rebuild + re-upload only the changed widget
271
+ // Step 6: Watch src/ for changes and rebuild + re-upload only the changed widget
138
272
  const srcDir = resolve(cwd, "src");
139
273
  let rebuilding = false;
140
274
  const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
@@ -153,7 +287,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
153
287
  const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
154
288
  const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
155
289
  if (widget) {
156
- await uploadAndRegister(apiUrl, distDir, widget);
290
+ await uploadAndRegister(apiUrl, distDir, widget, token);
157
291
  log.info(`Rebuilt & uploaded ${widget.tag}`);
158
292
  } else {
159
293
  log.warn(`No registry entry for ${widgetName} — skipping upload`);
@@ -179,7 +313,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
179
313
  log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
180
314
  log.info("Press Ctrl+C to disconnect.");
181
315
 
182
- // Step 6: Handle SIGINT — unregister and clean up
316
+ // Step 7: Handle SIGINT — unregister and clean up
183
317
  const cleanup = async () => {
184
318
  log.info("\nDisconnecting...");
185
319
  watcher.close();
@@ -187,8 +321,9 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
187
321
  for (const tag of registeredTags) {
188
322
  try {
189
323
  await trpcMutate({
190
- apiUrl: apiUrl.replace(/\/$/, ""),
324
+ apiUrl: api,
191
325
  path: "widget.unregister",
326
+ token,
192
327
  input: { tag },
193
328
  });
194
329
  } catch {
@@ -42,10 +42,12 @@ export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
42
42
 
43
43
  try {
44
44
  const manifest = readManifest(cwd, name);
45
- log.info(` Tag: ${manifest.tag}`);
46
45
  log.info(` Name: ${manifest.name}`);
47
46
  log.info(` Min Size: ${manifest.minSize.w}×${manifest.minSize.h}`);
48
47
  log.info(` Max Size: ${manifest.maxSize.w}×${manifest.maxSize.h}`);
48
+ if (manifest.defaultSize) {
49
+ log.info(` Default: ${manifest.defaultSize.w}×${manifest.defaultSize.h}`);
50
+ }
49
51
  log.info(` SDK Version: ${manifest.sdkVersion}`);
50
52
  if (manifest.version) {
51
53
  log.info(` Version: ${manifest.version}`);
@@ -85,7 +85,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
85
85
  } catch {}
86
86
  return {
87
87
  value: name,
88
- label: `${manifest.tag}${sizeInfo}`,
88
+ label: `${manifest.name}${sizeInfo}`,
89
89
  };
90
90
  });
91
91
 
@@ -106,7 +106,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
106
106
  const currentVersion = manifest.version ?? "0.0.0";
107
107
 
108
108
  const bump = await select({
109
- message: `${manifest.tag} version: ${currentVersion}. Bump?`,
109
+ message: `${manifest.name} version: ${currentVersion}. Bump?`,
110
110
  options: [
111
111
  { value: "keep", label: `Keep ${currentVersion}`, hint: "fails if already published" },
112
112
  { value: "patch", label: `Patch (${semver.inc(currentVersion, "patch")})` },
@@ -152,7 +152,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
152
152
 
153
153
  const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
154
154
 
155
- s.start(`Publishing ${manifest.tag}@${version}...`);
155
+ s.start(`Publishing ${manifest.name}@${version}...`);
156
156
 
157
157
  let publishData: Awaited<ReturnType<typeof requestPublish>>;
158
158
  try {
@@ -172,14 +172,14 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
172
172
  });
173
173
  } catch (err: any) {
174
174
  if (err.status === 409) {
175
- s.stop(`${manifest.tag}@${version} already published — bump version to republish`);
175
+ s.stop(`${manifest.name}@${version} already published — bump version to republish`);
176
176
  process.exit(0);
177
177
  }
178
178
  s.stop(`Publish failed: ${err.message}`);
179
179
  process.exit(1);
180
180
  }
181
181
 
182
- s.message(`Uploading ${manifest.tag} to CDN...`);
182
+ s.message(`Uploading ${manifest.name} to CDN...`);
183
183
  try {
184
184
  await uploadToR2(publishData.uploadUrl, bundleBuffer);
185
185
  } catch (err: any) {
@@ -187,10 +187,10 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
187
187
  process.exit(1);
188
188
  }
189
189
 
190
- s.message(`Confirming ${manifest.tag}...`);
190
+ s.message(`Confirming ${manifest.name}...`);
191
191
  try {
192
192
  const result = await confirmPublish(hubUrl, token!, publishData.versionId);
193
- s.stop(`Published ${manifest.tag}@${version}`);
193
+ s.stop(`Published ${manifest.name}@${version}`);
194
194
  log.info(`CDN: ${result.bundleUrl}`);
195
195
  } catch (err: any) {
196
196
  s.stop(`Confirmation failed: ${err.message}`);
@@ -10,8 +10,6 @@ import {
10
10
  type WidgetManifest,
11
11
  } from "../utils/manifest";
12
12
 
13
- const TAG_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
14
-
15
13
  interface ValidationResult {
16
14
  passed: boolean;
17
15
  errors: string[];
@@ -21,33 +19,46 @@ interface ValidationResult {
21
19
  function validateManifest(manifest: WidgetManifest): ValidationResult {
22
20
  const errors: string[] = [];
23
21
  const warnings: string[] = [];
22
+ const hasValidMinSize =
23
+ !!manifest.minSize &&
24
+ typeof manifest.minSize.w === "number" &&
25
+ typeof manifest.minSize.h === "number";
26
+ const hasValidMaxSize =
27
+ !!manifest.maxSize &&
28
+ typeof manifest.maxSize.w === "number" &&
29
+ typeof manifest.maxSize.h === "number";
24
30
 
25
31
  // Required fields
26
- if (!manifest.tag) errors.push("Missing required field: tag");
27
32
  if (!manifest.name) errors.push("Missing required field: name");
28
33
  if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
29
34
 
30
35
  // Size validation
31
- if (
32
- !manifest.minSize ||
33
- typeof manifest.minSize.w !== "number" ||
34
- typeof manifest.minSize.h !== "number"
35
- ) {
36
+ if (!hasValidMinSize) {
36
37
  errors.push("minSize must be an object with numeric w and h properties");
37
38
  }
39
+ if (!hasValidMaxSize) {
40
+ errors.push("maxSize must be an object with numeric w and h properties");
41
+ }
38
42
  if (
39
- !manifest.maxSize ||
40
- typeof manifest.maxSize.w !== "number" ||
41
- typeof manifest.maxSize.h !== "number"
43
+ manifest.defaultSize &&
44
+ (typeof manifest.defaultSize.w !== "number" || typeof manifest.defaultSize.h !== "number")
42
45
  ) {
43
- errors.push("maxSize must be an object with numeric w and h properties");
46
+ errors.push("defaultSize must be an object with numeric w and h properties");
44
47
  }
45
-
46
- // Tag format (stricter than schema — enforces kebab-case with at least one hyphen)
47
- if (manifest.tag && !TAG_REGEX.test(manifest.tag)) {
48
- errors.push(
49
- `Invalid tag format "${manifest.tag}" — must match ${TAG_REGEX} (e.g. glasshome-my-widget)`,
50
- );
48
+ if (hasValidMinSize && hasValidMaxSize) {
49
+ if (manifest.minSize.w > manifest.maxSize.w || manifest.minSize.h > manifest.maxSize.h) {
50
+ errors.push("minSize must be less than or equal to maxSize");
51
+ }
52
+ if (manifest.defaultSize) {
53
+ if (
54
+ manifest.defaultSize.w < manifest.minSize.w ||
55
+ manifest.defaultSize.h < manifest.minSize.h ||
56
+ manifest.defaultSize.w > manifest.maxSize.w ||
57
+ manifest.defaultSize.h > manifest.maxSize.h
58
+ ) {
59
+ errors.push("defaultSize must be within minSize and maxSize bounds");
60
+ }
61
+ }
51
62
  }
52
63
 
53
64
  // Schema validation
package/src/utils/api.ts CHANGED
@@ -7,6 +7,7 @@ interface TrpcMutationOptions {
7
7
  apiUrl: string;
8
8
  path: string;
9
9
  input: Record<string, unknown>;
10
+ token?: string;
10
11
  }
11
12
 
12
13
  interface TrpcResult<T = unknown> {
@@ -15,9 +16,11 @@ interface TrpcResult<T = unknown> {
15
16
 
16
17
  export async function trpcMutate<T = unknown>(opts: TrpcMutationOptions): Promise<T> {
17
18
  const url = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
19
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
20
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
18
21
  const res = await fetch(url, {
19
22
  method: "POST",
20
- headers: { "Content-Type": "application/json" },
23
+ headers,
21
24
  body: JSON.stringify(opts.input),
22
25
  });
23
26
 
@@ -34,15 +37,18 @@ interface TrpcQueryOptions {
34
37
  apiUrl: string;
35
38
  path: string;
36
39
  input?: Record<string, unknown>;
40
+ token?: string;
37
41
  }
38
42
 
39
43
  export async function trpcQuery<T = unknown>(opts: TrpcQueryOptions): Promise<T> {
40
44
  const base = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
41
45
  const url = opts.input ? `${base}?input=${encodeURIComponent(JSON.stringify(opts.input))}` : base;
46
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
47
+ if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
42
48
 
43
49
  const res = await fetch(url, {
44
50
  method: "GET",
45
- headers: { "Content-Type": "application/json" },
51
+ headers,
46
52
  });
47
53
 
48
54
  if (!res.ok) {
package/src/utils/auth.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
- interface StoredAuth {
5
+ interface HubAuth {
6
6
  accessToken: string;
7
7
  refreshToken: string;
8
8
  expiresAt: number;
@@ -10,6 +10,19 @@ interface StoredAuth {
10
10
  scope: string;
11
11
  }
12
12
 
13
+ interface HostAuth {
14
+ token: string;
15
+ expiresAt: number;
16
+ }
17
+
18
+ interface StoredAuthFile {
19
+ hub?: HubAuth;
20
+ hosts: Record<string, HostAuth>;
21
+ }
22
+
23
+ // Legacy shape written by older CLI versions (hub auth only, no hosts key)
24
+ type LegacyStoredAuth = HubAuth;
25
+
13
26
  const AUTH_DIR = join(homedir(), ".glasshome");
14
27
  const AUTH_FILE = join(AUTH_DIR, "auth.json");
15
28
 
@@ -19,31 +32,78 @@ function ensureDir(): void {
19
32
  }
20
33
  }
21
34
 
22
- export function storeToken(data: StoredAuth): void {
35
+ function readAuthFile(): StoredAuthFile {
36
+ if (!existsSync(AUTH_FILE)) return { hosts: {} };
37
+ try {
38
+ const raw = JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as
39
+ | StoredAuthFile
40
+ | LegacyStoredAuth;
41
+ // Migrate legacy format (hub-only, no hosts key)
42
+ if ("accessToken" in raw) {
43
+ return { hub: raw as HubAuth, hosts: {} };
44
+ }
45
+ const typed = raw as StoredAuthFile;
46
+ return { hub: typed.hub, hosts: typed.hosts ?? {} };
47
+ } catch {
48
+ return { hosts: {} };
49
+ }
50
+ }
51
+
52
+ function writeAuthFile(data: StoredAuthFile): void {
23
53
  ensureDir();
24
54
  writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
25
55
  }
26
56
 
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
- }
57
+ // --- Host-keyed token storage (for device auth / widget connect) ---
58
+
59
+ export function storeHostToken(host: string, token: string, expiresAt: number): void {
60
+ const data = readAuthFile();
61
+ data.hosts[host] = { token, expiresAt };
62
+ writeAuthFile(data);
63
+ }
64
+
65
+ export function getHostToken(host: string): string | null {
66
+ const data = readAuthFile();
67
+ const entry = data.hosts[host];
68
+ if (!entry) return null;
69
+ // Return null if expired (60s buffer)
70
+ if (Date.now() >= entry.expiresAt - 60_000) return null;
71
+ return entry.token;
72
+ }
73
+
74
+ export function clearHostToken(host: string): void {
75
+ const data = readAuthFile();
76
+ delete data.hosts[host];
77
+ writeAuthFile(data);
78
+ }
79
+
80
+ // --- Hub auth storage (for widget publishing) ---
81
+
82
+ export function storeToken(hubData: HubAuth): void {
83
+ const data = readAuthFile();
84
+ data.hub = hubData;
85
+ writeAuthFile(data);
86
+ }
87
+
88
+ export function getStoredAuth(): HubAuth | null {
89
+ return readAuthFile().hub ?? null;
34
90
  }
35
91
 
36
92
  export async function getToken(hubUrl: string): Promise<string | null> {
93
+ // Check host-keyed token first (for connect flow)
94
+ const host = extractHost(hubUrl);
95
+ const hostToken = getHostToken(host);
96
+ if (hostToken) return hostToken;
97
+
98
+ // Fall back to hub auth (for publishing)
37
99
  const stored = getStoredAuth();
38
100
  if (!stored) return null;
39
101
  if (stored.hubUrl !== hubUrl) return null;
40
102
 
41
- // Check if token is still valid (with 60s buffer)
42
103
  if (Date.now() < stored.expiresAt - 60_000) {
43
104
  return stored.accessToken;
44
105
  }
45
106
 
46
- // Try to refresh
47
107
  if (!stored.refreshToken) return null;
48
108
 
49
109
  try {
@@ -60,39 +120,39 @@ export async function getToken(hubUrl: string): Promise<string | null> {
60
120
 
61
121
  if (!res.ok) return null;
62
122
 
63
- const data = (await res.json()) as {
123
+ const tokenData = (await res.json()) as {
64
124
  access_token: string;
65
125
  refresh_token?: string;
66
126
  expires_in: number;
67
127
  };
68
128
 
69
129
  storeToken({
70
- accessToken: data.access_token,
71
- refreshToken: data.refresh_token ?? stored.refreshToken,
72
- expiresAt: Date.now() + data.expires_in * 1000,
130
+ accessToken: tokenData.access_token,
131
+ refreshToken: tokenData.refresh_token ?? stored.refreshToken,
132
+ expiresAt: Date.now() + tokenData.expires_in * 1000,
73
133
  hubUrl: stored.hubUrl,
74
134
  scope: stored.scope,
75
135
  });
76
136
 
77
- return data.access_token;
137
+ return tokenData.access_token;
78
138
  } catch {
79
139
  return null;
80
140
  }
81
141
  }
82
142
 
83
143
  export function clearToken(): void {
84
- if (existsSync(AUTH_FILE)) {
85
- unlinkSync(AUTH_FILE);
86
- }
144
+ const data = readAuthFile();
145
+ delete data.hub;
146
+ writeAuthFile(data);
87
147
  }
88
148
 
89
149
  export function getHubUrl(): string {
90
- const stored = getStoredAuth()?.hubUrl;
150
+ const stored = getStoredAuth();
91
151
  if (stored) {
92
- if (stored.includes("localhost") || stored.includes("127.0.0.1")) {
152
+ if (stored.hubUrl.includes("localhost") || stored.hubUrl.includes("127.0.0.1")) {
93
153
  clearToken();
94
154
  } else {
95
- return stored;
155
+ return stored.hubUrl;
96
156
  }
97
157
  }
98
158
  return "https://glasshome.app";
@@ -101,3 +161,14 @@ export function getHubUrl(): string {
101
161
  export function getScope(): string | null {
102
162
  return getStoredAuth()?.scope ?? null;
103
163
  }
164
+
165
+ // --- Helpers ---
166
+
167
+ export function extractHost(url: string): string {
168
+ try {
169
+ return new URL(url).host;
170
+ } catch {
171
+ // Bare host:port or path-less string
172
+ return url.replace(/^https?:\/\//, "").split("/")[0] ?? url;
173
+ }
174
+ }
@@ -31,6 +31,7 @@ export default defineWidget<WidgetConfig>({
31
31
  name: "WIDGET_NAME",
32
32
  minSize: { w: 1, h: 1 },
33
33
  maxSize: { w: 4, h: 4 },
34
+ defaultSize: { w: 2, h: 2 },
34
35
  sdkVersion: "^0.2.0",
35
36
  schema: {
36
37
  type: "object",