@glasshome/widget-cli 0.14.0 → 0.14.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,8 @@
9
9
  "scripts": {
10
10
  "cli": "bun bin/cli.ts",
11
11
  "test": "bun test",
12
- "typecheck": "tsc --noEmit"
12
+ "typecheck": "tsc --noEmit",
13
+ "check:dead": "fallow dead-code --fail-on-issues"
13
14
  },
14
15
  "files": [
15
16
  "bin",
@@ -20,6 +21,7 @@
20
21
  "@iconify/types": "^2.0.0",
21
22
  "@types/bun": "^1.4.0",
22
23
  "@types/semver": "^7.0.0",
24
+ "fallow": "3.17.0",
23
25
  "playwright": "^1.40.0",
24
26
  "typescript": "^7.0.2"
25
27
  },
@@ -28,9 +30,9 @@
28
30
  "@fontsource-variable/caveat": "^5.2.8",
29
31
  "@fontsource-variable/geist": "^5.2.9",
30
32
  "@fontsource-variable/geist-mono": "^5.2.8",
31
- "@glasshome/sync-layer": "0.9.0",
33
+ "@glasshome/sync-layer": "0.9.1",
32
34
  "@glasshome/widget-contract": "0.3.0",
33
- "@glasshome/widget-sdk": "1.17.0",
35
+ "@glasshome/widget-sdk": "1.18.0",
34
36
  "@iconify-json/lucide": "^1.2.0",
35
37
  "@iconify-json/mdi": "^1.2.3",
36
38
  "@iconify/utils": "^3.1.4",
@@ -42,7 +44,8 @@
42
44
  "vite-plugin-solid": "^2.11.0"
43
45
  },
44
46
  "peerDependencies": {
45
- "playwright": "^1.40.0"
47
+ "playwright": "^1.40.0",
48
+ "vite": "^7.3.1"
46
49
  },
