@glassly/cli 2.0.0-beta.4

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/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @glassly/cli
2
+
3
+ The `glassly` command: the one CLI for developing, releasing, and publishing
4
+ Glassly miniapps. Scaffolded projects install it as a dev dependency, so
5
+ `bun run dev`, `bun run release`, and `bun run pack` call it directly.
6
+
7
+ The project-side commands (`dev`, `build`, `pack`, `release`, `manifest`,
8
+ `permission`, `hardware`, `schema`) are implemented in
9
+ [`@glassly/miniapp-cli`](https://www.npmjs.com/package/@glassly/miniapp-cli),
10
+ which this package wraps. The account and Store commands (`login`, `org`,
11
+ `miniapps`, `releases`, `listing`, `media`, `publish`) talk to the Glassly
12
+ Developer Console.
13
+
14
+ > **Bun-only.** This CLI ships as TypeScript and runs under [Bun](https://bun.sh)
15
+ > (`#!/usr/bin/env bun`). Use `bun` / `bunx`, not `npx`/Node.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ bun add -g @glassly/cli
21
+ glassly --help
22
+ ```
23
+
24
+ Or run without installing:
25
+
26
+ ```bash
27
+ bunx @glassly/cli --help
28
+ ```
29
+
30
+ ## Develop and test on a phone
31
+
32
+ ```bash
33
+ glassly dev # hot-reload dev server + QR; signed dev identity when logged in
34
+ glassly release # build, pack, and serve a QR that installs on phones over LAN
35
+ glassly build # build both layers into dist/
36
+ glassly pack # production-build and zip into build/<pkg>-<version>.zip
37
+ glassly manifest # interactive miniapp.json wizard
38
+ glassly permission add MICROPHONE
39
+ glassly hardware add DISPLAY REQUIRED
40
+ glassly schema print # the miniapp.json JSON Schema
41
+ ```
42
+
43
+ `dev` and `release` take `--qr sm|md|lg` and `--qr-output <path>`. Every
44
+ project command takes `--cwd <path>` to run against another directory.
45
+
46
+ ## Publish to the Glassly Miniapp Store
47
+
48
+ ```bash
49
+ glassly login # sign in to the Developer Console (opens a browser)
50
+ glassly publish # build, pack, sign, upload, and submit for review
51
+ glassly miniapps list # miniapps owned by your org
52
+ glassly releases list <packageName>
53
+ glassly listing set <packageName> --tagline "..."
54
+ glassly media add <packageName> screenshot ./store/screenshots/01.png
55
+ ```
56
+
57
+ Run `glassly <command> --help` for the full option set.
58
+
59
+ ## Backend selection
60
+
61
+ The CLI talks to the production Glassly backend and Developer Console by
62
+ default; nothing needs configuring. To point it at another deployment, set
63
+ `CLOUD_CORE_URL`, `CLOUD_CONSOLE_URL`, and that backend's public WorkOS
64
+ AuthKit client id in `WORKOS_CLIENT_ID`. Logins are stored per backend URL.
package/bin/glassly.js ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // The CLI runs under Bun: the dev server, bundler plugins, and credential
3
+ // store use Bun APIs. This launcher lets `npx @glassly/cli` and plain `node`
4
+ // still work when Bun is installed, and explains what to do when it is not.
5
+ import {spawnSync} from "node:child_process";
6
+ import {fileURLToPath} from "node:url";
7
+
8
+ const entry = fileURLToPath(new URL("../src/index.ts", import.meta.url));
9
+
10
+ if (process.versions.bun) {
11
+ await import(entry);
12
+ } else {
13
+ const result = spawnSync("bun", [entry, ...process.argv.slice(2)], {stdio: "inherit"});
14
+ if (result.error && result.error.code === "ENOENT") {
15
+ console.error("glassly runs on Bun, which is not installed or not on PATH.");
16
+ console.error("Install it from https://bun.sh, then run: bunx @glassly/cli --help");
17
+ process.exit(1);
18
+ }
19
+ process.exit(result.status ?? 1);
20
+ }
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@glassly/cli",
3
+ "version": "2.0.0-beta.4",
4
+ "description": "The glassly command: develop, pack, release, and publish Glassly miniapps. Bun-only.",
5
+ "type": "module",
6
+ "bin": {
7
+ "glassly": "./bin/glassly.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "engines": {
14
+ "bun": ">=1.0.0"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public",
18
+ "tag": "dev"
19
+ },
20
+ "scripts": {
21
+ "dev": "bun src/index.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@glassly/miniapp-cli": "0.1.0-beta.4",
26
+ "commander": "^14.0.2",
27
+ "qrcode": "^1.5.4"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "1.3.14",
31
+ "@types/qrcode": "^1.5.5"
32
+ },
33
+ "keywords": [
34
+ "glassly",
35
+ "glassly",
36
+ "cli",
37
+ "miniapp"
38
+ ],
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/tetramo-labs/glassly.git",
42
+ "directory": "sdk/cli"
43
+ },
44
+ "homepage": "https://github.com/tetramo-labs/glassly/tree/dev/sdk/cli#readme",
45
+ "bugs": {
46
+ "url": "https://github.com/tetramo-labs/glassly/issues"
47
+ }
48
+ }
package/src/api.ts ADDED
@@ -0,0 +1,469 @@
1
+ import type { CliConfig } from "./config";
2
+ import type { CliCredentials } from "./credentials";
3
+
4
+ const DEVICE_AUTH_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
5
+
6
+ export type CliJwk = Record<string, unknown> & {
7
+ kty?: string;
8
+ crv?: string;
9
+ x?: string;
10
+ d?: string;
11
+ };
12
+
13
+ export interface DeviceAuthorizationResponse {
14
+ device_code: string;
15
+ user_code: string;
16
+ verification_uri: string;
17
+ verification_uri_complete: string;
18
+ expires_in: number;
19
+ interval: number;
20
+ }
21
+
22
+ export interface LoginTokenResponse {
23
+ access_token: string;
24
+ refresh_token?: string;
25
+ token_type?: "Bearer";
26
+ expires_in?: number;
27
+ authentication_method?: string;
28
+ organization_id?: string | null;
29
+ user: {
30
+ id: string;
31
+ email: string;
32
+ first_name?: string | null;
33
+ last_name?: string | null;
34
+ };
35
+ }
36
+
37
+ export interface PendingDeviceAuthorization {
38
+ status: "pending";
39
+ interval?: number;
40
+ }
41
+
42
+ export interface SlowDownDeviceAuthorization {
43
+ status: "slow_down";
44
+ interval?: number;
45
+ }
46
+
47
+ export interface DeveloperApp {
48
+ id: string;
49
+ packageName: string;
50
+ name: string;
51
+ description: string | null;
52
+ status: "active" | "archived" | "suspended";
53
+ activeRelease: DeveloperRelease | null;
54
+ latestRelease: DeveloperRelease | null;
55
+ releaseCount: number;
56
+ createdAt: string | null;
57
+ updatedAt: string | null;
58
+ }
59
+
60
+ export interface DeveloperRelease {
61
+ id: string;
62
+ version: string;
63
+ status: "draft" | "submitted" | "in_review" | "accepted" | "rejected" | "published" | "suspended";
64
+ releaseBundleAssetId: string | null;
65
+ bundleSha256: string | null;
66
+ bundleSizeBytes: number | null;
67
+ manifestSha256?: string | null;
68
+ signingKeyId?: string | null;
69
+ signedAt?: string | null;
70
+ reviewedBy?: string | null;
71
+ reviewNotes?: string | null;
72
+ createdAt: string | null;
73
+ updatedAt: string | null;
74
+ }
75
+
76
+ export type MediaRole = "store_icon" | "gallery_screenshot";
77
+
78
+ export interface MediaUpload {
79
+ role: MediaRole;
80
+ fileName: string;
81
+ contentType: string;
82
+ dataBase64: string;
83
+ }
84
+
85
+ export interface MediaAsset {
86
+ id: string;
87
+ role: MediaRole;
88
+ fileName: string;
89
+ contentType: string;
90
+ sizeBytes: number;
91
+ sha256: string;
92
+ width: number | null;
93
+ height: number | null;
94
+ sortOrder: number | null;
95
+ releaseId: string | null;
96
+ createdAt: string | null;
97
+ }
98
+
99
+ export interface ListingOverride {
100
+ tagline: string | null;
101
+ description: string | null;
102
+ tags: string[] | null;
103
+ featured: boolean;
104
+ featuredAt: string | null;
105
+ }
106
+
107
+ export interface DeveloperOrg {
108
+ id: string;
109
+ ownerUserId: string;
110
+ workosOrgId: string | null;
111
+ name: string;
112
+ packagePrefix: string;
113
+ packagePrefixStatus: "unverified" | "verified" | "rejected";
114
+ createdAt: string | null;
115
+ updatedAt: string | null;
116
+ }
117
+
118
+ export interface DeveloperSigningKey {
119
+ id: string;
120
+ orgId: string;
121
+ workosUserId: string;
122
+ publicKeyJwk: CliJwk;
123
+ status: "active" | "revoked";
124
+ lastUsedAt: string | null;
125
+ createdAt: string | null;
126
+ updatedAt: string | null;
127
+ }
128
+
129
+ export interface SignedBundleMetadata {
130
+ signingKeyId: string;
131
+ signature: string;
132
+ payload: {
133
+ packageName: string;
134
+ version: string;
135
+ bundleSha256: string;
136
+ manifestSha256: string;
137
+ createdAt: string;
138
+ };
139
+ }
140
+
141
+ export type PreinstallEnvironment = "debug" | "dev" | "staging" | "prod";
142
+ export type PreinstallPolicy = "install_once" | "keep_updated" | "mandatory";
143
+
144
+ export interface AdminUser {
145
+ developerId: string;
146
+ email: string;
147
+ }
148
+
149
+ export interface AdminRegistry {
150
+ id: string;
151
+ name: string;
152
+ environment: PreinstallEnvironment;
153
+ tenantId: string | null;
154
+ status: "draft" | "active" | "archived";
155
+ activeRevisionId: string | null;
156
+ createdAt: string | null;
157
+ updatedAt: string | null;
158
+ }
159
+
160
+ export interface AdminReleaseSummary {
161
+ id: string;
162
+ miniAppId?: string;
163
+ packageName: string;
164
+ displayName: string;
165
+ version: string;
166
+ status: DeveloperRelease["status"];
167
+ bundleSha256: string | null;
168
+ bundleSizeBytes: number | null;
169
+ createdAt: string | null;
170
+ }
171
+
172
+ export interface AdminRegistryRevision {
173
+ id: string;
174
+ registryId: string;
175
+ status: "draft" | "active" | "archived";
176
+ reason: string | null;
177
+ entries: Array<{
178
+ id: string;
179
+ miniAppId: string;
180
+ releaseId: string;
181
+ required: boolean;
182
+ installPolicy: PreinstallPolicy;
183
+ minMobileVersion: string | null;
184
+ maxMobileVersion: string | null;
185
+ priority: number;
186
+ }>;
187
+ promotedAt: string | null;
188
+ createdAt: string | null;
189
+ updatedAt: string | null;
190
+ }
191
+
192
+ export async function startLogin(config: CliConfig): Promise<DeviceAuthorizationResponse> {
193
+ assertWorkosClientId(config);
194
+ const body = new URLSearchParams({ client_id: config.workosClientId });
195
+ const response = await fetch(`${config.workosApiBaseUrl}/user_management/authorize/device`, {
196
+ method: "POST",
197
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
198
+ body,
199
+ });
200
+ if (!response.ok) throw new Error(await errorMessage(response));
201
+ return (await response.json()) as DeviceAuthorizationResponse;
202
+ }
203
+
204
+ export async function pollLoginToken(
205
+ config: CliConfig,
206
+ deviceCode: string,
207
+ ): Promise<LoginTokenResponse | PendingDeviceAuthorization | SlowDownDeviceAuthorization> {
208
+ assertWorkosClientId(config);
209
+ const body = new URLSearchParams({
210
+ grant_type: DEVICE_AUTH_GRANT_TYPE,
211
+ device_code: deviceCode,
212
+ client_id: config.workosClientId,
213
+ });
214
+ const response = await fetch(`${config.workosApiBaseUrl}/user_management/authenticate`, {
215
+ method: "POST",
216
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
217
+ body,
218
+ });
219
+ if (response.status === 400 || response.status === 403) {
220
+ const result = await response.json().catch(() => ({})) as { error?: string; error_description?: string };
221
+ if (result.error === "authorization_pending") return { status: "pending" };
222
+ if (result.error === "slow_down") return { status: "slow_down" };
223
+ throw new Error(result.error_description || result.error || `HTTP ${response.status}`);
224
+ }
225
+ if (!response.ok) throw new Error(await errorMessage(response));
226
+ return (await response.json()) as LoginTokenResponse;
227
+ }
228
+
229
+ export async function refreshLoginToken(
230
+ config: CliConfig,
231
+ refreshToken: string,
232
+ organizationId?: string | null,
233
+ ): Promise<LoginTokenResponse> {
234
+ assertWorkosClientId(config);
235
+ const body: Record<string, string> = {
236
+ client_id: config.workosClientId,
237
+ grant_type: "refresh_token",
238
+ refresh_token: refreshToken,
239
+ };
240
+ if (organizationId) body.organization_id = organizationId;
241
+
242
+ const response = await fetch(`${config.workosApiBaseUrl}/user_management/authenticate`, {
243
+ method: "POST",
244
+ headers: { "content-type": "application/json", accept: "application/json" },
245
+ body: JSON.stringify(body),
246
+ });
247
+ if (!response.ok) throw new Error(await errorMessage(response));
248
+ return (await response.json()) as LoginTokenResponse;
249
+ }
250
+
251
+ export async function listApps(credentials: CliCredentials): Promise<{ apps: DeveloperApp[] }> {
252
+ return coreRequest(credentials, "/api/console/apps");
253
+ }
254
+
255
+ export async function getAdminMe(credentials: CliCredentials): Promise<{ authenticated: true; admin: true; user: AdminUser | null }> {
256
+ return coreRequest(credentials, "/api/admin/me");
257
+ }
258
+
259
+ export async function listAdminRegistries(credentials: CliCredentials): Promise<{ registries: AdminRegistry[] }> {
260
+ return coreRequest(credentials, "/api/admin/preinstalled/registries");
261
+ }
262
+
263
+ export async function ensureAdminRegistry(
264
+ credentials: CliCredentials,
265
+ input: { environment: PreinstallEnvironment; name?: string; tenantId?: string | null },
266
+ ): Promise<{ registry: AdminRegistry }> {
267
+ return coreRequest(credentials, "/api/admin/preinstalled/registries", {
268
+ method: "POST",
269
+ body: JSON.stringify(input),
270
+ });
271
+ }
272
+
273
+ export async function listAdminPreinstallReleases(
274
+ credentials: CliCredentials,
275
+ ): Promise<{ releases: AdminReleaseSummary[] }> {
276
+ return coreRequest(credentials, "/api/admin/preinstalled/releases");
277
+ }
278
+
279
+ export async function listAdminRegistryRevisions(
280
+ credentials: CliCredentials,
281
+ registryId: string,
282
+ ): Promise<{ revisions: AdminRegistryRevision[] }> {
283
+ return coreRequest(credentials, `/api/admin/preinstalled/registries/${encodeURIComponent(registryId)}/revisions`);
284
+ }
285
+
286
+ export async function createAdminRegistryRevision(
287
+ credentials: CliCredentials,
288
+ registryId: string,
289
+ input: {
290
+ reason?: string | null;
291
+ entries: Array<{
292
+ releaseId: string;
293
+ required?: boolean;
294
+ installPolicy?: PreinstallPolicy;
295
+ minMobileVersion?: string | null;
296
+ maxMobileVersion?: string | null;
297
+ priority?: number;
298
+ }>;
299
+ },
300
+ ): Promise<{ revision: AdminRegistryRevision }> {
301
+ return coreRequest(credentials, `/api/admin/preinstalled/registries/${encodeURIComponent(registryId)}/revisions`, {
302
+ method: "POST",
303
+ body: JSON.stringify(input),
304
+ });
305
+ }
306
+
307
+ export async function promoteAdminRegistryRevision(
308
+ credentials: CliCredentials,
309
+ registryId: string,
310
+ revisionId: string,
311
+ ): Promise<{ registry: AdminRegistry; revision: AdminRegistryRevision }> {
312
+ return coreRequest(
313
+ credentials,
314
+ `/api/admin/preinstalled/registries/${encodeURIComponent(registryId)}/revisions/${encodeURIComponent(revisionId)}/promote`,
315
+ { method: "POST" },
316
+ );
317
+ }
318
+
319
+ export async function getOrg(credentials: CliCredentials): Promise<{ org: DeveloperOrg | null }> {
320
+ return coreRequest(credentials, "/api/console/org");
321
+ }
322
+
323
+ export async function upsertOrg(
324
+ credentials: CliCredentials,
325
+ input: { displayName: string; packagePrefix: string },
326
+ ): Promise<{ org: DeveloperOrg }> {
327
+ return coreRequest(credentials, "/api/console/org", {
328
+ method: "PUT",
329
+ body: JSON.stringify(input),
330
+ });
331
+ }
332
+
333
+ export async function createApp(
334
+ credentials: CliCredentials,
335
+ input: { packageName: string; displayName: string; description?: string | null },
336
+ ): Promise<{ app: DeveloperApp }> {
337
+ return coreRequest(credentials, "/api/console/apps", {
338
+ method: "POST",
339
+ body: JSON.stringify(input),
340
+ });
341
+ }
342
+
343
+ export async function deleteApp(credentials: CliCredentials, packageName: string): Promise<{ ok: true }> {
344
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}`, {
345
+ method: "DELETE",
346
+ });
347
+ }
348
+
349
+ export async function listReleases(
350
+ credentials: CliCredentials,
351
+ packageName: string,
352
+ ): Promise<{ releases: DeveloperRelease[] }> {
353
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}/releases`);
354
+ }
355
+
356
+ export async function updateListing(
357
+ credentials: CliCredentials,
358
+ packageName: string,
359
+ input: { tagline?: string | null; description?: string | null; tags?: string[] | null },
360
+ ): Promise<{ listing: ListingOverride }> {
361
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}/listing`, {
362
+ method: "PUT",
363
+ body: JSON.stringify(input),
364
+ });
365
+ }
366
+
367
+ export async function listMedia(credentials: CliCredentials, packageName: string): Promise<{ media: MediaAsset[] }> {
368
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}/media`);
369
+ }
370
+
371
+ export async function addMedia(
372
+ credentials: CliCredentials,
373
+ packageName: string,
374
+ input: MediaUpload,
375
+ ): Promise<{ asset: MediaAsset }> {
376
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}/media`, {
377
+ method: "POST",
378
+ body: JSON.stringify(input),
379
+ });
380
+ }
381
+
382
+ export async function deleteMedia(credentials: CliCredentials, packageName: string, assetId: string): Promise<{ ok: true }> {
383
+ return coreRequest(
384
+ credentials,
385
+ `/api/console/apps/${encodeURIComponent(packageName)}/media/${encodeURIComponent(assetId)}`,
386
+ { method: "DELETE" },
387
+ );
388
+ }
389
+
390
+ export async function createRelease(
391
+ credentials: CliCredentials,
392
+ input: {
393
+ packageName: string;
394
+ version: string;
395
+ manifest: Record<string, unknown>;
396
+ bundleBase64: string;
397
+ fileName?: string;
398
+ signedBundle: SignedBundleMetadata;
399
+ media?: MediaUpload[];
400
+ },
401
+ ): Promise<{ release: DeveloperRelease }> {
402
+ return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(input.packageName)}/releases`, {
403
+ method: "POST",
404
+ body: JSON.stringify(input),
405
+ });
406
+ }
407
+
408
+ export async function listSigningKeys(credentials: CliCredentials): Promise<{ keys: DeveloperSigningKey[] }> {
409
+ return coreRequest(credentials, "/api/console/signing-keys");
410
+ }
411
+
412
+ export async function registerSigningKey(
413
+ credentials: CliCredentials,
414
+ input: { publicKeyJwk: CliJwk },
415
+ ): Promise<{ key: DeveloperSigningKey }> {
416
+ return coreRequest(credentials, "/api/console/signing-keys", {
417
+ method: "POST",
418
+ body: JSON.stringify(input),
419
+ });
420
+ }
421
+
422
+ export async function submitRelease(
423
+ credentials: CliCredentials,
424
+ input: {
425
+ packageName: string;
426
+ releaseId: string;
427
+ },
428
+ ): Promise<{ release: DeveloperRelease }> {
429
+ return coreRequest(
430
+ credentials,
431
+ `/api/console/apps/${encodeURIComponent(input.packageName)}/releases/${encodeURIComponent(input.releaseId)}/submit`,
432
+ { method: "POST" },
433
+ );
434
+ }
435
+
436
+ async function coreRequest<T>(
437
+ credentials: CliCredentials,
438
+ path: string,
439
+ init?: RequestInit,
440
+ ): Promise<T> {
441
+ const response = await fetch(`${credentials.coreUrl}${path}`, {
442
+ ...init,
443
+ headers: {
444
+ accept: "application/json",
445
+ authorization: `Bearer ${credentials.token}`,
446
+ ...(init?.body ? { "content-type": "application/json" } : {}),
447
+ ...init?.headers,
448
+ },
449
+ });
450
+ if (!response.ok) throw new Error(await errorMessage(response));
451
+ return await response.json() as T;
452
+ }
453
+
454
+ function assertWorkosClientId(config: CliConfig): void {
455
+ if (!config.workosClientId) {
456
+ throw new Error(
457
+ "WORKOS_CLIENT_ID is set but empty. Unset it to use the production login, or set it to the AuthKit client id of the backend in CLOUD_CORE_URL.",
458
+ );
459
+ }
460
+ }
461
+
462
+ async function errorMessage(response: Response): Promise<string> {
463
+ try {
464
+ const body = (await response.json()) as { error?: string; error_description?: string };
465
+ return body.error_description || body.error || `HTTP ${response.status}`;
466
+ } catch {
467
+ return `HTTP ${response.status}`;
468
+ }
469
+ }
package/src/config.ts ADDED
@@ -0,0 +1,34 @@
1
+ export interface CliConfig {
2
+ coreUrl: string;
3
+ consoleUrl: string;
4
+ workosClientId: string;
5
+ workosApiBaseUrl: string;
6
+ }
7
+
8
+ // Production is the default so a developer can run `glassly login` with no
9
+ // setup. The WorkOS client id is the public AuthKit identifier of the
10
+ // production environment, not a secret. Internal use against dev or staging
11
+ // overrides all three through the environment (see the glassly:* scripts in
12
+ // cloud-v2/package.json, which take them from Doppler).
13
+ const PRODUCTION = {
14
+ coreUrl: "https://core.glassly.ai",
15
+ consoleUrl: "https://console.glassly.ai",
16
+ workosClientId: "client_01KY5EVQT1PE8V11ZKZ1Q56XTK",
17
+ };
18
+
19
+ export function getConfig(): CliConfig {
20
+ return {
21
+ coreUrl: normalizeUrl(process.env.CLOUD_CORE_URL || PRODUCTION.coreUrl),
22
+ consoleUrl: normalizeUrl(process.env.CLOUD_CONSOLE_URL || PRODUCTION.consoleUrl),
23
+ workosClientId: process.env.WORKOS_CLIENT_ID || PRODUCTION.workosClientId,
24
+ workosApiBaseUrl: normalizeUrl(process.env.WORKOS_API_BASE_URL || "https://api.workos.com"),
25
+ };
26
+ }
27
+
28
+ function normalizeUrl(value: string): string {
29
+ const url = new URL(value);
30
+ url.pathname = url.pathname.replace(/\/+$/, "");
31
+ url.search = "";
32
+ url.hash = "";
33
+ return url.toString().replace(/\/$/, "");
34
+ }