@glasshome/widget-cli 0.1.0
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/LICENSE +21 -0
- package/README.md +35 -0
- package/bin/cli.ts +141 -0
- package/bin/create.ts +220 -0
- package/package.json +50 -0
- package/src/commands/add.ts +136 -0
- package/src/commands/build.ts +22 -0
- package/src/commands/connect.ts +208 -0
- package/src/commands/dev-registry.ts +86 -0
- package/src/commands/info.ts +76 -0
- package/src/commands/login.ts +163 -0
- package/src/commands/publish.ts +266 -0
- package/src/commands/upgrade.ts +134 -0
- package/src/commands/validate.ts +192 -0
- package/src/utils/api.ts +55 -0
- package/src/utils/auth.ts +95 -0
- package/src/utils/hub-api.ts +104 -0
- package/src/utils/manifest.ts +126 -0
- package/template/.gitignore.template +4 -0
- package/template/.mise.toml.template +3 -0
- package/template/README.md.template +90 -0
- package/template/package.json.template +24 -0
- package/template/src/index.tsx.template +49 -0
- package/template/tsconfig.json.template +14 -0
- package/template/vite.config.ts.template +7 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync, watch } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { log, spinner } from "@clack/prompts";
|
|
4
|
+
import { buildWidgets } from "@glasshome/widget-sdk/vite";
|
|
5
|
+
import { trpcMutate, trpcQuery } from "../utils/api";
|
|
6
|
+
|
|
7
|
+
interface RegistryWidget {
|
|
8
|
+
tag: string;
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
bundleUrl: string;
|
|
12
|
+
sdkVersion: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface RegistryJson {
|
|
17
|
+
version: number;
|
|
18
|
+
widgets: RegistryWidget[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Upload a single widget bundle to the API and register it.
|
|
23
|
+
*/
|
|
24
|
+
async function uploadAndRegister(
|
|
25
|
+
apiUrl: string,
|
|
26
|
+
distDir: string,
|
|
27
|
+
widget: RegistryWidget,
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
const api = apiUrl.replace(/\/$/, "");
|
|
30
|
+
|
|
31
|
+
// Read the bundle file from dist/ (bundleUrl is relative like "./area.js")
|
|
32
|
+
const bundleFilename = widget.bundleUrl.replace(/^\.\//, "");
|
|
33
|
+
const bundlePath = resolve(distDir, bundleFilename);
|
|
34
|
+
|
|
35
|
+
if (!existsSync(bundlePath)) {
|
|
36
|
+
log.warn(`Bundle not found: ${bundlePath} — skipping ${widget.tag}`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const bundleContent = readFileSync(bundlePath, "utf-8");
|
|
41
|
+
|
|
42
|
+
// Upload bundle to API
|
|
43
|
+
const uploadRes = await fetch(`${api}/bundles/${widget.tag}`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "Content-Type": "application/javascript" },
|
|
46
|
+
body: bundleContent,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!uploadRes.ok) {
|
|
50
|
+
throw new Error(`Failed to upload bundle for ${widget.tag}: HTTP ${uploadRes.status}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Register the widget with a local bundle path (skips redundant download)
|
|
54
|
+
const manifest = { ...widget };
|
|
55
|
+
delete (manifest as Record<string, unknown>).bundleUrl;
|
|
56
|
+
|
|
57
|
+
await trpcMutate({
|
|
58
|
+
apiUrl: api,
|
|
59
|
+
path: "widget.register",
|
|
60
|
+
input: {
|
|
61
|
+
tag: widget.tag,
|
|
62
|
+
name: widget.name,
|
|
63
|
+
version: widget.version,
|
|
64
|
+
bundleUrl: `/bundles/${widget.tag}/bundle.js`,
|
|
65
|
+
manifestJson: JSON.stringify(manifest),
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Read registry.json and upload all widget bundles.
|
|
72
|
+
* @returns Array of widget tags that were registered
|
|
73
|
+
*/
|
|
74
|
+
async function uploadAllWidgets(apiUrl: string, distDir: string): Promise<string[]> {
|
|
75
|
+
const registryPath = resolve(distDir, "registry.json");
|
|
76
|
+
const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
77
|
+
const tags: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const widget of registry.widgets) {
|
|
80
|
+
await uploadAndRegister(apiUrl, distDir, widget);
|
|
81
|
+
tags.push(widget.tag);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return tags;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function runConnect(apiUrl: string, cwd: string): Promise<void> {
|
|
88
|
+
const distDir = resolve(cwd, "dist");
|
|
89
|
+
const solid = (await import("vite-plugin-solid")).default;
|
|
90
|
+
const buildOpts = { srcDir: "src", outDir: "dist", plugins: [solid()] };
|
|
91
|
+
|
|
92
|
+
// Step 1: Initial build
|
|
93
|
+
const s = spinner();
|
|
94
|
+
s.start("Building widgets...");
|
|
95
|
+
try {
|
|
96
|
+
const origCwd = process.cwd();
|
|
97
|
+
process.chdir(cwd);
|
|
98
|
+
await buildWidgets(buildOpts);
|
|
99
|
+
process.chdir(origCwd);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
s.stop("Build failed");
|
|
102
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
s.stop("Build complete");
|
|
106
|
+
|
|
107
|
+
// Step 2: Check registry was generated
|
|
108
|
+
const registryPath = resolve(distDir, "registry.json");
|
|
109
|
+
if (!existsSync(registryPath)) {
|
|
110
|
+
log.error("dist/registry.json not found after build. Check your vite.config.ts.");
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Step 3: Enable dev mode if needed
|
|
115
|
+
s.start("Registering widgets with dashboard...");
|
|
116
|
+
try {
|
|
117
|
+
const configData = await trpcQuery<{ devMode: boolean }>({
|
|
118
|
+
apiUrl: apiUrl.replace(/\/$/, ""),
|
|
119
|
+
path: "appConfig.get",
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (!configData.devMode) {
|
|
123
|
+
await trpcMutate({
|
|
124
|
+
apiUrl: apiUrl.replace(/\/$/, ""),
|
|
125
|
+
path: "appConfig.toggleDevMode",
|
|
126
|
+
input: {},
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
// Non-fatal — dev mode may already be enabled
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Step 4: Upload bundles and register widgets
|
|
134
|
+
const registeredTags = await uploadAllWidgets(apiUrl, distDir);
|
|
135
|
+
s.stop("Widgets registered");
|
|
136
|
+
|
|
137
|
+
// Step 5: Watch src/ for changes and rebuild + re-upload only the changed widget
|
|
138
|
+
const srcDir = resolve(cwd, "src");
|
|
139
|
+
let rebuilding = false;
|
|
140
|
+
const watcher = watch(srcDir, { recursive: true }, async (_event, filename) => {
|
|
141
|
+
if (rebuilding || !filename) return;
|
|
142
|
+
rebuilding = true;
|
|
143
|
+
try {
|
|
144
|
+
// Determine which widget changed from the file path (first path segment under src/)
|
|
145
|
+
const widgetName = filename.split(/[\\/]/)[0] ?? filename;
|
|
146
|
+
const origCwd = process.cwd();
|
|
147
|
+
process.chdir(cwd);
|
|
148
|
+
await buildWidgets({ ...buildOpts, only: [widgetName] });
|
|
149
|
+
process.chdir(origCwd);
|
|
150
|
+
|
|
151
|
+
// Upload only the changed widget
|
|
152
|
+
const registryPath = resolve(distDir, "registry.json");
|
|
153
|
+
const registry: RegistryJson = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
154
|
+
const widget = registry.widgets.find((w) => w.bundleUrl === `./${widgetName}.js`);
|
|
155
|
+
if (widget) {
|
|
156
|
+
await uploadAndRegister(apiUrl, distDir, widget);
|
|
157
|
+
log.info(`Rebuilt & uploaded ${widget.tag}`);
|
|
158
|
+
} else {
|
|
159
|
+
log.warn(`No registry entry for ${widgetName} — skipping upload`);
|
|
160
|
+
}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
log.warn(`Rebuild failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
163
|
+
} finally {
|
|
164
|
+
rebuilding = false;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Count widgets
|
|
169
|
+
const widgetCount = existsSync(resolve(cwd, "src"))
|
|
170
|
+
? readdirSync(resolve(cwd, "src")).filter(
|
|
171
|
+
(d) =>
|
|
172
|
+
statSync(resolve(cwd, "src", d)).isDirectory() &&
|
|
173
|
+
existsSync(resolve(cwd, "src", d, "manifest.json")),
|
|
174
|
+
).length
|
|
175
|
+
: 0;
|
|
176
|
+
|
|
177
|
+
log.success(`Connected! ${widgetCount} widget(s) are live.`);
|
|
178
|
+
log.info("Bundles uploaded to API. Widgets registered with local paths.");
|
|
179
|
+
log.info("File changes auto-rebuild and re-upload. Add widgets with `bun widget add`.");
|
|
180
|
+
log.info("Press Ctrl+C to disconnect.");
|
|
181
|
+
|
|
182
|
+
// Step 6: Handle SIGINT — unregister and clean up
|
|
183
|
+
const cleanup = async () => {
|
|
184
|
+
log.info("\nDisconnecting...");
|
|
185
|
+
watcher.close();
|
|
186
|
+
|
|
187
|
+
for (const tag of registeredTags) {
|
|
188
|
+
try {
|
|
189
|
+
await trpcMutate({
|
|
190
|
+
apiUrl: apiUrl.replace(/\/$/, ""),
|
|
191
|
+
path: "widget.unregister",
|
|
192
|
+
input: { tag },
|
|
193
|
+
});
|
|
194
|
+
} catch {
|
|
195
|
+
// Non-fatal — API may be down
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
log.info("Widgets unregistered");
|
|
199
|
+
|
|
200
|
+
process.exit(0);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
process.on("SIGINT", cleanup);
|
|
204
|
+
process.on("SIGTERM", cleanup);
|
|
205
|
+
|
|
206
|
+
// Keep alive
|
|
207
|
+
await new Promise(() => {});
|
|
208
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { log, outro, spinner } from "@clack/prompts";
|
|
2
|
+
import { trpcMutate, trpcQuery } from "../utils/api";
|
|
3
|
+
|
|
4
|
+
interface DevStatusResponse {
|
|
5
|
+
devMode: boolean;
|
|
6
|
+
registries: Array<{ url: string; name: string; isDefault: boolean }>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export async function runAddDevRegistry(options: {
|
|
10
|
+
registryUrl?: string;
|
|
11
|
+
apiUrl?: string;
|
|
12
|
+
}): Promise<void> {
|
|
13
|
+
const registryUrl = options.registryUrl || "http://localhost:5173/__widgets/registry.json";
|
|
14
|
+
const apiUrl = options.apiUrl || "http://localhost:3333";
|
|
15
|
+
|
|
16
|
+
const s = spinner();
|
|
17
|
+
|
|
18
|
+
// Step 1: Check if dev mode is enabled
|
|
19
|
+
s.start("Checking dev mode status...");
|
|
20
|
+
let devEnabled = false;
|
|
21
|
+
try {
|
|
22
|
+
const status = await trpcQuery<DevStatusResponse>({
|
|
23
|
+
apiUrl,
|
|
24
|
+
path: "appConfig.get",
|
|
25
|
+
});
|
|
26
|
+
devEnabled = status.devMode;
|
|
27
|
+
} catch (err) {
|
|
28
|
+
s.stop("Failed to connect");
|
|
29
|
+
log.error(
|
|
30
|
+
`Could not reach dashboard API at ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`,
|
|
31
|
+
);
|
|
32
|
+
log.info("Make sure the dashboard API server is running and the URL is correct.");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Step 2: Enable dev mode if needed
|
|
37
|
+
if (!devEnabled) {
|
|
38
|
+
s.message("Enabling dev mode...");
|
|
39
|
+
try {
|
|
40
|
+
const result = await trpcMutate<{ devMode: boolean }>({
|
|
41
|
+
apiUrl,
|
|
42
|
+
path: "appConfig.toggleDevMode",
|
|
43
|
+
input: {},
|
|
44
|
+
});
|
|
45
|
+
if (!result.devMode) {
|
|
46
|
+
s.stop("Failed");
|
|
47
|
+
log.error("Could not enable dev mode");
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
s.stop("Failed");
|
|
52
|
+
log.error(`Failed to enable dev mode: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
s.stop("Dev mode enabled");
|
|
57
|
+
|
|
58
|
+
// Step 3: Add the registry via /dev/registries endpoint
|
|
59
|
+
s.start("Adding dev registry...");
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${apiUrl.replace(/\/$/, "")}/dev/registries`, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "Content-Type": "application/json" },
|
|
64
|
+
body: JSON.stringify({ url: registryUrl, name: "Local Dev Server" }),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
if (res.status === 409) {
|
|
68
|
+
s.stop("Registry already registered");
|
|
69
|
+
log.info(`Registry ${registryUrl} is already configured`);
|
|
70
|
+
} else if (res.ok) {
|
|
71
|
+
s.stop("Registry added");
|
|
72
|
+
log.success(`Added registry: ${registryUrl}`);
|
|
73
|
+
} else {
|
|
74
|
+
const err = (await res.json()) as { error: string };
|
|
75
|
+
s.stop("Failed");
|
|
76
|
+
log.error(`Failed to add registry: ${err.error}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
} catch (err) {
|
|
80
|
+
s.stop("Failed");
|
|
81
|
+
log.error(`Failed to add registry: ${err instanceof Error ? err.message : String(err)}`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
outro("Done! Refresh your dashboard to see dev widgets.");
|
|
86
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { log } from "@clack/prompts";
|
|
4
|
+
import {
|
|
5
|
+
discoverWidgets,
|
|
6
|
+
formatBytes,
|
|
7
|
+
getWidgetBundleInfo,
|
|
8
|
+
readManifest,
|
|
9
|
+
readRegistry,
|
|
10
|
+
} from "../utils/manifest";
|
|
11
|
+
|
|
12
|
+
export async function runInfo(cwd: string, widgetName?: string): Promise<void> {
|
|
13
|
+
// Read package.json
|
|
14
|
+
const pkgPath = resolve(cwd, "package.json");
|
|
15
|
+
if (!existsSync(pkgPath)) {
|
|
16
|
+
log.error("No package.json found in current directory.");
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
21
|
+
log.info(`Project: ${pkg.name ?? "unknown"}`);
|
|
22
|
+
log.info(`Version: ${pkg.version ?? "0.0.0"}`);
|
|
23
|
+
|
|
24
|
+
const widgets = discoverWidgets(cwd);
|
|
25
|
+
|
|
26
|
+
if (widgets.length === 0) {
|
|
27
|
+
log.warn("No widgets found in src/. Run `bun widget add` to create one.");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const toShow = widgetName ? widgets.filter((w) => w === widgetName) : widgets;
|
|
32
|
+
|
|
33
|
+
if (widgetName && toShow.length === 0) {
|
|
34
|
+
log.error(`Widget "${widgetName}" not found. Available: ${widgets.join(", ")}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
log.info(`\nWidgets: ${widgets.length} total`);
|
|
39
|
+
|
|
40
|
+
for (const name of toShow) {
|
|
41
|
+
log.info(`\n--- ${name} ---`);
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const manifest = readManifest(cwd, name);
|
|
45
|
+
log.info(` Tag: ${manifest.tag}`);
|
|
46
|
+
log.info(` Name: ${manifest.name}`);
|
|
47
|
+
log.info(` Type: ${manifest.type}`);
|
|
48
|
+
log.info(` Size: ${manifest.size}`);
|
|
49
|
+
log.info(` SDK Version: ${manifest.sdkVersion}`);
|
|
50
|
+
if (manifest.version) {
|
|
51
|
+
log.info(` Version: ${manifest.version}`);
|
|
52
|
+
}
|
|
53
|
+
if (manifest.description) {
|
|
54
|
+
log.info(` Description: ${manifest.description}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Bundle size (if built)
|
|
58
|
+
try {
|
|
59
|
+
const info = getWidgetBundleInfo(cwd, name);
|
|
60
|
+
log.info(` Bundle: ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`);
|
|
61
|
+
} catch {
|
|
62
|
+
log.info(" Bundle: not built yet");
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.warn(` Could not read manifest: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Registry summary
|
|
70
|
+
const registry = readRegistry(cwd);
|
|
71
|
+
if (registry) {
|
|
72
|
+
log.info(
|
|
73
|
+
`\nRegistry: ${registry.widgets.length} widget(s) (generated ${registry.generatedAt})`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import { log, spinner } from "@clack/prompts";
|
|
4
|
+
import open from "open";
|
|
5
|
+
import { getHubUrl, storeToken } from "../utils/auth";
|
|
6
|
+
|
|
7
|
+
const CLIENT_ID = "glasshome-widget-cli";
|
|
8
|
+
const CLIENT_SECRET = "glasshome-cli-public";
|
|
9
|
+
const REDIRECT_PORT = 9274;
|
|
10
|
+
const REDIRECT_URI = `http://localhost:${REDIRECT_PORT}/callback`;
|
|
11
|
+
const LOGIN_TIMEOUT_MS = 120_000;
|
|
12
|
+
|
|
13
|
+
function base64url(buf: Buffer): string {
|
|
14
|
+
return buf.toString("base64url");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runLogin(hubUrl?: string): Promise<void> {
|
|
18
|
+
const hub = hubUrl ?? getHubUrl();
|
|
19
|
+
|
|
20
|
+
// Generate PKCE parameters
|
|
21
|
+
const codeVerifier = base64url(randomBytes(32));
|
|
22
|
+
const codeChallenge = base64url(createHash("sha256").update(codeVerifier).digest());
|
|
23
|
+
const state = randomBytes(16).toString("hex");
|
|
24
|
+
|
|
25
|
+
const s = spinner();
|
|
26
|
+
|
|
27
|
+
// Start local callback server
|
|
28
|
+
const { code, receivedState } = await new Promise<{
|
|
29
|
+
code: string;
|
|
30
|
+
receivedState: string;
|
|
31
|
+
}>((resolve, reject) => {
|
|
32
|
+
const timeout = setTimeout(() => {
|
|
33
|
+
server.close();
|
|
34
|
+
reject(new Error("Login timed out — no response from browser within 120 seconds."));
|
|
35
|
+
}, LOGIN_TIMEOUT_MS);
|
|
36
|
+
|
|
37
|
+
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
38
|
+
const url = new URL(req.url!, `http://localhost:${REDIRECT_PORT}`);
|
|
39
|
+
|
|
40
|
+
if (url.pathname !== "/callback") {
|
|
41
|
+
res.writeHead(404);
|
|
42
|
+
res.end("Not found");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const code = url.searchParams.get("code");
|
|
47
|
+
const receivedState = url.searchParams.get("state");
|
|
48
|
+
const error = url.searchParams.get("error");
|
|
49
|
+
|
|
50
|
+
if (error) {
|
|
51
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
52
|
+
res.end("<html><head><script>window.close()</script></head><body></body></html>");
|
|
53
|
+
clearTimeout(timeout);
|
|
54
|
+
server.close();
|
|
55
|
+
reject(new Error(`OAuth error: ${error}`));
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!code || !receivedState) {
|
|
60
|
+
res.writeHead(400);
|
|
61
|
+
res.end("Missing code or state");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
66
|
+
res.end("<html><head><script>window.close()</script></head><body></body></html>");
|
|
67
|
+
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
server.close();
|
|
70
|
+
resolve({ code, receivedState });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
server.listen(REDIRECT_PORT, () => {
|
|
74
|
+
const authUrl = new URL(`${hub}/api/auth/oauth2/authorize`);
|
|
75
|
+
authUrl.searchParams.set("client_id", CLIENT_ID);
|
|
76
|
+
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
|
|
77
|
+
authUrl.searchParams.set("response_type", "code");
|
|
78
|
+
authUrl.searchParams.set("code_challenge", codeChallenge);
|
|
79
|
+
authUrl.searchParams.set("code_challenge_method", "S256");
|
|
80
|
+
authUrl.searchParams.set("state", state);
|
|
81
|
+
authUrl.searchParams.set("scope", "openid profile email");
|
|
82
|
+
|
|
83
|
+
log.info(`Opening browser for login at ${hub}`);
|
|
84
|
+
s.start("Waiting for browser login...");
|
|
85
|
+
open(authUrl.toString()).catch(() => {
|
|
86
|
+
log.warn(`Could not open browser. Visit this URL manually:\n ${authUrl.toString()}`);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
s.stop("Login complete");
|
|
92
|
+
|
|
93
|
+
// Verify state
|
|
94
|
+
if (receivedState !== state) {
|
|
95
|
+
s.stop("Login failed");
|
|
96
|
+
log.error("State mismatch. Possible CSRF attack.");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Exchange code for tokens
|
|
101
|
+
s.message("Exchanging code for tokens...");
|
|
102
|
+
|
|
103
|
+
const tokenRes = await fetch(`${hub}/api/auth/oauth2/token`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
106
|
+
body: new URLSearchParams({
|
|
107
|
+
grant_type: "authorization_code",
|
|
108
|
+
code,
|
|
109
|
+
redirect_uri: REDIRECT_URI,
|
|
110
|
+
code_verifier: codeVerifier,
|
|
111
|
+
client_id: CLIENT_ID,
|
|
112
|
+
client_secret: CLIENT_SECRET,
|
|
113
|
+
}),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
if (!tokenRes.ok) {
|
|
117
|
+
s.stop("Login failed");
|
|
118
|
+
const text = await tokenRes.text();
|
|
119
|
+
log.error(`Token exchange failed: ${text}`);
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const tokenData = (await tokenRes.json()) as {
|
|
124
|
+
access_token: string;
|
|
125
|
+
refresh_token: string;
|
|
126
|
+
expires_in: number;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// Fetch user info for scope
|
|
130
|
+
s.message("Fetching user info...");
|
|
131
|
+
|
|
132
|
+
const userRes = await fetch(`${hub}/api/auth/oauth2/userinfo`, {
|
|
133
|
+
headers: { Authorization: `Bearer ${tokenData.access_token}` },
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
if (!userRes.ok) {
|
|
137
|
+
s.stop("Login failed");
|
|
138
|
+
log.error("Could not fetch user info.");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const userInfo = (await userRes.json()) as {
|
|
143
|
+
name?: string;
|
|
144
|
+
email?: string;
|
|
145
|
+
sub?: string;
|
|
146
|
+
};
|
|
147
|
+
const displayName = userInfo.name || userInfo.email || userInfo.sub || "unknown";
|
|
148
|
+
const scopeBase = userInfo.name || userInfo.email?.split("@")[0] || userInfo.sub || "unknown";
|
|
149
|
+
const scope = scopeBase.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
150
|
+
|
|
151
|
+
// Store tokens
|
|
152
|
+
storeToken({
|
|
153
|
+
accessToken: tokenData.access_token,
|
|
154
|
+
refreshToken: tokenData.refresh_token,
|
|
155
|
+
expiresAt: Date.now() + tokenData.expires_in * 1000,
|
|
156
|
+
hubUrl: hub,
|
|
157
|
+
scope,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
s.stop("Logged in successfully");
|
|
161
|
+
log.success(`Authenticated as ${displayName} (scope: ${scope})`);
|
|
162
|
+
log.info(`Hub: ${hub}`);
|
|
163
|
+
}
|