47
50
  "peerDependenciesMeta": {
48
51
  "playwright": {
@@ -4,7 +4,16 @@ import { log, note, spinner } from "@clack/prompts";
4
4
  import color from "picocolors";
5
5
  import { buildWidgets, createIntrospectSession } from "@glasshome/widget-sdk/vite";
6
6
  import { trpcMutate, trpcQuery } from "../utils/api";
7
- import { clearHostToken, extractHost, getHostToken, storeHostToken } from "../utils/auth";
7
+ import {
8
+ clearHostToken,
9
+ type DeviceGrant,
10
+ type DevicePollOutcome,
11
+ deviceAuthorize,
12
+ extractHost,
13
+ getHostToken,
14
+ pollDeviceToken,
15
+ storeHostToken,
16
+ } from "../utils/auth";
8
17
  import { lintAndReport } from "../utils/lint-source";
9
18
  import { withQuietStdout } from "../utils/quiet";
10
19
 
@@ -111,23 +120,11 @@ async function uploadAllWidgets(apiUrl: string, distDir: string, token: string):
111
120
  return slugs;
112
121
  }
113
122
 
114
- export async function runConnect(
115
- apiUrl: string,
116
- cwd: string,
117
- opts: { reAuth?: boolean } = {},
118
- ): Promise<void> {
119
- const distDir = resolve(cwd, "dist");
120
- const solid = (await import("vite-plugin-solid")).default;
121
- // delegateEvents: false, widgets run in closed shadow roots where Solid's
122
- // document-level event delegation cannot see the target.
123
- const buildOpts = {
124
- srcDir: "src",
125
- outDir: "dist",
126
- plugins: [solid({ solid: { delegateEvents: false } })],
127
- };
123
+ type Spinner = ReturnType<typeof spinner>;
128
124
 
129
- // Step 1: Initial build
130
- const s = spinner();
125
+ type BuildOpts = NonNullable<Parameters<typeof buildWidgets>[0]>;
126
+
127
+ async function buildOrExit(cwd: string, buildOpts: BuildOpts, s: Spinner): Promise<void> {
131
128
  s.start("Building widgets...");
132
129
  try {
133
130
  const origCwd = process.cwd();
@@ -140,210 +137,131 @@ export async function runConnect(
140
137
  process.exit(1);
141
138
  }
142
139
  s.stop("Build complete");
143
- lintAndReport(cwd);
144
-
145
- // Step 2: Check registry was generated
146
- const registryPath = resolve(distDir, "registry.json");
147
- if (!existsSync(registryPath)) {
148
- log.error("dist/registry.json not found after build. Check your vite.config.ts.");
149
- process.exit(1);
150
- }
151
-
152
- // Step 3: Obtain bearer token via device authorization flow
153
- const api = apiUrl.replace(/\/$/, "");
154
- const host = extractHost(api);
155
- let token = "";
156
-
157
- if (opts.reAuth) {
158
- clearHostToken(host);
159
- log.info("Discarded stored credentials (--re-auth)");
160
- }
140
+ }
161
141
 
162
- // Validate stored token before using it
142
+ async function validStoredToken(api: string, host: string): Promise<string> {
163
143
  const existingToken = getHostToken(host);
164
- if (existingToken) {
165
- try {
166
- const check = await fetch(`${api}/api/auth/get-session`, {
167
- headers: { Authorization: `Bearer ${existingToken}` },
168
- });
169
- if (check.ok) {
170
- const body = (await check.json()) as { session?: unknown } | null;
171
- if (body?.session) {
172
- token = existingToken;
173
- log.info("Using stored credentials");
174
- } else {
175
- clearHostToken(host);
176
- log.warn("Stored credentials expired, re-authenticating");
177
- }
178
- } else {
179
- clearHostToken(host);
180
- log.warn("Stored credentials expired, re-authenticating");
181
- }
182
- } catch {
183
- log.warn("Could not validate stored credentials, re-authenticating");
144
+ if (!existingToken) return "";
145
+ try {
146
+ const check = await fetch(`${api}/api/auth/get-session`, {
147
+ headers: { Authorization: `Bearer ${existingToken}` },
148
+ });
149
+ const body = check.ok ? ((await check.json()) as { session?: unknown } | null) : null;
150
+ if (body?.session) {
151
+ log.info("Using stored credentials");
152
+ return existingToken;
184
153
  }
154
+ clearHostToken(host);
155
+ log.warn("Stored credentials expired, re-authenticating");
156
+ } catch {
157
+ log.warn("Could not validate stored credentials, re-authenticating");
185
158
  }
159
+ return "";
160
+ }
186
161
 
187
- if (!token) {
188
- // Request a device code
189
- s.start("Requesting authorization code...");
190
- let deviceCode: string;
191
- let userCode: string;
192
- let verificationUriComplete: string;
193
- let expiresIn: number;
194
- let interval: number;
195
-
196
- try {
197
- const res = await fetch(`${api}/api/auth/device/code`, {
198
- method: "POST",
199
- headers: { "Content-Type": "application/json" },
200
- body: JSON.stringify({ client_id: "glasshome-widget-cli" }),
201
- });
202
- if (!res.ok) {
203
- throw new Error(`HTTP ${res.status}`);
204
- }
205
- const data = (await res.json()) as {
206
- device_code: string;
207
- user_code: string;
208
- verification_uri: string;
209
- verification_uri_complete?: string;
210
- expires_in: number;
211
- interval: number;
212
- };
213
- deviceCode = data.device_code;
214
- userCode = data.user_code;
215
- verificationUriComplete =
216
- data.verification_uri_complete ??
217
- `${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`;
218
- expiresIn = data.expires_in;
219
- interval = data.interval ?? 5;
220
- } catch (err) {
221
- s.stop("Failed to request device code");
222
- log.warn(
223
- `Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
224
- );
225
- // token stays empty, caught by guard below
226
- }
162
+ const POLL_FAILURE_MESSAGE: Record<
163
+ Exclude<DevicePollOutcome["kind"], "authorized" | "error" | "failed" | "timeout">,
164
+ string
165
+ > = {
166
+ missing_token: "Auth response missing session token",
167
+ denied: "Authorization denied",
168
+ expired: "Device code expired",
169
+ };
170
+
171
+ function reportPollOutcome(outcome: DevicePollOutcome, s: Spinner): void {
172
+ switch (outcome.kind) {
173
+ case "authorized":
174
+ case "timeout":
175
+ return;
176
+ case "error":
177
+ s.stop(`Auth error: ${outcome.code}`);
178
+ return;
179
+ case "failed":
180
+ s.stop("Authorization failed");
181
+ log.warn(`Error polling for token: ${outcome.message}`);
182
+ return;
183
+ default:
184
+ s.stop(POLL_FAILURE_MESSAGE[outcome.kind]);
185
+ }
186
+ }
227
187
 
228
- if (deviceCode!) {
229
- s.stop("Authorization code ready");
230
-
231
- // Prompt user to visit the verification URL
232
- log.info(`Open in browser: ${verificationUriComplete!}`);
233
- log.info(`Device code: ${userCode!}`);
234
-
235
- // Try to open browser automatically
236
- await import("open")
237
- .then((m) => m.default(verificationUriComplete!))
238
- .catch(() => {});
239
-
240
- // Poll for approval
241
- s.start("Waiting for authorization (approve in your browser)...");
242
- const deadline = Date.now() + expiresIn! * 1000;
243
-
244
- while (Date.now() < deadline) {
245
- await new Promise((r) => setTimeout(r, interval! * 1000));
246
-
247
- try {
248
- const res = await fetch(`${api}/api/auth/device/token`, {
249
- method: "POST",
250
- headers: { "Content-Type": "application/json" },
251
- body: JSON.stringify({
252
- grant_type: "urn:ietf:params:oauth:grant-type:device_code",
253
- device_code: deviceCode,
254
- client_id: "glasshome-widget-cli",
255
- }),
256
- });
257
-
258
- if (res.ok) {
259
- // better-auth's device-authorization plugin returns an OAuth-style
260
- // bundle: { access_token, token_type, expires_in, scope }. The
261
- // access_token IS the first-party session token.
262
- const data = (await res.json()) as {
263
- access_token?: string;
264
- expires_in?: number;
265
- };
266
- if (!data.access_token) {
267
- s.stop("Auth response missing session token");
268
- break;
269
- }
270
- token = data.access_token;
271
- const tokenExpiresAt = Date.now() + (data.expires_in ?? 0) * 1000;
272
- storeHostToken(host, token, tokenExpiresAt);
273
- break;
274
- }
275
-
276
- const errData = (await res.json()) as { error?: string };
277
- if (errData.error === "authorization_pending" || errData.error === "slow_down") {
278
- if (errData.error === "slow_down") {
279
- interval = Math.min(interval! + 5, 30);
280
- }
281
- continue;
282
- }
283
- if (errData.error === "access_denied") {
284
- s.stop("Authorization denied");
285
- break;
286
- }
287
- if (errData.error === "expired_token") {
288
- s.stop("Device code expired");
289
- break;
290
- }
291
- // Unexpected error, stop polling
292
- s.stop(`Auth error: ${errData.error ?? res.status}`);
293
- break;
294
- } catch (err) {
295
- s.stop("Authorization failed");
296
- log.warn(`Error polling for token: ${err instanceof Error ? err.message : String(err)}`);
297
- break;
298
- }
299
- }
188
+ async function authorizeThisDevice(api: string, host: string, s: Spinner): Promise<string> {
189
+ s.start("Requesting authorization code...");
190
+ let grant: DeviceGrant;
191
+ try {
192
+ grant = await deviceAuthorize(api);
193
+ } catch (err) {
194
+ s.stop("Failed to request device code");
195
+ log.warn(
196
+ `Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
197
+ );
198
+ return "";
199
+ }
300
200
 
301
- if (token) {
302
- s.stop("Authorized");
303
- } else {
304
- s.stop("Not authorized");
305
- }
306
- }
201
+ s.stop("Authorization code ready");
202
+ log.info(`Open in browser: ${grant.verificationUriComplete}`);
203
+ log.info(`Device code: ${grant.userCode}`);
204
+ await import("open")
205
+ .then((m) => m.default(grant.verificationUriComplete))
206
+ .catch(() => {});
207
+
208
+ s.start("Waiting for authorization (approve in your browser)...");
209
+ const outcome = await pollDeviceToken(api, grant);
210
+ reportPollOutcome(outcome, s);
211
+ if (outcome.kind !== "authorized") {
212
+ s.stop("Not authorized");
213
+ return "";
307
214
  }
215
+ storeHostToken(host, outcome.token, Date.now() + outcome.expiresIn * 1000);
216
+ s.stop("Authorized");
217
+ return outcome.token;
218
+ }
308
219
 
309
- if (!token) {
310
- log.warn("Authentication failed, widgets won't be connected. Log in at the dashboard first, then restart.");
311
- return;
220
+ async function obtainToken(
221
+ api: string,
222
+ opts: { reAuth?: boolean },
223
+ s: Spinner,
224
+ ): Promise<string> {
225
+ const host = extractHost(api);
226
+ if (opts.reAuth) {
227
+ clearHostToken(host);
228
+ log.info("Discarded stored credentials (--re-auth)");
312
229
  }
230
+ const stored = await validStoredToken(api, host);
231
+ return stored || authorizeThisDevice(api, host, s);
232
+ }
313
233
 
314
- // Step 4: Enable dev mode if needed
315
- s.start("Registering widgets with dashboard...");
234
+ async function ensureDevMode(api: string, token: string): Promise<void> {
316
235
  try {
317
236
  const configData = await trpcQuery<{ devMode: boolean }>({
318
237
  apiUrl: api,
319
238
  path: "appConfig.get",
320
239
  });
321
-
322
240
  if (!configData.devMode) {
323
- await trpcMutate({
324
- apiUrl: api,
325
- path: "appConfig.toggleDevMode",
326
- token,
327
- input: {},
328
- });
241
+ await trpcMutate({ apiUrl: api, path: "appConfig.toggleDevMode", token, input: {} });
329
242
  }
330
243
  } catch {
331
244
  // Non-fatal, dev mode may already be enabled
332
245
  }
246
+ }
333
247
 
334
- // Step 5: Upload bundles and register widgets
335
- const registeredTags = await uploadAllWidgets(apiUrl, distDir, token);
336
- s.stop("Widgets registered");
248
+ interface ReuploadWatch {
249
+ close: () => Promise<void>;
250
+ }
337
251
 
338
- // Step 6: Watch src/ for changes and rebuild + re-upload only the changed widget.
339
- //
340
- // Debounce strategy: collect changed widget names into `pending`, fire after
341
- // DEBOUNCE_MS of quiet. If new changes arrive while a build is in flight,
342
- // they accumulate and trigger one more pass after the current build finishes.
343
- // Replaces a previous `rebuilding` boolean guard that silently dropped events
344
- // arriving during a build (lost edits) and was vulnerable to Linux fs.watch
345
- // coalescing two rapid writes into one event with stale content.
346
- const srcDir = resolve(cwd, "src");
252
+ /**
253
+ * Rebuild and re-upload only the widgets whose sources changed. Changes collect
254
+ * into `pending` and flush after DEBOUNCE_MS of quiet; changes arriving during a
255
+ * build trigger one more pass after it, so no edit is dropped.
256
+ */
257
+ function watchAndReupload(opts: {
258
+ cwd: string;
259
+ apiUrl: string;
260
+ distDir: string;
261
+ buildOpts: BuildOpts;
262
+ token: string;
263
+ }): ReuploadWatch {
264
+ const { cwd, apiUrl, distDir, buildOpts, token } = opts;
347
265
  const DEBOUNCE_MS = 150;
348
266
  const pending = new Set<string>();
349
267
  let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -372,8 +290,9 @@ export async function runConnect(
372
290
  process.chdir(origCwd);
373
291
  }
374
292
 
375
- const registryPath = resolve(distDir, "registry.json");
376
- const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
293
+ const registry: RegistryJson = JSON.parse(
294
+ readFileSync(resolve(distDir, "registry.json"), "utf-8"),
295
+ );
377
296
  for (const widgetName of widgets) {
378
297
  const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
379
298
  if (widget) {
@@ -387,14 +306,13 @@ export async function runConnect(
387
306
  log.warn(`Rebuild failed: ${err instanceof Error ? err.message : String(err)}`);
388
307
  } finally {
389
308
  buildInFlight = false;
390
- // Re-flush if events accumulated during this build.
391
309
  if (pending.size > 0) {
392
- flush();
310
+ void flush();
393
311
  }
394
312
  }
395
313
  }
396
314
 
397
- const watcher = watch(srcDir, { recursive: true }, (_event, filename) => {
315
+ const watcher = watch(resolve(cwd, "src"), { recursive: true }, (_event, filename) => {
398
316
  if (!filename) return;
399
317
  const widgetName = filename.split(/[\\/]/)[0] ?? filename;
400
318
  pending.add(widgetName);
@@ -402,16 +320,28 @@ export async function runConnect(
402
320
  debounceTimer = setTimeout(flush, DEBOUNCE_MS);
403
321
  });
404
322
 
405
- // Count widgets
406
- const widgetCount = existsSync(resolve(cwd, "src"))
407
- ? readdirSync(resolve(cwd, "src")).filter(
408
- (d) =>
409
- statSync(resolve(cwd, "src", d)).isDirectory() &&
410
- existsSync(resolve(cwd, "src", d, "manifest.json")),
411
- ).length
412
- : 0;
323
+ return {
324
+ close: async () => {
325
+ if (debounceTimer) clearTimeout(debounceTimer);
326
+ watcher.close();
327
+ // connect never exits normally, so without this the worker only dies via
328
+ // its stdin-EOF watchdog.
329
+ await introspectSession.dispose();
330
+ },
331
+ };
332
+ }
333
+
334
+ function countWidgets(cwd: string): number {
335
+ const srcDir = resolve(cwd, "src");
336
+ if (!existsSync(srcDir)) return 0;
337
+ return readdirSync(srcDir).filter(
338
+ (d) =>
339
+ statSync(resolve(srcDir, d)).isDirectory() && existsSync(resolve(srcDir, d, "manifest.json")),
340
+ ).length;
341
+ }
413
342
 
414
- log.success(color.green(`Connected: ${widgetCount} widget(s) live`));
343
+ function announceConnected(cwd: string): void {
344
+ log.success(color.green(`Connected: ${countWidgets(cwd)} widget(s) live`));
415
345
  note(
416
346
  [
417
347
  `${color.cyan("watching")} src/ (edits auto-rebuild & re-upload)`,
@@ -420,22 +350,24 @@ export async function runConnect(
420
350
  ].join("\n"),
421
351
  "Live testing",
422
352
  );
353
+ }
423
354
 
424
- // Step 7: Handle SIGINT, unregister and clean up
355
+ function disconnectOnSignal(opts: {
356
+ api: string;
357
+ token: string;
358
+ registeredTags: string[];
359
+ reupload: ReuploadWatch;
360
+ }): void {
425
361
  const cleanup = async () => {
426
362
  log.info("Disconnecting...");
427
- if (debounceTimer) clearTimeout(debounceTimer);
428
- watcher.close();
429
- // connect never exits normally, so without this the worker only dies via
430
- // its stdin-EOF watchdog.
431
- await introspectSession.dispose();
363
+ await opts.reupload.close();
432
364
 
433
- for (const slug of registeredTags) {
365
+ for (const slug of opts.registeredTags) {
434
366
  try {
435
367
  await trpcMutate({
436
- apiUrl: api,
368
+ apiUrl: opts.api,
437
369
  path: "widget.unregister",
438
- token,
370
+ token: opts.token,
439
371
  input: { scope: "local", name: slug },
440
372
  });
441
373
  } catch {
@@ -449,6 +381,47 @@ export async function runConnect(
449
381
 
450
382
  process.on("SIGINT", cleanup);
451
383
  process.on("SIGTERM", cleanup);
384
+ }
385
+
386
+ export async function runConnect(
387
+ apiUrl: string,
388
+ cwd: string,
389
+ opts: { reAuth?: boolean } = {},
390
+ ): Promise<void> {
391
+ const distDir = resolve(cwd, "dist");
392
+ const solid = (await import("vite-plugin-solid")).default;
393
+ // delegateEvents: false, widgets run in closed shadow roots where Solid's
394
+ // document-level event delegation cannot see the target.
395
+ const buildOpts: BuildOpts = {
396
+ srcDir: "src",
397
+ outDir: "dist",
398
+ plugins: [solid({ solid: { delegateEvents: false } })],
399
+ };
400
+
401
+ const s = spinner();
402
+ await buildOrExit(cwd, buildOpts, s);
403
+ lintAndReport(cwd);
404
+
405
+ if (!existsSync(resolve(distDir, "registry.json"))) {
406
+ log.error("dist/registry.json not found after build. Check your vite.config.ts.");
407
+ process.exit(1);
408
+ }
409
+
410
+ const api = apiUrl.replace(/\/$/, "");
411
+ const token = await obtainToken(api, opts, s);
412
+ if (!token) {
413
+ log.warn("Authentication failed, widgets won't be connected. Log in at the dashboard first, then restart.");
414
+ return;
415
+ }
416
+
417
+ s.start("Registering widgets with dashboard...");
418
+ await ensureDevMode(api, token);
419
+ const registeredTags = await uploadAllWidgets(apiUrl, distDir, token);
420
+ s.stop("Widgets registered");
421
+
422
+ const reupload = watchAndReupload({ cwd, apiUrl, distDir, buildOpts, token });
423
+ announceConnected(cwd);
424
+ disconnectOnSignal({ api, token, registeredTags, reupload });
452
425
 
453
426
  // Keep alive
454
427
  await new Promise(() => {});
@@ -42,7 +42,7 @@ export async function runLogin(hubUrl?: string): Promise<void> {
42
42
  }, LOGIN_TIMEOUT_MS);
43
43
 
44
44
  const server = createServer((req: IncomingMessage, res: ServerResponse) => {
45
- const url = new URL(req.url!, `http://${REDIRECT_HOST}:${REDIRECT_PORT}`);
45
+ const url = new URL(req.url ?? "/", `http://${REDIRECT_HOST}:${REDIRECT_PORT}`);
46
46
 
47
47
  if (url.pathname !== "/callback") {
48
48
  res.writeHead(404);
@@ -1,6 +1,6 @@
1
1
  import { log } from "@clack/prompts";
2
2
  import color from "picocolors";
3
- import { runPreview as capturePreview } from "../preview/capture";
3
+ import { capturePreviews } from "../preview/capture";
4
4
  import { type Box, DEFAULT_SIZES, runSweep, type Theme } from "../preview/sweep";
5
5
  import { withQuietStdout } from "../utils/quiet";
6
6
 
@@ -32,10 +32,10 @@ export async function runPreview(cwd: string, names: string[], isolate: boolean)
32
32
 
33
33
  log.info(names.length ? `Previewing ${names.join(", ")}` : "Previewing all widgets");
34
34
 
35
- let summary: Awaited<ReturnType<typeof capturePreview>>;
35
+ let summary: Awaited<ReturnType<typeof capturePreviews>>;
36
36
  try {
37
37
  summary = await withQuietStdout(() =>
38
- capturePreview({ projectDir: cwd, only: names, isolate, onProgress: progress }),
38
+ capturePreviews({ projectDir: cwd, only: names, isolate, onProgress: progress }),
39
39
  );
40
40
  } catch (err) {
41
41
  log.error(err instanceof Error ? err.message : String(err));
@@ -23,6 +23,29 @@ interface PublishOptions {
23
23
  scope?: string;
24
24
  }
25
25
 
26
+ async function authenticate(hubUrl: string): Promise<string> {
27
+ const stored = await getToken(hubUrl);
28
+ if (stored) return stored;
29
+
30
+ log.warn("Not authenticated. Starting login...");
31
+ try {
32
+ await runLogin(hubUrl);
33
+ } catch (err) {
34
+ log.error(err instanceof Error ? err.message : "Login failed.");
35
+ process.exit(1);
36
+ }
37
+ const token = await getToken(hubUrl);
38
+ if (!token) {
39
+ log.error("Authentication failed. Run `glasshome-widget login` manually.");
40
+ process.exit(1);
41
+ }
42
+ return token;
43
+ }
44
+
45
+ function errorMessage(err: unknown): string {
46
+ return err instanceof Error ? err.message : String(err);
47
+ }
48
+
26
49
  export async function runPublish(
27
50
  cwd: string,
28
51
  hubUrlOverride?: string,
@@ -39,25 +62,10 @@ export async function runPublish(
39
62
  // Stop an unsupported CLI before it uploads against a protocol it predates.
40
63
  await enforceCliVersion(hubUrl);
41
64
 
42
- let token = await getToken(hubUrl);
43
-
44
- if (!token) {
45
- log.warn("Not authenticated. Starting login...");
46
- try {
47
- await runLogin(hubUrl);
48
- } catch (err) {
49
- log.error(err instanceof Error ? err.message : "Login failed.");
50
- process.exit(1);
51
- }
52
- token = await getToken(hubUrl);
53
- if (!token) {
54
- log.error("Authentication failed. Run `glasshome-widget login` manually.");
55
- process.exit(1);
56
- }
57
- }
65
+ const token = await authenticate(hubUrl);
58
66
 
59
67
  // Step 3: Select scope
60
- const scopes = await fetchScopes(hubUrl, token!);
68
+ const scopes = await fetchScopes(hubUrl, token);
61
69
  if (scopes.length === 0) {
62
70
  log.error("No publishing scopes available. Ensure your account is properly set up.");
63
71
  process.exit(1);
@@ -72,8 +80,8 @@ export async function runPublish(
72
80
  }
73
81
  scope = match.name;
74
82
  log.info(`Publishing as @${scope} (${match.type})`);
75
- } else if (scopes.length === 1) {
76
- const only = scopes[0]!;
83
+ } else if (scopes.length === 1 && scopes[0]) {
84
+ const only = scopes[0];
77
85
  scope = only.name;
78
86
  log.info(`Publishing as @${scope} (${only.type})`);
79
87
  } else {
@@ -226,7 +234,7 @@ export async function runPublish(
226
234
 
227
235
  let publishData: Awaited<ReturnType<typeof requestPublish>>;
228
236
  try {
229
- publishData = await requestPublish(hubUrl, token!, {
237
+ publishData = await requestPublish(hubUrl, token, {
230
238
  scope,
231
239
  name: widgetName,
232
240
  displayName: builtManifest.name,
@@ -243,20 +251,20 @@ export async function runPublish(
243
251
  : {}),
244
252
  manifestJson: JSON.stringify(builtManifest),
245
253
  });
246
- } catch (err: any) {
247
- if (err.status === 409) {
254
+ } catch (err) {
255
+ if (err instanceof Error && "status" in err && err.status === 409) {
248
256
  s.stop(`${builtManifest.name}@${version} already published, bump version to republish`);
249
257
  process.exit(0);
250
258
  }
251
- s.stop(`Publish failed: ${err.message}`);
259
+ s.stop(`Publish failed: ${errorMessage(err)}`);
252
260
  process.exit(1);
253
261
  }
254
262
 
255
263
  s.message(`Uploading ${builtManifest.name} to CDN...`);
256
264
  try {
257
265
  await uploadToR2(publishData.uploadUrl, bundleBuffer);
258
- } catch (err: any) {
259
- s.stop(`Upload failed: ${err.message}`);
266
+ } catch (err) {
267
+ s.stop(`Upload failed: ${errorMessage(err)}`);
260
268
  process.exit(1);
261
269
  }
262
270
 
@@ -270,20 +278,20 @@ export async function runPublish(
270
278
  s.message(`Uploading ${builtManifest.name} styles to CDN...`);
271
279
  try {
272
280
  await uploadToR2(publishData.cssUploadUrl, cssBuffer, "text/css");
273
- } catch (err: any) {
274
- s.stop(`CSS upload failed: ${err.message}`);
281
+ } catch (err) {
282
+ s.stop(`CSS upload failed: ${errorMessage(err)}`);
275
283
  process.exit(1);
276
284
  }
277
285
  }
278
286
 
279
287
  s.message(`Confirming ${builtManifest.name}...`);
280
288
  try {
281
- const result = await confirmPublish(hubUrl, token!, publishData.versionId);
289
+ const result = await confirmPublish(hubUrl, token, publishData.versionId);
282
290
  s.stop(`Published ${builtManifest.name}@${version}`);
283
291
  log.info(`CDN: ${result.bundleUrl}`);
284
292
  if (result.cssUrl) log.info(`CSS: ${result.cssUrl}`);
285
- } catch (err: any) {
286
- s.stop(`Confirmation failed: ${err.message}`);
293
+ } catch (err) {
294
+ s.stop(`Confirmation failed: ${errorMessage(err)}`);
287
295
  process.exit(1);
288
296
  }
289
297
  }
@@ -9,7 +9,7 @@ import { Node, type ObjectLiteralExpression, Project, SyntaxKind } from "ts-morp
9
9
  * compiles and validates identically. Powered by `bun widget migrate config`.
10
10
  */
11
11
 
12
- export interface MigrationWarning {
12
+ interface MigrationWarning {
13
13
  field: string;
14
14
  reason: string;
15
15
  }
@@ -19,7 +19,7 @@ interface ShotListEntry {
19
19
  size: { w: number; h: number };
20
20
  }
21
21
 
22
- export interface Failure {
22
+ interface Failure {
23
23
  widget: string;
24
24
  kind: "network" | "hang" | "integrity";
25
25
  detail: string;
@@ -54,7 +54,7 @@ export function slug(s: string): string {
54
54
  * in light and dark under the render worker's constraints (frozen clock, DNS
55
55
  * blackhole, per-render timeout, verify-before-execute hash pin).
56
56
  */
57
- export async function runPreview(opts: PreviewOptions): Promise<PreviewSummary> {
57
+ export async function capturePreviews(opts: PreviewOptions): Promise<PreviewSummary> {
58
58
  const projectDir = resolve(opts.projectDir);
59
59
  const only = opts.only ?? [];
60
60
  const isolate = opts.isolate ?? false;
@@ -16,7 +16,7 @@ import { nixLdLibraryPath } from "./nix-ld";
16
16
 
17
17
  /** Hard wall-clock cap per render. A widget that hangs (camera waiting on a
18
18
  stream that will never arrive) must be killed, not allowed to stall the run. */
19
- export const RENDER_TIMEOUT_MS = 30_000;
19
+ const RENDER_TIMEOUT_MS = 30_000;
20
20
 
21
21
  export interface EgressLock {
22
22
  /**
@@ -40,7 +40,7 @@ export interface EgressLock {
40
40
  * closer analogue of the container egress allowlist the worker will actually
41
41
  * rely on.
42
42
  */
43
- export const NO_EGRESS_ARGS = [
43
+ const NO_EGRESS_ARGS = [
44
44
  "--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE localhost",
45
45
  ];
46
46
 
@@ -97,7 +97,7 @@ export function hashWidgetArtifacts(distDir: string, widget: string): string {
97
97
  * time-of-day-dependent widgets (clock, weather, sun-driven energy) land in a
98
98
  * flattering, stable state instead of drifting with the run.
99
99
  */
100
- export const FROZEN_TIME = new Date("2026-06-15T12:34:00Z");
100
+ const FROZEN_TIME = new Date("2026-06-15T12:34:00Z");
101
101
 
102
102
  /**
103
103
  * Freeze time, then step it forward deliberately.
@@ -223,6 +223,8 @@ async function main(): Promise<void> {
223
223
  };
224
224
  mount(stage, def, config, ctx, css);
225
225
 
226
+ // fonts.ready settles before the shadow root's text is laid out, so a cold page captures the fallback face.
227
+ await Promise.all([...document.fonts].map((face) => face.load().catch(() => undefined)));
226
228
  await document.fonts.ready;
227
229
  // Signal to the capture driver that mount + fonts settled.
228
230
  document.documentElement.setAttribute("data-harness-ready", "1");
@@ -62,6 +62,7 @@ const rowsFor = (height: number) => Math.round((height + 16) / 86);
62
62
 
63
63
  // Roots stay closed as in dash, so a handler that cannot fire there fails here too; --eval and --click reach in through this.
64
64
  function keepShadowRoots(): void {
65
+ // oxlint-disable-next-line typescript/unbound-method -- captured to re-call with .call(this) from the patch
65
66
  const attach = Element.prototype.attachShadow;
66
67
  Element.prototype.attachShadow = function (init) {
67
68
  const root = attach.call(this, init);
@@ -116,11 +117,9 @@ export async function runSweep(opts: SweepOptions): Promise<SweepSummary> {
116
117
  }
117
118
  await page.locator("#stage").screenshot({ path: file, omitBackground: true });
118
119
  if (!opts.evaluate) return undefined;
119
- return page.evaluate((code) => {
120
- const root = (document.getElementById("stage") as { __widgetRoot?: ShadowRoot } | null)
121
- ?.__widgetRoot;
122
- return new Function("root", `return (${code});`)(root);
123
- }, opts.evaluate);
120
+ return page.evaluate(
121
+ `(function (root) { return (${opts.evaluate}); })(document.getElementById("stage")?.__widgetRoot)`,
122
+ );
124
123
  };
125
124
 
126
125
  try {
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ type DeviceGrant,
4
+ type DevicePollDeps,
5
+ type DevicePollOutcome,
6
+ deviceAuthorize,
7
+ pollDeviceToken,
8
+ } from "./auth";
9
+
10
+ const API = "http://dash.test";
11
+
12
+ const grant: DeviceGrant = {
13
+ deviceCode: "dev-123",
14
+ userCode: "ABCD-EFGH",
15
+ verificationUriComplete: `${API}/device?user_code=ABCD-EFGH`,
16
+ expiresIn: 60,
17
+ interval: 5,
18
+ };
19
+
20
+ function json(status: number, body: unknown): Response {
21
+ return new Response(JSON.stringify(body), { status });
22
+ }
23
+
24
+ function fakeDeps(responses: Array<Response | Error>) {
25
+ let clock = 0;
26
+ const sleeps: number[] = [];
27
+ const bodies: unknown[] = [];
28
+ const deps: DevicePollDeps = {
29
+ now: () => clock,
30
+ sleep: (ms) => {
31
+ sleeps.push(ms);
32
+ clock += ms;
33
+ return Promise.resolve();
34
+ },
35
+ fetch: (url, init) => {
36
+ expect(url).toBe(`${API}/api/auth/device/token`);
37
+ bodies.push(typeof init.body === "string" ? JSON.parse(init.body) : init.body);
38
+ const next = responses.shift();
39
+ if (!next) throw new Error("unexpected poll");
40
+ return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
41
+ },
42
+ };
43
+ return { deps, sleeps, bodies };
44
+ }
45
+
46
+ describe("pollDeviceToken", () => {
47
+ test("keeps polling while pending and returns the token once approved", async () => {
48
+ const { deps, sleeps, bodies } = fakeDeps([
49
+ json(400, { error: "authorization_pending" }),
50
+ json(200, { access_token: "tok", expires_in: 3600 }),
51
+ ]);
52
+ const outcome = await pollDeviceToken(API, grant, deps);
53
+ expect(outcome).toEqual({ kind: "authorized", token: "tok", expiresIn: 3600 });
54
+ expect(sleeps).toEqual([5000, 5000]);
55
+ expect(bodies[0]).toEqual({
56
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
57
+ device_code: "dev-123",
58
+ client_id: "glasshome-widget-cli",
59
+ });
60
+ });
61
+
62
+ test("slow_down widens the interval, capped at 30s", async () => {
63
+ const { deps, sleeps } = fakeDeps([
64
+ ...Array.from({ length: 6 }, () => json(400, { error: "slow_down" })),
65
+ json(200, { access_token: "tok" }),
66
+ ]);
67
+ const outcome = await pollDeviceToken(API, { ...grant, expiresIn: 600 }, deps);
68
+ expect(outcome).toEqual({ kind: "authorized", token: "tok", expiresIn: 0 });
69
+ expect(sleeps).toEqual([5000, 10000, 15000, 20000, 25000, 30000, 30000]);
70
+ });
71
+
72
+ test("maps terminal errors to outcomes", async () => {
73
+ const cases: Array<[Response | Error, DevicePollOutcome]> = [
74
+ [json(400, { error: "access_denied" }), { kind: "denied" }],
75
+ [json(400, { error: "expired_token" }), { kind: "expired" }],
76
+ [json(400, { error: "invalid_grant" }), { kind: "error", code: "invalid_grant" }],
77
+ [json(500, {}), { kind: "error", code: 500 }],
78
+ [json(200, {}), { kind: "missing_token" }],
79
+ [new Error("ECONNREFUSED"), { kind: "failed", message: "ECONNREFUSED" }],
80
+ ];
81
+ for (const [response, expected] of cases) {
82
+ const { deps } = fakeDeps([response]);
83
+ expect(await pollDeviceToken(API, grant, deps)).toEqual(expected);
84
+ }
85
+ });
86
+
87
+ test("times out when the code expires while still pending", async () => {
88
+ const { deps, sleeps } = fakeDeps(
89
+ Array.from({ length: 2 }, () => json(400, { error: "authorization_pending" })),
90
+ );
91
+ const outcome = await pollDeviceToken(API, { ...grant, expiresIn: 10 }, deps);
92
+ expect(outcome).toEqual({ kind: "timeout" });
93
+ expect(sleeps).toEqual([5000, 5000]);
94
+ });
95
+ });
96
+
97
+ describe("deviceAuthorize", () => {
98
+ test("builds the verification URL when the server omits the complete one", async () => {
99
+ const result = await deviceAuthorize(API, () =>
100
+ Promise.resolve(
101
+ json(200, {
102
+ device_code: "dev-1",
103
+ user_code: "A B",
104
+ verification_uri: `${API}/device`,
105
+ expires_in: 900,
106
+ }),
107
+ ),
108
+ );
109
+ expect(result).toEqual({
110
+ deviceCode: "dev-1",
111
+ userCode: "A B",
112
+ verificationUriComplete: `${API}/device?user_code=A%20B`,
113
+ expiresIn: 900,
114
+ interval: 5,
115
+ });
116
+ });
117
+
118
+ test("throws on a non-OK response", async () => {
119
+ const outcome = deviceAuthorize(API, () => Promise.resolve(json(503, {})));
120
+ await expect(outcome).rejects.toThrow("HTTP 503");
121
+ });
122
+
123
+ test("throws when the server answers without a device code", async () => {
124
+ const outcome = deviceAuthorize(API, () =>
125
+ Promise.resolve(json(200, { user_code: "A", verification_uri: `${API}/device`, expires_in: 900 })),
126
+ );
127
+ await expect(outcome).rejects.toThrow("no device code");
128
+ });
129
+ });
package/src/utils/auth.ts CHANGED
@@ -85,7 +85,7 @@ export function storeToken(hubData: HubAuth): void {
85
85
  writeAuthFile(data);
86
86
  }
87
87
 
88
- export function getStoredAuth(): HubAuth | null {
88
+ function getStoredAuth(): HubAuth | null {
89
89
  return readAuthFile().hub ?? null;
90
90
  }
91
91
 
@@ -139,7 +139,7 @@ export async function getToken(hubUrl: string): Promise<string | null> {
139
139
  }
140
140
  }
141
141
 
142
- export function clearToken(): void {
142
+ function clearToken(): void {
143
143
  const data = readAuthFile();
144
144
  delete data.hub;
145
145
  writeAuthFile(data);
@@ -157,8 +157,122 @@ export function getHubUrl(): string {
157
157
  return "https://glasshome.app";
158
158
  }
159
159
 
160
- export function getScope(): string | null {
161
- return getStoredAuth()?.scope ?? null;
160
+ // --- OAuth device authorization grant (RFC 8628) ---
161
+
162
+ const CLI_CLIENT_ID = "glasshome-widget-cli";
163
+
164
+ export interface DeviceGrant {
165
+ deviceCode: string;
166
+ userCode: string;
167
+ verificationUriComplete: string;
168
+ expiresIn: number;
169
+ interval: number;
170
+ }
171
+
172
+ export type DevicePollOutcome =
173
+ | { kind: "authorized"; token: string; expiresIn: number }
174
+ | { kind: "missing_token" }
175
+ | { kind: "denied" }
176
+ | { kind: "expired" }
177
+ | { kind: "error"; code: string | number }
178
+ | { kind: "failed"; message: string }
179
+ | { kind: "timeout" };
180
+
181
+ type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
182
+
183
+ export interface DevicePollDeps {
184
+ fetch: FetchFn;
185
+ sleep: (ms: number) => Promise<void>;
186
+ now: () => number;
187
+ }
188
+
189
+ const realPollDeps: DevicePollDeps = {
190
+ fetch: (url, init) => fetch(url, init),
191
+ sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
192
+ now: () => Date.now(),
193
+ };
194
+
195
+ export async function deviceAuthorize(
196
+ api: string,
197
+ fetchImpl: FetchFn = realPollDeps.fetch,
198
+ ): Promise<DeviceGrant> {
199
+ const res = await fetchImpl(`${api}/api/auth/device/code`, {
200
+ method: "POST",
201
+ headers: { "Content-Type": "application/json" },
202
+ body: JSON.stringify({ client_id: CLI_CLIENT_ID }),
203
+ });
204
+ if (!res.ok) {
205
+ throw new Error(`HTTP ${res.status}`);
206
+ }
207
+ const data = (await res.json()) as {
208
+ device_code: string;
209
+ user_code: string;
210
+ verification_uri: string;
211
+ verification_uri_complete?: string;
212
+ expires_in: number;
213
+ interval?: number;
214
+ };
215
+ if (!data.device_code) throw new Error("Device authorization returned no device code");
216
+ return {
217
+ deviceCode: data.device_code,
218
+ userCode: data.user_code,
219
+ verificationUriComplete:
220
+ data.verification_uri_complete ??
221
+ `${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`,
222
+ expiresIn: data.expires_in,
223
+ interval: data.interval ?? 5,
224
+ };
225
+ }
226
+
227
+ export async function pollDeviceToken(
228
+ api: string,
229
+ grant: DeviceGrant,
230
+ deps: DevicePollDeps = realPollDeps,
231
+ ): Promise<DevicePollOutcome> {
232
+ const deadline = deps.now() + grant.expiresIn * 1000;
233
+ let interval = grant.interval;
234
+
235
+ while (deps.now() < deadline) {
236
+ await deps.sleep(interval * 1000);
237
+
238
+ try {
239
+ const res = await deps.fetch(`${api}/api/auth/device/token`, {
240
+ method: "POST",
241
+ headers: { "Content-Type": "application/json" },
242
+ body: JSON.stringify({
243
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
244
+ device_code: grant.deviceCode,
245
+ client_id: CLI_CLIENT_ID,
246
+ }),
247
+ });
248
+
249
+ if (res.ok) {
250
+ // better-auth's device plugin returns an OAuth bundle whose access_token is the session token.
251
+ const data = (await res.json()) as { access_token?: string; expires_in?: number };
252
+ if (!data.access_token) return { kind: "missing_token" };
253
+ return { kind: "authorized", token: data.access_token, expiresIn: data.expires_in ?? 0 };
254
+ }
255
+
256
+ const errData = (await res.json()) as { error?: string };
257
+ switch (errData.error) {
258
+ case "slow_down":
259
+ interval = Math.min(interval + 5, 30);
260
+ continue;
261
+ case "authorization_pending":
262
+ continue;
263
+ case "access_denied":
264
+ return { kind: "denied" };
265
+ case "expired_token":
266
+ return { kind: "expired" };
267
+ default:
268
+ return { kind: "error", code: errData.error ?? res.status };
269
+ }
270
+ } catch (err) {
271
+ return { kind: "failed", message: err instanceof Error ? err.message : String(err) };
272
+ }
273
+ }
274
+
275
+ return { kind: "timeout" };
162
276
  }
163
277
 
164
278
  // --- Helpers ---
@@ -42,7 +42,7 @@ const registryMatchers = deprecations
42
42
  .map((d) => ({ entry: d, re: new RegExp(d.sourcePattern as string) }));
43
43
 
44
44
  /** Scan one widget's source and return deprecation findings. */
45
- export function lintWidgetSource(cwd: string, widget: string): SourceLintFinding[] {
45
+ function lintWidgetSource(cwd: string, widget: string): SourceLintFinding[] {
46
46
  const findings: SourceLintFinding[] = [];
47
47
  for (const file of widgetSourceFiles(cwd, widget)) {
48
48
  const rel = relative(cwd, file);
@@ -48,7 +48,7 @@ export function getInstalledSdkVersion(cwd: string): string | null {
48
48
  * The SDK this CLI itself was built against, used when scaffolding a project
49
49
  * that has nothing installed yet.
50
50
  */
51
- export function getCliSdkVersion(): string | null {
51
+ function getCliSdkVersion(): string | null {
52
52
  try {
53
53
  const pkgPath = resolve(import.meta.dir, "../../package.json");
54
54
  if (!existsSync(pkgPath)) return null;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { log } from "@clack/prompts";
@@ -136,7 +136,7 @@ export async function notifyCliUpdate(): Promise<void> {
136
136
  }
137
137
 
138
138
  /** The @glasshome/widget-sdk version this project declares in its package.json. */
139
- export function getProjectSdkVersion(cwd: string): string | null {
139
+ function getProjectSdkVersion(cwd: string): string | null {
140
140
  try {
141
141
  const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf-8")) as {
142
142
  dependencies?: Record<string, string>;