@stackframe/stack-cli 2.8.86 → 2.8.88

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/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
3
  import * as fs from "fs";
4
- import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs";
4
+ import { chmodSync, createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
5
5
  import { fileURLToPath } from "url";
6
6
  import * as path from "path";
7
7
  import { dirname, join, resolve } from "path";
@@ -11,9 +11,14 @@ import { homedir } from "os";
11
11
  import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
12
12
  import { checkbox, confirm, input, select } from "@inquirer/prompts";
13
13
  import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
14
+ import { createInitPrompt } from "@stackframe/stack-shared/dist/helpers/init-prompt";
14
15
  import { query } from "@anthropic-ai/claude-agent-sdk";
15
- import * as readline from "readline";
16
- import { execFileSync, spawn } from "child_process";
16
+ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
17
+ import { execFileSync, execSync, spawn } from "child_process";
18
+ import extract from "extract-zip";
19
+ import { createInterface } from "readline";
20
+ import { Readable } from "stream";
21
+ import { pipeline } from "stream/promises";
17
22
 
18
23
  //#region src/lib/errors.ts
19
24
  var CliError = class extends Error {
@@ -208,11 +213,16 @@ function registerExecCommand(program) {
208
213
 
209
214
  //#endregion
210
215
  //#region src/commands/config-file.ts
216
+ const SHOW_ONBOARDING_STACK_CONFIG_VALUE = "show-onboarding";
211
217
  function isConfigOverride(value) {
212
218
  if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
213
219
  const prototype = Object.getPrototypeOf(value);
214
220
  return prototype === Object.prototype || prototype === null;
215
221
  }
222
+ function parseConfigOverride(value) {
223
+ if (value === SHOW_ONBOARDING_STACK_CONFIG_VALUE) return {};
224
+ return isConfigOverride(value) ? value : null;
225
+ }
216
226
  function parseGitHubRepository() {
217
227
  const repository = process.env.GITHUB_REPOSITORY;
218
228
  if (!repository) return null;
@@ -288,8 +298,8 @@ function registerConfigCommand(program) {
288
298
  if (ext !== ".js" && ext !== ".ts") throw new CliError("Config file must have a .js or .ts extension.");
289
299
  if (!fs.existsSync(filePath)) throw new CliError(`Config file not found: ${filePath}`);
290
300
  const { createJiti } = await import("jiti");
291
- const config = (await createJiti(import.meta.url).import(filePath)).config;
292
- if (!isConfigOverride(config)) throw new CliError(`Config file must export a plain \`config\` object. Example: import type { StackConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@stackframe/js"}"; export const config: StackConfig = { ... };`);
301
+ const config = parseConfigOverride((await createJiti(import.meta.url).import(filePath)).config);
302
+ if (config == null) throw new CliError(`Config file must export a plain \`config\` object or "show-onboarding". Example: import type { StackConfig } from "${detectImportPackageFromDir(path.dirname(filePath)) ?? "@stackframe/js"}"; export const config: StackConfig = { ... };`);
293
303
  const source = buildConfigPushSource(opts.configFile);
294
304
  if (isProjectAuthWithSecretServerKey(auth)) await pushConfigWithSecretServerKey(auth, config, source);
295
305
  else {
@@ -307,131 +317,24 @@ function isNonInteractiveEnv() {
307
317
  }
308
318
 
309
319
  //#endregion
310
- //#region src/lib/init-prompt.ts
311
- const createInitPrompt = (web, configPath) => `=============================
312
- STACK AUTH SETUP INSTRUCTIONS
313
- =============================
314
-
315
- These instructions describe how to set up Stack Auth.
316
- ${web ? `
317
- First of all, if you have access to a terminal, it is better to use the \`npx @stackframe/stack-cli init\`` : ""}
318
-
319
- Install mcp server from https://mcp.stack-auth.com if not already installed
320
-
321
- For reference, questions, or information on Stack Auth, you can query the docs on https://docs.stack-auth.com via curl or any tools that you have
322
-
323
- ## Setup
324
-
325
- ### 1) Install the package
326
-
327
- Run the install command using whatever package manager the project uses (npm, yarn, pnpm, bun):
328
-
329
- | Framework | Package |
330
- |-----------|---------|
331
- | Next.js | \`@stackframe/stack\` |
332
- | React | \`@stackframe/react\` |
333
- | Vanilla JS | \`@stackframe/js\` |
334
-
335
- ### 2) Create the Stack apps
336
-
337
- Depending on whether you're on a client or a server, you will want to create stackClientApp or stackServerApp. Some environments, like Next.js, have both, so create both files.
338
-
339
- The stack client app has client-level permissions. It contains most of the useful methods and hooks for your client-side code.
340
- The stack server app has full read and write access to all users. It requires STACK_SECRET_SERVER_KEY env variable and should only be used in secure context
341
-
342
- In Next.js, env vars are auto-detected (NEXT_PUBLIC_STACK_PROJECT_ID etc.), so the constructor needs no explicit config. For other frameworks, you must pass projectId explicitly using the framework's env var access method. Pass publishableClientKey only if your project is configured to require publishable client keys.
343
-
344
- The tokenStore should be "nextjs-cookie" for Next.js, or "cookie" for all other frameworks.
345
-
346
- Make sure to set redirectMethod on non next.js frameworks. For example for tanstack router import like so:
347
- import { useNavigate } from '@tanstack/react-router'
348
-
349
- \`\`\`ts
350
- // src/stack/client.ts
351
- import { StackClientApp } from "@stackframe/stack"; // or "@stackframe/react" or "@stackframe/js"
352
-
353
- export const stackClientApp = new StackClientApp({
354
- // Next.js: omit projectId/publishableClientKey (auto-detected from NEXT_PUBLIC_ env vars)
355
- // Other frameworks: pass projectId explicitly, and publishableClientKey only if required by your project. For Vite:
356
- // projectId: import.meta.env.VITE_STACK_PROJECT_ID,
357
- // publishableClientKey: import.meta.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY,
358
- tokenStore: "nextjs-cookie", // or "cookie" for non-Next.js,
359
- // redirectMethod: { useNavigate } // or "window"
360
- });
361
- \`\`\`
362
-
363
- If the framework has server-side support (e.g. Next.js), also create a server app:
364
-
365
- \`\`\`ts
366
- // src/stack/server.ts
367
- import "server-only";
368
- import { StackServerApp } from "@stackframe/stack";
369
- import { stackClientApp } from "./client";
370
-
371
- export const stackServerApp = new StackServerApp({
372
- inheritsFrom: stackClientApp,
373
- });
374
- \`\`\`
375
-
376
- ### 3) Create the Stack handler (if available in framework)
377
-
378
- This sets up pages for sign in, sign up, password reset, etc.
379
-
380
- \`\`\`tsx
381
- import { StackHandler } from "@stackframe/stack"; // Next.js
382
- // import { StackHandler } from "@stackframe/react"; // React
383
-
384
- export default function Handler() {
385
- return <StackHandler fullPage />;
386
- }
387
- \`\`\`
388
-
389
- ### 4) Create a Suspense boundary
390
-
391
- Suspense is necessary for many stack auth hooks such as useUser to function. Add a loading component with a custom loading indicator for the current project. Don't add if one already exists
392
-
393
- For example:
394
- \`\`\`tsx
395
- //src/loading.tsx
396
-
397
- export default function Loading() {
398
- return <p>Loading...</p>
320
+ //#region src/lib/create-project.ts
321
+ async function createProjectInteractively(user, opts = {}) {
322
+ let displayName = opts.displayName;
323
+ if (!displayName) {
324
+ if (isNonInteractiveEnv()) throw new CliError("--display-name is required in non-interactive environments (CI).");
325
+ displayName = await input({
326
+ message: "Project display name:",
327
+ default: opts.defaultDisplayName,
328
+ validate: (v) => v.trim().length > 0 || "Display name cannot be empty."
329
+ });
330
+ }
331
+ const teams = await user.listTeams();
332
+ if (teams.length === 0) throw new CliError("No teams found on your account. Create a team at app.stack-auth.com first.");
333
+ return await user.createProject({
334
+ displayName: displayName.trim(),
335
+ teamId: teams[0].id
336
+ });
399
337
  }
400
- \`\`\`
401
-
402
- ### 5) Link environment variables
403
-
404
- This is only necessary if not using local emulator. Ensure these are ignored by git.
405
-
406
- Rename the env var keys in .env to match the framework's convention for client-exposed variables. For example, Vite requires VITE_ prefix, Next.js uses NEXT_PUBLIC_, etc. The values should stay the same — only rename the keys.
407
-
408
- The required variables are:
409
- - Project ID (e.g. NEXT_PUBLIC_STACK_PROJECT_ID, VITE_STACK_PROJECT_ID, etc.)
410
- - Secret server key: STACK_SECRET_SERVER_KEY (only for frameworks with server-side support, no prefix needed)
411
-
412
- The publishable client key (e.g. NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, VITE_STACK_PUBLISHABLE_CLIENT_KEY, etc.) is only required if your project has publishable client keys enabled as a requirement.
413
-
414
- ### 6) React only: Wrap the entire page in a Stack provider
415
-
416
- This is used for the useUser and useStackApp hooks.
417
-
418
- \`\`\`tsx
419
- import { StackProvider, StackTheme } from "@stackframe/stack";
420
- import { stackClientApp } from "../stack/client"; // adjust relative path
421
- \`\`\`
422
-
423
- Then wrap the body content:
424
-
425
- \`\`\`tsx
426
- return (
427
- <body>
428
- <StackProvider app={stackClientApp}>
429
- <StackTheme>{children}</StackTheme>
430
- </StackProvider>
431
- </body>
432
- );
433
- \`\`\`
434
- `;
435
338
 
436
339
  //#endregion
437
340
  //#region src/lib/claude-agent.ts
@@ -589,7 +492,7 @@ async function runClaudeAgent(options) {
589
492
  //#endregion
590
493
  //#region src/commands/init.ts
591
494
  function registerInitCommand(program) {
592
- program.command("init").description("Initialize Stack Auth in your project").option("--mode <mode>", "Mode: create, link-config, or link-cloud (skips interactive prompts)").option("--apps <apps>", "Comma-separated app IDs to enable (for create mode)").option("--config-file <path>", "Path to existing config file (for link-config mode)").option("--select-project-id <id>", "Project ID to link (for link-cloud mode)").option("--output-dir <dir>", "Directory to write output files (defaults to cwd)").option("--no-agent", "Skip Claude agent and print setup instructions instead").action(async (opts) => {
495
+ program.command("init").description("Initialize Stack Auth in your project").option("--mode <mode>", "Mode: create, create-cloud, link-config, or link-cloud (skips interactive prompts)").option("--apps <apps>", "Comma-separated app IDs to enable (for create mode)").option("--config-file <path>", "Path to existing config file (for link-config mode)").option("--select-project-id <id>", "Project ID to link (for link-cloud mode)").option("--output-dir <dir>", "Directory to write output files (defaults to cwd)").option("--no-agent", "Skip Claude agent and print setup instructions instead").action(async (opts) => {
593
496
  if (!(opts.mode != null) && isNonInteractiveEnv()) throw new CliError("stack init requires an interactive terminal. Use --mode flag for non-interactive usage.");
594
497
  try {
595
498
  await runInit(program, opts);
@@ -602,15 +505,71 @@ function registerInitCommand(program) {
602
505
  }
603
506
  });
604
507
  }
508
+ function validateOptions(opts) {
509
+ if (opts.selectProjectId && opts.configFile) throw new CliError("--select-project-id and --config-file cannot be used together.");
510
+ const incompatible = {
511
+ "create": ["selectProjectId", "configFile"],
512
+ "create-cloud": [
513
+ "selectProjectId",
514
+ "configFile",
515
+ "apps"
516
+ ],
517
+ "link-config": ["selectProjectId", "apps"],
518
+ "link-cloud": ["configFile", "apps"]
519
+ };
520
+ const flagNames = {
521
+ selectProjectId: "--select-project-id",
522
+ configFile: "--config-file",
523
+ apps: "--apps"
524
+ };
525
+ if (opts.mode) {
526
+ for (const key of incompatible[opts.mode]) if (opts[key] != null) throw new CliError(`${flagNames[key]} cannot be used with --mode ${opts.mode}.`);
527
+ }
528
+ }
605
529
  async function runInit(program, opts) {
606
530
  const flags = program.opts();
607
531
  const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
532
+ if (!fs.existsSync(outputDir)) throw new CliError(`Output directory does not exist: ${outputDir}`);
533
+ validateOptions(opts);
608
534
  console.log("Welcome to Stack Auth!\n");
609
- const mode = "link";
535
+ let mode;
536
+ if (opts.mode) mode = opts.mode;
537
+ else if (opts.selectProjectId) mode = "link-cloud";
538
+ else if (opts.configFile) mode = "link-config";
539
+ else if (await select({
540
+ message: "Would you like to link to an existing project, or create a new one?",
541
+ choices: [{
542
+ name: "Create a new project",
543
+ value: "create"
544
+ }, {
545
+ name: "Link an existing project",
546
+ value: "link"
547
+ }]
548
+ }) === "link") mode = "link";
549
+ else mode = await select({
550
+ message: "Where would you like to create the project?",
551
+ choices: [{
552
+ name: "Stack Auth Cloud",
553
+ value: "hosted"
554
+ }, {
555
+ name: "Local (requires local emulator installation, ~1.3gb storage required)",
556
+ value: "local"
557
+ }]
558
+ }) === "local" ? "create" : "create-cloud";
610
559
  let configPath;
611
- if (mode === "link" || mode === "link-config" || mode === "link-cloud") configPath = (await handleLink(flags, opts, outputDir)).configPath;
612
- else if (mode === "create") configPath = (await handleCreate(opts, outputDir)).configPath;
613
- else throw new CliError(`Unknown mode: ${mode}`);
560
+ switch (mode) {
561
+ case "link":
562
+ case "link-config":
563
+ case "link-cloud":
564
+ configPath = (await handleLink(flags, opts, outputDir, mode)).configPath;
565
+ break;
566
+ case "create":
567
+ configPath = (await handleCreate(opts, outputDir)).configPath;
568
+ break;
569
+ case "create-cloud":
570
+ configPath = (await handleCreateCloud(flags, opts, outputDir)).configPath;
571
+ break;
572
+ }
614
573
  const initPrompt = createInitPrompt(false, configPath);
615
574
  if (opts.agent !== false && !isNonInteractiveEnv()) {
616
575
  if (!await runClaudeAgent({
@@ -622,11 +581,20 @@ async function runInit(program, opts) {
622
581
  }
623
582
  } else console.log("\n" + initPrompt);
624
583
  }
625
- async function handleLink(flags, opts, outputDir) {
584
+ async function handleLink(flags, opts, outputDir, resolvedMode) {
626
585
  let source;
627
- if (opts.mode === "link-config") source = "config-file";
628
- else if (opts.mode === "link-cloud") source = "cloud";
629
- else source = "cloud";
586
+ if (resolvedMode === "link-config") source = "config-file";
587
+ else if (resolvedMode === "link-cloud") source = "cloud";
588
+ else source = await select({
589
+ message: "How would you like to link your project?",
590
+ choices: [{
591
+ name: "Link from config file",
592
+ value: "config-file"
593
+ }, {
594
+ name: "Link from app.stack-auth.com",
595
+ value: "cloud"
596
+ }]
597
+ });
630
598
  if (source === "config-file") return await handleLinkFromConfigFile(opts);
631
599
  return await handleLinkFromCloud(flags, opts, outputDir);
632
600
  }
@@ -644,43 +612,34 @@ async function handleLinkFromConfigFile(opts) {
644
612
  console.log(`\nLinked to config file: ${configPath}`);
645
613
  return { configPath };
646
614
  }
647
- async function handleLinkFromCloud(flags, opts, outputDir) {
648
- let sessionAuth;
615
+ async function ensureLoggedInSession(flags) {
649
616
  try {
650
- sessionAuth = resolveSessionAuth(flags);
617
+ return resolveSessionAuth(flags);
651
618
  } catch (e) {
652
619
  if (e instanceof AuthError) {
653
620
  if (isNonInteractiveEnv()) throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN.");
654
621
  console.log("You need to log in first.\n");
655
622
  await performLogin(flags);
656
- sessionAuth = resolveSessionAuth(flags);
657
- } else throw e;
623
+ return resolveSessionAuth(flags);
624
+ }
625
+ throw e;
658
626
  }
659
- const projects = await (await getInternalUser(sessionAuth)).listOwnedProjects();
660
- if (projects.length === 0) throw new CliError("You don't own any projects. Create one at app.stack-auth.com first.");
661
- let projectId;
662
- if (opts.selectProjectId) {
663
- if (!projects.find((p) => p.id === opts.selectProjectId)) throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`);
664
- projectId = opts.selectProjectId;
665
- } else projectId = await select({
666
- message: "Select a project:",
667
- choices: projects.map((p) => ({
668
- name: `${p.displayName} (${p.id})`,
669
- value: p.id
670
- }))
671
- });
672
- const apiKey = await projects.find((p) => p.id === projectId).app.createInternalApiKey({
627
+ }
628
+ async function writeProjectKeysToEnv(project, outputDir) {
629
+ const apiKey = await project.app.createInternalApiKey({
673
630
  description: "Created by CLI init script",
674
631
  expiresAt: new Date(Date.now() + 1e3 * 60 * 60 * 24 * 365 * 200),
675
632
  hasPublishableClientKey: true,
676
633
  hasSecretServerKey: true,
677
634
  hasSuperSecretAdminKey: false
678
635
  });
636
+ const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
637
+ const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true");
679
638
  const envLines = [
680
639
  "# Stack Auth",
681
- `NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`,
682
- `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`,
683
- `STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`
640
+ `NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`,
641
+ `NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`,
642
+ `STACK_SECRET_SERVER_KEY=${secretServerKey}`
684
643
  ].join("\n");
685
644
  const envPath = path.resolve(outputDir, ".env");
686
645
  if (fs.existsSync(envPath)) {
@@ -702,6 +661,41 @@ async function handleLinkFromCloud(flags, opts, outputDir) {
702
661
  fs.writeFileSync(envPath, envLines + "\n");
703
662
  console.log("\nCreated .env with Stack Auth keys");
704
663
  }
664
+ }
665
+ async function handleCreateCloud(flags, opts, outputDir) {
666
+ const newProject = await createProjectInteractively(await getInternalUser(await ensureLoggedInSession(flags)), { defaultDisplayName: path.basename(outputDir) });
667
+ console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
668
+ await writeProjectKeysToEnv(newProject, outputDir);
669
+ return {};
670
+ }
671
+ async function handleLinkFromCloud(flags, opts, outputDir) {
672
+ const user = await getInternalUser(await ensureLoggedInSession(flags));
673
+ let projects = await user.listOwnedProjects();
674
+ let autoCreatedProjectId = null;
675
+ if (projects.length === 0) {
676
+ if (isNonInteractiveEnv()) throw new CliError("No projects found. Run `stack project create --display-name <name>` first, or set --select-project-id.");
677
+ if (!await confirm({
678
+ message: "You don't have any Stack Auth projects yet. Would you like to create one?",
679
+ default: true
680
+ })) throw new CliError("You don't own any projects. Create one at app.stack-auth.com or re-run and choose to create one.");
681
+ const newProject = await createProjectInteractively(user, { defaultDisplayName: path.basename(outputDir) });
682
+ console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
683
+ projects = [newProject];
684
+ autoCreatedProjectId = newProject.id;
685
+ }
686
+ let projectId;
687
+ if (opts.selectProjectId) {
688
+ if (!projects.find((p) => p.id === opts.selectProjectId)) throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`);
689
+ projectId = opts.selectProjectId;
690
+ } else if (autoCreatedProjectId) projectId = autoCreatedProjectId;
691
+ else projectId = await select({
692
+ message: "Select a project:",
693
+ choices: projects.map((p) => ({
694
+ name: `${p.displayName} (${p.id})`,
695
+ value: p.id
696
+ }))
697
+ });
698
+ await writeProjectKeysToEnv(projects.find((p) => p.id === projectId), outputDir);
705
699
  return {};
706
700
  }
707
701
  async function performLogin(flags) {
@@ -744,6 +738,16 @@ async function handleCreate(opts, outputDir) {
744
738
  }
745
739
  const content = renderConfigFileContent({ apps: { installed: Object.fromEntries(selectedApps.map((appId) => [appId, { enabled: true }])) } }, detectImportPackageFromDir(path.dirname(configPath)));
746
740
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
741
+ if (fs.existsSync(configPath)) {
742
+ if (isNonInteractiveEnv()) throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`);
743
+ if (!await confirm({
744
+ message: `Config file already exists at ${configPath}. Overwrite?`,
745
+ default: false
746
+ })) {
747
+ console.log("\nLeaving existing config file unchanged.");
748
+ return { configPath };
749
+ }
750
+ }
747
751
  fs.writeFileSync(configPath, content);
748
752
  console.log(`\nConfig file written to ${configPath}`);
749
753
  return { configPath };
@@ -751,18 +755,6 @@ async function handleCreate(opts, outputDir) {
751
755
 
752
756
  //#endregion
753
757
  //#region src/commands/project.ts
754
- function prompt(question) {
755
- const rl = readline.createInterface({
756
- input: process.stdin,
757
- output: process.stdout
758
- });
759
- return new Promise((resolve) => {
760
- rl.question(question, (answer) => {
761
- rl.close();
762
- resolve(answer);
763
- });
764
- });
765
- }
766
758
  function registerProjectCommand(program) {
767
759
  const project = program.command("project").description("Manage projects");
768
760
  project.command("list").description("List your owned projects").action(async () => {
@@ -780,19 +772,7 @@ function registerProjectCommand(program) {
780
772
  }
781
773
  });
782
774
  project.command("create").description("Create a new project").option("--display-name <name>", "Project display name").action(async (opts) => {
783
- const user = await getInternalUser(resolveSessionAuth(program.opts()));
784
- let displayName = opts.displayName;
785
- if (!displayName) {
786
- if (isNonInteractiveEnv()) throw new CliError("--display-name is required in non-interactive environments (CI).");
787
- displayName = await prompt("Project display name: ");
788
- if (!displayName.trim()) throw new CliError("Display name cannot be empty.");
789
- }
790
- const teams = await user.listTeams();
791
- if (teams.length === 0) throw new CliError("No teams found. You need a team to create a project.");
792
- const newProject = await user.createProject({
793
- displayName,
794
- teamId: teams[0].id
795
- });
775
+ const newProject = await createProjectInteractively(await getInternalUser(resolveSessionAuth(program.opts())), { displayName: opts.displayName });
796
776
  if (program.opts().json) console.log(JSON.stringify({
797
777
  id: newProject.id,
798
778
  displayName: newProject.displayName
@@ -801,16 +781,296 @@ function registerProjectCommand(program) {
801
781
  });
802
782
  }
803
783
 
784
+ //#endregion
785
+ //#region src/lib/iso.ts
786
+ const SECTOR = 2048;
787
+ function bothEndian32(n) {
788
+ const b = Buffer.alloc(8);
789
+ b.writeUInt32LE(n, 0);
790
+ b.writeUInt32BE(n, 4);
791
+ return b;
792
+ }
793
+ function bothEndian16(n) {
794
+ const b = Buffer.alloc(4);
795
+ b.writeUInt16LE(n, 0);
796
+ b.writeUInt16BE(n, 2);
797
+ return b;
798
+ }
799
+ function padString(s, len, fill = " ") {
800
+ const buf = Buffer.alloc(len, fill.charCodeAt(0));
801
+ buf.write(s.slice(0, len), 0, "ascii");
802
+ return buf;
803
+ }
804
+ function ucs2BE(s) {
805
+ const buf = Buffer.alloc(s.length * 2);
806
+ for (let i = 0; i < s.length; i++) buf.writeUInt16BE(s.charCodeAt(i), i * 2);
807
+ return buf;
808
+ }
809
+ function padUcs2BE(s, byteLen) {
810
+ const buf = Buffer.alloc(byteLen);
811
+ const wholeChars = Math.floor(byteLen / 2);
812
+ for (let i = 0; i < wholeChars; i++) buf.writeUInt16BE(i < s.length ? s.charCodeAt(i) : 32, i * 2);
813
+ if (byteLen % 2 === 1) buf[byteLen - 1] = 32;
814
+ return buf;
815
+ }
816
+ function dirRecordingDate(d) {
817
+ const buf = Buffer.alloc(7);
818
+ buf[0] = d.getUTCFullYear() - 1900;
819
+ buf[1] = d.getUTCMonth() + 1;
820
+ buf[2] = d.getUTCDate();
821
+ buf[3] = d.getUTCHours();
822
+ buf[4] = d.getUTCMinutes();
823
+ buf[5] = d.getUTCSeconds();
824
+ buf[6] = 0;
825
+ return buf;
826
+ }
827
+ function volumeDate(d) {
828
+ const pad = (n, w) => String(n).padStart(w, "0");
829
+ const s = pad(d.getUTCFullYear(), 4) + pad(d.getUTCMonth() + 1, 2) + pad(d.getUTCDate(), 2) + pad(d.getUTCHours(), 2) + pad(d.getUTCMinutes(), 2) + pad(d.getUTCSeconds(), 2) + "00";
830
+ const buf = Buffer.alloc(17);
831
+ buf.write(s, 0, 16, "ascii");
832
+ buf[16] = 0;
833
+ return buf;
834
+ }
835
+ const UNUSED_VOLUME_DATE = (() => {
836
+ const buf = Buffer.alloc(17, "0".charCodeAt(0));
837
+ buf[16] = 0;
838
+ return buf;
839
+ })();
840
+ function isoFileIdentifier(name) {
841
+ const upper = name.toUpperCase();
842
+ return Buffer.from(`${upper};1`, "ascii");
843
+ }
844
+ function buildDirRecord(extentSector, dataLength, isDir, recDate, idBytes) {
845
+ const lenFi = idBytes.length;
846
+ const pad = lenFi % 2 === 0 ? 1 : 0;
847
+ const lenDr = 33 + lenFi + pad;
848
+ const buf = Buffer.alloc(lenDr);
849
+ buf[0] = lenDr;
850
+ buf[1] = 0;
851
+ bothEndian32(extentSector).copy(buf, 2);
852
+ bothEndian32(dataLength).copy(buf, 10);
853
+ recDate.copy(buf, 18);
854
+ buf[25] = isDir ? 2 : 0;
855
+ buf[26] = 0;
856
+ buf[27] = 0;
857
+ bothEndian16(1).copy(buf, 28);
858
+ buf[32] = lenFi;
859
+ idBytes.copy(buf, 33);
860
+ return buf;
861
+ }
862
+ function buildRootDirEntries(rootSector, rootSize, recDate, files) {
863
+ const records = [];
864
+ records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([0])));
865
+ records.push(buildDirRecord(rootSector, rootSize, true, recDate, Buffer.from([1])));
866
+ for (const f of files) records.push(buildDirRecord(f.sector, f.size, false, recDate, f.idBytes));
867
+ const sectors = [];
868
+ let current = Buffer.alloc(0);
869
+ for (const r of records) {
870
+ if (current.length + r.length > SECTOR) {
871
+ sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
872
+ current = Buffer.alloc(0);
873
+ }
874
+ current = Buffer.concat([current, r]);
875
+ }
876
+ if (current.length > 0) sectors.push(Buffer.concat([current, Buffer.alloc(SECTOR - current.length)]));
877
+ return Buffer.concat(sectors);
878
+ }
879
+ function buildPathTable(rootSector, byteOrder) {
880
+ const buf = Buffer.alloc(10);
881
+ buf[0] = 1;
882
+ buf[1] = 0;
883
+ if (byteOrder === "LE") {
884
+ buf.writeUInt32LE(rootSector, 2);
885
+ buf.writeUInt16LE(1, 6);
886
+ } else {
887
+ buf.writeUInt32BE(rootSector, 2);
888
+ buf.writeUInt16BE(1, 6);
889
+ }
890
+ buf[8] = 0;
891
+ buf[9] = 0;
892
+ return buf;
893
+ }
894
+ function padToSector(buf) {
895
+ const rem = buf.length % SECTOR;
896
+ if (rem === 0) return buf;
897
+ return Buffer.concat([buf, Buffer.alloc(SECTOR - rem)]);
898
+ }
899
+ function buildVolumeDescriptor(opts) {
900
+ const buf = Buffer.alloc(SECTOR);
901
+ buf[0] = opts.joliet ? 2 : 1;
902
+ buf.write("CD001", 1, 5, "ascii");
903
+ buf[6] = 1;
904
+ buf[7] = 0;
905
+ if (opts.joliet) padUcs2BE("", 32).copy(buf, 8);
906
+ else padString("", 32).copy(buf, 8);
907
+ if (opts.joliet) padUcs2BE(opts.volumeId, 32).copy(buf, 40);
908
+ else padString(opts.volumeId, 32).copy(buf, 40);
909
+ bothEndian32(opts.volumeSpaceSize).copy(buf, 80);
910
+ if (opts.joliet) {
911
+ buf[88] = 37;
912
+ buf[89] = 47;
913
+ buf[90] = 69;
914
+ }
915
+ bothEndian16(1).copy(buf, 120);
916
+ bothEndian16(1).copy(buf, 124);
917
+ bothEndian16(SECTOR).copy(buf, 128);
918
+ bothEndian32(opts.pathTableSize).copy(buf, 132);
919
+ buf.writeUInt32LE(opts.lPathSector, 140);
920
+ buf.writeUInt32LE(0, 144);
921
+ buf.writeUInt32BE(opts.mPathSector, 148);
922
+ buf.writeUInt32BE(0, 152);
923
+ opts.rootDirRecord.copy(buf, 156);
924
+ const padFn = opts.joliet ? (s, n) => padUcs2BE(s, n) : (s, n) => padString(s, n);
925
+ padFn("", 128).copy(buf, 190);
926
+ padFn("", 128).copy(buf, 318);
927
+ padFn("", 128).copy(buf, 446);
928
+ padFn("", 128).copy(buf, 574);
929
+ padFn("", 37).copy(buf, 702);
930
+ padFn("", 37).copy(buf, 739);
931
+ padFn("", 37).copy(buf, 776);
932
+ opts.date.copy(buf, 813);
933
+ opts.date.copy(buf, 830);
934
+ UNUSED_VOLUME_DATE.copy(buf, 847);
935
+ UNUSED_VOLUME_DATE.copy(buf, 864);
936
+ buf[881] = 1;
937
+ return buf;
938
+ }
939
+ function buildVolumeDescriptorTerminator() {
940
+ const buf = Buffer.alloc(SECTOR);
941
+ buf[0] = 255;
942
+ buf.write("CD001", 1, 5, "ascii");
943
+ buf[6] = 1;
944
+ return buf;
945
+ }
946
+ function buildIso(volumeId, files) {
947
+ const date = /* @__PURE__ */ new Date();
948
+ const recDate = dirRecordingDate(date);
949
+ const volDateBuf = volumeDate(date);
950
+ const isoEntries = files.map((f) => ({
951
+ file: f,
952
+ idBytes: isoFileIdentifier(f.name)
953
+ }));
954
+ const jolietEntries = files.map((f) => ({
955
+ file: f,
956
+ idBytes: ucs2BE(f.name)
957
+ }));
958
+ const dirRecLen = (lenFi) => 33 + lenFi + (lenFi % 2 === 0 ? 1 : 0);
959
+ const isoRootSize = 68 + isoEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
960
+ const jolietRootSize = 68 + jolietEntries.reduce((acc, e) => acc + dirRecLen(e.idBytes.length), 0);
961
+ if (isoRootSize > SECTOR || jolietRootSize > SECTOR) throw new Error(`Root directory exceeds ${SECTOR} bytes; multi-sector root not supported.`);
962
+ const sysAreaSectors = 16;
963
+ const isoLPathSector = sysAreaSectors + 1 + 1 + 1;
964
+ const isoMPathSector = isoLPathSector + 1;
965
+ const jolietLPathSector = isoMPathSector + 1;
966
+ const jolietMPathSector = jolietLPathSector + 1;
967
+ const isoRootSector = jolietMPathSector + 1;
968
+ const jolietRootSector = isoRootSector + 1;
969
+ let nextSector = jolietRootSector + 1;
970
+ const fileLayout = files.map((f) => {
971
+ const sector = nextSector;
972
+ const sectors = Math.max(1, Math.ceil(f.data.length / SECTOR));
973
+ nextSector += sectors;
974
+ return {
975
+ file: f,
976
+ sector,
977
+ size: f.data.length
978
+ };
979
+ });
980
+ const totalSectors = nextSector;
981
+ const pathTableSize = 10;
982
+ const rootIdent = Buffer.from([0]);
983
+ const isoRootDirRecordVD = buildDirRecord(isoRootSector, SECTOR, true, recDate, rootIdent);
984
+ const jolietRootDirRecordVD = buildDirRecord(jolietRootSector, SECTOR, true, recDate, rootIdent);
985
+ const pvd = buildVolumeDescriptor({
986
+ joliet: false,
987
+ volumeId,
988
+ volumeSpaceSize: totalSectors,
989
+ pathTableSize,
990
+ lPathSector: isoLPathSector,
991
+ mPathSector: isoMPathSector,
992
+ rootDirRecord: isoRootDirRecordVD,
993
+ date: volDateBuf
994
+ });
995
+ const svd = buildVolumeDescriptor({
996
+ joliet: true,
997
+ volumeId,
998
+ volumeSpaceSize: totalSectors,
999
+ pathTableSize,
1000
+ lPathSector: jolietLPathSector,
1001
+ mPathSector: jolietMPathSector,
1002
+ rootDirRecord: jolietRootDirRecordVD,
1003
+ date: volDateBuf
1004
+ });
1005
+ const term = buildVolumeDescriptorTerminator();
1006
+ const isoLPath = padToSector(buildPathTable(isoRootSector, "LE"));
1007
+ const isoMPath = padToSector(buildPathTable(isoRootSector, "BE"));
1008
+ const jolietLPath = padToSector(buildPathTable(jolietRootSector, "LE"));
1009
+ const jolietMPath = padToSector(buildPathTable(jolietRootSector, "BE"));
1010
+ const isoRoot = buildRootDirEntries(isoRootSector, SECTOR, recDate, isoEntries.map((e, i) => ({
1011
+ idBytes: e.idBytes,
1012
+ sector: fileLayout[i].sector,
1013
+ size: fileLayout[i].size
1014
+ })));
1015
+ const jolietRoot = buildRootDirEntries(jolietRootSector, SECTOR, recDate, jolietEntries.map((e, i) => ({
1016
+ idBytes: e.idBytes,
1017
+ sector: fileLayout[i].sector,
1018
+ size: fileLayout[i].size
1019
+ })));
1020
+ const fileBuffers = fileLayout.map((f) => {
1021
+ const reservedBytes = Math.max(1, Math.ceil(f.file.data.length / SECTOR)) * SECTOR;
1022
+ if (f.file.data.length === reservedBytes) return f.file.data;
1023
+ const out = Buffer.alloc(reservedBytes);
1024
+ f.file.data.copy(out, 0);
1025
+ return out;
1026
+ });
1027
+ return Buffer.concat([
1028
+ Buffer.alloc(sysAreaSectors * SECTOR),
1029
+ pvd,
1030
+ svd,
1031
+ term,
1032
+ isoLPath,
1033
+ isoMPath,
1034
+ jolietLPath,
1035
+ jolietMPath,
1036
+ isoRoot,
1037
+ jolietRoot,
1038
+ ...fileBuffers
1039
+ ]);
1040
+ }
1041
+ function writeIso(path, volumeId, files) {
1042
+ writeFileSync(path, buildIso(volumeId, files));
1043
+ }
1044
+
804
1045
  //#endregion
805
1046
  //#region src/commands/emulator.ts
806
1047
  const DEFAULT_EMULATOR_BACKEND_PORT = 26701;
807
- function emulatorBackendPort() {
808
- const raw = process.env.EMULATOR_BACKEND_PORT;
809
- if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
1048
+ const DEFAULT_EMULATOR_DASHBOARD_PORT = 26700;
1049
+ const DEFAULT_EMULATOR_MINIO_PORT = 26702;
1050
+ const DEFAULT_EMULATOR_INBUCKET_PORT = 26703;
1051
+ const DEFAULT_EMULATOR_MOCK_OAUTH_PORT = 26704;
1052
+ const DEFAULT_PORT_PREFIX = "81";
1053
+ const GITHUB_API = "https://api.github.com";
1054
+ const DEFAULT_REPO = "stack-auth/stack-auth";
1055
+ const AARCH64_FIRMWARE_PATHS = [
1056
+ "/opt/homebrew/share/qemu/edk2-aarch64-code.fd",
1057
+ "/usr/share/qemu/edk2-aarch64-code.fd",
1058
+ "/usr/share/AAVMF/AAVMF_CODE.fd",
1059
+ "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd"
1060
+ ];
1061
+ function envPort(name, fallback) {
1062
+ const raw = process.env[name];
1063
+ if (!raw) return fallback;
810
1064
  const parsed = Number(raw);
811
- if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`Invalid EMULATOR_BACKEND_PORT: ${raw}`);
1065
+ if (!Number.isInteger(parsed) || parsed <= 0) throw new CliError(`Invalid ${name}: ${raw}`);
812
1066
  return parsed;
813
1067
  }
1068
+ function emulatorDashboardPort() {
1069
+ return envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT);
1070
+ }
1071
+ function emulatorBackendPort() {
1072
+ return envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT);
1073
+ }
814
1074
  function emulatorHome() {
815
1075
  return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator");
816
1076
  }
@@ -826,11 +1086,13 @@ function internalPckPath() {
826
1086
  async function readInternalPck(timeoutMs = 6e4) {
827
1087
  const path = internalPckPath();
828
1088
  const deadline = Date.now() + timeoutMs;
829
- let delay = 250;
1089
+ let delay = 50;
830
1090
  while (Date.now() < deadline) {
831
- if (existsSync(path)) {
1091
+ try {
832
1092
  const contents = readFileSync(path, "utf-8").trim();
833
1093
  if (contents) return contents;
1094
+ } catch (e) {
1095
+ if (e.code !== "ENOENT") throw e;
834
1096
  }
835
1097
  await new Promise((r) => setTimeout(r, delay));
836
1098
  delay = Math.min(delay * 2, 2e3);
@@ -851,35 +1113,96 @@ async function fetchEmulatorCredentials(pck, backendPort, configFile) {
851
1113
  });
852
1114
  if (!res.ok) throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`);
853
1115
  const data = await res.json();
1116
+ if (typeof data.project_id !== "string" || typeof data.publishable_client_key !== "string" || typeof data.secret_server_key !== "string" || typeof data.onboarding_status !== "string" || typeof data.onboarding_outstanding !== "boolean") throw new CliError("Local emulator project endpoint returned an invalid credentials response.");
854
1117
  return {
855
1118
  project_id: data.project_id,
856
1119
  publishable_client_key: data.publishable_client_key,
857
- secret_server_key: data.secret_server_key
1120
+ secret_server_key: data.secret_server_key,
1121
+ onboarding_status: data.onboarding_status,
1122
+ onboarding_outstanding: data.onboarding_outstanding
858
1123
  };
859
1124
  }
860
- function gh(args) {
1125
+ function localEmulatorDashboardBaseUrl() {
1126
+ const explicit = process.env.STACK_LOCAL_EMULATOR_DASHBOARD_URL;
1127
+ if (explicit && explicit.trim().length > 0) return explicit.replace(/\/$/, "");
1128
+ return `http://localhost:${emulatorDashboardPort()}`;
1129
+ }
1130
+ function openUrlInBrowser(url) {
1131
+ try {
1132
+ if (process.platform === "darwin") {
1133
+ execFileSync("open", [url], { stdio: "ignore" });
1134
+ return true;
1135
+ }
1136
+ if (process.platform === "win32") {
1137
+ execFileSync("cmd", [
1138
+ "/c",
1139
+ "start",
1140
+ "",
1141
+ url
1142
+ ], { stdio: "ignore" });
1143
+ return true;
1144
+ }
1145
+ execFileSync("xdg-open", [url], { stdio: "ignore" });
1146
+ return true;
1147
+ } catch {
1148
+ return false;
1149
+ }
1150
+ }
1151
+ function maybeOpenOnboardingPage(credentials) {
1152
+ if (!credentials.onboarding_outstanding) return;
1153
+ const url = `${localEmulatorDashboardBaseUrl()}/new-project?project_id=${encodeURIComponent(credentials.project_id)}`;
1154
+ if (openUrlInBrowser(url)) console.log(`Onboarding is still pending for project ${credentials.project_id}. Opened: ${url}`);
1155
+ else console.warn(`Onboarding is still pending for project ${credentials.project_id}. Open this URL manually: ${url}`);
1156
+ }
1157
+ function githubToken() {
1158
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
861
1159
  try {
862
- return execFileSync("gh", args, {
1160
+ return execFileSync("gh", ["auth", "token"], {
863
1161
  encoding: "utf-8",
864
1162
  stdio: [
865
1163
  "pipe",
866
1164
  "pipe",
867
1165
  "pipe"
868
1166
  ]
869
- }).trim();
870
- } catch (err) {
871
- if (err instanceof Error && "stderr" in err && typeof err.stderr === "string") throw new CliError(`GitHub CLI error: ${err.stderr}`);
872
- throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
1167
+ }).trim() || void 0;
1168
+ } catch {
1169
+ return;
1170
+ }
1171
+ }
1172
+ async function ghApi(path) {
1173
+ const token = githubToken();
1174
+ const headers = {
1175
+ Accept: "application/vnd.github+json",
1176
+ "X-GitHub-Api-Version": "2022-11-28"
1177
+ };
1178
+ if (token) headers.Authorization = `Bearer ${token}`;
1179
+ const res = await fetch(`${GITHUB_API}${path}`, { headers });
1180
+ if (!res.ok) {
1181
+ const body = await res.text().catch(() => "");
1182
+ const hint = res.status === 401 || res.status === 403 ? " (set GITHUB_TOKEN or run `gh auth login` for higher rate limits / private access)" : "";
1183
+ throw new CliError(`GitHub API ${res.status} ${res.statusText} for ${path}${hint}${body ? `: ${body.slice(0, 300)}` : ""}`);
873
1184
  }
1185
+ return await res.json();
874
1186
  }
875
1187
  function emulatorScriptsDir() {
876
1188
  const here = dirname(fileURLToPath(import.meta.url));
877
1189
  const bundled = join(here, "emulator");
878
- if (existsSync(join(bundled, "run-emulator.sh"))) return bundled;
1190
+ if (existsSync(join(bundled, "run-emulator.sh"))) return ensureExecutable(bundled);
879
1191
  const repo = resolve(here, "../../../docker/local-emulator/qemu");
880
- if (existsSync(join(repo, "run-emulator.sh"))) return repo;
1192
+ if (existsSync(join(repo, "run-emulator.sh"))) return ensureExecutable(repo);
881
1193
  throw new CliError("Emulator scripts not found in CLI bundle.");
882
1194
  }
1195
+ function ensureExecutable(scriptsDir) {
1196
+ try {
1197
+ chmodSync(join(scriptsDir, "run-emulator.sh"), 493);
1198
+ } catch {}
1199
+ return scriptsDir;
1200
+ }
1201
+ function baseEnvPath() {
1202
+ const path = resolve(emulatorScriptsDir(), "..", ".env.development");
1203
+ if (!existsSync(path)) throw new CliError(`Emulator base.env not found at ${path}`);
1204
+ return path;
1205
+ }
883
1206
  function emulatorSpawnEnv(extra) {
884
1207
  return {
885
1208
  ...process.env,
@@ -888,6 +1211,34 @@ function emulatorSpawnEnv(extra) {
888
1211
  ...extra
889
1212
  };
890
1213
  }
1214
+ function prepareRuntimeConfigIso() {
1215
+ const vmDir = join(emulatorRunDir(), "vm");
1216
+ mkdirSync(vmDir, { recursive: true });
1217
+ const portPrefix = process.env.PORT_PREFIX ?? process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? DEFAULT_PORT_PREFIX;
1218
+ const dashboardPort = envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT);
1219
+ const backendPort = envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT);
1220
+ const minioPort = envPort("EMULATOR_MINIO_PORT", DEFAULT_EMULATOR_MINIO_PORT);
1221
+ const inbucketPort = envPort("EMULATOR_INBUCKET_PORT", DEFAULT_EMULATOR_INBUCKET_PORT);
1222
+ const mockOAuthPort = envPort("EMULATOR_MOCK_OAUTH_PORT", DEFAULT_EMULATOR_MOCK_OAUTH_PORT);
1223
+ const runtimeEnv = [
1224
+ `STACK_EMULATOR_PORT_PREFIX=${portPrefix}`,
1225
+ `STACK_EMULATOR_DASHBOARD_HOST_PORT=${dashboardPort}`,
1226
+ `STACK_EMULATOR_BACKEND_HOST_PORT=${backendPort}`,
1227
+ `STACK_EMULATOR_MINIO_HOST_PORT=${minioPort}`,
1228
+ `STACK_EMULATOR_INBUCKET_HOST_PORT=${inbucketPort}`,
1229
+ `STACK_EMULATOR_MOCK_OAUTH_HOST_PORT=${mockOAuthPort}`,
1230
+ `STACK_EMULATOR_VM_DIR_HOST=${vmDir}`,
1231
+ ""
1232
+ ].join("\n");
1233
+ const baseEnv = readFileSync(baseEnvPath());
1234
+ writeIso(join(vmDir, "runtime-config.iso"), "STACKCFG", [{
1235
+ name: "runtime.env",
1236
+ data: Buffer.from(runtimeEnv, "utf-8")
1237
+ }, {
1238
+ name: "base.env",
1239
+ data: baseEnv
1240
+ }]);
1241
+ }
891
1242
  function runEmulator(action, env) {
892
1243
  const scriptsDir = emulatorScriptsDir();
893
1244
  mkdirSync(emulatorRunDir(), { recursive: true });
@@ -916,115 +1267,305 @@ function isEmulatorRunning() {
916
1267
  }
917
1268
  }
918
1269
  async function startEmulator(arch) {
919
- mkdirSync(emulatorImageDir(), { recursive: true });
920
1270
  if (!existsSync(join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`))) {
921
1271
  console.log("No emulator image found. Pulling latest...");
922
- pullRelease(arch);
1272
+ await pullRelease(arch);
1273
+ await captureLocalSnapshot(arch);
923
1274
  }
924
- await runEmulator("start", { EMULATOR_ARCH: arch });
1275
+ prepareRuntimeConfigIso();
1276
+ await runEmulator("start", {
1277
+ EMULATOR_ARCH: arch,
1278
+ STACK_EMULATOR_CLI_WROTE_ISO: "1"
1279
+ });
925
1280
  }
926
1281
  function resolveArch(raw) {
927
1282
  const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
928
1283
  if (arch === "arm64" || arch === "amd64") return arch;
929
1284
  throw new CliError(`Invalid architecture: ${raw ?? process.arch}. Expected arm64 or amd64.`);
930
1285
  }
931
- function pullRelease(arch, opts = {}) {
932
- const repo = opts.repo ?? "stack-auth/stack-auth";
1286
+ async function pullRelease(arch, opts = {}) {
1287
+ const repo = opts.repo ?? DEFAULT_REPO;
933
1288
  const branch = opts.branch ?? "dev";
934
1289
  const tag = opts.tag ?? `emulator-${branch}-latest`;
935
- const asset = `stack-emulator-${arch}.qcow2`;
936
1290
  const imageDir = emulatorImageDir();
937
1291
  mkdirSync(imageDir, { recursive: true });
1292
+ const diskAsset = `stack-emulator-${arch}.qcow2`;
1293
+ const diskMatch = (await ghApi(`/repos/${repo}/releases/tags/${tag}`)).assets.find((a) => a.name === diskAsset);
1294
+ if (!diskMatch) throw new CliError(`Asset ${diskAsset} not found in release ${tag}. Run 'stack emulator list-releases' to see available releases.`);
1295
+ await downloadReleaseAsset(diskMatch, imageDir, diskAsset, githubToken(), tag);
1296
+ }
1297
+ async function captureLocalSnapshot(arch) {
1298
+ preflightForVmStart("pull", arch);
1299
+ prepareRuntimeConfigIso();
1300
+ console.log("Capturing local snapshot (first-time, ~1-3 min cold boot + capture)...");
1301
+ await runEmulator("capture", { EMULATOR_ARCH: arch });
1302
+ }
1303
+ async function downloadReleaseAsset(match, imageDir, asset, token, tag) {
938
1304
  const dest = join(imageDir, asset);
939
1305
  const tmpDest = `${dest}.download`;
940
1306
  console.log(`Pulling ${asset} from release ${tag}...`);
1307
+ const headers = { Accept: "application/octet-stream" };
1308
+ if (token) headers.Authorization = `Bearer ${token}`;
941
1309
  try {
942
- execFileSync("gh", [
943
- "release",
944
- "download",
945
- tag,
946
- "--repo",
947
- repo,
948
- "--pattern",
949
- asset,
950
- "--output",
951
- tmpDest,
952
- "--clobber"
953
- ], { stdio: "inherit" });
1310
+ await downloadWithProgress(match.url, headers, tmpDest, match.size);
954
1311
  } catch (err) {
955
1312
  if (existsSync(tmpDest)) unlinkSync(tmpDest);
956
- throw new CliError(`Failed to download ${asset} from release ${tag}: ${err instanceof Error ? err.message : err}\nRun 'stack emulator list-releases' to see available releases.`);
1313
+ if (err instanceof CliError) throw err;
1314
+ throw new CliError(`Failed to download ${asset} from release ${tag}: ${err instanceof Error ? err.message : err}`);
957
1315
  }
958
1316
  renameSync(tmpDest, dest);
959
1317
  console.log(`Downloaded: ${dest}`);
960
1318
  }
1319
+ async function downloadWithProgress(url, headers, dest, totalBytes) {
1320
+ const res = await fetch(url, {
1321
+ headers,
1322
+ redirect: "follow"
1323
+ });
1324
+ if (!res.ok || !res.body) throw new CliError(`Download failed (${res.status} ${res.statusText}): ${url}`);
1325
+ const total = totalBytes ?? (Number(res.headers.get("content-length")) || 0);
1326
+ const isTty = Boolean(process.stderr.isTTY);
1327
+ const startedAt = Date.now();
1328
+ let downloaded = 0;
1329
+ let lastRender = 0;
1330
+ const render = (final) => {
1331
+ const now = Date.now();
1332
+ if (!final && now - lastRender < 100) return;
1333
+ lastRender = now;
1334
+ const elapsed = Math.max(.001, (now - startedAt) / 1e3);
1335
+ const speed = downloaded / elapsed;
1336
+ const line = renderProgressLine(downloaded, total, speed);
1337
+ if (isTty) process.stderr.write(`\r\x1b[2K${line}`);
1338
+ else if (final) process.stderr.write(`${line}\n`);
1339
+ };
1340
+ const body = Readable.fromWeb(res.body);
1341
+ body.on("data", (chunk) => {
1342
+ downloaded += chunk.byteLength;
1343
+ render(false);
1344
+ });
1345
+ await pipeline(body, createWriteStream(dest));
1346
+ render(true);
1347
+ if (isTty) process.stderr.write("\n");
1348
+ }
1349
+ function renderProgressLine(downloaded, total, bytesPerSec) {
1350
+ const barWidth = 30;
1351
+ const pct = total > 0 ? Math.min(100, downloaded / total * 100) : 0;
1352
+ const filled = total > 0 ? Math.round(downloaded / total * barWidth) : 0;
1353
+ return ` [${"█".repeat(filled) + "░".repeat(Math.max(0, barWidth - filled))}] ${total > 0 ? `${pct.toFixed(1).padStart(5)}%` : " ? "} ${total > 0 ? `${formatBytes(downloaded)}/${formatBytes(total)}` : formatBytes(downloaded)} ${`${formatBytes(bytesPerSec)}/s`}${total > 0 && bytesPerSec > 0 ? ` eta ${formatDuration((total - downloaded) / bytesPerSec)}` : ""}`;
1354
+ }
1355
+ function formatBytes(bytes) {
1356
+ if (!Number.isFinite(bytes) || bytes < 0) return "?";
1357
+ const units = [
1358
+ "B",
1359
+ "KB",
1360
+ "MB",
1361
+ "GB",
1362
+ "TB"
1363
+ ];
1364
+ let v = bytes;
1365
+ let i = 0;
1366
+ while (v >= 1024 && i < units.length - 1) {
1367
+ v /= 1024;
1368
+ i++;
1369
+ }
1370
+ return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${units[i]}`;
1371
+ }
1372
+ function formatDuration(seconds) {
1373
+ if (!Number.isFinite(seconds) || seconds < 0) return "?";
1374
+ const s = Math.round(seconds);
1375
+ if (s < 60) return `${s}s`;
1376
+ const m = Math.floor(s / 60);
1377
+ const rs = s % 60;
1378
+ if (m < 60) return `${m}m${rs.toString().padStart(2, "0")}s`;
1379
+ return `${Math.floor(m / 60)}h${(m % 60).toString().padStart(2, "0")}m`;
1380
+ }
1381
+ function commandExists(bin) {
1382
+ try {
1383
+ execFileSync(process.platform === "win32" ? "where" : "which", [bin], { stdio: "pipe" });
1384
+ return true;
1385
+ } catch {
1386
+ return false;
1387
+ }
1388
+ }
1389
+ function platformInstallHint(linuxPkg, macPkg) {
1390
+ switch (process.platform) {
1391
+ case "darwin": return `brew install ${macPkg}`;
1392
+ case "linux": return `apt install ${linuxPkg} (or your distro's equivalent)`;
1393
+ default: return `install ${macPkg}`;
1394
+ }
1395
+ }
1396
+ function bin(name, linuxPkg, macPkg) {
1397
+ return {
1398
+ name,
1399
+ linuxPkg,
1400
+ macPkg
1401
+ };
1402
+ }
1403
+ function installHint(b) {
1404
+ return platformInstallHint(b.linuxPkg, b.macPkg);
1405
+ }
1406
+ function requireBinaries(commandName, bins) {
1407
+ const missing = bins.filter((b) => !commandExists(b.name));
1408
+ if (missing.length === 0) return;
1409
+ throw new CliError(`\`stack emulator ${commandName}\` requires the following missing binaries:\n${missing.map((b) => ` - ${b.name} → ${installHint(b)}`).join("\n")}`);
1410
+ }
1411
+ function warnIfMissing(commandName, bins) {
1412
+ const missing = bins.filter((b) => !commandExists(b.name));
1413
+ if (missing.length === 0) return;
1414
+ for (const b of missing) console.warn(`[stack emulator ${commandName}] optional dep '${b.name}' missing — feature degraded. Install: ${installHint(b)}`);
1415
+ }
1416
+ async function confirmPrompt(question) {
1417
+ if (!process.stdin.isTTY) throw new CliError("Cannot prompt for confirmation: stdin is not a TTY. Install the missing dependencies manually and retry.");
1418
+ const rl = createInterface({
1419
+ input: process.stdin,
1420
+ output: process.stdout
1421
+ });
1422
+ return await new Promise((resolvePromise) => {
1423
+ rl.question(`${question} [y/N] `, (answer) => {
1424
+ rl.close();
1425
+ resolvePromise(/^y(es)?$/i.test(answer.trim()));
1426
+ });
1427
+ });
1428
+ }
1429
+ async function ensureDepsForPull(arch) {
1430
+ const missingBins = [
1431
+ archSpecificQemuBin(arch),
1432
+ ...commonVmBins(),
1433
+ bin("zstd", "zstd", "zstd")
1434
+ ].filter((b) => !commandExists(b.name));
1435
+ const firmwareMissing = arch === "arm64" && !aarch64FirmwareAvailable();
1436
+ if (missingBins.length === 0 && !firmwareMissing) return;
1437
+ const platform = process.platform;
1438
+ const linuxHasApt = platform === "linux" && commandExists("apt-get");
1439
+ if (platform !== "darwin" && !linuxHasApt) {
1440
+ preflightForVmStart("pull", arch);
1441
+ return;
1442
+ }
1443
+ if (!process.stdin.isTTY) {
1444
+ preflightForVmStart("pull", arch);
1445
+ return;
1446
+ }
1447
+ console.log("The emulator needs the following dependencies that aren't installed:");
1448
+ for (const b of missingBins) console.log(` - ${b.name}`);
1449
+ if (firmwareMissing) console.log(" - aarch64 UEFI firmware");
1450
+ console.log();
1451
+ const pkgs = /* @__PURE__ */ new Set();
1452
+ for (const b of missingBins) pkgs.add(platform === "darwin" ? b.macPkg : b.linuxPkg);
1453
+ if (firmwareMissing && platform === "linux") pkgs.add("qemu-efi-aarch64");
1454
+ if (firmwareMissing && platform === "darwin") pkgs.add("qemu");
1455
+ const pkgList = Array.from(pkgs).sort();
1456
+ if (pkgList.length === 0) {
1457
+ preflightForVmStart("pull", arch);
1458
+ return;
1459
+ }
1460
+ const brewMissing = platform === "darwin" && !commandExists("brew");
1461
+ console.log("Proposed install plan:");
1462
+ if (brewMissing) {
1463
+ console.log(" - install Homebrew by running the official installer:");
1464
+ console.log(" /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"");
1465
+ console.log(" (executes remote code from raw.githubusercontent.com — review https://brew.sh if unsure)");
1466
+ }
1467
+ if (platform === "darwin") console.log(` - brew install ${pkgList.join(" ")}`);
1468
+ else console.log(` - sudo apt-get update && sudo apt-get install -y ${pkgList.join(" ")}`);
1469
+ console.log();
1470
+ if (!await confirmPrompt("Proceed with install?")) throw new CliError("Dependency install declined. Install the missing packages manually and retry.");
1471
+ if (brewMissing) {
1472
+ console.log("\nInstalling Homebrew...");
1473
+ execSync("/bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"", { stdio: "inherit" });
1474
+ }
1475
+ console.log("\nInstalling packages...");
1476
+ if (platform === "darwin") execFileSync(commandExists("brew") ? "brew" : existsSync("/opt/homebrew/bin/brew") ? "/opt/homebrew/bin/brew" : "/usr/local/bin/brew", ["install", ...pkgList], { stdio: "inherit" });
1477
+ else {
1478
+ execFileSync("sudo", ["apt-get", "update"], { stdio: "inherit" });
1479
+ execFileSync("sudo", [
1480
+ "apt-get",
1481
+ "install",
1482
+ "-y",
1483
+ ...pkgList
1484
+ ], { stdio: "inherit" });
1485
+ }
1486
+ console.log();
1487
+ }
1488
+ function aarch64FirmwareAvailable() {
1489
+ return AARCH64_FIRMWARE_PATHS.some((p) => existsSync(p));
1490
+ }
1491
+ function commonVmBins() {
1492
+ return [
1493
+ bin("qemu-img", "qemu-utils", "qemu"),
1494
+ bin("socat", "socat", "socat"),
1495
+ bin("curl", "curl", "curl"),
1496
+ bin("nc", "ncat", "netcat"),
1497
+ bin("lsof", "lsof", "lsof"),
1498
+ bin("openssl", "openssl", "openssl")
1499
+ ];
1500
+ }
1501
+ function archSpecificQemuBin(arch) {
1502
+ if (arch === "arm64") return bin("qemu-system-aarch64", "qemu-system-arm", "qemu");
1503
+ return bin("qemu-system-x86_64", "qemu-system-x86", "qemu");
1504
+ }
1505
+ function preflightForVmStart(commandName, arch) {
1506
+ requireBinaries(commandName, [archSpecificQemuBin(arch), ...commonVmBins()]);
1507
+ warnIfMissing(commandName, [bin("zstd", "zstd", "zstd")]);
1508
+ if (arch === "arm64" && !aarch64FirmwareAvailable()) throw new CliError(`aarch64 UEFI firmware not found. Looked in:\n${AARCH64_FIRMWARE_PATHS.map((p) => ` - ${p}`).join("\n")}\nInstall: ${platformInstallHint("qemu-efi-aarch64", "qemu")}`);
1509
+ }
1510
+ async function downloadArtifactByName(repo, runId, name, destDir) {
1511
+ const token = githubToken();
1512
+ if (!token) throw new CliError("Downloading workflow run artifacts requires authentication. Set GITHUB_TOKEN or run `gh auth login`.");
1513
+ const match = (await ghApi(`/repos/${repo}/actions/runs/${runId}/artifacts?per_page=100`)).artifacts.find((a) => a.name === name);
1514
+ if (!match) return false;
1515
+ const zipPath = join(destDir, `${name}.zip`);
1516
+ console.log(`Downloading artifact '${name}' from run ${runId}...`);
1517
+ await downloadWithProgress(`${GITHUB_API}/repos/${repo}/actions/artifacts/${match.id}/zip`, {
1518
+ Accept: "application/vnd.github+json",
1519
+ Authorization: `Bearer ${token}`
1520
+ }, zipPath, match.size_in_bytes);
1521
+ await extract(zipPath, { dir: destDir });
1522
+ unlinkSync(zipPath);
1523
+ return true;
1524
+ }
961
1525
  function registerEmulatorCommand(program) {
962
1526
  const emulator = program.command("emulator").description("Manage the QEMU local emulator");
963
- emulator.command("pull").description("Download an emulator image from GitHub Releases or a PR build").option("--arch <arch>", "Target architecture (default: current system arch)").option("--branch <branch>", "Release branch (default: dev)").option("--tag <tag>", "Specific release tag (default: latest)").option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)").option("--pr <number>", "Pull from a PR's CI artifacts").option("--run <id>", "Pull from a specific workflow run's artifacts").action(async (opts) => {
1527
+ emulator.command("pull").description("Download an emulator image from GitHub Releases or a PR build, then capture a local fast-start snapshot").option("--arch <arch>", "Target architecture (default: current system arch)").option("--branch <branch>", "Release branch (default: dev)").option("--tag <tag>", "Specific release tag (default: latest)").option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)").option("--pr <number>", "Pull from a PR's CI artifacts").option("--run <id>", "Pull from a specific workflow run's artifacts").option("--skip-snapshot", "Download only the qcow2; skip the one-time local snapshot capture").action(async (opts) => {
964
1528
  const arch = resolveArch(opts.arch);
965
- const repo = opts.repo ?? "stack-auth/stack-auth";
1529
+ if (!opts.skipSnapshot) await ensureDepsForPull(arch);
1530
+ const repo = opts.repo ?? DEFAULT_REPO;
966
1531
  if (opts.run || opts.pr) {
967
1532
  let runId = opts.run;
968
1533
  if (!runId) {
969
1534
  console.log(`Finding latest successful build for PR #${opts.pr}...`);
970
- const { headRefName } = JSON.parse(gh([
971
- "pr",
972
- "view",
973
- opts.pr,
974
- "--repo",
975
- repo,
976
- "--json",
977
- "headRefName"
978
- ]));
979
- const runs = JSON.parse(gh([
980
- "run",
981
- "list",
982
- "--repo",
983
- repo,
984
- "--workflow",
985
- "qemu-emulator-build.yaml",
986
- "--branch",
987
- headRefName,
988
- "--status",
989
- "success",
990
- "--limit",
991
- "1",
992
- "--json",
993
- "databaseId"
994
- ]));
995
- if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
996
- runId = String(runs[0].databaseId);
1535
+ const headRefName = (await ghApi(`/repos/${repo}/pulls/${opts.pr}`)).head.ref;
1536
+ const runs = await ghApi(`/repos/${repo}/actions/workflows/qemu-emulator-build.yaml/runs?branch=${encodeURIComponent(headRefName)}&status=success&per_page=1`);
1537
+ if (runs.workflow_runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
1538
+ runId = String(runs.workflow_runs[0].id);
997
1539
  }
998
1540
  const imageDir = emulatorImageDir();
999
1541
  mkdirSync(imageDir, { recursive: true });
1000
1542
  const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
1543
+ const snapshotDest = join(imageDir, `stack-emulator-${arch}.savevm.zst`);
1544
+ const snapshotRawDest = join(imageDir, `stack-emulator-${arch}.savevm.raw`);
1001
1545
  if (existsSync(dest)) unlinkSync(dest);
1002
- console.log(`Downloading qemu-emulator-${arch} from workflow run ${runId}...`);
1003
- try {
1004
- execFileSync("gh", [
1005
- "run",
1006
- "download",
1007
- runId,
1008
- "--repo",
1009
- repo,
1010
- "--name",
1011
- `qemu-emulator-${arch}`,
1012
- "--dir",
1013
- imageDir
1014
- ], { stdio: "inherit" });
1015
- } catch (err) {
1016
- throw new CliError(`Failed to download artifact from run ${runId}: ${err instanceof Error ? err.message : err}`);
1017
- }
1546
+ if (existsSync(snapshotDest)) unlinkSync(snapshotDest);
1547
+ if (existsSync(snapshotRawDest)) unlinkSync(snapshotRawDest);
1548
+ if (!await downloadArtifactByName(repo, runId, `qemu-emulator-${arch}`, imageDir)) throw new CliError(`Artifact qemu-emulator-${arch} not found in workflow run ${runId}.`);
1018
1549
  if (!existsSync(dest)) throw new CliError(`Expected image not found at ${dest} after download.`);
1019
1550
  console.log(`Downloaded: ${dest}`);
1020
- } else pullRelease(arch, {
1021
- repo,
1022
- branch: opts.branch,
1023
- tag: opts.tag
1024
- });
1551
+ } else {
1552
+ const imageDir = emulatorImageDir();
1553
+ const snapshotDest = join(imageDir, `stack-emulator-${arch}.savevm.zst`);
1554
+ const snapshotRawDest = join(imageDir, `stack-emulator-${arch}.savevm.raw`);
1555
+ if (existsSync(snapshotDest)) unlinkSync(snapshotDest);
1556
+ if (existsSync(snapshotRawDest)) unlinkSync(snapshotRawDest);
1557
+ await pullRelease(arch, {
1558
+ repo,
1559
+ branch: opts.branch,
1560
+ tag: opts.tag
1561
+ });
1562
+ }
1563
+ if (opts.skipSnapshot) console.log("--skip-snapshot: not capturing a local snapshot. First `stack emulator start` will cold-boot.");
1564
+ else await captureLocalSnapshot(arch);
1025
1565
  });
1026
1566
  emulator.command("start").description("Start the emulator in the background (auto-pulls the latest image if none exists)").option("--arch <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.").option("--config-file <path>", "Path to a config file; when set, credentials for this project are printed to stdout as JSON").action(async (opts) => {
1027
1567
  const arch = resolveArch(opts.arch);
1568
+ preflightForVmStart("start", arch);
1028
1569
  let resolvedConfigFile;
1029
1570
  if (opts.configFile) {
1030
1571
  resolvedConfigFile = resolve(opts.configFile);
@@ -1034,11 +1575,17 @@ function registerEmulatorCommand(program) {
1034
1575
  else await startEmulator(arch);
1035
1576
  if (resolvedConfigFile) {
1036
1577
  const creds = await fetchEmulatorCredentials(await readInternalPck(), emulatorBackendPort(), resolvedConfigFile);
1037
- console.log(JSON.stringify(creds, null, 2));
1578
+ maybeOpenOnboardingPage(creds);
1579
+ console.log(JSON.stringify({
1580
+ project_id: creds.project_id,
1581
+ publishable_client_key: creds.publishable_client_key,
1582
+ secret_server_key: creds.secret_server_key
1583
+ }, null, 2));
1038
1584
  }
1039
1585
  });
1040
1586
  emulator.command("run").description("Start the emulator, run a command, and stop the emulator when the command exits").argument("<cmd>", "Command to run (e.g. \"npm run dev\")").option("--arch <arch>", "Target architecture").option("--config-file <path>", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child").action(async (cmd, opts) => {
1041
1587
  const arch = resolveArch(opts.arch);
1588
+ preflightForVmStart("run", arch);
1042
1589
  let resolvedConfigFile;
1043
1590
  if (opts.configFile) {
1044
1591
  resolvedConfigFile = resolve(opts.configFile);
@@ -1052,14 +1599,21 @@ function registerEmulatorCommand(program) {
1052
1599
  const pck = await readInternalPck();
1053
1600
  const backendPort = emulatorBackendPort();
1054
1601
  const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile);
1602
+ maybeOpenOnboardingPage(creds);
1055
1603
  const apiUrl = `http://127.0.0.1:${backendPort}`;
1056
1604
  childEnv.STACK_PROJECT_ID = creds.project_id;
1057
1605
  childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id;
1606
+ childEnv.VITE_STACK_PROJECT_ID = creds.project_id;
1607
+ childEnv.EXPO_PUBLIC_STACK_PROJECT_ID = creds.project_id;
1058
1608
  childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1059
1609
  childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1610
+ childEnv.VITE_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1611
+ childEnv.EXPO_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key;
1060
1612
  childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key;
1061
1613
  childEnv.STACK_API_URL = apiUrl;
1062
1614
  childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl;
1615
+ childEnv.VITE_STACK_API_URL = apiUrl;
1616
+ childEnv.EXPO_PUBLIC_STACK_API_URL = apiUrl;
1063
1617
  }
1064
1618
  const child = spawn(cmd, {
1065
1619
  shell: true,
@@ -1078,24 +1632,34 @@ function registerEmulatorCommand(program) {
1078
1632
  if (alreadyRunning) process.exit(exitCode);
1079
1633
  else {
1080
1634
  console.log("\nStopping emulator...");
1081
- runEmulator("stop").catch(() => {}).finally(() => process.exit(exitCode));
1635
+ const warnStopFailed = (e) => {
1636
+ const msg = e instanceof Error ? e.message : String(e);
1637
+ process.stderr.write(`Failed to stop emulator cleanly: ${msg}\n`);
1638
+ };
1639
+ runEmulator("stop").catch(warnStopFailed).finally(() => process.exit(exitCode));
1082
1640
  }
1083
1641
  });
1084
1642
  });
1085
- emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
1086
- emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));
1087
- emulator.command("status").description("Show emulator and service health").action(() => runEmulator("status"));
1088
- emulator.command("list-releases").description("List available emulator releases").option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)").action((opts) => {
1089
- const repo = opts.repo ?? "stack-auth/stack-auth";
1643
+ emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => {
1644
+ requireBinaries("stop", [bin("socat", "socat", "socat")]);
1645
+ return runEmulator("stop");
1646
+ });
1647
+ emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => {
1648
+ requireBinaries("reset", [bin("socat", "socat", "socat")]);
1649
+ return runEmulator("reset");
1650
+ });
1651
+ emulator.command("status").description("Show emulator and service health").action(() => {
1652
+ requireBinaries("status", [bin("curl", "curl", "curl"), bin("nc", "ncat", "netcat")]);
1653
+ return runEmulator("status");
1654
+ });
1655
+ emulator.command("list-releases").description("List available emulator releases").option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)").action(async (opts) => {
1656
+ const repo = opts.repo ?? DEFAULT_REPO;
1090
1657
  console.log(`Available emulator releases from ${repo}:\n`);
1091
- const lines = gh([
1092
- "release",
1093
- "list",
1094
- "--repo",
1095
- repo,
1096
- "--limit",
1097
- "20"
1098
- ]).split("\n").filter((l) => l.toLowerCase().includes("emulator"));
1658
+ const lines = (await ghApi(`/repos/${repo}/releases?per_page=50`)).filter((r) => (r.tag_name + " " + (r.name ?? "")).toLowerCase().includes("emulator")).slice(0, 20).map((r) => {
1659
+ const status = r.draft ? "Draft" : r.prerelease ? "Pre-release" : "Latest";
1660
+ const date = r.published_at ? r.published_at.slice(0, 10) : "";
1661
+ return `${r.tag_name}\t${status}\t${date}`;
1662
+ });
1099
1663
  if (lines.length === 0) console.log("No emulator releases found.");
1100
1664
  else for (const line of lines) console.log(line);
1101
1665
  });