@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.
@@ -0,0 +1,266 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync, writeFileSync } from "node:fs";
3
+ import { resolve } from "node:path";
4
+ import { cancel, isCancel, log, multiselect, select, spinner } from "@clack/prompts";
5
+ import semver from "semver";
6
+ import { getHubUrl, getToken } from "../utils/auth";
7
+ import { confirmPublish, fetchScopes, requestPublish, uploadToR2 } from "../utils/hub-api";
8
+ import {
9
+ discoverWidgets,
10
+ formatBytes,
11
+ getWidgetBundleInfo,
12
+ readManifest,
13
+ writeManifest,
14
+ } from "../utils/manifest";
15
+ import { runLogin } from "./login";
16
+
17
+ export async function runPublish(cwd: string): Promise<void> {
18
+ // Step 1: Validate all widgets
19
+ log.info("Running validation...");
20
+ const { runValidate } = await import("./validate");
21
+ const valid = await runValidate(cwd);
22
+ if (!valid) {
23
+ log.error("Fix validation errors before publishing.");
24
+ process.exit(1);
25
+ }
26
+
27
+ // Step 2: Read package.json
28
+ const pkgPath = resolve(cwd, "package.json");
29
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
30
+ const currentVersion = pkg.version ?? "0.0.0";
31
+
32
+ // Step 3: Version bump prompt
33
+ const bump = await select({
34
+ message: `Current version: ${currentVersion}. Bump version?`,
35
+ options: [
36
+ { value: "keep", label: `Keep ${currentVersion}` },
37
+ {
38
+ value: "patch",
39
+ label: `Patch (${semver.inc(currentVersion, "patch")})`,
40
+ },
41
+ {
42
+ value: "minor",
43
+ label: `Minor (${semver.inc(currentVersion, "minor")})`,
44
+ },
45
+ {
46
+ value: "major",
47
+ label: `Major (${semver.inc(currentVersion, "major")})`,
48
+ },
49
+ ],
50
+ });
51
+
52
+ if (isCancel(bump)) {
53
+ cancel("Publish cancelled.");
54
+ process.exit(0);
55
+ }
56
+
57
+ let version = currentVersion;
58
+ if (bump !== "keep") {
59
+ version = semver.inc(currentVersion, bump as "patch" | "minor" | "major") ?? currentVersion;
60
+
61
+ pkg.version = version;
62
+ writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
63
+
64
+ const allWidgets = discoverWidgets(cwd);
65
+ for (const name of allWidgets) {
66
+ const manifest = readManifest(cwd, name);
67
+ manifest.version = version;
68
+ writeManifest(cwd, name, manifest);
69
+ }
70
+
71
+ log.info(`Version bumped to ${version}`);
72
+ }
73
+
74
+ // Step 4: Rebuild
75
+ const s = spinner();
76
+ s.start("Building widgets...");
77
+ const buildProc = Bun.spawnSync(["bun", "run", "build"], { cwd });
78
+ if (buildProc.exitCode !== 0) {
79
+ s.stop("Build failed");
80
+ log.error(buildProc.stderr.toString());
81
+ process.exit(1);
82
+ }
83
+ s.stop("Build complete");
84
+
85
+ // Step 5: Show summary
86
+ const widgets = discoverWidgets(cwd);
87
+ log.success(`Ready for publishing: ${pkg.name}@${version}`);
88
+ log.info("");
89
+
90
+ for (const name of widgets) {
91
+ const manifest = readManifest(cwd, name);
92
+ let sizeInfo = "";
93
+ try {
94
+ const info = getWidgetBundleInfo(cwd, name);
95
+ sizeInfo = ` — ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`;
96
+ } catch {}
97
+ log.info(
98
+ ` ${manifest.tag} [${manifest.minSize.w}x${manifest.minSize.h} - ${manifest.maxSize.w}x${manifest.maxSize.h}]${sizeInfo}`,
99
+ );
100
+ }
101
+
102
+ // Step 6: Authenticate with Hub
103
+ const hubUrl = getHubUrl();
104
+ let token = await getToken(hubUrl);
105
+
106
+ if (!token) {
107
+ log.warn("Not authenticated with Hub. Starting login...");
108
+ try {
109
+ await runLogin(hubUrl);
110
+ } catch (err) {
111
+ log.error(err instanceof Error ? err.message : "Login failed.");
112
+ process.exit(1);
113
+ }
114
+ token = await getToken(hubUrl);
115
+ if (!token) {
116
+ log.error("Authentication failed. Run `bun widget login` manually.");
117
+ process.exit(1);
118
+ }
119
+ }
120
+
121
+ // Fetch available scopes from hub
122
+ const scopes = await fetchScopes(hubUrl, token!);
123
+ if (scopes.length === 0) {
124
+ log.error("No publishing scopes available. Ensure your account is properly set up.");
125
+ process.exit(1);
126
+ }
127
+
128
+ let scope: string;
129
+ if (scopes.length === 1) {
130
+ const only = scopes[0]!;
131
+ scope = only.name;
132
+ log.info(`Publishing as @${scope} (${only.type})`);
133
+ } else {
134
+ const choice = await select({
135
+ message: "Publish under which scope?",
136
+ options: scopes.map((s) => ({
137
+ value: s.name,
138
+ label: s.type === "personal" ? `@${s.name} (personal)` : `@${s.name} (${s.displayName})`,
139
+ })),
140
+ });
141
+
142
+ if (isCancel(choice)) {
143
+ cancel("Publish cancelled.");
144
+ process.exit(0);
145
+ }
146
+
147
+ scope = choice as string;
148
+ }
149
+
150
+ // Step 7: Select widgets to publish
151
+ const widgetOptions = widgets.map((name) => {
152
+ const manifest = readManifest(cwd, name);
153
+ return {
154
+ value: name,
155
+ label: `${manifest.tag} (${manifest.minSize.w}x${manifest.minSize.h} - ${manifest.maxSize.w}x${manifest.maxSize.h})`,
156
+ };
157
+ });
158
+
159
+ const selected = await multiselect({
160
+ message: "Select widgets to publish:",
161
+ options: widgetOptions,
162
+ initialValues: widgets,
163
+ });
164
+
165
+ if (isCancel(selected)) {
166
+ cancel("Publish cancelled.");
167
+ process.exit(0);
168
+ }
169
+
170
+ const selectedWidgets = selected as string[];
171
+ if (selectedWidgets.length === 0) {
172
+ log.warn("No widgets selected.");
173
+ return;
174
+ }
175
+
176
+ // Step 8: Publish each selected widget
177
+ const published: Array<{ name: string; cdnUrl: string }> = [];
178
+ const skipped: Array<{ name: string; reason: string }> = [];
179
+
180
+ for (const widgetName of selectedWidgets) {
181
+ const manifest = readManifest(cwd, widgetName);
182
+ const distPath = resolve(cwd, "dist", `${widgetName}.js`);
183
+
184
+ let bundleBuffer: Buffer;
185
+ try {
186
+ bundleBuffer = Buffer.from(readFileSync(distPath));
187
+ } catch {
188
+ log.error(`Bundle not found: dist/${widgetName}.js`);
189
+ continue;
190
+ }
191
+
192
+ // Compute SHA-256 hash
193
+ const sha256Hash = createHash("sha256").update(bundleBuffer).digest("hex");
194
+
195
+ s.start(`Publishing ${manifest.tag}@${version}...`);
196
+
197
+ // Request publish (get presigned URL)
198
+ let publishData: Awaited<ReturnType<typeof requestPublish>>;
199
+ try {
200
+ publishData = await requestPublish(hubUrl, token!, {
201
+ scope,
202
+ name: widgetName,
203
+ displayName: manifest.name,
204
+ description: manifest.description,
205
+ minSize: manifest.minSize,
206
+ maxSize: manifest.maxSize,
207
+ sdkVersion: manifest.sdkVersion,
208
+ version,
209
+ bundleSize: bundleBuffer.byteLength,
210
+ sha256Hash,
211
+ manifestJson: JSON.stringify(manifest),
212
+ });
213
+ } catch (err: any) {
214
+ if (err.status === 409) {
215
+ s.stop(`Skipped ${manifest.tag}@${version}`);
216
+ log.warn(`${manifest.tag}@${version}: already published (immutable)`);
217
+ skipped.push({ name: manifest.tag, reason: "version already exists" });
218
+ continue;
219
+ }
220
+ s.stop(`Failed ${manifest.tag}`);
221
+ log.error(`${manifest.tag}: ${err.message}`);
222
+ continue;
223
+ }
224
+
225
+ // Upload to R2
226
+ s.message(`Uploading ${manifest.tag} to CDN...`);
227
+ try {
228
+ await uploadToR2(publishData.uploadUrl, bundleBuffer);
229
+ } catch (err: any) {
230
+ s.stop(`Upload failed for ${manifest.tag}`);
231
+ log.error(`${manifest.tag}: ${err.message}`);
232
+ continue;
233
+ }
234
+
235
+ // Confirm publish
236
+ s.message(`Confirming ${manifest.tag}...`);
237
+ try {
238
+ const result = await confirmPublish(hubUrl, token!, publishData.versionId);
239
+ s.stop(`Published ${manifest.tag}@${version}`);
240
+ published.push({ name: manifest.tag, cdnUrl: result.bundleUrl });
241
+ } catch (err: any) {
242
+ s.stop(`Confirm failed for ${manifest.tag}`);
243
+ log.error(`${manifest.tag}: ${err.message}`);
244
+ }
245
+ }
246
+
247
+ // Step 9: Summary
248
+ log.info("");
249
+ if (published.length > 0) {
250
+ log.success(`Published ${published.length} widget(s):`);
251
+ for (const w of published) {
252
+ log.info(` ${w.name} -> ${w.cdnUrl}`);
253
+ }
254
+ }
255
+
256
+ if (skipped.length > 0) {
257
+ log.warn(`Skipped ${skipped.length} widget(s):`);
258
+ for (const w of skipped) {
259
+ log.info(` ${w.name}: ${w.reason}`);
260
+ }
261
+ }
262
+
263
+ if (published.length === 0 && skipped.length === 0) {
264
+ log.error("No widgets were published.");
265
+ }
266
+ }
@@ -0,0 +1,134 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { log, spinner } from "@clack/prompts";
4
+ import { discoverWidgets, readManifest, writeManifest } from "../utils/manifest";
5
+ import { runValidate } from "./validate";
6
+
7
+ /** Walk up from cwd looking for a package.json with a `workspaces` field. */
8
+ function findMonorepoRoot(from: string): string | null {
9
+ let dir = resolve(from);
10
+ const root = (dir.match(/^[A-Za-z]:\\/) ?? ["/"])[0];
11
+ while (true) {
12
+ const pkgPath = join(dir, "package.json");
13
+ if (existsSync(pkgPath)) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
16
+ if (pkg.workspaces) return dir;
17
+ } catch {}
18
+ }
19
+ const parent = dirname(dir);
20
+ if (parent === dir || parent === root) break;
21
+ dir = parent;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Find @glasshome/widget-sdk version from workspace resolution or node_modules. */
27
+ function findWorkspaceSdkVersion(monorepoRoot: string): string | null {
28
+ const sdkPkgPaths = [
29
+ join(monorepoRoot, "widget-sdk/package.json"),
30
+ join(monorepoRoot, "node_modules/@glasshome/widget-sdk/package.json"),
31
+ ];
32
+
33
+ for (const sdkPath of sdkPkgPaths) {
34
+ if (existsSync(sdkPath)) {
35
+ try {
36
+ const sdkPkg = JSON.parse(readFileSync(sdkPath, "utf-8"));
37
+ if (sdkPkg.version) return sdkPkg.version;
38
+ } catch {}
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /** Update sdkVersion in all manifest.json files. */
45
+ function syncManifestSdkVersions(cwd: string, newVersion: string): void {
46
+ const widgets = discoverWidgets(cwd);
47
+ if (widgets.length === 0) return;
48
+
49
+ const sdkRange = `^${newVersion}`;
50
+ for (const name of widgets) {
51
+ const manifest = readManifest(cwd, name);
52
+ if (manifest.sdkVersion !== sdkRange) {
53
+ manifest.sdkVersion = sdkRange;
54
+ writeManifest(cwd, name, manifest);
55
+ }
56
+ }
57
+ log.info(`Updated sdkVersion to ${sdkRange} in ${widgets.length} manifest(s)`);
58
+ }
59
+
60
+ export async function runUpgrade(cwd: string): Promise<void> {
61
+ const pkgPath = resolve(cwd, "package.json");
62
+ if (!existsSync(pkgPath)) {
63
+ log.error("No package.json found in current directory.");
64
+ process.exit(1);
65
+ }
66
+
67
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
68
+ const currentVersion =
69
+ pkg.peerDependencies?.["@glasshome/widget-sdk"] ??
70
+ pkg.devDependencies?.["@glasshome/widget-sdk"] ??
71
+ pkg.dependencies?.["@glasshome/widget-sdk"];
72
+
73
+ if (!currentVersion) {
74
+ log.error("@glasshome/widget-sdk is not listed in dependencies or peerDependencies.");
75
+ process.exit(1);
76
+ }
77
+
78
+ log.info(`Current @glasshome/widget-sdk version: ${currentVersion}`);
79
+
80
+ const monorepoRoot = findMonorepoRoot(cwd);
81
+
82
+ if (monorepoRoot) {
83
+ // Workspace mode: read SDK version from workspace, sync with bun install
84
+ const sdkVersion = findWorkspaceSdkVersion(monorepoRoot);
85
+
86
+ if (!sdkVersion) {
87
+ log.warn(
88
+ "Could not find @glasshome/widget-sdk in the workspace. Run `bun install` from the monorepo root.",
89
+ );
90
+ return;
91
+ }
92
+
93
+ const cleanCurrent = currentVersion.replace(/^[\^~]/, "");
94
+ if (cleanCurrent === sdkVersion) {
95
+ log.success(`Already up to date with workspace SDK (${sdkVersion})`);
96
+ // Still sync manifests in case they're out of date
97
+ syncManifestSdkVersions(cwd, sdkVersion);
98
+ return;
99
+ }
100
+
101
+ log.info(`Workspace SDK version: ${sdkVersion}`);
102
+
103
+ const s = spinner();
104
+ s.start("Syncing workspace dependencies...");
105
+ const installProc = Bun.spawnSync(["bun", "install"], { cwd: monorepoRoot });
106
+ if (installProc.exitCode !== 0) {
107
+ s.stop("Install failed");
108
+ log.error(installProc.stderr.toString());
109
+ process.exit(1);
110
+ }
111
+ s.stop("Dependencies synced");
112
+
113
+ // Sync sdkVersion in all manifest.json files
114
+ syncManifestSdkVersions(cwd, sdkVersion);
115
+
116
+ // Run validate to check compatibility
117
+ log.info("Checking compatibility...");
118
+ const valid = await runValidate(cwd);
119
+ if (valid) {
120
+ log.success(`Synced with workspace @glasshome/widget-sdk@${sdkVersion}`);
121
+ } else {
122
+ log.warn(`Synced to ${sdkVersion} but validation has warnings/errors. Check above.`);
123
+ }
124
+ } else {
125
+ // Standalone mode: can't fetch from npm (private package), guide user
126
+ log.info("@glasshome/widget-sdk is a workspace package and is not published to npm.");
127
+ log.info("");
128
+ log.step("To upgrade:");
129
+ log.info(" 1. Update the @glasshome/widget-sdk version in your package.json peerDependencies");
130
+ log.info(" 2. Run `bun install` to install the new version");
131
+ log.info(" 3. Run `bun widget upgrade` again to sync manifest files");
132
+ log.info(" 4. Run `bun widget validate` to check compatibility");
133
+ }
134
+ }
@@ -0,0 +1,192 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { log, spinner } from "@clack/prompts";
4
+ import {
5
+ discoverWidgets,
6
+ formatBytes,
7
+ getWidgetBundleInfo,
8
+ readManifest,
9
+ readRegistry,
10
+ type WidgetManifest,
11
+ } from "../utils/manifest";
12
+
13
+ const TAG_REGEX = /^[a-z][a-z0-9]*(-[a-z0-9]+)+$/;
14
+
15
+ interface ValidationResult {
16
+ passed: boolean;
17
+ errors: string[];
18
+ warnings: string[];
19
+ }
20
+
21
+ function validateManifest(manifest: WidgetManifest): ValidationResult {
22
+ const errors: string[] = [];
23
+ const warnings: string[] = [];
24
+
25
+ // Required fields
26
+ if (!manifest.tag) errors.push("Missing required field: tag");
27
+ if (!manifest.name) errors.push("Missing required field: name");
28
+ if (!manifest.sdkVersion) errors.push("Missing required field: sdkVersion");
29
+
30
+ // Tag format
31
+ if (manifest.tag && !TAG_REGEX.test(manifest.tag)) {
32
+ errors.push(
33
+ `Invalid tag format "${manifest.tag}" — must match ${TAG_REGEX} (e.g. glasshome-my-widget)`,
34
+ );
35
+ }
36
+
37
+ // minSize validation
38
+ if (
39
+ !manifest.minSize ||
40
+ typeof manifest.minSize !== "object" ||
41
+ typeof manifest.minSize.w !== "number" ||
42
+ typeof manifest.minSize.h !== "number"
43
+ ) {
44
+ errors.push("minSize must be an object with numeric w and h properties");
45
+ }
46
+
47
+ // maxSize validation
48
+ if (
49
+ !manifest.maxSize ||
50
+ typeof manifest.maxSize !== "object" ||
51
+ typeof manifest.maxSize.w !== "number" ||
52
+ typeof manifest.maxSize.h !== "number"
53
+ ) {
54
+ errors.push("maxSize must be an object with numeric w and h properties");
55
+ }
56
+
57
+ // Schema validation
58
+ if (manifest.schema) {
59
+ if (typeof manifest.schema !== "object") {
60
+ errors.push("Schema must be an object");
61
+ } else {
62
+ if (manifest.schema.type !== "object") {
63
+ warnings.push('Schema type should be "object"');
64
+ }
65
+ if (!manifest.schema.properties || typeof manifest.schema.properties !== "object") {
66
+ warnings.push("Schema should have a properties object");
67
+ }
68
+ }
69
+
70
+ // Check defaultConfig matches schema properties
71
+ if (manifest.defaultConfig && manifest.schema.properties) {
72
+ const schemaKeys = Object.keys(manifest.schema.properties as Record<string, unknown>);
73
+ const configKeys = Object.keys(manifest.defaultConfig);
74
+ for (const key of configKeys) {
75
+ if (!schemaKeys.includes(key)) {
76
+ warnings.push(`defaultConfig key "${key}" is not defined in schema properties`);
77
+ }
78
+ }
79
+ }
80
+ }
81
+
82
+ return {
83
+ passed: errors.length === 0,
84
+ errors,
85
+ warnings,
86
+ };
87
+ }
88
+
89
+ export async function runValidate(cwd: string, widgetName?: string): Promise<boolean> {
90
+ const widgets = discoverWidgets(cwd);
91
+
92
+ if (widgets.length === 0) {
93
+ log.error("No widgets found in src/. Each widget needs a manifest.json.");
94
+ return false;
95
+ }
96
+
97
+ const toValidate = widgetName ? widgets.filter((w) => w === widgetName) : widgets;
98
+
99
+ if (widgetName && toValidate.length === 0) {
100
+ log.error(`Widget "${widgetName}" not found. Available: ${widgets.join(", ")}`);
101
+ return false;
102
+ }
103
+
104
+ // Auto-build if dist/ is missing
105
+ const distDir = resolve(cwd, "dist");
106
+ if (!existsSync(distDir)) {
107
+ const s = spinner();
108
+ s.start("Building widgets (dist/ not found)...");
109
+ const proc = Bun.spawnSync(["bun", "run", "build"], { cwd });
110
+ if (proc.exitCode !== 0) {
111
+ s.stop("Build failed");
112
+ log.error(proc.stderr.toString());
113
+ return false;
114
+ }
115
+ s.stop("Build complete");
116
+ }
117
+
118
+ let allPassed = true;
119
+
120
+ for (const name of toValidate) {
121
+ log.info(`\nValidating widget: ${name}`);
122
+
123
+ // Read and validate manifest.json
124
+ let manifest: WidgetManifest;
125
+ try {
126
+ manifest = readManifest(cwd, name);
127
+ } catch (err) {
128
+ log.error(` Failed to read manifest: ${err instanceof Error ? err.message : String(err)}`);
129
+ allPassed = false;
130
+ continue;
131
+ }
132
+
133
+ const result = validateManifest(manifest);
134
+
135
+ if (result.errors.length > 0) {
136
+ for (const err of result.errors) {
137
+ log.error(` - ${err}`);
138
+ }
139
+ }
140
+
141
+ if (result.warnings.length > 0) {
142
+ for (const warn of result.warnings) {
143
+ log.warn(` - ${warn}`);
144
+ }
145
+ }
146
+
147
+ // Check bundle exists
148
+ const bundlePath = resolve(cwd, "dist", `${name}.js`);
149
+ if (!existsSync(bundlePath)) {
150
+ log.error(` - dist/${name}.js not found`);
151
+ allPassed = false;
152
+ } else {
153
+ try {
154
+ const info = getWidgetBundleInfo(cwd, name);
155
+ log.info(` Bundle: ${formatBytes(info.raw)} (${formatBytes(info.gzip)} gzipped)`);
156
+ } catch {
157
+ // Ignore size reporting errors
158
+ }
159
+ }
160
+
161
+ if (result.passed) {
162
+ log.success(` ${name}: passed`);
163
+ } else {
164
+ log.error(` ${name}: failed`);
165
+ allPassed = false;
166
+ }
167
+ }
168
+
169
+ // Validate registry.json
170
+ const registry = readRegistry(cwd);
171
+ if (!registry) {
172
+ log.error("\ndist/registry.json not found");
173
+ allPassed = false;
174
+ } else {
175
+ const registryTags = new Set(registry.widgets.map((w) => w.bundleUrl));
176
+ for (const name of toValidate) {
177
+ if (!registryTags.has(`./${name}.js`)) {
178
+ log.error(` Widget "${name}" not listed in registry.json`);
179
+ allPassed = false;
180
+ }
181
+ }
182
+ log.info(`\nRegistry: ${registry.widgets.length} widget(s)`);
183
+ }
184
+
185
+ if (allPassed) {
186
+ log.success("\nAll checks passed");
187
+ } else {
188
+ log.error("\nValidation failed");
189
+ }
190
+
191
+ return allPassed;
192
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Raw HTTP helpers for calling tRPC endpoints without the client library.
3
+ * The API uses no transformer, so plain JSON is sent/received.
4
+ */
5
+
6
+ interface TrpcMutationOptions {
7
+ apiUrl: string;
8
+ path: string;
9
+ input: Record<string, unknown>;
10
+ }
11
+
12
+ interface TrpcResult<T = unknown> {
13
+ result: { data: T };
14
+ }
15
+
16
+ export async function trpcMutate<T = unknown>(opts: TrpcMutationOptions): Promise<T> {
17
+ const url = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
18
+ const res = await fetch(url, {
19
+ method: "POST",
20
+ headers: { "Content-Type": "application/json" },
21
+ body: JSON.stringify(opts.input),
22
+ });
23
+
24
+ if (!res.ok) {
25
+ const text = await res.text();
26
+ throw new Error(`API error ${res.status}: ${text}`);
27
+ }
28
+
29
+ const json = (await res.json()) as TrpcResult<T>;
30
+ return json.result.data;
31
+ }
32
+
33
+ interface TrpcQueryOptions {
34
+ apiUrl: string;
35
+ path: string;
36
+ input?: Record<string, unknown>;
37
+ }
38
+
39
+ export async function trpcQuery<T = unknown>(opts: TrpcQueryOptions): Promise<T> {
40
+ const base = `${opts.apiUrl.replace(/\/$/, "")}/trpc/${opts.path}`;
41
+ const url = opts.input ? `${base}?input=${encodeURIComponent(JSON.stringify(opts.input))}` : base;
42
+
43
+ const res = await fetch(url, {
44
+ method: "GET",
45
+ headers: { "Content-Type": "application/json" },
46
+ });
47
+
48
+ if (!res.ok) {
49
+ const text = await res.text();
50
+ throw new Error(`API error ${res.status}: ${text}`);
51
+ }
52
+
53
+ const json = (await res.json()) as TrpcResult<T>;
54
+ return json.result.data;
55
+ }