@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 +23 -20
- package/package.json +2 -2
- package/src/commands/connect.ts +75 -28
- package/src/commands/validate.ts +10 -0
package/bin/cli.ts
CHANGED
|
@@ -51,16 +51,25 @@ Examples:
|
|
|
51
51
|
glasshome-widget publish --dir packages/widgets
|
|
52
52
|
`.trim();
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
|
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:
|
|
145
|
-
bump:
|
|
146
|
-
scope:
|
|
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.
|
|
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.
|
|
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"
|
package/src/commands/connect.ts
CHANGED
|
@@ -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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
213
|
-
|
|
223
|
+
session?: { token: string; expiresAt: string | number | Date };
|
|
224
|
+
user?: unknown;
|
|
214
225
|
};
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
295
|
-
|
|
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
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
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) {
|
package/src/commands/validate.ts
CHANGED
|
@@ -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");
|