@stackframe/stack-cli 2.8.85 → 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/emulator/cloud-init/emulator/user-data +200 -16
- package/dist/emulator/common.sh +139 -0
- package/dist/emulator/run-emulator.sh +704 -60
- package/dist/index.js +849 -285
- package/dist/index.js.map +1 -1
- package/package.json +7 -5
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
|
|
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 {
|
|
@@ -61,7 +66,7 @@ function removeConfigValue(key) {
|
|
|
61
66
|
//#region src/lib/auth.ts
|
|
62
67
|
const DEFAULT_API_URL = "https://api.stack-auth.com";
|
|
63
68
|
const DEFAULT_DASHBOARD_URL = "https://app.stack-auth.com";
|
|
64
|
-
const DEFAULT_PUBLISHABLE_CLIENT_KEY = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? "
|
|
69
|
+
const DEFAULT_PUBLISHABLE_CLIENT_KEY = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? "pck_9bbqvqsbh0gdb6smk11d71qg4ktc4rz8ya7cc69yndm7g";
|
|
65
70
|
function resolveApiUrl() {
|
|
66
71
|
return process.env.STACK_API_URL ?? readConfigValue("STACK_API_URL") ?? DEFAULT_API_URL;
|
|
67
72
|
}
|
|
@@ -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 (
|
|
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/
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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 (
|
|
628
|
-
else if (
|
|
629
|
-
else source =
|
|
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
|
|
648
|
-
let sessionAuth;
|
|
615
|
+
async function ensureLoggedInSession(flags) {
|
|
649
616
|
try {
|
|
650
|
-
|
|
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
|
-
|
|
657
|
-
}
|
|
623
|
+
return resolveSessionAuth(flags);
|
|
624
|
+
}
|
|
625
|
+
throw e;
|
|
658
626
|
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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=${
|
|
682
|
-
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${
|
|
683
|
-
`STACK_SECRET_SERVER_KEY=${
|
|
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
|
|
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
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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
|
|
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 =
|
|
1089
|
+
let delay = 50;
|
|
830
1090
|
while (Date.now() < deadline) {
|
|
831
|
-
|
|
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
|
|
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",
|
|
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
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
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
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() =>
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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 =
|
|
1092
|
-
"release"
|
|
1093
|
-
"
|
|
1094
|
-
|
|
1095
|
-
|
|
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
|
});
|