@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/src/index.ts ADDED
@@ -0,0 +1,1200 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { Command } from "commander";
4
+ import {
5
+ addHardwareCmd,
6
+ addPermissionCmd,
7
+ buildProduction as buildMiniappProduction,
8
+ dev as devMiniapp,
9
+ listHardwareCmd,
10
+ listPermissionsCmd,
11
+ pack as packMiniapp,
12
+ regenerateSchemaFile,
13
+ release as releaseMiniapp,
14
+ removeHardwareCmd,
15
+ removePermissionCmd,
16
+ runManifestWizard,
17
+ schemaPrint,
18
+ type QRSize,
19
+ } from "@glassly/miniapp-cli";
20
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
21
+ import { basename, extname, join, resolve } from "node:path";
22
+ import {
23
+ addMedia,
24
+ createAdminRegistryRevision,
25
+ createApp,
26
+ createRelease,
27
+ deleteApp,
28
+ deleteMedia,
29
+ ensureAdminRegistry,
30
+ getAdminMe,
31
+ getOrg,
32
+ listAdminPreinstallReleases,
33
+ listAdminRegistries,
34
+ listAdminRegistryRevisions,
35
+ listApps,
36
+ listMedia,
37
+ listReleases,
38
+ promoteAdminRegistryRevision,
39
+ pollLoginToken,
40
+ refreshLoginToken,
41
+ startLogin,
42
+ submitRelease,
43
+ updateListing,
44
+ upsertOrg,
45
+ type AdminReleaseSummary,
46
+ type AdminRegistry,
47
+ type MediaRole,
48
+ type MediaUpload,
49
+ type PreinstallEnvironment,
50
+ type PreinstallPolicy,
51
+ } from "./api";
52
+ import { getConfig } from "./config";
53
+ import { clearCredentials, loadCredentials, saveCredentials, type CliCredentials } from "./credentials";
54
+ import { openBrowser } from "./open-browser";
55
+ import { encodeDevAttestation, ensureSigningKey, signBundleMetadata, signDevAttestation } from "./signing";
56
+
57
+ const cliVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version as string;
58
+
59
+ const QR_SIZES: Record<string, QRSize> = {
60
+ sm: "small",
61
+ small: "small",
62
+ md: "medium",
63
+ medium: "medium",
64
+ lg: "large",
65
+ large: "large",
66
+ };
67
+
68
+ function parseQrSize(raw: string): QRSize {
69
+ const size = QR_SIZES[raw.toLowerCase()];
70
+ if (!size) throw new Error(`Invalid --qr size "${raw}" (expected small|medium|large or sm|md|lg)`);
71
+ return size;
72
+ }
73
+
74
+ const program = new Command();
75
+
76
+ program
77
+ .name("glassly")
78
+ .description("Glassly developer CLI")
79
+ .version(cliVersion);
80
+
81
+ program
82
+ .command("login")
83
+ .description("Sign in to Glassly Developer Console")
84
+ .option("--no-open", "print the login URL without opening a browser")
85
+ .action(async (options: { open: boolean }) => {
86
+ const config = getConfig();
87
+ let challenge;
88
+ try {
89
+ challenge = await startLogin(config);
90
+ } catch (error) {
91
+ console.error(error instanceof Error ? error.message : String(error));
92
+ process.exitCode = 1;
93
+ return;
94
+ }
95
+
96
+ console.log("Sign in to Glassly Developer Console");
97
+ console.log("");
98
+ console.log(`Open: ${challenge.verification_uri_complete}`);
99
+ console.log(`Code: ${challenge.user_code}`);
100
+ console.log("");
101
+
102
+ if (options.open) {
103
+ const opened = await openBrowser(challenge.verification_uri_complete);
104
+ if (!opened) console.log("Could not open a browser automatically.");
105
+ }
106
+
107
+ const deadline = Date.now() + challenge.expires_in * 1000;
108
+ process.stdout.write("Waiting for browser approval");
109
+
110
+ while (Date.now() < deadline) {
111
+ const token = await pollLoginToken(config, challenge.device_code);
112
+ if ("status" in token) {
113
+ if (token.status === "slow_down") {
114
+ await sleep(challenge.interval * 1000);
115
+ }
116
+ } else {
117
+ process.stdout.write("\n");
118
+ const storedAt = new Date();
119
+ const expiresAt =
120
+ typeof token.expires_in === "number"
121
+ ? new Date(storedAt.getTime() + token.expires_in * 1000)
122
+ : expiresAtFromToken(token.access_token)
123
+ ? new Date(expiresAtFromToken(token.access_token)! * 1000)
124
+ : undefined;
125
+ const storage = await saveCredentials({
126
+ token: token.access_token,
127
+ refreshToken: token.refresh_token,
128
+ workosUserId: token.user.id,
129
+ email: token.user.email,
130
+ organizationId: token.organization_id,
131
+ authenticationMethod: token.authentication_method,
132
+ coreUrl: config.coreUrl,
133
+ storedAt: storedAt.toISOString(),
134
+ expiresAt: expiresAt?.toISOString(),
135
+ });
136
+ console.log(`Signed in as ${token.user.email}`);
137
+ if (token.organization_id) console.log(`Organization: ${token.organization_id}`);
138
+ console.log(`Credentials stored in ${storage === "keychain" ? "OS keychain" : "~/.glassly/cli-v2"}`);
139
+ return;
140
+ }
141
+
142
+ process.stdout.write(".");
143
+ await sleep(challenge.interval * 1000);
144
+ }
145
+
146
+ process.stdout.write("\n");
147
+ console.error("Login timed out. Run `glassly login` to try again.");
148
+ process.exitCode = 1;
149
+ });
150
+
151
+ program
152
+ .command("whoami")
153
+ .description("Show the current CLI login")
154
+ .action(async () => {
155
+ const config = getConfig();
156
+ const creds = await loadFreshCredentials(config);
157
+ if (!creds) {
158
+ console.error("Not signed in. Run `glassly login`.");
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+
163
+ console.log(`Email: ${creds.email}`);
164
+ console.log(`WorkOS user: ${creds.workosUserId}`);
165
+ if (creds.organizationId) console.log(`Organization: ${creds.organizationId}`);
166
+ console.log(`Core: ${config.coreUrl}`);
167
+ if (creds.expiresAt) console.log(`Expires: ${new Date(creds.expiresAt).toLocaleString()}`);
168
+ });
169
+
170
+ const org = program.command("org").description("Manage the current developer organization");
171
+
172
+ org
173
+ .command("show")
174
+ .description("Show the current developer organization")
175
+ .action(async () => {
176
+ const creds = await requireCredentials();
177
+ if (!creds) return;
178
+
179
+ try {
180
+ const { org: developerOrg } = await getOrg(creds);
181
+ if (!developerOrg) {
182
+ console.log("No developer org yet. Run `glassly org init --name \"Your Org\" --prefix com.example`.");
183
+ return;
184
+ }
185
+
186
+ console.log(`Name: ${developerOrg.name}`);
187
+ console.log(`Package prefix: ${developerOrg.packagePrefix}`);
188
+ console.log(`Prefix status: ${developerOrg.packagePrefixStatus}`);
189
+ if (developerOrg.workosOrgId) console.log(`WorkOS org: ${developerOrg.workosOrgId}`);
190
+ } catch (error) {
191
+ fail(error);
192
+ }
193
+ });
194
+
195
+ org
196
+ .command("init")
197
+ .description("Create or update the current developer organization")
198
+ .requiredOption("--name <name>", "organization display name")
199
+ .requiredOption("--prefix <prefix>", "package prefix, e.g. com.example")
200
+ .action(async (options: { name: string; prefix: string }) => {
201
+ const creds = await requireCredentials();
202
+ if (!creds) return;
203
+
204
+ try {
205
+ const { org: developerOrg } = await upsertOrg(creds, {
206
+ displayName: options.name,
207
+ packagePrefix: options.prefix,
208
+ });
209
+ console.log(`Developer org ready: ${developerOrg.name}`);
210
+ console.log(`Package prefix: ${developerOrg.packagePrefix} (${developerOrg.packagePrefixStatus})`);
211
+ if (developerOrg.workosOrgId) console.log(`WorkOS org: ${developerOrg.workosOrgId}`);
212
+ } catch (error) {
213
+ fail(error);
214
+ }
215
+ });
216
+
217
+ const miniapps = program.command("miniapps").description("Manage miniapp package records");
218
+
219
+ miniapps
220
+ .command("list")
221
+ .description("List miniapps owned by the current developer org")
222
+ .action(async () => {
223
+ const creds = await requireCredentials();
224
+ if (!creds) return;
225
+
226
+ try {
227
+ const { apps: appList } = await listApps(creds);
228
+ if (appList.length === 0) {
229
+ console.log("No miniapps yet.");
230
+ return;
231
+ }
232
+
233
+ for (const app of appList) {
234
+ const release = app.activeRelease ?? app.latestRelease;
235
+ const releaseLabel = release ? `${release.version} (${release.status})` : "no releases";
236
+ console.log(`${app.packageName}\t${app.name}\t${app.status}\t${releaseLabel}`);
237
+ }
238
+ } catch (error) {
239
+ fail(error);
240
+ }
241
+ });
242
+
243
+ miniapps
244
+ .command("create")
245
+ .argument("<packageName>", "stable package name, e.g. com.glassly.myminiapp")
246
+ .requiredOption("--name <name>", "display name")
247
+ .option("--description <description>", "short miniapp description")
248
+ .description("Reserve a miniapp package name")
249
+ .action(async (packageName: string, options: { name: string; description?: string }) => {
250
+ const creds = await requireCredentials();
251
+ if (!creds) return;
252
+
253
+ try {
254
+ const { app } = await createApp(creds, {
255
+ packageName,
256
+ displayName: options.name,
257
+ description: options.description ?? null,
258
+ });
259
+ console.log(`Miniapp ready: ${app.packageName} (${app.name})`);
260
+ } catch (error) {
261
+ fail(error);
262
+ }
263
+ });
264
+
265
+ miniapps
266
+ .command("delete")
267
+ .argument("<packageName>", "package name to archive")
268
+ .description("Archive a miniapp package record")
269
+ .action(async (packageName: string) => {
270
+ const creds = await requireCredentials();
271
+ if (!creds) return;
272
+
273
+ try {
274
+ await deleteApp(creds, packageName);
275
+ console.log(`Archived ${packageName}`);
276
+ } catch (error) {
277
+ fail(error);
278
+ }
279
+ });
280
+
281
+ const releases = program.command("releases").description("Inspect miniapp releases");
282
+
283
+ releases
284
+ .command("list")
285
+ .argument("<packageName>", "package name")
286
+ .description("List releases for a miniapp")
287
+ .action(async (packageName: string) => {
288
+ const creds = await requireCredentials();
289
+ if (!creds) return;
290
+
291
+ try {
292
+ const { releases: releaseList } = await listReleases(creds, packageName);
293
+ if (releaseList.length === 0) {
294
+ console.log("No releases yet.");
295
+ return;
296
+ }
297
+
298
+ for (const release of releaseList) {
299
+ const size = release.bundleSizeBytes ? `${Math.round(release.bundleSizeBytes / 1024)} KB` : "no bundle";
300
+ console.log(`${release.version}\t${release.status}\t${size}\t${release.bundleSha256 ?? "no hash"}`);
301
+ }
302
+ } catch (error) {
303
+ fail(error);
304
+ }
305
+ });
306
+
307
+ releases
308
+ .command("submit")
309
+ .argument("<packageName>", "package name")
310
+ .argument("<releaseId>", "release id")
311
+ .description("Submit an uploaded release for admin review")
312
+ .action(async (packageName: string, releaseId: string) => {
313
+ const creds = await requireCredentials();
314
+ if (!creds) return;
315
+
316
+ try {
317
+ const { release } = await submitRelease(creds, { packageName, releaseId });
318
+ console.log(`Submitted ${packageName}@${release.version} for review`);
319
+ } catch (error) {
320
+ fail(error);
321
+ }
322
+ });
323
+
324
+ const listing = program.command("listing").description("Store listing copy (overrides the manifest)");
325
+
326
+ listing
327
+ .command("set")
328
+ .argument("<packageName>", "package name")
329
+ .option("--tagline <text>", "one line under the name (blank clears the override)")
330
+ .option("--description <text>", "store description (blank clears the override)")
331
+ .option("--tags <a,b,c>", "comma-separated lowercase tags (blank clears the override)")
332
+ .description("Set store listing overrides for a miniapp")
333
+ .action(async (packageName: string, options: { tagline?: string; description?: string; tags?: string }) => {
334
+ const creds = await requireCredentials();
335
+ if (!creds) return;
336
+ if (options.tagline === undefined && options.description === undefined && options.tags === undefined) {
337
+ fail(new Error("pass at least one of --tagline, --description, --tags"));
338
+ return;
339
+ }
340
+
341
+ try {
342
+ const input: { tagline?: string | null; description?: string | null; tags?: string[] | null } = {};
343
+ if (options.tagline !== undefined) input.tagline = options.tagline.trim() || null;
344
+ if (options.description !== undefined) input.description = options.description.trim() || null;
345
+ if (options.tags !== undefined) {
346
+ const tags = options.tags.split(",").map(tag => tag.trim()).filter(Boolean);
347
+ input.tags = tags.length > 0 ? tags : null;
348
+ }
349
+ const { listing: saved } = await updateListing(creds, packageName, input);
350
+ console.log(`Listing for ${packageName}`);
351
+ console.log(` tagline: ${saved.tagline ?? "(manifest)"}`);
352
+ console.log(` description: ${saved.description ?? "(manifest)"}`);
353
+ console.log(` tags: ${saved.tags ? saved.tags.join(", ") : "(manifest)"}`);
354
+ } catch (error) {
355
+ fail(error);
356
+ }
357
+ });
358
+
359
+ const media = program.command("media").description("Store icon and screenshots");
360
+
361
+ media
362
+ .command("list")
363
+ .argument("<packageName>", "package name")
364
+ .description("List store media for a miniapp")
365
+ .action(async (packageName: string) => {
366
+ const creds = await requireCredentials();
367
+ if (!creds) return;
368
+
369
+ try {
370
+ const { media: assets } = await listMedia(creds, packageName);
371
+ if (assets.length === 0) {
372
+ console.log("No store media yet.");
373
+ return;
374
+ }
375
+ for (const asset of assets) {
376
+ const dims = asset.width && asset.height ? `${asset.width}x${asset.height}` : "?";
377
+ const origin = asset.role === "store_icon" ? (asset.releaseId ? "bundle" : "upload") : `#${asset.sortOrder ?? 0}`;
378
+ console.log(`${asset.id}\t${asset.role}\t${origin}\t${dims}\t${Math.round(asset.sizeBytes / 1024)} KB\t${asset.fileName}`);
379
+ }
380
+ } catch (error) {
381
+ fail(error);
382
+ }
383
+ });
384
+
385
+ media
386
+ .command("add")
387
+ .argument("<packageName>", "package name")
388
+ .argument("<file>", "PNG or JPEG file")
389
+ .option("--role <role>", "store_icon or gallery_screenshot", "gallery_screenshot")
390
+ .description("Upload a store icon or screenshot")
391
+ .action(async (packageName: string, file: string, options: { role: string }) => {
392
+ const creds = await requireCredentials();
393
+ if (!creds) return;
394
+
395
+ try {
396
+ const role = parseMediaRole(options.role);
397
+ const { asset } = await addMedia(creds, packageName, readMediaFile(resolve(file), role));
398
+ console.log(`Uploaded ${asset.role} ${asset.id} (${asset.width}x${asset.height}, ${Math.round(asset.sizeBytes / 1024)} KB)`);
399
+ } catch (error) {
400
+ fail(error);
401
+ }
402
+ });
403
+
404
+ media
405
+ .command("rm")
406
+ .argument("<packageName>", "package name")
407
+ .argument("<assetId>", "asset id from `media list`")
408
+ .description("Remove an uploaded icon or screenshot")
409
+ .action(async (packageName: string, assetId: string) => {
410
+ const creds = await requireCredentials();
411
+ if (!creds) return;
412
+
413
+ try {
414
+ await deleteMedia(creds, packageName, assetId);
415
+ console.log(`Removed ${assetId}`);
416
+ } catch (error) {
417
+ fail(error);
418
+ }
419
+ });
420
+
421
+ const admin = program.command("admin").description("Internal Glassly admin operations");
422
+
423
+ admin
424
+ .command("me")
425
+ .description("Show the current admin identity")
426
+ .action(async () => {
427
+ const creds = await requireCredentials();
428
+ if (!creds) return;
429
+
430
+ try {
431
+ const me = await getAdminMe(creds);
432
+ console.log(`Admin: ${me.user?.email ?? "unknown"}`);
433
+ console.log(`Core: ${creds.coreUrl}`);
434
+ } catch (error) {
435
+ fail(error);
436
+ }
437
+ });
438
+
439
+ const preinstall = admin
440
+ .command("preinstall")
441
+ .description("Manage the internal preinstalled miniapp registry");
442
+
443
+ preinstall
444
+ .command("releases")
445
+ .description("List releases eligible for preinstall")
446
+ .option("-e, --environment <environment>", "target registry environment")
447
+ .option("--verbose", "include release ids")
448
+ .action(async (options: { environment?: string; verbose?: boolean }) => {
449
+ const creds = await requireCredentials();
450
+ if (!creds) return;
451
+
452
+ try {
453
+ const environment = options.environment
454
+ ? parseEnvironment(options.environment)
455
+ : inferEnvironment(creds.coreUrl);
456
+ const { releases: releaseList } = await listAdminPreinstallReleases(creds);
457
+ if (releaseList.length === 0) {
458
+ console.log("No publishable releases.");
459
+ return;
460
+ }
461
+ const activeReleaseIds = await activePreinstallReleaseIds(creds, environment);
462
+ const groups = groupAdminReleases(releaseList, activeReleaseIds);
463
+ console.log(`Publishable miniapps for ${environment} preinstall:`);
464
+ for (const group of groups) {
465
+ console.log(formatAdminReleaseGroup(group));
466
+ if (options.verbose) {
467
+ for (const release of group.releases) {
468
+ const marker = release.id === group.current?.id ? "*" : " ";
469
+ console.log(` ${marker} ${release.version}\t${release.status}\t${release.id}`);
470
+ }
471
+ }
472
+ }
473
+ console.log("");
474
+ console.log("Publish with:");
475
+ console.log(` ${commandNameForCoreUrl(creds.coreUrl)} admin preinstall publish --release ${groups[0]?.packageName}@${groups[0]?.latest.version} --environment ${environment}`);
476
+ } catch (error) {
477
+ fail(error);
478
+ }
479
+ });
480
+
481
+ preinstall
482
+ .command("status")
483
+ .description("Show the active preinstall registry for an environment")
484
+ .option("-e, --environment <environment>", "target registry environment")
485
+ .action(async (options: { environment?: string }) => {
486
+ const creds = await requireCredentials();
487
+ if (!creds) return;
488
+
489
+ try {
490
+ const environment = options.environment
491
+ ? parseEnvironment(options.environment)
492
+ : inferEnvironment(creds.coreUrl);
493
+ const { registries } = await listAdminRegistries(creds);
494
+ const registry = registries.find(item => item.environment === environment && item.name === "default");
495
+ if (!registry) {
496
+ console.log(`No active ${environment} preinstall registry.`);
497
+ return;
498
+ }
499
+
500
+ console.log(`${environment} preinstall registry`);
501
+ console.log(`Registry: ${registry.id}`);
502
+ console.log(`Status: ${registry.status}`);
503
+ if (!registry.activeRevisionId) {
504
+ console.log("Active revision: none");
505
+ return;
506
+ }
507
+
508
+ const { revisions } = await listAdminRegistryRevisions(creds, registry.id);
509
+ const active = revisions.find(revision => revision.id === registry.activeRevisionId);
510
+ console.log(`Active revision: ${registry.activeRevisionId}`);
511
+ if (!active || active.entries.length === 0) {
512
+ console.log("No preinstalled releases.");
513
+ return;
514
+ }
515
+
516
+ const { releases: releaseList } = await listAdminPreinstallReleases(creds);
517
+ const byId = new Map(releaseList.map(release => [release.id, release]));
518
+ for (const entry of active.entries) {
519
+ const release = byId.get(entry.releaseId);
520
+ const label = release ? `${release.packageName}@${release.version}` : entry.releaseId;
521
+ console.log(`- ${label}\t${installModeLabel(entry.installPolicy)}`);
522
+ }
523
+ } catch (error) {
524
+ fail(error);
525
+ }
526
+ });
527
+
528
+ preinstall
529
+ .command("registries")
530
+ .description("List preinstall registries and active revisions")
531
+ .option("-e, --environment <environment>", "filter by environment")
532
+ .action(async (options: { environment?: string }) => {
533
+ const creds = await requireCredentials();
534
+ if (!creds) return;
535
+
536
+ try {
537
+ const environment = options.environment ? parseEnvironment(options.environment) : null;
538
+ const { registries } = await listAdminRegistries(creds);
539
+ const filtered = environment
540
+ ? registries.filter(registry => registry.environment === environment)
541
+ : registries;
542
+ if (filtered.length === 0) {
543
+ console.log("No preinstall registries.");
544
+ return;
545
+ }
546
+
547
+ for (const registry of filtered) {
548
+ console.log(formatRegistry(registry));
549
+ if (registry.activeRevisionId) {
550
+ const { revisions } = await listAdminRegistryRevisions(creds, registry.id);
551
+ const active = revisions.find(revision => revision.id === registry.activeRevisionId);
552
+ if (active) {
553
+ console.log(` active revision: ${active.id}`);
554
+ for (const entry of active.entries) {
555
+ console.log(` - ${entry.releaseId}\t${installModeLabel(entry.installPolicy)}`);
556
+ }
557
+ }
558
+ }
559
+ }
560
+ } catch (error) {
561
+ fail(error);
562
+ }
563
+ });
564
+
565
+ preinstall
566
+ .command("publish")
567
+ .description("Replace the active preinstalled miniapp list with selected releases")
568
+ .option("-r, --release <release>", "release id or package@version installed by default: once, on phones that do not have it; repeat for multiple releases", collect, [])
569
+ .option("-m, --mandatory <release>", "release reinstalled whenever it is missing; repeat for multiple releases", collect, [])
570
+ .option("-e, --environment <environment>", "target registry environment")
571
+ .option("--reason <reason>", "audit reason")
572
+ .option("--yes", "confirm prod registry replacement")
573
+ .action(async (options: {
574
+ release: string[];
575
+ mandatory: string[];
576
+ environment?: string;
577
+ reason?: string;
578
+ yes?: boolean;
579
+ }) => {
580
+ const creds = await requireCredentials();
581
+ if (!creds) return;
582
+
583
+ try {
584
+ const environment = options.environment
585
+ ? parseEnvironment(options.environment)
586
+ : inferEnvironment(creds.coreUrl);
587
+ if (environment === "prod" && !options.yes) {
588
+ throw new Error("prod preinstall publish requires --yes");
589
+ }
590
+
591
+ if (options.release.length + options.mandatory.length === 0) {
592
+ throw new Error("give at least one --release or --mandatory");
593
+ }
594
+
595
+ const { releases: releaseList } = await listAdminPreinstallReleases(creds);
596
+ const selected = resolveReleaseRefs([...options.release, ...options.mandatory], releaseList);
597
+ // The mandatory releases come last in `selected`.
598
+ const mandatoryIds = new Set(selected.slice(options.release.length).map(release => release.id));
599
+ const policyOf = (releaseId: string): PreinstallPolicy =>
600
+ mandatoryIds.has(releaseId) ? "mandatory" : "install_once";
601
+ const { registry } = await ensureAdminRegistry(creds, { environment });
602
+ const { revision } = await createAdminRegistryRevision(creds, registry.id, {
603
+ reason: options.reason ?? `Admin preinstall registry publish for ${environment}`,
604
+ entries: selected.map((release, index) => ({
605
+ releaseId: release.id,
606
+ required: true,
607
+ installPolicy: policyOf(release.id),
608
+ priority: index,
609
+ })),
610
+ });
611
+ const promoted = await promoteAdminRegistryRevision(creds, registry.id, revision.id);
612
+
613
+ console.log(`Published ${selected.length} release(s) to ${environment} preinstall registry.`);
614
+ console.log(`Registry: ${promoted.registry.id}`);
615
+ console.log(`Revision: ${promoted.revision.id}`);
616
+ for (const release of selected) {
617
+ console.log(`- ${release.packageName}@${release.version}\t${release.id}\t${installModeLabel(policyOf(release.id))}`);
618
+ }
619
+ } catch (error) {
620
+ fail(error);
621
+ }
622
+ });
623
+
624
+ program
625
+ .command("dev")
626
+ .description("Start the local miniapp dev server with signed Cloud V2 identity when logged in")
627
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
628
+ .option("--auth", "require signed dev auto-auth setup before starting")
629
+ .option("-q, --qr <size>", "terminal QR size: small|medium|large (sm|md|lg)", "medium")
630
+ .option("--qr-output <path>", "also write the QR as a PNG at this path")
631
+ .action(async (options: { cwd: string; auth?: boolean; qr: string; qrOutput?: string }) => {
632
+ const cwd = resolve(options.cwd);
633
+ try {
634
+ const qrSize = parseQrSize(options.qr);
635
+ const manifest = readManifest(cwd);
636
+ const packageName = stringField(manifest, "packageName");
637
+ const name = stringField(manifest, "name") || packageName;
638
+ const description = typeof manifest.description === "string" ? manifest.description : null;
639
+ const creds = await loadFreshCredentials(getConfig());
640
+ let signer:
641
+ | ((input: { packageName: string; devServerUrl: string }) => string)
642
+ | undefined;
643
+
644
+ if (creds) {
645
+ await ensureMiniappRecord(creds, { packageName, displayName: name, description });
646
+ const signingKey = await ensureSigningKey(creds);
647
+ signer = ({ packageName: signedPackageName, devServerUrl }) =>
648
+ encodeDevAttestation(signDevAttestation({
649
+ signingKey,
650
+ packageName: signedPackageName,
651
+ devServerUrl,
652
+ }));
653
+ console.log(`Dev auto-auth enabled for ${packageName}`);
654
+ } else if (options.auth) {
655
+ throw new Error("Not signed in. Run `glassly login` before `glassly dev --auth`.");
656
+ } else {
657
+ console.log("Dev auto-auth disabled. Run `glassly login` if this miniapp uses session.auth.");
658
+ }
659
+
660
+ await devMiniapp({ cwd, qrSize, qrOutput: options.qrOutput, signDevAttestation: signer });
661
+ } catch (error) {
662
+ fail(error);
663
+ }
664
+ });
665
+
666
+ program
667
+ .command("build")
668
+ .description("Build the current miniapp")
669
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
670
+ .action(async (options: { cwd: string }) => {
671
+ try {
672
+ await buildMiniappProduction(resolve(options.cwd));
673
+ } catch (error) {
674
+ fail(error);
675
+ }
676
+ });
677
+
678
+ program
679
+ .command("pack")
680
+ .description("Pack the current miniapp into build/<packageName>-<version>.zip")
681
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
682
+ .option("--no-build", "skip production build before packing")
683
+ .action(async (options: { cwd: string; build: boolean }) => {
684
+ try {
685
+ await packMiniapp({ cwd: resolve(options.cwd), build: options.build });
686
+ } catch (error) {
687
+ fail(error);
688
+ }
689
+ });
690
+
691
+ program
692
+ .command("release")
693
+ .description("Build, pack, and serve a QR that installs the miniapp on phones over LAN")
694
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
695
+ .option("--no-cache", "rebuild even if the cached zip is newer than every source file")
696
+ .option("-q, --qr <size>", "terminal QR size: small|medium|large (sm|md|lg)", "medium")
697
+ .option("--qr-output <path>", "also write the QR as a PNG at this path")
698
+ .action(async (options: { cwd: string; cache: boolean; qr: string; qrOutput?: string }) => {
699
+ try {
700
+ await releaseMiniapp({
701
+ cwd: resolve(options.cwd),
702
+ noCache: !options.cache,
703
+ qrSize: parseQrSize(options.qr),
704
+ qrOutput: options.qrOutput,
705
+ });
706
+ } catch (error) {
707
+ fail(error);
708
+ }
709
+ });
710
+
711
+ program
712
+ .command("manifest")
713
+ .description("Edit miniapp.json interactively")
714
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
715
+ .action(async (options: { cwd: string }) => {
716
+ try {
717
+ process.chdir(resolve(options.cwd));
718
+ await runManifestWizard();
719
+ } catch (error) {
720
+ fail(error);
721
+ }
722
+ });
723
+
724
+ const permission = program.command("permission").description("Declared permissions in miniapp.json");
725
+
726
+ permission
727
+ .command("list")
728
+ .description("List declared permissions")
729
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
730
+ .action(async (options: { cwd: string }) => {
731
+ try {
732
+ process.chdir(resolve(options.cwd));
733
+ await listPermissionsCmd();
734
+ } catch (error) {
735
+ fail(error);
736
+ }
737
+ });
738
+
739
+ permission
740
+ .command("add")
741
+ .argument("[type]", "permission type, e.g. MICROPHONE (interactive when omitted)")
742
+ .description("Add a permission")
743
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
744
+ .action(async (type: string | undefined, options: { cwd: string }) => {
745
+ try {
746
+ process.chdir(resolve(options.cwd));
747
+ await addPermissionCmd(type);
748
+ } catch (error) {
749
+ fail(error);
750
+ }
751
+ });
752
+
753
+ permission
754
+ .command("remove")
755
+ .argument("[type]", "permission type (interactive when omitted)")
756
+ .description("Remove a declared permission")
757
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
758
+ .action(async (type: string | undefined, options: { cwd: string }) => {
759
+ try {
760
+ process.chdir(resolve(options.cwd));
761
+ await removePermissionCmd(type);
762
+ } catch (error) {
763
+ fail(error);
764
+ }
765
+ });
766
+
767
+ const hardware = program.command("hardware").description("Hardware requirements in miniapp.json");
768
+
769
+ hardware
770
+ .command("list")
771
+ .description("List declared hardware requirements")
772
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
773
+ .action(async (options: { cwd: string }) => {
774
+ try {
775
+ process.chdir(resolve(options.cwd));
776
+ await listHardwareCmd();
777
+ } catch (error) {
778
+ fail(error);
779
+ }
780
+ });
781
+
782
+ hardware
783
+ .command("add")
784
+ .argument("[type]", "hardware type, e.g. DISPLAY (interactive when omitted)")
785
+ .argument("[level]", "REQUIRED or OPTIONAL")
786
+ .description("Add a hardware requirement")
787
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
788
+ .action(async (type: string | undefined, level: string | undefined, options: { cwd: string }) => {
789
+ try {
790
+ process.chdir(resolve(options.cwd));
791
+ await addHardwareCmd(type, level);
792
+ } catch (error) {
793
+ fail(error);
794
+ }
795
+ });
796
+
797
+ hardware
798
+ .command("remove")
799
+ .argument("[type]", "hardware type (interactive when omitted)")
800
+ .description("Remove a declared hardware requirement")
801
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
802
+ .action(async (type: string | undefined, options: { cwd: string }) => {
803
+ try {
804
+ process.chdir(resolve(options.cwd));
805
+ await removeHardwareCmd(type);
806
+ } catch (error) {
807
+ fail(error);
808
+ }
809
+ });
810
+
811
+ const schema = program.command("schema").description("The miniapp.json JSON Schema");
812
+
813
+ schema
814
+ .command("print")
815
+ .description("Print the miniapp.json JSON Schema to stdout")
816
+ .action(() => {
817
+ schemaPrint();
818
+ });
819
+
820
+ schema
821
+ .command("regenerate")
822
+ .description("Rewrite @glassly/miniapp-cli's published schema file (package-internal)")
823
+ .action(() => {
824
+ regenerateSchemaFile();
825
+ });
826
+
827
+ program
828
+ .command("publish")
829
+ .description("Build, pack, and upload the current miniapp release bundle")
830
+ .option("--cwd <path>", "miniapp project directory", process.cwd())
831
+ .option("--no-build", "skip running bun run build before packing")
832
+ .option("--no-pack", "skip running bun run pack and upload the existing build zip")
833
+ .action(async (options: { cwd: string; build: boolean; pack: boolean }) => {
834
+ const creds = await requireCredentials();
835
+ if (!creds) return;
836
+
837
+ const cwd = resolve(options.cwd);
838
+ try {
839
+ const manifest = readManifest(cwd);
840
+ const packageName = stringField(manifest, "packageName");
841
+ const version = stringField(manifest, "version");
842
+ const name = stringField(manifest, "name") || packageName;
843
+ const description = typeof manifest.description === "string" ? manifest.description : null;
844
+
845
+ await ensureMiniappRecord(creds, { packageName, displayName: name, description });
846
+
847
+ if (options.pack) {
848
+ await packMiniapp({ cwd, build: options.build });
849
+ } else if (options.build) {
850
+ await buildMiniappProduction(cwd);
851
+ }
852
+
853
+ const zipPath = join(cwd, "build", `${packageName}-${version}.zip`);
854
+ if (!existsSync(zipPath)) {
855
+ throw new Error(`Release bundle not found: ${zipPath}`);
856
+ }
857
+ const bundle = readFileSync(zipPath);
858
+ const signingKey = await ensureSigningKey(creds);
859
+ const signedBundle = signBundleMetadata({
860
+ signingKey,
861
+ packageName,
862
+ version,
863
+ manifest,
864
+ bundle,
865
+ });
866
+ const storeMedia = collectStoreMedia(cwd);
867
+ const { release } = await createRelease(creds, {
868
+ packageName,
869
+ version,
870
+ manifest,
871
+ bundleBase64: bundle.toString("base64"),
872
+ fileName: basename(zipPath),
873
+ signedBundle,
874
+ media: storeMedia.length > 0 ? storeMedia : undefined,
875
+ });
876
+ const submitted = await submitRelease(creds, {
877
+ packageName,
878
+ releaseId: release.id,
879
+ });
880
+ const sizeKb = Math.round(statSync(zipPath).size / 1024);
881
+ console.log(`Published ${packageName}@${release.version}`);
882
+ console.log(`Release: ${submitted.release.status}`);
883
+ console.log(`Bundle: ${basename(zipPath)} (${sizeKb} KB)`);
884
+ console.log(`Signing key: ${signedBundle.signingKeyId}`);
885
+ if (release.bundleSha256) console.log(`SHA-256: ${release.bundleSha256}`);
886
+ if (storeMedia.length > 0) {
887
+ const icons = storeMedia.filter(item => item.role === "store_icon").length;
888
+ const shots = storeMedia.length - icons;
889
+ console.log(`Store media: ${icons ? "icon override, " : ""}${shots} screenshot${shots === 1 ? "" : "s"} from store/`);
890
+ }
891
+ } catch (error) {
892
+ fail(error);
893
+ }
894
+ });
895
+
896
+ program
897
+ .command("logout")
898
+ .description("Clear the current CLI login")
899
+ .action(async () => {
900
+ await clearCredentials(getConfig().coreUrl);
901
+ console.log("Logged out");
902
+ });
903
+
904
+ program.parse();
905
+
906
+ async function requireCredentials(): Promise<CliCredentials | null> {
907
+ const config = getConfig();
908
+ const creds = await loadFreshCredentials(config);
909
+ if (!creds) {
910
+ console.error("Not signed in. Run `glassly login`.");
911
+ process.exitCode = 1;
912
+ return null;
913
+ }
914
+ return creds;
915
+ }
916
+
917
+ async function loadFreshCredentials(config = getConfig()): Promise<CliCredentials | null> {
918
+ const creds = await loadCredentials(config.coreUrl);
919
+ if (!creds) return null;
920
+ if (!shouldRefresh(creds)) return creds;
921
+
922
+ if (!creds.refreshToken) {
923
+ console.error("Session expired. Run `glassly login`.");
924
+ process.exitCode = 1;
925
+ return null;
926
+ }
927
+
928
+ try {
929
+ const refreshed = await refreshLoginToken(config, creds.refreshToken, creds.organizationId);
930
+ const storedAt = new Date();
931
+ const expiresAt =
932
+ typeof refreshed.expires_in === "number"
933
+ ? new Date(storedAt.getTime() + refreshed.expires_in * 1000)
934
+ : expiresAtFromToken(refreshed.access_token)
935
+ ? new Date(expiresAtFromToken(refreshed.access_token)! * 1000)
936
+ : undefined;
937
+ const nextCredentials: CliCredentials = {
938
+ token: refreshed.access_token,
939
+ refreshToken: refreshed.refresh_token ?? creds.refreshToken,
940
+ workosUserId: refreshed.user.id,
941
+ email: refreshed.user.email,
942
+ organizationId: refreshed.organization_id,
943
+ authenticationMethod: refreshed.authentication_method ?? creds.authenticationMethod,
944
+ coreUrl: config.coreUrl,
945
+ storedAt: storedAt.toISOString(),
946
+ expiresAt: expiresAt?.toISOString(),
947
+ };
948
+ await saveCredentials(nextCredentials);
949
+ return nextCredentials;
950
+ } catch (error) {
951
+ console.error(error instanceof Error ? error.message : String(error));
952
+ process.exitCode = 1;
953
+ return null;
954
+ }
955
+ }
956
+
957
+ function shouldRefresh(creds: CliCredentials): boolean {
958
+ const expiresAtMs = creds.expiresAt ? Date.parse(creds.expiresAt) : expiresAtFromToken(creds.token) * 1000;
959
+ if (!Number.isFinite(expiresAtMs) || expiresAtMs <= 0) return false;
960
+ return expiresAtMs - Date.now() < 60_000;
961
+ }
962
+
963
+ function expiresAtFromToken(token: string): number {
964
+ try {
965
+ const payload = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8")) as { exp?: unknown };
966
+ return typeof payload.exp === "number" ? payload.exp : 0;
967
+ } catch {
968
+ return 0;
969
+ }
970
+ }
971
+
972
+ function readManifest(cwd: string): Record<string, unknown> {
973
+ const path = join(cwd, "miniapp.json");
974
+ if (!existsSync(path)) throw new Error(`miniapp.json not found in ${cwd}`);
975
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as unknown;
976
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
977
+ throw new Error("miniapp.json must be a JSON object");
978
+ }
979
+ return parsed as Record<string, unknown>;
980
+ }
981
+
982
+ const MEDIA_CONTENT_TYPES: Record<string, string> = {
983
+ ".png": "image/png",
984
+ ".jpg": "image/jpeg",
985
+ ".jpeg": "image/jpeg",
986
+ };
987
+
988
+ function parseMediaRole(value: string): MediaRole {
989
+ const normalized = value.trim().toLowerCase();
990
+ if (normalized === "store_icon" || normalized === "gallery_screenshot") return normalized;
991
+ throw new Error("role must be store_icon or gallery_screenshot");
992
+ }
993
+
994
+ function readMediaFile(path: string, role: MediaRole): MediaUpload {
995
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`);
996
+ const contentType = MEDIA_CONTENT_TYPES[extname(path).toLowerCase()];
997
+ if (!contentType) throw new Error(`${basename(path)}: only .png, .jpg and .jpeg files can be uploaded`);
998
+ return {
999
+ role,
1000
+ fileName: basename(path),
1001
+ contentType,
1002
+ dataBase64: readFileSync(path).toString("base64"),
1003
+ };
1004
+ }
1005
+
1006
+ /**
1007
+ * Store media next to miniapp.json, uploaded with the release:
1008
+ * store/icon.png optional override of the bundle icon
1009
+ * store/screenshots/*.png gallery, in file-name order
1010
+ */
1011
+ function collectStoreMedia(cwd: string): MediaUpload[] {
1012
+ const storeDir = join(cwd, "store");
1013
+ if (!existsSync(storeDir) || !statSync(storeDir).isDirectory()) return [];
1014
+ const out: MediaUpload[] = [];
1015
+ const icon = join(storeDir, "icon.png");
1016
+ if (existsSync(icon)) out.push(readMediaFile(icon, "store_icon"));
1017
+ const shotsDir = join(storeDir, "screenshots");
1018
+ if (existsSync(shotsDir) && statSync(shotsDir).isDirectory()) {
1019
+ const files = readdirSync(shotsDir)
1020
+ .filter(name => MEDIA_CONTENT_TYPES[extname(name).toLowerCase()] !== undefined)
1021
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }));
1022
+ if (files.length > 8) throw new Error(`store/screenshots has ${files.length} images; the store allows at most 8`);
1023
+ for (const name of files) out.push(readMediaFile(join(shotsDir, name), "gallery_screenshot"));
1024
+ }
1025
+ return out;
1026
+ }
1027
+
1028
+ function stringField(manifest: Record<string, unknown>, field: string): string {
1029
+ const value = manifest[field];
1030
+ if (typeof value !== "string" || !value.trim()) {
1031
+ throw new Error(`miniapp.json is missing string field "${field}"`);
1032
+ }
1033
+ return value.trim();
1034
+ }
1035
+
1036
+ async function ensureMiniappRecord(
1037
+ creds: CliCredentials,
1038
+ input: { packageName: string; displayName: string; description?: string | null },
1039
+ ): Promise<void> {
1040
+ try {
1041
+ await createApp(creds, input);
1042
+ return;
1043
+ } catch (error) {
1044
+ const message = error instanceof Error ? error.message : String(error);
1045
+ if (!message.toLowerCase().includes("package name is already claimed")) {
1046
+ throw error;
1047
+ }
1048
+ }
1049
+
1050
+ const { apps } = await listApps(creds);
1051
+ const existing = apps.find(app => app.packageName === input.packageName && app.status !== "archived");
1052
+ if (!existing) {
1053
+ throw new Error(`Package ${input.packageName} is already claimed by another developer org.`);
1054
+ }
1055
+ }
1056
+
1057
+ function fail(error: unknown): void {
1058
+ console.error(error instanceof Error ? error.message : String(error));
1059
+ process.exitCode = 1;
1060
+ }
1061
+
1062
+ function collect(value: string, previous: string[]): string[] {
1063
+ return [...previous, value];
1064
+ }
1065
+
1066
+ function parseEnvironment(value: string): PreinstallEnvironment {
1067
+ const normalized = value.trim().toLowerCase();
1068
+ if (["debug", "dev", "staging", "prod"].includes(normalized)) {
1069
+ return normalized as PreinstallEnvironment;
1070
+ }
1071
+ throw new Error("environment must be debug, dev, staging, or prod");
1072
+ }
1073
+
1074
+ function inferEnvironment(coreUrl: string): PreinstallEnvironment {
1075
+ const host = new URL(coreUrl).hostname;
1076
+ if (host.includes(".debug.")) return "debug";
1077
+ if (host.includes(".dev.")) return "dev";
1078
+ if (host.includes(".staging.")) return "staging";
1079
+ if (host === "localhost" || host === "127.0.0.1") return "dev";
1080
+ return "prod";
1081
+ }
1082
+
1083
+ /** The two ways a phone treats a preinstalled release. */
1084
+ function installModeLabel(policy: PreinstallPolicy): string {
1085
+ return policy === "mandatory" ? "mandatory" : "installed by default";
1086
+ }
1087
+
1088
+ function resolveReleaseRefs(refs: string[], releases: AdminReleaseSummary[]): AdminReleaseSummary[] {
1089
+ const selected = refs.map(ref => {
1090
+ const trimmed = ref.trim();
1091
+ const byId = releases.find(release => release.id === trimmed);
1092
+ if (byId) return byId;
1093
+
1094
+ const atIndex = trimmed.lastIndexOf("@");
1095
+ if (atIndex > 0) {
1096
+ const packageName = trimmed.slice(0, atIndex);
1097
+ const version = trimmed.slice(atIndex + 1);
1098
+ const matches = releases.filter(release => release.packageName === packageName && release.version === version);
1099
+ if (matches.length === 1) return matches[0];
1100
+ if (matches.length > 1) throw new Error(`release ref ${trimmed} matched more than one release`);
1101
+ }
1102
+
1103
+ throw new Error(`release not found or not publishable: ${trimmed}`);
1104
+ });
1105
+
1106
+ const seen = new Set<string>();
1107
+ const seenPackages = new Map<string, string>();
1108
+ for (const release of selected) {
1109
+ if (seen.has(release.id)) throw new Error(`duplicate release: ${release.id}`);
1110
+ seen.add(release.id);
1111
+
1112
+ const previous = seenPackages.get(release.packageName);
1113
+ if (previous) {
1114
+ throw new Error(
1115
+ `preinstall registry can only include one release per miniapp; ${release.packageName} selected as ${previous} and ${release.version}`,
1116
+ );
1117
+ }
1118
+ seenPackages.set(release.packageName, release.version);
1119
+ }
1120
+ return selected;
1121
+ }
1122
+
1123
+ async function activePreinstallReleaseIds(
1124
+ credentials: CliCredentials,
1125
+ environment: PreinstallEnvironment,
1126
+ ): Promise<Set<string>> {
1127
+ const { registries } = await listAdminRegistries(credentials);
1128
+ const registry = registries.find(item => item.environment === environment && item.name === "default");
1129
+ if (!registry?.activeRevisionId) return new Set();
1130
+ const { revisions } = await listAdminRegistryRevisions(credentials, registry.id);
1131
+ const active = revisions.find(revision => revision.id === registry.activeRevisionId);
1132
+ return new Set(active?.entries.map(entry => entry.releaseId) ?? []);
1133
+ }
1134
+
1135
+ interface AdminReleaseGroup {
1136
+ packageName: string;
1137
+ displayName: string;
1138
+ latest: AdminReleaseSummary;
1139
+ current: AdminReleaseSummary | null;
1140
+ releases: AdminReleaseSummary[];
1141
+ }
1142
+
1143
+ function groupAdminReleases(
1144
+ releases: AdminReleaseSummary[],
1145
+ activeReleaseIds: Set<string>,
1146
+ ): AdminReleaseGroup[] {
1147
+ const groups = new Map<string, AdminReleaseSummary[]>();
1148
+ for (const release of releases) {
1149
+ groups.set(release.packageName, [...(groups.get(release.packageName) ?? []), release]);
1150
+ }
1151
+
1152
+ return [...groups.values()].map(groupReleases => {
1153
+ const sorted = [...groupReleases].sort(compareReleaseRecency);
1154
+ return {
1155
+ packageName: sorted[0]!.packageName,
1156
+ displayName: sorted[0]!.displayName,
1157
+ latest: sorted[0]!,
1158
+ current: sorted.find(release => activeReleaseIds.has(release.id)) ?? null,
1159
+ releases: sorted,
1160
+ };
1161
+ }).sort((a, b) => {
1162
+ if (a.current && !b.current) return -1;
1163
+ if (!a.current && b.current) return 1;
1164
+ return a.packageName.localeCompare(b.packageName);
1165
+ });
1166
+ }
1167
+
1168
+ function compareReleaseRecency(a: AdminReleaseSummary, b: AdminReleaseSummary): number {
1169
+ const aTime = a.createdAt ? Date.parse(a.createdAt) : 0;
1170
+ const bTime = b.createdAt ? Date.parse(b.createdAt) : 0;
1171
+ if (aTime !== bTime) return bTime - aTime;
1172
+ return b.version.localeCompare(a.version, undefined, { numeric: true, sensitivity: "base" });
1173
+ }
1174
+
1175
+ function formatAdminReleaseGroup(group: AdminReleaseGroup): string {
1176
+ const marker = group.current ? "ACTIVE" : " ";
1177
+ const current = group.current?.version ?? "none";
1178
+ const latest = group.latest.version;
1179
+ const label = group.displayName && group.displayName !== group.packageName
1180
+ ? `${group.displayName} (${group.packageName})`
1181
+ : group.packageName;
1182
+ return `${marker} ${label}\tcurrent=${current}\tlatest=${latest}\tversions=${group.releases.length}`;
1183
+ }
1184
+
1185
+ function formatRegistry(registry: AdminRegistry): string {
1186
+ const active = registry.activeRevisionId ? `active=${registry.activeRevisionId}` : "no active revision";
1187
+ return `${registry.id}\t${registry.environment}\t${registry.name}\t${registry.status}\t${active}`;
1188
+ }
1189
+
1190
+ function commandNameForCoreUrl(coreUrl: string): string {
1191
+ const environment = inferEnvironment(coreUrl);
1192
+ const host = new URL(coreUrl).hostname;
1193
+ if (host === "localhost" || host === "127.0.0.1") return "glassly:local";
1194
+ if (environment === "prod") return "glassly:prod";
1195
+ return `glassly:${environment}`;
1196
+ }
1197
+
1198
+ function sleep(ms: number): Promise<void> {
1199
+ return new Promise((resolve) => setTimeout(resolve, ms));
1200
+ }