@glasshome/widget-cli 0.4.3 → 0.4.5
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 +4 -2
- package/src/commands/add.ts +1 -23
- package/src/commands/connect.ts +106 -94
- package/src/utils/hub-api.ts +2 -2
- package/template/README.md.template +1 -1
- package/template/src/index.tsx.template +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -16,7 +16,9 @@
|
|
|
16
16
|
"template"
|
|
17
17
|
],
|
|
18
18
|
"devDependencies": {
|
|
19
|
-
"@types/bun": "latest"
|
|
19
|
+
"@types/bun": "latest",
|
|
20
|
+
"@types/semver": "^7.0.0",
|
|
21
|
+
"vite-plugin-solid": "^2.11.0"
|
|
20
22
|
},
|
|
21
23
|
"dependencies": {
|
|
22
24
|
"@clack/prompts": "^0.10.0",
|
package/src/commands/add.ts
CHANGED
|
@@ -15,11 +15,10 @@ export function scaffoldWidget(
|
|
|
15
15
|
cwd: string,
|
|
16
16
|
opts: {
|
|
17
17
|
widgetName: string;
|
|
18
|
-
tag: string;
|
|
19
18
|
description: string;
|
|
20
19
|
},
|
|
21
20
|
): void {
|
|
22
|
-
const { widgetName,
|
|
21
|
+
const { widgetName, description } = opts;
|
|
23
22
|
const widgetDir = resolve(cwd, "src", widgetName);
|
|
24
23
|
|
|
25
24
|
if (existsSync(widgetDir)) {
|
|
@@ -34,13 +33,11 @@ export function scaffoldWidget(
|
|
|
34
33
|
// Read index.tsx template from the template directory
|
|
35
34
|
const templateDir = resolve(import.meta.dir, "../../template");
|
|
36
35
|
let srcContent = readFileSync(resolve(templateDir, "src/index.tsx.template"), "utf-8");
|
|
37
|
-
srcContent = srcContent.replace(/WIDGET_TAG/g, tag);
|
|
38
36
|
srcContent = srcContent.replace(/WIDGET_NAME/g, displayName);
|
|
39
37
|
writeFileSync(resolve(widgetDir, "index.tsx"), srcContent);
|
|
40
38
|
|
|
41
39
|
// Generate manifest.json
|
|
42
40
|
const manifest = {
|
|
43
|
-
tag,
|
|
44
41
|
name: displayName,
|
|
45
42
|
description: widgetDescription,
|
|
46
43
|
minSize: { w: 1, h: 1 },
|
|
@@ -55,7 +52,6 @@ export function scaffoldWidget(
|
|
|
55
52
|
/** Interactive prompts for widget details. Returns null if cancelled. */
|
|
56
53
|
export async function promptWidgetDetails(defaults?: { widgetName?: string }): Promise<{
|
|
57
54
|
widgetName: string;
|
|
58
|
-
tag: string;
|
|
59
55
|
description: string;
|
|
60
56
|
} | null> {
|
|
61
57
|
const widgetName = await text({
|
|
@@ -75,23 +71,6 @@ export async function promptWidgetDetails(defaults?: { widgetName?: string }): P
|
|
|
75
71
|
return null;
|
|
76
72
|
}
|
|
77
73
|
|
|
78
|
-
const tag = await text({
|
|
79
|
-
message: "Custom element tag",
|
|
80
|
-
placeholder: `glasshome-${widgetName}`,
|
|
81
|
-
defaultValue: `glasshome-${widgetName}`,
|
|
82
|
-
validate(value) {
|
|
83
|
-
if (!value) return "Tag is required";
|
|
84
|
-
if (!/^[a-z]/.test(value)) return "Tag must start with a lowercase letter";
|
|
85
|
-
if (!value.includes("-")) return "Custom element tags must contain a hyphen";
|
|
86
|
-
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(value))
|
|
87
|
-
return "Tag must be lowercase alphanumeric with hyphens (e.g. glasshome-my-widget)";
|
|
88
|
-
},
|
|
89
|
-
});
|
|
90
|
-
if (isCancel(tag)) {
|
|
91
|
-
cancel("Operation cancelled.");
|
|
92
|
-
return null;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
74
|
const description = await text({
|
|
96
75
|
message: "Description (optional)",
|
|
97
76
|
placeholder: "A GlassHome dashboard widget",
|
|
@@ -104,7 +83,6 @@ export async function promptWidgetDetails(defaults?: { widgetName?: string }): P
|
|
|
104
83
|
|
|
105
84
|
return {
|
|
106
85
|
widgetName: widgetName as string,
|
|
107
|
-
tag: tag as string,
|
|
108
86
|
description: description as string,
|
|
109
87
|
};
|
|
110
88
|
}
|
package/src/commands/connect.ts
CHANGED
|
@@ -6,7 +6,6 @@ import { trpcMutate, trpcQuery } from "../utils/api";
|
|
|
6
6
|
import { extractHost, getHostToken, storeHostToken } from "../utils/auth";
|
|
7
7
|
|
|
8
8
|
interface RegistryWidget {
|
|
9
|
-
tag: string;
|
|
10
9
|
name: string;
|
|
11
10
|
version: string;
|
|
12
11
|
bundleUrl: string;
|
|
@@ -29,20 +28,18 @@ async function uploadAndRegister(
|
|
|
29
28
|
token: string,
|
|
30
29
|
): Promise<void> {
|
|
31
30
|
const api = apiUrl.replace(/\/$/, "");
|
|
31
|
+
// Derive slug from bundleUrl (e.g. "./area.js" → "area")
|
|
32
|
+
const slug = widget.bundleUrl.replace(/^\.\//, "").replace(/\.js$/, "");
|
|
32
33
|
|
|
33
|
-
|
|
34
|
-
const bundleFilename = widget.bundleUrl.replace(/^\.\//, "");
|
|
35
|
-
const bundlePath = resolve(distDir, bundleFilename);
|
|
36
|
-
|
|
34
|
+
const bundlePath = resolve(distDir, `${slug}.js`);
|
|
37
35
|
if (!existsSync(bundlePath)) {
|
|
38
|
-
log.warn(`Bundle not found: ${bundlePath} — skipping ${
|
|
36
|
+
log.warn(`Bundle not found: ${bundlePath} — skipping ${slug}`);
|
|
39
37
|
return;
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
const bundleContent = readFileSync(bundlePath, "utf-8");
|
|
43
41
|
|
|
44
|
-
|
|
45
|
-
const uploadRes = await fetch(`${api}/bundles/local/${widget.tag}`, {
|
|
42
|
+
const uploadRes = await fetch(`${api}/bundles/local/local/${slug}`, {
|
|
46
43
|
method: "POST",
|
|
47
44
|
headers: {
|
|
48
45
|
"Content-Type": "application/javascript",
|
|
@@ -52,10 +49,9 @@ async function uploadAndRegister(
|
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
if (!uploadRes.ok) {
|
|
55
|
-
throw new Error(`Failed to upload bundle for ${
|
|
52
|
+
throw new Error(`Failed to upload bundle for ${slug}: HTTP ${uploadRes.status}`);
|
|
56
53
|
}
|
|
57
54
|
|
|
58
|
-
// Register the widget with a local bundle path (skips redundant download)
|
|
59
55
|
const manifest = { ...widget };
|
|
60
56
|
delete (manifest as Record<string, unknown>).bundleUrl;
|
|
61
57
|
|
|
@@ -64,10 +60,10 @@ async function uploadAndRegister(
|
|
|
64
60
|
path: "widget.register",
|
|
65
61
|
token,
|
|
66
62
|
input: {
|
|
67
|
-
|
|
68
|
-
name:
|
|
63
|
+
scope: "local",
|
|
64
|
+
name: slug,
|
|
69
65
|
version: widget.version,
|
|
70
|
-
bundleUrl: `/bundles/local/${
|
|
66
|
+
bundleUrl: `/bundles/local/local/${slug}/bundle.js`,
|
|
71
67
|
manifestJson: JSON.stringify(manifest),
|
|
72
68
|
},
|
|
73
69
|
});
|
|
@@ -75,19 +71,20 @@ async function uploadAndRegister(
|
|
|
75
71
|
|
|
76
72
|
/**
|
|
77
73
|
* Read registry.json and upload all widget bundles.
|
|
78
|
-
* @returns Array of widget
|
|
74
|
+
* @returns Array of widget slugs that were registered
|
|
79
75
|
*/
|
|
80
76
|
async function uploadAllWidgets(apiUrl: string, distDir: string, token: string): Promise<string[]> {
|
|
81
77
|
const registryPath = resolve(distDir, "registry.json");
|
|
82
78
|
const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
83
|
-
const
|
|
79
|
+
const slugs: string[] = [];
|
|
84
80
|
|
|
85
81
|
for (const widget of registry.widgets) {
|
|
82
|
+
const slug = widget.bundleUrl.replace(/^\.\//, "").replace(/\.js$/, "");
|
|
86
83
|
await uploadAndRegister(apiUrl, distDir, widget, token);
|
|
87
|
-
|
|
84
|
+
slugs.push(slug);
|
|
88
85
|
}
|
|
89
86
|
|
|
90
|
-
return
|
|
87
|
+
return slugs;
|
|
91
88
|
}
|
|
92
89
|
|
|
93
90
|
export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
@@ -122,11 +119,25 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
122
119
|
const host = extractHost(api);
|
|
123
120
|
let token = "";
|
|
124
121
|
|
|
122
|
+
// Validate stored token before using it
|
|
125
123
|
const existingToken = getHostToken(host);
|
|
126
124
|
if (existingToken) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
125
|
+
try {
|
|
126
|
+
const check = await fetch(`${api}/api/auth/get-session`, {
|
|
127
|
+
headers: { Authorization: `Bearer ${existingToken}` },
|
|
128
|
+
});
|
|
129
|
+
if (check.ok) {
|
|
130
|
+
token = existingToken;
|
|
131
|
+
log.info("Using stored credentials for dashboard.");
|
|
132
|
+
} else {
|
|
133
|
+
log.warn("Stored credentials expired — re-authenticating.");
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
log.warn("Could not validate stored credentials — re-authenticating.");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!token) {
|
|
130
141
|
// Request a device code
|
|
131
142
|
s.start("Requesting authorization code...");
|
|
132
143
|
let deviceCode: string;
|
|
@@ -160,88 +171,89 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
160
171
|
interval = data.interval ?? 5;
|
|
161
172
|
} catch (err) {
|
|
162
173
|
s.stop("Failed to request device code");
|
|
163
|
-
log.
|
|
174
|
+
log.warn(
|
|
164
175
|
`Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
|
|
165
176
|
);
|
|
166
|
-
|
|
177
|
+
// token stays empty — caught by guard below
|
|
167
178
|
}
|
|
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
179
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
180
|
+
if (deviceCode!) {
|
|
181
|
+
s.stop("Authorization code ready");
|
|
182
|
+
|
|
183
|
+
// Prompt user to visit the verification URL
|
|
184
|
+
log.info(`Open in browser: ${verificationUriComplete!}`);
|
|
185
|
+
log.info(`Device code: ${userCode!}`);
|
|
186
|
+
|
|
187
|
+
// Try to open browser automatically
|
|
188
|
+
await import("open")
|
|
189
|
+
.then((m) => m.default(verificationUriComplete!))
|
|
190
|
+
.catch(() => {});
|
|
191
|
+
|
|
192
|
+
// Poll for approval
|
|
193
|
+
s.start("Waiting for authorization (approve in your browser)...");
|
|
194
|
+
const deadline = Date.now() + expiresIn! * 1000;
|
|
195
|
+
|
|
196
|
+
while (Date.now() < deadline) {
|
|
197
|
+
await new Promise((r) => setTimeout(r, interval! * 1000));
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const res = await fetch(`${api}/api/auth/device/token`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: { "Content-Type": "application/json" },
|
|
203
|
+
body: JSON.stringify({
|
|
204
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
205
|
+
device_code: deviceCode,
|
|
206
|
+
client_id: "glasshome-widget-cli",
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (res.ok) {
|
|
211
|
+
const data = (await res.json()) as {
|
|
212
|
+
access_token: string;
|
|
213
|
+
expires_in: number;
|
|
214
|
+
};
|
|
215
|
+
token = data.access_token;
|
|
216
|
+
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
|
217
|
+
storeHostToken(host, token, tokenExpiresAt);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
197
220
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
221
|
+
const errData = (await res.json()) as { error?: string };
|
|
222
|
+
if (errData.error === "authorization_pending" || errData.error === "slow_down") {
|
|
223
|
+
if (errData.error === "slow_down") {
|
|
224
|
+
interval = Math.min(interval! + 5, 30);
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (errData.error === "access_denied") {
|
|
229
|
+
s.stop("Authorization denied");
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
if (errData.error === "expired_token") {
|
|
233
|
+
s.stop("Device code expired");
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
// Unexpected error — stop polling
|
|
237
|
+
s.stop(`Auth error: ${errData.error ?? res.status}`);
|
|
238
|
+
break;
|
|
239
|
+
} catch (err) {
|
|
240
|
+
s.stop("Authorization failed");
|
|
241
|
+
log.warn(`Error polling for token: ${err instanceof Error ? err.message : String(err)}`);
|
|
207
242
|
break;
|
|
208
243
|
}
|
|
244
|
+
}
|
|
209
245
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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);
|
|
246
|
+
if (token) {
|
|
247
|
+
s.stop("Authorized");
|
|
248
|
+
} else {
|
|
249
|
+
s.stop("Not authorized");
|
|
236
250
|
}
|
|
237
251
|
}
|
|
252
|
+
}
|
|
238
253
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
process.exit(1);
|
|
243
|
-
}
|
|
244
|
-
s.stop("Authorized");
|
|
254
|
+
if (!token) {
|
|
255
|
+
log.warn("Authentication failed — widgets won't be connected. Log in at the dashboard first, then restart.");
|
|
256
|
+
return;
|
|
245
257
|
}
|
|
246
258
|
|
|
247
259
|
// Step 4: Enable dev mode if needed
|
|
@@ -288,7 +300,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
288
300
|
const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
|
|
289
301
|
if (widget) {
|
|
290
302
|
await uploadAndRegister(apiUrl, distDir, widget, token);
|
|
291
|
-
log.info(`Rebuilt & uploaded ${
|
|
303
|
+
log.info(`Rebuilt & uploaded ${widgetName}`);
|
|
292
304
|
} else {
|
|
293
305
|
log.warn(`No registry entry for ${widgetName} — skipping upload`);
|
|
294
306
|
}
|
|
@@ -318,13 +330,13 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
318
330
|
log.info("\nDisconnecting...");
|
|
319
331
|
watcher.close();
|
|
320
332
|
|
|
321
|
-
for (const
|
|
333
|
+
for (const slug of registeredTags) {
|
|
322
334
|
try {
|
|
323
335
|
await trpcMutate({
|
|
324
336
|
apiUrl: api,
|
|
325
337
|
path: "widget.unregister",
|
|
326
338
|
token,
|
|
327
|
-
input: {
|
|
339
|
+
input: { scope: "local", name: slug },
|
|
328
340
|
});
|
|
329
341
|
} catch {
|
|
330
342
|
// Non-fatal — API may be down
|
package/src/utils/hub-api.ts
CHANGED
|
@@ -107,11 +107,11 @@ export async function confirmPublish(
|
|
|
107
107
|
return res.json() as Promise<PublishConfirmResponse>;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
export async function uploadToR2(uploadUrl: string, bundleBuffer:
|
|
110
|
+
export async function uploadToR2(uploadUrl: string, bundleBuffer: Uint8Array): Promise<void> {
|
|
111
111
|
const res = await fetch(uploadUrl, {
|
|
112
112
|
method: "PUT",
|
|
113
113
|
headers: { "Content-Type": "application/javascript" },
|
|
114
|
-
body: bundleBuffer,
|
|
114
|
+
body: bundleBuffer as unknown as BodyInit,
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
if (!res.ok) {
|
|
@@ -56,7 +56,7 @@ PROJECT_NAME/
|
|
|
56
56
|
Each widget subdirectory contains:
|
|
57
57
|
|
|
58
58
|
- **`index.tsx`** — SolidJS component + `defineWidget()` registration
|
|
59
|
-
- **`manifest.json`** — metadata (
|
|
59
|
+
- **`manifest.json`** — metadata (name, description, size, version)
|
|
60
60
|
|
|
61
61
|
## CLI commands
|
|
62
62
|
|