@glasshome/widget-cli 0.3.2 → 0.4.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 +4 -3
- package/src/commands/add.ts +1 -0
- package/src/commands/connect.ts +150 -15
- package/src/commands/info.ts +3 -1
- package/src/commands/publish.ts +7 -7
- package/src/commands/validate.ts +29 -18
- package/src/utils/api.ts +8 -2
- package/src/utils/auth.ts +94 -23
- package/template/src/index.tsx.template +1 -0
package/package.json
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/widget-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "CLI for creating and managing GlassHome widgets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"glasshome-widget": "bin/cli.ts"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
|
-
"cli": "bun bin/cli.ts"
|
|
10
|
+
"cli": "bun bin/cli.ts",
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
11
12
|
},
|
|
12
13
|
"files": [
|
|
13
14
|
"bin",
|
|
@@ -16,7 +17,7 @@
|
|
|
16
17
|
],
|
|
17
18
|
"dependencies": {
|
|
18
19
|
"@clack/prompts": "^0.10.0",
|
|
19
|
-
"@glasshome/widget-sdk": "
|
|
20
|
+
"@glasshome/widget-sdk": "0.2.3",
|
|
20
21
|
"open": "^11.0.0",
|
|
21
22
|
"semver": "^7.6.0"
|
|
22
23
|
},
|
package/src/commands/add.ts
CHANGED
package/src/commands/connect.ts
CHANGED
|
@@ -3,6 +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
7
|
|
|
7
8
|
interface RegistryWidget {
|
|
8
9
|
tag: string;
|
|
@@ -25,6 +26,7 @@ async function uploadAndRegister(
|
|
|
25
26
|
apiUrl: string,
|
|
26
27
|
distDir: string,
|
|
27
28
|
widget: RegistryWidget,
|
|
29
|
+
token: string,
|
|
28
30
|
): Promise<void> {
|
|
29
31
|
const api = apiUrl.replace(/\/$/, "");
|
|
30
32
|
|
|
@@ -39,10 +41,13 @@ async function uploadAndRegister(
|
|
|
39
41
|
|
|
40
42
|
const bundleContent = readFileSync(bundlePath, "utf-8");
|
|
41
43
|
|
|
42
|
-
// Upload bundle to API
|
|
43
|
-
const uploadRes = await fetch(`${api}/bundles/${widget.tag}`, {
|
|
44
|
+
// Upload bundle to API (local registry namespace)
|
|
45
|
+
const uploadRes = await fetch(`${api}/bundles/local/${widget.tag}`, {
|
|
44
46
|
method: "POST",
|
|
45
|
-
headers: {
|
|
47
|
+
headers: {
|
|
48
|
+
"Content-Type": "application/javascript",
|
|
49
|
+
Authorization: `Bearer ${token}`,
|
|
50
|
+
},
|
|
46
51
|
body: bundleContent,
|
|
47
52
|
});
|
|
48
53
|
|
|
@@ -57,11 +62,12 @@ async function uploadAndRegister(
|
|
|
57
62
|
await trpcMutate({
|
|
58
63
|
apiUrl: api,
|
|
59
64
|
path: "widget.register",
|
|
65
|
+
token,
|
|
60
66
|
input: {
|
|
61
67
|
tag: widget.tag,
|
|
62
68
|
name: widget.name,
|
|
63
69
|
version: widget.version,
|
|
64
|
-
bundleUrl: `/bundles/${widget.tag}/bundle.js`,
|
|
70
|
+
bundleUrl: `/bundles/local/${widget.tag}/bundle.js`,
|
|
65
71
|
manifestJson: JSON.stringify(manifest),
|
|
66
72
|
},
|
|
67
73
|
});
|
|
@@ -71,13 +77,13 @@ async function uploadAndRegister(
|
|
|
71
77
|
* Read registry.json and upload all widget bundles.
|
|
72
78
|
* @returns Array of widget tags that were registered
|
|
73
79
|
*/
|
|
74
|
-
async function uploadAllWidgets(apiUrl: string, distDir: string): Promise<string[]> {
|
|
80
|
+
async function uploadAllWidgets(apiUrl: string, distDir: string, token: string): Promise<string[]> {
|
|
75
81
|
const registryPath = resolve(distDir, "registry.json");
|
|
76
82
|
const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
77
83
|
const tags: string[] = [];
|
|
78
84
|
|
|
79
85
|
for (const widget of registry.widgets) {
|
|
80
|
-
await uploadAndRegister(apiUrl, distDir, widget);
|
|
86
|
+
await uploadAndRegister(apiUrl, distDir, widget, token);
|
|
81
87
|
tags.push(widget.tag);
|
|
82
88
|
}
|
|
83
89
|
|
|
@@ -111,18 +117,146 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
111
117
|
process.exit(1);
|
|
112
118
|
}
|
|
113
119
|
|
|
114
|
-
// Step 3:
|
|
120
|
+
// Step 3: Obtain bearer token via device authorization flow
|
|
121
|
+
const api = apiUrl.replace(/\/$/, "");
|
|
122
|
+
const host = extractHost(api);
|
|
123
|
+
let token = "";
|
|
124
|
+
|
|
125
|
+
const existingToken = getHostToken(host);
|
|
126
|
+
if (existingToken) {
|
|
127
|
+
token = existingToken;
|
|
128
|
+
log.info("Using stored credentials for dashboard.");
|
|
129
|
+
} else {
|
|
130
|
+
// Request a device code
|
|
131
|
+
s.start("Requesting authorization code...");
|
|
132
|
+
let deviceCode: string;
|
|
133
|
+
let userCode: string;
|
|
134
|
+
let verificationUriComplete: string;
|
|
135
|
+
let expiresIn: number;
|
|
136
|
+
let interval: number;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const res = await fetch(`${api}/api/auth/device/code`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ client_id: "glasshome-widget-cli" }),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
throw new Error(`HTTP ${res.status}`);
|
|
146
|
+
}
|
|
147
|
+
const data = (await res.json()) as {
|
|
148
|
+
device_code: string;
|
|
149
|
+
user_code: string;
|
|
150
|
+
verification_uri_complete: string;
|
|
151
|
+
expires_in: number;
|
|
152
|
+
interval: number;
|
|
153
|
+
};
|
|
154
|
+
deviceCode = data.device_code;
|
|
155
|
+
userCode = data.user_code;
|
|
156
|
+
// Build verification URL from the dashboard URL the user provided,
|
|
157
|
+
// not the API response (which uses the API server's origin)
|
|
158
|
+
verificationUriComplete = `${api}/device?user_code=${data.user_code}`;
|
|
159
|
+
expiresIn = data.expires_in;
|
|
160
|
+
interval = data.interval ?? 5;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
s.stop("Failed to request device code");
|
|
163
|
+
log.error(
|
|
164
|
+
`Could not reach dashboard at ${api}: ${err instanceof Error ? err.message : String(err)}`,
|
|
165
|
+
);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
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
|
+
|
|
184
|
+
while (Date.now() < deadline) {
|
|
185
|
+
await new Promise((r) => setTimeout(r, interval * 1000));
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const res = await fetch(`${api}/api/auth/device/token`, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: { "Content-Type": "application/json" },
|
|
191
|
+
body: JSON.stringify({
|
|
192
|
+
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
|
|
193
|
+
device_code: deviceCode,
|
|
194
|
+
client_id: "glasshome-widget-cli",
|
|
195
|
+
}),
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
if (res.ok) {
|
|
199
|
+
const data = (await res.json()) as {
|
|
200
|
+
access_token: string;
|
|
201
|
+
expires_in: number;
|
|
202
|
+
};
|
|
203
|
+
token = data.access_token;
|
|
204
|
+
const tokenExpiresAt = Date.now() + data.expires_in * 1000;
|
|
205
|
+
storeHostToken(host, token, tokenExpiresAt);
|
|
206
|
+
approved = true;
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const errData = (await res.json()) as { error?: string };
|
|
211
|
+
if (errData.error === "authorization_pending" || errData.error === "slow_down") {
|
|
212
|
+
// Continue polling
|
|
213
|
+
if (errData.error === "slow_down") {
|
|
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);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!approved) {
|
|
240
|
+
s.stop("Timed out");
|
|
241
|
+
log.error("Authorization timed out. Run `bun widget connect` again.");
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
s.stop("Authorized");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Step 4: Enable dev mode if needed
|
|
115
248
|
s.start("Registering widgets with dashboard...");
|
|
116
249
|
try {
|
|
117
250
|
const configData = await trpcQuery<{ devMode: boolean }>({
|
|
118
|
-
apiUrl:
|
|
251
|
+
apiUrl: api,
|
|
119
252
|
path: "appConfig.get",
|
|
120
253
|
});
|
|
121
254
|
|
|
122
255
|
if (!configData.devMode) {
|
|
123
256
|
await trpcMutate({
|
|
124
|
-
apiUrl:
|
|
257
|
+
apiUrl: api,
|
|
125
258
|
path: "appConfig.toggleDevMode",
|
|
259
|
+
token,
|
|
126
260
|
input: {},
|
|
127
261
|
});
|
|
128
262
|
}
|
|
@@ -130,11 +264,11 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
130
264
|
// Non-fatal — dev mode may already be enabled
|
|
131
265
|
}
|
|
132
266
|
|
|
133
|
-
// Step
|
|
134
|
-
const registeredTags = await uploadAllWidgets(apiUrl, distDir);
|
|
267
|
+
// Step 5: Upload bundles and register widgets
|
|
268
|
+
const registeredTags = await uploadAllWidgets(apiUrl, distDir, token);
|
|
135
269
|
s.stop("Widgets registered");
|
|
136
270
|
|
|
137
|
-
// Step
|
|
271
|
+
// Step 6: Watch src/ for changes and rebuild + re-upload only the changed widget
|
|
138
272
|
const srcDir = resolve(cwd, "src");
|
|
139
273
|
let rebuilding = false;
|
|
140
274
|
const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
|
|
@@ -153,7 +287,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
153
287
|
const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
154
288
|
const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
|
|
155
289
|
if (widget) {
|
|
156
|
-
await uploadAndRegister(apiUrl, distDir, widget);
|
|
290
|
+
await uploadAndRegister(apiUrl, distDir, widget, token);
|
|
157
291
|
log.info(`Rebuilt & uploaded ${widget.tag}`);
|
|
158
292
|
} else {
|
|
159
293
|
log.warn(`No registry entry for ${widgetName} — skipping upload`);
|
|
@@ -179,7 +313,7 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
179
313
|
log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
|
|
180
314
|
log.info("Press Ctrl+C to disconnect.");
|
|
181
315
|
|
|
182
|
-
// Step
|
|
316
|
+
// Step 7: Handle SIGINT — unregister and clean up
|
|
183
317
|
const cleanup = async () => {
|
|
184
318
|
log.info("\nDisconnecting...");
|
|
185
319
|
watcher.close();
|
|
@@ -187,8 +321,9 @@ export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
|
187
321
|
for (const tag of registeredTags) {
|
|
188
322
|
try {
|
|
189
323
|
await trpcMutate({
|
|
190
|
-
apiUrl:
|
|
324
|
+
apiUrl: api,
|
|
191
325
|
path: "widget.unregister",
|
|
326
|
+
token,
|
|
192
327
|
input: { tag },
|
|
193
328
|
});
|
|
194
329
|
} catch {
|
package/src/commands/info.ts
CHANGED
|
@@ -42,10 +42,12 @@ export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
|
|
|
42
42
|
|
|
43
43
|
try {
|
|
44
44
|
const manifest = readManifest(cwd, name);
|
|
45
|
-
log.info(` Tag: ${manifest.tag}`);
|
|
46
45
|
log.info(` Name: ${manifest.name}`);
|
|
47
46
|
log.info(` Min Size: ${manifest.minSize.w}×${manifest.minSize.h}`);
|
|
48
47
|
log.info(` Max Size: ${manifest.maxSize.w}×${manifest.maxSize.h}`);
|
|
48
|
+
if (manifest.defaultSize) {
|
|
49
|
+
log.info(` Default: ${manifest.defaultSize.w}×${manifest.defaultSize.h}`);
|
|
50
|
+
}
|
|
49
51
|
log.info(` SDK Version: ${manifest.sdkVersion}`);
|
|
50
52
|
if (manifest.version) {
|
|
51
53
|
log.info(` Version: ${manifest.version}`);
|
package/src/commands/publish.ts
CHANGED
|
@@ -85,7 +85,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
|
|
|
85
85
|
} catch {}
|
|
86
86
|
return {
|
|
87
87
|
value: name,
|
|
88
|
-
label: `${manifest.
|
|
88
|
+
label: `${manifest.name}${sizeInfo}`,
|
|
89
89
|
};
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -106,7 +106,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
|
|
|
106
106
|
const currentVersion = manifest.version ?? "0.0.0";
|
|
107
107
|
|
|
108
108
|
const bump = await select({
|
|
109
|
-
message: `${manifest.
|
|
109
|
+
message: `${manifest.name} version: ${currentVersion}. Bump?`,
|
|
110
110
|
options: [
|
|
111
111
|
{ value: "keep", label: `Keep ${currentVersion}`, hint: "fails if already published" },
|
|
112
112
|
{ value: "patch", label: `Patch (${semver.inc(currentVersion, "patch")})` },
|
|
@@ -152,7 +152,7 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
|
|
|
152
152
|
|
|
153
153
|
const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
|
|
154
154
|
|
|
155
|
-
s.start(`Publishing ${manifest.
|
|
155
|
+
s.start(`Publishing ${manifest.name}@${version}...`);
|
|
156
156
|
|
|
157
157
|
let publishData: Awaited<ReturnType<typeof requestPublish>>;
|
|
158
158
|
try {
|
|
@@ -172,14 +172,14 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
|
|
|
172
172
|
});
|
|
173
173
|
} catch (err: any) {
|
|
174
174
|
if (err.status === 409) {
|
|
175
|
-
s.stop(`${manifest.
|
|
175
|
+
s.stop(`${manifest.name}@${version} already published — bump version to republish`);
|
|
176
176
|
process.exit(0);
|
|
177
177
|
}
|
|
178
178
|
s.stop(`Publish failed: ${err.message}`);
|
|
179
179
|
process.exit(1);
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
-
s.message(`Uploading ${manifest.
|
|
182
|
+
s.message(`Uploading ${manifest.name} to CDN...`);
|
|
183
183
|
try {
|
|
184
184
|
await uploadToR2(publishData.uploadUrl, bundleBuffer);
|
|
185
185
|
} catch (err: any) {
|
|
@@ -187,10 +187,10 @@ export async function runPublish(cwd: string, hubUrlOverride?: string): Promise<
|
|
|
187
187
|
process.exit(1);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
s.message(`Confirming ${manifest.
|
|
190
|
+
s.message(`Confirming ${manifest.name}...`);
|
|
191
191
|
try {
|
|
192
192
|
const result = await confirmPublish(hubUrl, token!, publishData.versionId);
|
|
193
|
-
s.stop(`Published ${manifest.
|
|
193
|
+
s.stop(`Published ${manifest.name}@${version}`);
|
|
194
194
|
log.info(`CDN: ${result.bundleUrl}`);
|
|
195
195
|
} catch (err: any) {
|
|
196
196
|
s.stop(`Confirmation failed: ${err.message}`);
|
package/src/commands/validate.ts
CHANGED
|
@@ -10,8 +10,6 @@ import {
|
|
|
10
10
|
type WidgetManifest,
|
|
11
11
|
} from "../utils/manifest";
|
|
12
12
|
|
|
13
|
-
const TAG_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
|
|
14
|
-
|
|
15
13
|
interface ValidationResult {
|
|
16
14
|
passed: boolean;
|
|
17
15
|
errors: string[];
|
|
@@ -21,33 +19,46 @@ interface ValidationResult {
|
|
|
21
19
|
function validateManifest(manifest: WidgetManifest): ValidationResult {
|
|
22
20
|
const errors: string[] = [];
|
|
23
21
|
const warnings: string[] = [];
|
|
22
|
+
const hasValidMinSize =
|
|
23
|
+
!!manifest.minSize &&
|
|
24
|
+
typeof manifest.minSize.w === "number" &&
|
|
25
|
+
typeof manifest.minSize.h === "number";
|
|
26
|
+
const hasValidMaxSize =
|
|
27
|
+
!!manifest.maxSize &&
|
|
28
|
+
typeof manifest.maxSize.w === "number" &&
|
|
29
|
+
typeof manifest.maxSize.h === "number";
|
|
24
30
|
|
|
25
31
|
// Required fields
|
|
26
|
-
if (!manifest.tag) errors.push("Missing required field: tag");
|
|
27
32
|
if (!manifest.name) errors.push("Missing required field: name");
|
|
28
33
|
if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
|
|
29
34
|
|
|
30
35
|
// Size validation
|
|
31
|
-
if (
|
|
32
|
-
!manifest.minSize ||
|
|
33
|
-
typeof manifest.minSize.w !== "number" ||
|
|
34
|
-
typeof manifest.minSize.h !== "number"
|
|
35
|
-
) {
|
|
36
|
+
if (!hasValidMinSize) {
|
|
36
37
|
errors.push("minSize must be an object with numeric w and h properties");
|
|
37
38
|
}
|
|
39
|
+
if (!hasValidMaxSize) {
|
|
40
|
+
errors.push("maxSize must be an object with numeric w and h properties");
|
|
41
|
+
}
|
|
38
42
|
if (
|
|
39
|
-
|
|
40
|
-
typeof manifest.
|
|
41
|
-
typeof manifest.maxSize.h !== "number"
|
|
43
|
+
manifest.defaultSize &&
|
|
44
|
+
(typeof manifest.defaultSize.w !== "number" || typeof manifest.defaultSize.h !== "number")
|
|
42
45
|
) {
|
|
43
|
-
errors.push("
|
|
46
|
+
errors.push("defaultSize must be an object with numeric w and h properties");
|
|
44
47
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
if (hasValidMinSize && hasValidMaxSize) {
|
|
49
|
+
if (manifest.minSize.w > manifest.maxSize.w || manifest.minSize.h > manifest.maxSize.h) {
|
|
50
|
+
errors.push("minSize must be less than or equal to maxSize");
|
|
51
|
+
}
|
|
52
|
+
if (manifest.defaultSize) {
|
|
53
|
+
if (
|
|
54
|
+
manifest.defaultSize.w < manifest.minSize.w ||
|
|
55
|
+
manifest.defaultSize.h < manifest.minSize.h ||
|
|
56
|
+
manifest.defaultSize.w > manifest.maxSize.w ||
|
|
57
|
+
manifest.defaultSize.h > manifest.maxSize.h
|
|
58
|
+
) {
|
|
59
|
+
errors.push("defaultSize must be within minSize and maxSize bounds");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
// Schema validation
|
package/src/utils/api.ts
CHANGED
|
@@ -7,6 +7,7 @@ interface TrpcMutationOptions {
|
|
|
7
7
|
apiUrl: string;
|
|
8
8
|
path: string;
|
|
9
9
|
input: Record<string, unknown>;
|
|
10
|
+
token?: string;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
interface TrpcResult<T = unknown> {
|
|
@@ -15,9 +16,11 @@ interface TrpcResult<T = unknown> {
|
|
|
15
16
|
|
|
16
17
|
export async function trpcMutate<T = unknown>(opts: TrpcMutationOptions): Promise<T> {
|
|
17
18
|
const url = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
|
|
19
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
20
|
+
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
18
21
|
const res = await fetch(url, {
|
|
19
22
|
method: "POST",
|
|
20
|
-
headers
|
|
23
|
+
headers,
|
|
21
24
|
body: JSON.stringify(opts.input),
|
|
22
25
|
});
|
|
23
26
|
|
|
@@ -34,15 +37,18 @@ interface TrpcQueryOptions {
|
|
|
34
37
|
apiUrl: string;
|
|
35
38
|
path: string;
|
|
36
39
|
input?: Record<string, unknown>;
|
|
40
|
+
token?: string;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export async function trpcQuery<T = unknown>(opts: TrpcQueryOptions): Promise<T> {
|
|
40
44
|
const base = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
|
|
41
45
|
const url = opts.input ? `${base}?input=${encodeURIComponent(JSON.stringify(opts.input))}` : base;
|
|
46
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
47
|
+
if (opts.token) headers.Authorization = `Bearer ${opts.token}`;
|
|
42
48
|
|
|
43
49
|
const res = await fetch(url, {
|
|
44
50
|
method: "GET",
|
|
45
|
-
headers
|
|
51
|
+
headers,
|
|
46
52
|
});
|
|
47
53
|
|
|
48
54
|
if (!res.ok) {
|
package/src/utils/auth.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
interface
|
|
5
|
+
interface HubAuth {
|
|
6
6
|
accessToken: string;
|
|
7
7
|
refreshToken: string;
|
|
8
8
|
expiresAt: number;
|
|
@@ -10,6 +10,19 @@ interface StoredAuth {
|
|
|
10
10
|
scope: string;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
interface HostAuth {
|
|
14
|
+
token: string;
|
|
15
|
+
expiresAt: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface StoredAuthFile {
|
|
19
|
+
hub?: HubAuth;
|
|
20
|
+
hosts: Record<string, HostAuth>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Legacy shape written by older CLI versions (hub auth only, no hosts key)
|
|
24
|
+
type LegacyStoredAuth = HubAuth;
|
|
25
|
+
|
|
13
26
|
const AUTH_DIR = join(homedir(), ".glasshome");
|
|
14
27
|
const AUTH_FILE = join(AUTH_DIR, "auth.json");
|
|
15
28
|
|
|
@@ -19,31 +32,78 @@ function ensureDir(): void {
|
|
|
19
32
|
}
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
|
|
35
|
+
function readAuthFile(): StoredAuthFile {
|
|
36
|
+
if (!existsSync(AUTH_FILE)) return { hosts: {} };
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(readFileSync(AUTH_FILE, "utf-8")) as
|
|
39
|
+
| StoredAuthFile
|
|
40
|
+
| LegacyStoredAuth;
|
|
41
|
+
// Migrate legacy format (hub-only, no hosts key)
|
|
42
|
+
if ("accessToken" in raw) {
|
|
43
|
+
return { hub: raw as HubAuth, hosts: {} };
|
|
44
|
+
}
|
|
45
|
+
const typed = raw as StoredAuthFile;
|
|
46
|
+
return { hub: typed.hub, hosts: typed.hosts ?? {} };
|
|
47
|
+
} catch {
|
|
48
|
+
return { hosts: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function writeAuthFile(data: StoredAuthFile): void {
|
|
23
53
|
ensureDir();
|
|
24
54
|
writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
25
55
|
}
|
|
26
56
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
57
|
+
// --- Host-keyed token storage (for device auth / widget connect) ---
|
|
58
|
+
|
|
59
|
+
export function storeHostToken(host: string, token: string, expiresAt: number): void {
|
|
60
|
+
const data = readAuthFile();
|
|
61
|
+
data.hosts[host] = { token, expiresAt };
|
|
62
|
+
writeAuthFile(data);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getHostToken(host: string): string | null {
|
|
66
|
+
const data = readAuthFile();
|
|
67
|
+
const entry = data.hosts[host];
|
|
68
|
+
if (!entry) return null;
|
|
69
|
+
// Return null if expired (60s buffer)
|
|
70
|
+
if (Date.now() >= entry.expiresAt - 60_000) return null;
|
|
71
|
+
return entry.token;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function clearHostToken(host: string): void {
|
|
75
|
+
const data = readAuthFile();
|
|
76
|
+
delete data.hosts[host];
|
|
77
|
+
writeAuthFile(data);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// --- Hub auth storage (for widget publishing) ---
|
|
81
|
+
|
|
82
|
+
export function storeToken(hubData: HubAuth): void {
|
|
83
|
+
const data = readAuthFile();
|
|
84
|
+
data.hub = hubData;
|
|
85
|
+
writeAuthFile(data);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function getStoredAuth(): HubAuth | null {
|
|
89
|
+
return readAuthFile().hub ?? null;
|
|
34
90
|
}
|
|
35
91
|
|
|
36
92
|
export async function getToken(hubUrl: string): Promise<string | null> {
|
|
93
|
+
// Check host-keyed token first (for connect flow)
|
|
94
|
+
const host = extractHost(hubUrl);
|
|
95
|
+
const hostToken = getHostToken(host);
|
|
96
|
+
if (hostToken) return hostToken;
|
|
97
|
+
|
|
98
|
+
// Fall back to hub auth (for publishing)
|
|
37
99
|
const stored = getStoredAuth();
|
|
38
100
|
if (!stored) return null;
|
|
39
101
|
if (stored.hubUrl !== hubUrl) return null;
|
|
40
102
|
|
|
41
|
-
// Check if token is still valid (with 60s buffer)
|
|
42
103
|
if (Date.now() < stored.expiresAt - 60_000) {
|
|
43
104
|
return stored.accessToken;
|
|
44
105
|
}
|
|
45
106
|
|
|
46
|
-
// Try to refresh
|
|
47
107
|
if (!stored.refreshToken) return null;
|
|
48
108
|
|
|
49
109
|
try {
|
|
@@ -60,39 +120,39 @@ export async function getToken(hubUrl: string): Promise<string | null> {
|
|
|
60
120
|
|
|
61
121
|
if (!res.ok) return null;
|
|
62
122
|
|
|
63
|
-
const
|
|
123
|
+
const tokenData = (await res.json()) as {
|
|
64
124
|
access_token: string;
|
|
65
125
|
refresh_token?: string;
|
|
66
126
|
expires_in: number;
|
|
67
127
|
};
|
|
68
128
|
|
|
69
129
|
storeToken({
|
|
70
|
-
accessToken:
|
|
71
|
-
refreshToken:
|
|
72
|
-
expiresAt: Date.now() +
|
|
130
|
+
accessToken: tokenData.access_token,
|
|
131
|
+
refreshToken: tokenData.refresh_token ?? stored.refreshToken,
|
|
132
|
+
expiresAt: Date.now() + tokenData.expires_in * 1000,
|
|
73
133
|
hubUrl: stored.hubUrl,
|
|
74
134
|
scope: stored.scope,
|
|
75
135
|
});
|
|
76
136
|
|
|
77
|
-
return
|
|
137
|
+
return tokenData.access_token;
|
|
78
138
|
} catch {
|
|
79
139
|
return null;
|
|
80
140
|
}
|
|
81
141
|
}
|
|
82
142
|
|
|
83
143
|
export function clearToken(): void {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
144
|
+
const data = readAuthFile();
|
|
145
|
+
delete data.hub;
|
|
146
|
+
writeAuthFile(data);
|
|
87
147
|
}
|
|
88
148
|
|
|
89
149
|
export function getHubUrl(): string {
|
|
90
|
-
const stored = getStoredAuth()
|
|
150
|
+
const stored = getStoredAuth();
|
|
91
151
|
if (stored) {
|
|
92
|
-
if (stored.includes("localhost") || stored.includes("127.0.0.1")) {
|
|
152
|
+
if (stored.hubUrl.includes("localhost") || stored.hubUrl.includes("127.0.0.1")) {
|
|
93
153
|
clearToken();
|
|
94
154
|
} else {
|
|
95
|
-
return stored;
|
|
155
|
+
return stored.hubUrl;
|
|
96
156
|
}
|
|
97
157
|
}
|
|
98
158
|
return "https://glasshome.app";
|
|
@@ -101,3 +161,14 @@ export function getHubUrl(): string {
|
|
|
101
161
|
export function getScope(): string | null {
|
|
102
162
|
return getStoredAuth()?.scope ?? null;
|
|
103
163
|
}
|
|
164
|
+
|
|
165
|
+
// --- Helpers ---
|
|
166
|
+
|
|
167
|
+
export function extractHost(url: string): string {
|
|
168
|
+
try {
|
|
169
|
+
return new URL(url).host;
|
|
170
|
+
} catch {
|
|
171
|
+
// Bare host:port or path-less string
|
|
172
|
+
return url.replace(/^https?:\/\//, "").split("/")[0] ?? url;
|
|
173
|
+
}
|
|
174
|
+
}
|