@glasshome/widget-cli 0.14.1 → 0.14.3
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 +8 -5
- package/src/commands/connect.ts +194 -221
- package/src/commands/login.ts +1 -1
- package/src/commands/preview.ts +3 -3
- package/src/commands/publish.ts +38 -30
- package/src/migrate/config-codemod.ts +1 -1
- package/src/preview/capture.ts +2 -2
- package/src/preview/constraints.ts +3 -3
- package/src/preview/sweep.ts +4 -5
- package/src/utils/auth.test.ts +129 -0
- package/src/utils/auth.ts +118 -4
- package/src/utils/lint-source.ts +1 -1
- package/src/utils/sdk-version.ts +1 -1
- package/src/utils/version.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.3",
|
|
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.
|
|
33
|
+
"@glasshome/sync-layer": "0.10.1",
|
|
32
34
|
"@glasshome/widget-contract": "0.3.0",
|
|
33
|
-
"@glasshome/widget-sdk": "1.
|
|
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": {
|
package/src/commands/connect.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
142
|
+
async function validStoredToken(api: string, host: string): Promise<string> {
|
|
163
143
|
const existingToken = getHostToken(host);
|
|
164
|
-
if (existingToken)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
248
|
+
interface ReuploadWatch {
|
|
249
|
+
close: () => Promise<void>;
|
|
250
|
+
}
|
|
337
251
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
|
376
|
-
|
|
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(
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
)
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {});
|
package/src/commands/login.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/preview.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { log } from "@clack/prompts";
|
|
2
2
|
import color from "picocolors";
|
|
3
|
-
import {
|
|
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
|
|
35
|
+
let summary: Awaited<ReturnType<typeof capturePreviews>>;
|
|
36
36
|
try {
|
|
37
37
|
summary = await withQuietStdout(() =>
|
|
38
|
-
|
|
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));
|
package/src/commands/publish.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
259
|
-
s.stop(`Upload failed: ${err
|
|
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
|
|
274
|
-
s.stop(`CSS upload failed: ${err
|
|
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
|
|
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
|
|
286
|
-
s.stop(`Confirmation failed: ${err
|
|
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
|
-
|
|
12
|
+
interface MigrationWarning {
|
|
13
13
|
field: string;
|
|
14
14
|
reason: string;
|
|
15
15
|
}
|
package/src/preview/capture.ts
CHANGED
|
@@ -19,7 +19,7 @@ interface ShotListEntry {
|
|
|
19
19
|
size: { w: number; h: number };
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
+
const FROZEN_TIME = new Date("2026-06-15T12:34:00Z");
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* Freeze time, then step it forward deliberately.
|
package/src/preview/sweep.ts
CHANGED
|
@@ -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(
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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 ---
|
package/src/utils/lint-source.ts
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/src/utils/sdk-version.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/utils/version.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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>;
|