@glasshome/widget-cli 0.4.7 → 0.4.10

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/bin/cli.ts CHANGED
@@ -51,16 +51,25 @@ Examples:
51
51
  glasshome-widget publish --dir packages/widgets
52
52
  `.trim();
53
53
 
54
- const rawArgs = process.argv.slice(2);
55
- const dirFlagIdx = rawArgs.indexOf("--dir");
56
- let explicitDir: string | undefined;
57
- if (dirFlagIdx !== -1) {
58
- explicitDir = rawArgs[dirFlagIdx + 1];
59
- rawArgs.splice(dirFlagIdx, 2);
60
- }
61
- const [command, ...args] = rawArgs;
62
-
63
- if (command === "help" || command === "--help" || command === "-h") {
54
+ import { parseArgs } from "node:util";
55
+
56
+ const { values: flags, positionals } = parseArgs({
57
+ args: process.argv.slice(2),
58
+ options: {
59
+ dir: { type: "string" },
60
+ name: { type: "string" },
61
+ bump: { type: "string" },
62
+ scope: { type: "string" },
63
+ help: { type: "boolean", short: "h" },
64
+ },
65
+ allowPositionals: true,
66
+ strict: false,
67
+ });
68
+
69
+ const explicitDir = flags.dir;
70
+ const [command, ...args] = positionals;
71
+
72
+ if (command === "help" || flags.help) {
64
73
  console.log(HELP);
65
74
  process.exit(0);
66
75
  }
@@ -132,18 +141,12 @@ switch (effectiveCommand) {
132
141
  }
133
142
 
134
143
  case "publish": {
135
- const hubUrlArg = args.find((a) => !a.startsWith("--"));
136
- const nameIdx = rawArgs.indexOf("--name");
137
- const bumpIdx = rawArgs.indexOf("--bump");
138
- const scopeIdx = rawArgs.indexOf("--scope");
139
- const pubName = nameIdx !== -1 ? rawArgs[nameIdx + 1] : undefined;
140
- const pubBump = bumpIdx !== -1 ? rawArgs[bumpIdx + 1] : undefined;
141
- const pubScope = scopeIdx !== -1 ? rawArgs[scopeIdx + 1] : undefined;
144
+ const hubUrlArg = args[0];
142
145
  const { runPublish } = await import("../src/commands/publish");
143
146
  await runPublish(resolveWidgetDir(), hubUrlArg, {
144
- name: pubName,
145
- bump: pubBump as "keep" | "patch" | "minor" | "major" | undefined,
146
- scope: pubScope,
147
+ name: flags.name,
148
+ bump: flags.bump as "keep" | "patch" | "minor" | "major" | undefined,
149
+ scope: flags.scope,
147
150
  });
148
151
  outro("Done");
149
152
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/widget-cli",
3
- "version": "0.4.7",
3
+ "version": "0.4.10",
4
4
  "description": "CLI for creating and managing GlassHome widgets",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,7 @@
21
21
  },
22
22
  "dependencies": {
23
23
  "@clack/prompts": "^0.10.0",
24
- "@glasshome/widget-sdk": "0.2.5",
24
+ "@glasshome/widget-sdk": "^0.5.0",
25
25
  "open": "^11.0.0",
26
26
  "semver": "^7.6.0",
27
27
  "vite-plugin-solid": "^2.11.0"
@@ -3,7 +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
+ import { clearHostToken, extractHost, getHostToken, storeHostToken } from "../utils/auth";
7
7
 
8
8
  interface RegistryWidget {
9
9
  name: string;
@@ -127,9 +127,16 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
127
127
  headers: { Authorization: `Bearer ${existingToken}` },
128
128
  });
129
129
  if (check.ok) {
130
- token = existingToken;
131
- log.info("Using stored credentials for dashboard.");
130
+ const body = (await check.json()) as { session?: unknown } | null;
131
+ if (body?.session) {
132
+ token = existingToken;
133
+ log.info("Using stored credentials for dashboard.");
134
+ } else {
135
+ clearHostToken(host);
136
+ log.warn("Stored credentials expired — re-authenticating.");
137
+ }
132
138
  } else {
139
+ clearHostToken(host);
133
140
  log.warn("Stored credentials expired — re-authenticating.");
134
141
  }
135
142
  } catch {
@@ -158,15 +165,16 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
158
165
  const data = (await res.json()) as {
159
166
  device_code: string;
160
167
  user_code: string;
161
- verification_uri_complete: string;
168
+ verification_uri: string;
169
+ verification_uri_complete?: string;
162
170
  expires_in: number;
163
171
  interval: number;
164
172
  };
165
173
  deviceCode = data.device_code;
166
174
  userCode = data.user_code;
167
- // Build verification URL from the dashboard URL the user provided,
168
- // not the API response (which uses the API server's origin)
169
- verificationUriComplete = `${api}/device?user_code=${data.user_code}`;
175
+ verificationUriComplete =
176
+ data.verification_uri_complete ??
177
+ `${data.verification_uri}?user_code=${encodeURIComponent(data.user_code)}`;
170
178
  expiresIn = data.expires_in;
171
179
  interval = data.interval ?? 5;
172
180
  } catch (err) {
@@ -208,12 +216,19 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
208
216
  });
209
217
 
210
218
  if (res.ok) {
219
+ // better-auth's device-authorization plugin returns a
220
+ // first-party session, not an OIDC token bundle. The bearer is
221
+ // session.token; expiry is session.expiresAt (ISO/epoch/Date).
211
222
  const data = (await res.json()) as {
212
- access_token: string;
213
- expires_in: number;
223
+ session?: { token: string; expiresAt: string | number | Date };
224
+ user?: unknown;
214
225
  };
215
- token = data.access_token;
216
- const tokenExpiresAt = Date.now() + data.expires_in * 1000;
226
+ if (!data.session?.token) {
227
+ s.stop("Auth response missing session token");
228
+ break;
229
+ }
230
+ token = data.session.token;
231
+ const tokenExpiresAt = new Date(data.session.expiresAt).getTime();
217
232
  storeHostToken(host, token, tokenExpiresAt);
218
233
  break;
219
234
  }
@@ -280,35 +295,66 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
280
295
  const registeredTags = await uploadAllWidgets(apiUrl, distDir, token);
281
296
  s.stop("Widgets registered");
282
297
 
283
- // Step 6: Watch src/ for changes and rebuild + re-upload only the changed widget
298
+ // Step 6: Watch src/ for changes and rebuild + re-upload only the changed widget.
299
+ //
300
+ // Debounce strategy: collect changed widget names into `pending`, fire after
301
+ // DEBOUNCE_MS of quiet. If new changes arrive while a build is in flight,
302
+ // they accumulate and trigger one more pass after the current build finishes.
303
+ // Replaces a previous `rebuilding` boolean guard that silently dropped events
304
+ // arriving during a build (lost edits) and was vulnerable to Linux fs.watch
305
+ // coalescing two rapid writes into one event with stale content.
284
306
  const srcDir = resolve(cwd, "src");
285
- let rebuilding = false;
286
- const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
287
- if (rebuilding || !filename) return;
288
- rebuilding = true;
307
+ const DEBOUNCE_MS = 150;
308
+ const pending = new Set<string>();
309
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
310
+ let buildInFlight = false;
311
+
312
+ async function flush(): Promise<void> {
313
+ debounceTimer = null;
314
+ if (buildInFlight) return;
315
+ if (pending.size === 0) return;
316
+
317
+ const widgets = [...pending];
318
+ pending.clear();
319
+ buildInFlight = true;
320
+
289
321
  try {
290
- // Determine which widget changed from the file path (first path segment under src/)
291
- const widgetName = filename.split(/[\\/]/)[0] ?? filename;
292
322
  const origCwd = process.cwd();
293
323
  process.chdir(cwd);
294
- await buildWidgets({ ...buildOpts, only: [widgetName] });
295
- process.chdir(origCwd);
324
+ try {
325
+ await buildWidgets({ ...buildOpts, only: widgets });
326
+ } finally {
327
+ process.chdir(origCwd);
328
+ }
296
329
 
297
- // Upload only the changed widget
298
330
  const registryPath = resolve(distDir, "registry.json");
299
331
  const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
300
- const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
301
- if (widget) {
302
- await uploadAndRegister(apiUrl, distDir, widget, token);
303
- log.info(`Rebuilt & uploaded ${widgetName}`);
304
- } else {
305
- log.warn(`No registry entry for ${widgetName} — skipping upload`);
332
+ for (const widgetName of widgets) {
333
+ const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
334
+ if (widget) {
335
+ await uploadAndRegister(apiUrl, distDir, widget, token);
336
+ log.info(`Rebuilt & uploaded ${widgetName}`);
337
+ } else {
338
+ log.warn(`No registry entry for ${widgetName} — skipping upload`);
339
+ }
306
340
  }
307
341
  } catch (err) {
308
342
  log.warn(`Rebuild failed: ${err instanceof Error ? err.message : String(err)}`);
309
343
  } finally {
310
- rebuilding = false;
344
+ buildInFlight = false;
345
+ // Re-flush if events accumulated during this build.
346
+ if (pending.size > 0) {
347
+ flush();
348
+ }
311
349
  }
350
+ }
351
+
352
+ const watcher = watch(srcDir, { recursive: true }, (_event, filename) => {
353
+ if (!filename) return;
354
+ const widgetName = filename.split(/[\\/]/)[0] ?? filename;
355
+ pending.add(widgetName);
356
+ if (debounceTimer) clearTimeout(debounceTimer);
357
+ debounceTimer = setTimeout(flush, DEBOUNCE_MS);
312
358
  });
313
359
 
314
360
  // Count widgets
@@ -328,6 +374,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
328
374
  // Step 7: Handle SIGINT — unregister and clean up
329
375
  const cleanup = async () => {
330
376
  log.info("\nDisconnecting...");
377
+ if (debounceTimer) clearTimeout(debounceTimer);
331
378
  watcher.close();
332
379
 
333
380
  for (const slug of registeredTags) {
@@ -32,6 +32,16 @@ function validateManifest(manifest: WidgetManifest): ValidationResult {
32
32
  if (!manifest.name) errors.push("Missing required field: name");
33
33
  if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
34
34
 
35
+ // INS-05: "*" sdkVersion is rejected at publish time — authors must declare
36
+ // a real range (e.g. "^0.3.0"). Installed widgets that already ship "*" are
37
+ // still tolerated by the loader with a warn-and-mount, but new publishes
38
+ // are blocked here.
39
+ if (manifest.sdkVersion === "*") {
40
+ errors.push(
41
+ `sdkVersion "*" is not allowed — declare a semver range (e.g. "^0.3.0") to opt into host compatibility checks`,
42
+ );
43
+ }
44
+
35
45
  // Size validation
36
46
  if (!hasValidMinSize) {
37
47
  errors.push("minSize must be an object with numeric w and h properties");