@glassly/cli 2.0.0-dev.0 → 2.0.0-dev.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # @glassly/cli
2
2
 
3
- The Glassly developer CLI — the `glassly` command. Build, publish, and manage
4
- Glassly miniapps against the Cloud V2 developer console.
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.
5
6
 
6
- It wraps [`@glassly/miniapp-cli`](https://www.npmjs.com/package/@glassly/miniapp-cli)
7
- (the `dev` / `build` / `pack` author flow) and adds account and store operations:
8
- `login`, `whoami`, `org`, `miniapps`, `releases`, and `publish`.
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.
9
13
 
10
14
  > **Bun-only.** This CLI ships as TypeScript and runs under [Bun](https://bun.sh)
11
15
  > (`#!/usr/bin/env bun`). Use `bun` / `bunx`, not `npx`/Node.
@@ -13,26 +17,48 @@ It wraps [`@glassly/miniapp-cli`](https://www.npmjs.com/package/@glassly/miniapp
13
17
  ## Install
14
18
 
15
19
  ```bash
16
- bun add -g @glassly/cli@dev
20
+ bun add -g @glassly/cli
17
21
  glassly --help
18
22
  ```
19
23
 
20
24
  Or run without installing:
21
25
 
22
26
  ```bash
23
- bunx @glassly/cli@dev --help
27
+ bunx @glassly/cli --help
24
28
  ```
25
29
 
26
- ## Common commands
30
+ ## Develop and test on a phone
27
31
 
28
32
  ```bash
29
- glassly login # sign in to the Glassly Developer Console
30
- glassly dev # local dev server with a signed Cloud V2 identity
31
- glassly build # build the current miniapp
32
- glassly pack # zip dist/ into a submittable release
33
- glassly publish # upload + publish a release
34
- glassly miniapps list # miniapps owned by your org
35
- glassly releases submit # submit an uploaded release for review
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
36
55
  ```
37
56
 
38
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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@glassly/cli",
3
- "version": "2.0.0-dev.0",
4
- "description": "Glassly developer CLI — build, publish, and manage Glassly miniapps (Cloud V2). Bun-only.",
3
+ "version": "2.0.0-dev.2",
4
+ "description": "The glassly command: develop, pack, release, and publish Glassly miniapps. Bun-only.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "glassly": "./src/index.ts"
@@ -21,7 +21,7 @@
21
21
  "typecheck": "tsc --noEmit"
22
22
  },
23
23
  "dependencies": {
24
- "@glassly/miniapp-cli": "0.1.0-dev.1",
24
+ "@glassly/miniapp-cli": "0.1.0-dev.2",
25
25
  "commander": "^14.0.2",
26
26
  "qrcode": "^1.5.4"
27
27
  },
package/src/api.ts CHANGED
@@ -73,6 +73,37 @@ export interface DeveloperRelease {
73
73
  updatedAt: string | null;
74
74
  }
75
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
+
76
107
  export interface DeveloperOrg {
77
108
  id: string;
78
109
  ownerUserId: string;
@@ -322,6 +353,40 @@ export async function listReleases(
322
353
  return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(packageName)}/releases`);
323
354
  }
324
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
+
325
390
  export async function createRelease(
326
391
  credentials: CliCredentials,
327
392
  input: {
@@ -331,6 +396,7 @@ export async function createRelease(
331
396
  bundleBase64: string;
332
397
  fileName?: string;
333
398
  signedBundle: SignedBundleMetadata;
399
+ media?: MediaUpload[];
334
400
  },
335
401
  ): Promise<{ release: DeveloperRelease }> {
336
402
  return coreRequest(credentials, `/api/console/apps/${encodeURIComponent(input.packageName)}/releases`, {
@@ -388,7 +454,7 @@ async function coreRequest<T>(
388
454
  function assertWorkosClientId(config: CliConfig): void {
389
455
  if (!config.workosClientId) {
390
456
  throw new Error(
391
- "WORKOS_CLIENT_ID is not configured. Add the public AuthKit client id to Doppler cloud-v2/dev as WORKOS_CLIENT_ID.",
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.",
392
458
  );
393
459
  }
394
460
  }
package/src/config.ts CHANGED
@@ -5,12 +5,22 @@ export interface CliConfig {
5
5
  workosApiBaseUrl: string;
6
6
  }
7
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
+
8
19
  export function getConfig(): CliConfig {
9
- const workosClientId = process.env.GLASSLY_WORKOS_CLIENT_ID || process.env.WORKOS_CLIENT_ID || "";
10
20
  return {
11
- coreUrl: normalizeUrl(process.env.GLASSLY_CORE_URL || "http://localhost:3000"),
12
- consoleUrl: normalizeUrl(process.env.GLASSLY_CONSOLE_URL || "http://localhost:5173"),
13
- workosClientId,
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,
14
24
  workosApiBaseUrl: normalizeUrl(process.env.WORKOS_API_BASE_URL || "https://api.workos.com"),
15
25
  };
16
26
  }
@@ -4,8 +4,8 @@ import { join } from "node:path";
4
4
 
5
5
  const SERVICE = "glassly-cli-v2";
6
6
  const LEGACY_NAME = "credentials";
7
- const GLASSLY_DIR = join(homedir(), ".glassly");
8
- const CREDS_DIR = join(GLASSLY_DIR, "cli-v2");
7
+ const CONFIG_DIR = join(homedir(), ".glassly");
8
+ const CREDS_DIR = join(CONFIG_DIR, "cli-v2");
9
9
  const LEGACY_CREDS_FILE = join(CREDS_DIR, "credentials.json");
10
10
  const SIGNING_KEY_SERVICE = "glassly-cli-v2-signing";
11
11
 
@@ -60,13 +60,13 @@ export async function saveCredentials(credentials: CliCredentials): Promise<"key
60
60
 
61
61
  export async function loadCredentials(coreUrl?: string): Promise<CliCredentials | null> {
62
62
  const targetCoreUrl = coreUrl ? normalizeCoreUrl(coreUrl) : undefined;
63
- if (process.env.GLASSLY_CLI_TOKEN) {
63
+ if (process.env.CLI_TOKEN) {
64
64
  return {
65
- token: process.env.GLASSLY_CLI_TOKEN,
66
- workosUserId: process.env.GLASSLY_CLI_WORKOS_USER_ID || "unknown",
67
- email: process.env.GLASSLY_CLI_EMAIL || "unknown",
68
- organizationId: process.env.GLASSLY_CLI_ORGANIZATION_ID,
69
- coreUrl: targetCoreUrl || process.env.GLASSLY_CORE_URL || "http://localhost:3000",
65
+ token: process.env.CLI_TOKEN,
66
+ workosUserId: process.env.CLI_WORKOS_USER_ID || "unknown",
67
+ email: process.env.CLI_EMAIL || "unknown",
68
+ organizationId: process.env.CLI_ORGANIZATION_ID,
69
+ coreUrl: targetCoreUrl || process.env.CLOUD_CORE_URL || "http://localhost:3000",
70
70
  storedAt: new Date().toISOString(),
71
71
  };
72
72
  }
package/src/index.ts CHANGED
@@ -1,14 +1,31 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { Command } from "commander";
4
- import { buildProduction as buildMiniappProduction, dev as devMiniapp, pack as packMiniapp } from "@glassly/miniapp-cli";
5
- import { existsSync, readFileSync, statSync } from "node:fs";
6
- import { basename, join, resolve } from "node:path";
7
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,
8
24
  createAdminRegistryRevision,
9
25
  createApp,
10
26
  createRelease,
11
27
  deleteApp,
28
+ deleteMedia,
12
29
  ensureAdminRegistry,
13
30
  getAdminMe,
14
31
  getOrg,
@@ -16,15 +33,19 @@ import {
16
33
  listAdminRegistries,
17
34
  listAdminRegistryRevisions,
18
35
  listApps,
36
+ listMedia,
19
37
  listReleases,
20
38
  promoteAdminRegistryRevision,
21
39
  pollLoginToken,
22
40
  refreshLoginToken,
23
41
  startLogin,
24
42
  submitRelease,
43
+ updateListing,
25
44
  upsertOrg,
26
45
  type AdminReleaseSummary,
27
46
  type AdminRegistry,
47
+ type MediaRole,
48
+ type MediaUpload,
28
49
  type PreinstallEnvironment,
29
50
  type PreinstallPolicy,
30
51
  } from "./api";
@@ -33,12 +54,29 @@ import { clearCredentials, loadCredentials, saveCredentials, type CliCredentials
33
54
  import { openBrowser } from "./open-browser";
34
55
  import { encodeDevAttestation, ensureSigningKey, signBundleMetadata, signDevAttestation } from "./signing";
35
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
+
36
74
  const program = new Command();
37
75
 
38
76
  program
39
77
  .name("glassly")
40
78
  .description("Glassly developer CLI")
41
- .version("2.0.0-alpha.0");
79
+ .version(cliVersion);
42
80
 
43
81
  program
44
82
  .command("login")
@@ -283,6 +321,103 @@ releases
283
321
  }
284
322
  });
285
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
+
286
421
  const admin = program.command("admin").description("Internal Glassly admin operations");
287
422
 
288
423
  admin
@@ -383,7 +518,7 @@ preinstall
383
518
  for (const entry of active.entries) {
384
519
  const release = byId.get(entry.releaseId);
385
520
  const label = release ? `${release.packageName}@${release.version}` : entry.releaseId;
386
- console.log(`- ${label}\t${entry.installPolicy}\trequired=${entry.required}`);
521
+ console.log(`- ${label}\t${installModeLabel(entry.installPolicy)}`);
387
522
  }
388
523
  } catch (error) {
389
524
  fail(error);
@@ -417,7 +552,7 @@ preinstall
417
552
  if (active) {
418
553
  console.log(` active revision: ${active.id}`);
419
554
  for (const entry of active.entries) {
420
- console.log(` - ${entry.releaseId}\t${entry.installPolicy}\trequired=${entry.required}`);
555
+ console.log(` - ${entry.releaseId}\t${installModeLabel(entry.installPolicy)}`);
421
556
  }
422
557
  }
423
558
  }
@@ -430,17 +565,15 @@ preinstall
430
565
  preinstall
431
566
  .command("publish")
432
567
  .description("Replace the active preinstalled miniapp list with selected releases")
433
- .requiredOption("-r, --release <release>", "release id or package@version; repeat for multiple releases", collect, [])
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, [])
434
570
  .option("-e, --environment <environment>", "target registry environment")
435
- .option("--policy <policy>", "install policy for all entries", "keep_updated")
436
- .option("--required", "mark entries required")
437
571
  .option("--reason <reason>", "audit reason")
438
572
  .option("--yes", "confirm prod registry replacement")
439
573
  .action(async (options: {
440
574
  release: string[];
575
+ mandatory: string[];
441
576
  environment?: string;
442
- policy: string;
443
- required?: boolean;
444
577
  reason?: string;
445
578
  yes?: boolean;
446
579
  }) => {
@@ -451,20 +584,27 @@ preinstall
451
584
  const environment = options.environment
452
585
  ? parseEnvironment(options.environment)
453
586
  : inferEnvironment(creds.coreUrl);
454
- const installPolicy = parsePolicy(options.policy);
455
587
  if (environment === "prod" && !options.yes) {
456
588
  throw new Error("prod preinstall publish requires --yes");
457
589
  }
458
590
 
591
+ if (options.release.length + options.mandatory.length === 0) {
592
+ throw new Error("give at least one --release or --mandatory");
593
+ }
594
+
459
595
  const { releases: releaseList } = await listAdminPreinstallReleases(creds);
460
- const selected = resolveReleaseRefs(options.release, releaseList);
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";
461
601
  const { registry } = await ensureAdminRegistry(creds, { environment });
462
602
  const { revision } = await createAdminRegistryRevision(creds, registry.id, {
463
603
  reason: options.reason ?? `Admin preinstall registry publish for ${environment}`,
464
604
  entries: selected.map((release, index) => ({
465
605
  releaseId: release.id,
466
- required: Boolean(options.required),
467
- installPolicy,
606
+ required: true,
607
+ installPolicy: policyOf(release.id),
468
608
  priority: index,
469
609
  })),
470
610
  });
@@ -474,7 +614,7 @@ preinstall
474
614
  console.log(`Registry: ${promoted.registry.id}`);
475
615
  console.log(`Revision: ${promoted.revision.id}`);
476
616
  for (const release of selected) {
477
- console.log(`- ${release.packageName}@${release.version}\t${release.id}`);
617
+ console.log(`- ${release.packageName}@${release.version}\t${release.id}\t${installModeLabel(policyOf(release.id))}`);
478
618
  }
479
619
  } catch (error) {
480
620
  fail(error);
@@ -486,9 +626,12 @@ program
486
626
  .description("Start the local miniapp dev server with signed Cloud V2 identity when logged in")
487
627
  .option("--cwd <path>", "miniapp project directory", process.cwd())
488
628
  .option("--auth", "require signed dev auto-auth setup before starting")
489
- .action(async (options: { cwd: string; auth?: boolean }) => {
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 }) => {
490
632
  const cwd = resolve(options.cwd);
491
633
  try {
634
+ const qrSize = parseQrSize(options.qr);
492
635
  const manifest = readManifest(cwd);
493
636
  const packageName = stringField(manifest, "packageName");
494
637
  const name = stringField(manifest, "name") || packageName;
@@ -514,7 +657,7 @@ program
514
657
  console.log("Dev auto-auth disabled. Run `glassly login` if this miniapp uses session.auth.");
515
658
  }
516
659
 
517
- await devMiniapp({ cwd, signDevAttestation: signer });
660
+ await devMiniapp({ cwd, qrSize, qrOutput: options.qrOutput, signDevAttestation: signer });
518
661
  } catch (error) {
519
662
  fail(error);
520
663
  }
@@ -545,6 +688,142 @@ program
545
688
  }
546
689
  });
547
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
+
548
827
  program
549
828
  .command("publish")
550
829
  .description("Build, pack, and upload the current miniapp release bundle")
@@ -584,6 +863,7 @@ program
584
863
  manifest,
585
864
  bundle,
586
865
  });
866
+ const storeMedia = collectStoreMedia(cwd);
587
867
  const { release } = await createRelease(creds, {
588
868
  packageName,
589
869
  version,
@@ -591,6 +871,7 @@ program
591
871
  bundleBase64: bundle.toString("base64"),
592
872
  fileName: basename(zipPath),
593
873
  signedBundle,
874
+ media: storeMedia.length > 0 ? storeMedia : undefined,
594
875
  });
595
876
  const submitted = await submitRelease(creds, {
596
877
  packageName,
@@ -602,6 +883,11 @@ program
602
883
  console.log(`Bundle: ${basename(zipPath)} (${sizeKb} KB)`);
603
884
  console.log(`Signing key: ${signedBundle.signingKeyId}`);
604
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
+ }
605
891
  } catch (error) {
606
892
  fail(error);
607
893
  }
@@ -693,6 +979,52 @@ function readManifest(cwd: string): Record<string, unknown> {
693
979
  return parsed as Record<string, unknown>;
694
980
  }
695
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
+
696
1028
  function stringField(manifest: Record<string, unknown>, field: string): string {
697
1029
  const value = manifest[field];
698
1030
  if (typeof value !== "string" || !value.trim()) {
@@ -748,12 +1080,9 @@ function inferEnvironment(coreUrl: string): PreinstallEnvironment {
748
1080
  return "prod";
749
1081
  }
750
1082
 
751
- function parsePolicy(value: string): PreinstallPolicy {
752
- const normalized = value.trim().toLowerCase();
753
- if (["install_once", "keep_updated", "mandatory"].includes(normalized)) {
754
- return normalized as PreinstallPolicy;
755
- }
756
- throw new Error("policy must be install_once, keep_updated, or mandatory");
1083
+ /** The two ways a phone treats a preinstalled release. */
1084
+ function installModeLabel(policy: PreinstallPolicy): string {
1085
+ return policy === "mandatory" ? "mandatory" : "installed by default";
757
1086
  }
758
1087
 
759
1088
  function resolveReleaseRefs(refs: string[], releases: AdminReleaseSummary[]): AdminReleaseSummary[] {