stack/packages/stack-cli/src/commands/init.ts
BilalG1 f7e389809e
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
DB migration compat / Check if migrations changed (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Build and Run / docker (push) Has been cancelled
Runs E2E API Tests (Local Emulator) / E2E Tests (Local Emulator, Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (mock, 22.x) (push) Has been cancelled
Runs E2E API Tests / E2E Tests (Node ${{ matrix.node-version }}, Freestyle ${{ matrix.freestyle-mode }}) (prod, 22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E Fallback Tests / E2E Fallback Tests (Node ${{ matrix.node-version }}) (22.x) (push) Has been cancelled
Lint & build / lint_and_build (24) (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled
feat(hexclave): PR 1 — wire compatibility layer (invisible) (#1475)
## Summary

**Stacked on #1468** (`docs/hexclave-rename-plan` — the plan doc). Diff
vs that base = the actual PR 1 code.

This is **PR 1 of the Hexclave rebrand: the invisible compatibility
layer**. Everything is additive. Old SDKs, old wire identifiers, and old
env var names keep working unchanged. The backend dual-accepts and
dual-emits; new SDK code emits `x-hexclave-*` headers and the
`hexclave_` Bearer prefix; cookies dual-write; env vars dual-read across
every category. **No user-visible rebranding lands here** — that's PR 2.

See [`RENAME-TO-HEXCLAVE.md`](./RENAME-TO-HEXCLAVE.md) → *"PR 1
implementation guide"* for the full per-work-area spec, file pointers,
and chosen approach.

## What's implemented (all 14 PR-1 work-areas)

- **SDK export aliases** — `Hexclave*` aliases for the user-facing
`Stack*` exports added in `packages/template`; codegen propagates them
to `@stackframe/{js,stack,react,tanstack-start}`. React-only aliases
correctly excluded from `@stackframe/js`. (`e60550a2`)
- **JWT issuer dual-accept** — `decodeAccessToken` accepts both
`api.stack-auth.com` and `api.hexclave.com` issuers. Signing unchanged.
(`fc781def`)
- **Request-header dual-accept** — backend + dashboard proxies normalize
`x-hexclave-*` → `x-stack-*` at the existing empty proxy hook (so
`smart-request.tsx` and every route schema keep working unchanged); CORS
allowlists extended via a derive-once helper. (`2a056eac`)
- **MCP `ask_hexclave`** — registered alongside `ask_stack_auth` via a
shared helper; `ask_stack_auth` behavior byte-identical. (`30ffd604`)
- **Dev-tool** — DOM ids + header emit switched.
`window.HexclaveDevTool` exposed alongside `window.StackDevTool`.
(`32131ea7`)
- **The big consolidated commit** (`7fed864a`):
- **Env vars** — central `getEnvVariable` prefix-transform (HEXCLAVE
first, STACK fallback); dashboard + template client env files dual-read;
`turbo.json` globalEnv; `NEXT_PUBLIC_STACK_PORT_PREFIX` renamed outright
across ~82 files including docker.
- **Cookies** — dual-write/dual-read auth (`stack-access`/`-refresh-*`
and custom-domain variants), OAuth-state
(`stack-oauth-{inner,outer}-*`), and low-risk cookies (`stack-is-https`,
`stack-last-seen-changelog-version`). Bypass sites patched (backend
OAuth callback, dashboard remote-dev auth route, impersonation snippets,
snapshot serializer).
- **Bearer prefix** — SDK token parser accepts both `stackauth_` and
`hexclave_`; emits `hexclave_`. Discovery correction: this is purely
SDK-internal — the backend never parses it.
- **Response headers** — backend dual-emits
`x-hexclave-{request-id,actual-status,known-error}`; SDKs dual-read (new
first, stack fallback).
- **SDK request-header emit switch** —
`client/server/admin-interface.ts` + dashboard `api-headers.ts` +
`internal-project-headers.ts` + `feedback-form.tsx` switched to
`x-hexclave-*`. Plus `stack_response_mode` query param.
- **Storage keys** — dev-tool / cli-auth / oauth-button / docs keys
renamed (straight); `stack:session-replay:v1` dual-read so in-progress
recordings survive SDK upgrades; `stack_mfa_attempt_code` dual-read.
- **Query params** — cross-domain params dual-emit/dual-accept via
shared helpers; backend `oauth/authorize` accepts
`hexclave_response_mode` and `stack_response_mode`; `stack-init-id`
renamed.
- **`Symbol.for`** — app-internals symbol gets a parallel
`Symbol.for("Hexclave--app-internals")` getter on each attach site (no
read-site churn — old symbol still attached). 3 file-private symbols
renamed outright.
- **Config discovery** — prefer `hexclave.config.ts`, fall back to
`stack.config.ts` at every discovery site (CLI / dashboard / backend /
local-emulator); `init` writes the new filename; CLI credentials path
migrates.
- **Internal renames** — `StackAssertionError`,
`StackClient/Server/AdminInterface` renamed outright (no alias, per the
"internal-only → rename" rule). ~264 files touched.
- **Review-pass fixes** (`21217fbe`) — three real bugs found by parallel
review agents and fixed:
- `snapshot-serializer.ts` was interpolating the whole
`keyedCookieNamePrefixes` array (`${arr}`) — adding a second prefix
would have corrupted **every** OAuth-cookie snapshot, not just new ones.
- **Docker port-prefix producer/consumer mismatch** —
`entrypoint.sh`/`run-emulator.sh`/cloud-init `user-data` were still
producing `NEXT_PUBLIC_STACK_PORT_PREFIX` while the dashboard sentinel +
consumers had been renamed; silent self-host regression (custom port
prefix would be ignored).
- **Missing `hexclave-oauth-inner-*` dual-write** in the OAuth authorize
route — callback's fallback masked it but the dual-write was specified
by the plan.
- Plus: `mcp.test.ts` tool-list assertions updated to include
`ask_hexclave`; two dashboard header-emit sites switched to
`x-hexclave-*` for consistency.
- **E2E snapshot serializer follow-up** (`4b16cc5d`) —
`x-hexclave-request-id` added to the hidden-headers list (mirroring
`x-stack-request-id` treatment), and 2 sample inline snapshots
regenerated in `projects.test.ts` to include the new dual-emitted
headers.

## Verification

- **`pnpm typecheck`** — clean (the fresh-worktree `@/.source` / Prisma
codegen gap in `stack-docs` is pre-existing and unrelated).
- **`pnpm lint`** — 29/29 packages green.
- **`pnpm exec turbo run build --filter=./packages/*`** — 13/13 packages
build (including `@stackframe/stack-cli` once the dashboard standalone
is present).
- **Live E2E** against a running backend on `cl/hexclave-pr1`:
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/mcp.test.ts` — **6/6
pass** (verifies the new `ask_hexclave` tool — the hand-written inline
snapshot matched actual MCP server output).
- `pnpm test run
apps/e2e/tests/backend/endpoints/api/v1/internal/projects.test.ts` —
**11/11 pass** (verifies wire dual-accept + dual-emit end-to-end; the
snapshot serializer fix was found and applied during this check).

A four-agent parallel **review pass** also audited the full diff for
logic/runtime bugs across the work-areas (wire headers + JWT, cookies +
bearer + symbols, env vars, query params + config + MCP + aliases). All
in-slice review verdicts were ✓ except the three bugs listed above,
which are now fixed.

## Known follow-ups (out of scope for this PR)

- **E2E snapshots across the rest of the suite** — backend now
dual-emits `x-hexclave-{known-error,actual-status}` alongside
`x-stack-*`, which legitimately appears in inline snapshots throughout
`apps/e2e`. Two were regenerated here as a sample; the rest should regen
with `vitest -u` in CI.
- **Docker shell env vars beyond `PORT_PREFIX`** — `entrypoint.sh` still
reads `STACK_*` env vars directly (the JS-side `getEnvVariable`
transform doesn't help the shell). JS consumers dual-read so it works in
practice; full shell-level dual-read is a deeper self-host follow-up.
- **`@stackframe/stack-cli` build ordering** — pre-existing; needs
`build:rde-standalone` first. Not affected by this PR.

## Test plan

- [ ] CI runs full e2e suite (with `vitest -u` to absorb dual-emit
snapshot deltas, then committed back)
- [ ] Spot-check: an old SDK build (emitting only `x-stack-*`) still
authenticates against the new backend
- [ ] Spot-check: a new SDK (emitting `x-hexclave-*` / `Bearer
hexclave_*`) still authenticates against an old backend during deploy
ordering
- [ ] Manual: `npx @stackframe/stack-cli@latest init` (new onboarding
entrypoint) generates `hexclave.config.ts`
- [ ] Manual: existing `stack.config.ts`-only project still resolves (no
migration required)

---------

Co-authored-by: bilal <bilal@stack-auth.com>
2026-05-23 17:24:55 -07:00

453 lines
17 KiB
TypeScript

import { Command } from "commander";
import { select, input, checkbox, confirm } from "@inquirer/prompts";
import * as fs from "fs";
import * as path from "path";
import { StackClientApp } from "@stackframe/js";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { resolveLoginConfig, resolveSessionAuth, DEFAULT_PUBLISHABLE_CLIENT_KEY } from "../lib/auth.js";
import { getInternalUser } from "../lib/app.js";
import { writeConfigValue } from "../lib/config.js";
import { CliError, AuthError } from "../lib/errors.js";
import { isNonInteractiveEnv } from "../lib/interactive.js";
import { createInitPrompt } from "../lib/init-prompt.js";
import { createProjectInteractively } from "../lib/create-project.js";
import { runClaudeAgent } from "../lib/claude-agent.js";
import { resolveConfigFilePathOption } from "../lib/config-file-path.js";
import { isEmulatorImageInstalled } from "./emulator.js";
import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
const VALID_INIT_MODES = ["create", "create-cloud", "link-config", "link-cloud"] as const;
type InitMode = typeof VALID_INIT_MODES[number];
type InitOptions = {
mode?: InitMode,
apps?: string,
configFile?: string,
selectProjectId?: string,
outputDir?: string,
agent?: boolean,
displayName?: string,
};
export function registerInitCommand(program: Command) {
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")
.option("--display-name <name>", "Project display name (used by create-cloud mode)")
.action(async (opts: InitOptions) => {
if (opts.mode != null && !VALID_INIT_MODES.includes(opts.mode)) {
throw new CliError(`Invalid --mode: ${opts.mode}. Expected one of: ${VALID_INIT_MODES.join(", ")}.`);
}
const hasFlags = opts.mode != null || opts.configFile != null || opts.selectProjectId != null;
if (!hasFlags && isNonInteractiveEnv()) {
throw new CliError("stack init requires an interactive terminal. Use --mode flag for non-interactive usage.");
}
try {
await runInit(program, opts);
} catch (error: unknown) {
if (error != null && typeof error === "object" && "name" in error && error.name === "ExitPromptError") {
console.log("\nAborted.");
process.exit(0);
}
throw error;
}
});
}
function validateOptions(opts: InitOptions) {
if (opts.selectProjectId && opts.configFile) {
throw new CliError("--select-project-id and --config-file cannot be used together.");
}
const incompatible: Record<NonNullable<InitOptions["mode"]>, Array<keyof InitOptions>> = {
"create": ["selectProjectId", "configFile"],
"create-cloud": ["selectProjectId", "configFile", "apps"],
"link-config": ["selectProjectId", "apps"],
"link-cloud": ["configFile", "apps"],
};
const flagNames: Partial<Record<keyof InitOptions, string>> = {
selectProjectId: "--select-project-id",
configFile: "--config-file",
apps: "--apps",
};
if (opts.mode) {
for (const key of incompatible[opts.mode]) {
if (opts[key] != null) {
throw new CliError(`${flagNames[key]} cannot be used with --mode ${opts.mode}.`);
}
}
}
}
async function runInit(program: Command, opts: InitOptions) {
const flags = program.opts();
const outputDir = opts.outputDir ? path.resolve(opts.outputDir) : process.cwd();
if (!fs.existsSync(outputDir)) {
throw new CliError(`Output directory does not exist: ${outputDir}`);
}
validateOptions(opts);
console.log("Welcome to Stack Auth!\n");
let mode: string;
if (opts.mode) {
mode = opts.mode;
} else if (opts.selectProjectId) {
mode = "link-cloud";
} else if (opts.configFile) {
mode = "link-config";
} else {
console.log("Creating a new Stack Auth project.\n");
const localLabel = isEmulatorImageInstalled()
? "Local (emulator already installed)"
: "Local (requires local emulator installation, ~1.3gb storage required)";
const location = await select({
message: "Where would you like to create the project?",
choices: [
{ name: "Stack Auth Cloud", value: "hosted" as const },
{ name: localLabel, value: "local" as const },
],
});
mode = location === "local" ? "create" : "create-cloud";
}
let configPath: string | undefined;
let projectId: string | undefined;
if (mode === "link-config" || mode === "link-cloud") {
const result = await handleLink(flags, opts, outputDir, mode);
configPath = result.configPath;
projectId = result.projectId;
} else if (mode === "create") {
const result = await handleCreate(opts, outputDir);
configPath = result.configPath;
} else if (mode === "create-cloud") {
const result = await handleCreateCloud(flags, opts, outputDir);
configPath = result.configPath;
projectId = result.projectId;
} else {
throw new CliError(`Unknown mode: ${mode}`);
}
const initPrompt = createInitPrompt(false, configPath);
const useAgent = opts.agent !== false && !isNonInteractiveEnv();
if (useAgent) {
console.log("\nRunning your coding agent to wire up Stack Auth.");
console.log("This also registers the Stack Auth MCP server (https://mcp.stack-auth.com)");
console.log("so your agent can read the docs and answer Stack-specific questions going forward.\n");
const success = await runClaudeAgent({
prompt: `Execute ALL of the following setup steps in my project now. Do not ask questions — just detect the framework and package manager from existing files and proceed.\n\n${initPrompt}`,
cwd: outputDir,
});
if (!success) {
console.log("\nFalling back to manual instructions:\n");
console.log(initPrompt);
}
} else {
console.log("\n" + initPrompt);
}
const { dashboardUrl } = resolveLoginConfig();
printNextSteps({ mode, projectId, dashboardUrl });
}
function printNextSteps(args: { mode: string, projectId?: string, dashboardUrl: string }) {
console.log("\nYou're all set! What's next:\n");
console.log(" • Start your dev server, then visit /handler/sign-up to create a test user");
console.log(" (and /handler/sign-in to log in). Drop <UserButton /> into a page to see the session.");
if (args.mode === "create") {
console.log(" • You're wired up to the local emulator. Start it in another terminal:");
console.log(" npx @stackframe/stack-cli emulator start");
console.log(" Local dashboard: http://localhost:26700");
} else if (args.projectId) {
console.log(" • Manage this project in the dashboard:");
console.log(` ${args.dashboardUrl}/projects/${encodeURIComponent(args.projectId)}`);
}
console.log(" • Docs: https://docs.stack-auth.com");
console.log("");
}
async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string, resolvedMode: "link-config" | "link-cloud"): Promise<{ configPath?: string, projectId?: string }> {
if (resolvedMode === "link-config") {
return await handleLinkFromConfigFile(opts);
}
return await handleLinkFromCloud(flags, opts, outputDir);
}
async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath: string }> {
const filePath = opts.configFile ?? await input({
message: "Path to your existing stack.config.ts:",
validate: (value) => {
const resolved = path.resolve(value);
if (!fs.existsSync(resolved)) {
return `File not found: ${resolved}`;
}
if (fs.statSync(resolved).isDirectory()) {
return `--config-file must point to a config file, but got a directory: ${resolved}`;
}
return true;
},
});
const configPath = resolveConfigFilePathOption(filePath, { mustExist: true });
console.log(`\nLinked to config file: ${configPath}`);
return { configPath };
}
async function ensureLoggedInSession() {
try {
return resolveSessionAuth();
} catch (e) {
if (e instanceof AuthError) {
if (isNonInteractiveEnv()) {
throw new CliError("Not logged in. Run `stack login` first or set STACK_CLI_REFRESH_TOKEN.");
}
console.log("You need to log in first.\n");
await performLogin();
return resolveSessionAuth();
}
throw e;
}
}
async function writeProjectKeysToEnv(
project: { id: string, app: { createInternalApiKey: (opts: { description: string, expiresAt: Date, hasPublishableClientKey: boolean, hasSecretServerKey: boolean, hasSuperSecretAdminKey: boolean }) => Promise<{ publishableClientKey?: string | null, secretServerKey?: string | null }> } },
outputDir: string,
variant: "cloud" | "local" = "cloud",
) {
const apiKey = await project.app.createInternalApiKey({
description: "Created by CLI init script",
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 200), // 200 years
hasPublishableClientKey: true,
hasSecretServerKey: true,
hasSuperSecretAdminKey: false,
});
const publishableClientKey = apiKey.publishableClientKey ?? throwErr("createInternalApiKey returned no publishableClientKey despite hasPublishableClientKey=true");
const secretServerKey = apiKey.secretServerKey ?? throwErr("createInternalApiKey returned no secretServerKey despite hasSecretServerKey=true");
const header = variant === "local"
? [
"# Stack Auth — local emulator keys",
"# These credentials point at your local Stack Auth emulator, not a cloud project.",
"# They are only valid while the emulator is running (`stack emulator start`).",
]
: ["# Stack Auth"];
const envLines = [
...header,
`NEXT_PUBLIC_STACK_PROJECT_ID=${project.id}`,
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${publishableClientKey}`,
`STACK_SECRET_SERVER_KEY=${secretServerKey}`,
].join("\n");
const envPath = path.resolve(outputDir, ".env");
if (fs.existsSync(envPath)) {
const existing = fs.readFileSync(envPath, "utf-8");
const separator = existing.endsWith("\n") ? "\n" : "\n\n";
if (isNonInteractiveEnv()) {
fs.appendFileSync(envPath, separator + envLines + "\n");
console.log("\nAppended Stack Auth keys to .env");
} else {
const shouldAppend = await confirm({
message: `.env file already exists. Append Stack Auth keys?`,
default: true,
});
if (shouldAppend) {
fs.appendFileSync(envPath, separator + envLines + "\n");
console.log("\nAppended Stack Auth keys to .env");
} else {
console.log("\nHere are your environment variables:\n");
console.log(envLines);
}
}
} else {
fs.writeFileSync(envPath, envLines + "\n");
console.log("\nCreated .env with Stack Auth keys");
}
}
async function handleCreateCloud(_flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string, projectId?: string }> {
const sessionAuth = await ensureLoggedInSession();
const user = await getInternalUser(sessionAuth);
const { dashboardUrl } = resolveLoginConfig();
const newProject = await createProjectInteractively(user, {
displayName: opts.displayName,
defaultDisplayName: path.basename(outputDir),
dashboardUrl,
});
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
await writeProjectKeysToEnv(newProject, outputDir);
return { projectId: newProject.id };
}
async function handleLinkFromCloud(_flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string, projectId?: string }> {
const sessionAuth = await ensureLoggedInSession();
const user = await getInternalUser(sessionAuth);
let projects = await user.listOwnedProjects();
let autoCreatedProjectId: string | null = null;
if (projects.length === 0) {
if (opts.selectProjectId) {
throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects. Check the ID or omit --select-project-id to create a new project interactively.`);
}
if (isNonInteractiveEnv()) {
throw new CliError("No projects found. Run `stack project create --display-name <name>` first.");
}
const shouldCreate = await confirm({
message: "You don't have any Stack Auth projects yet. Would you like to create one?",
default: true,
});
if (!shouldCreate) {
const { dashboardUrl } = resolveLoginConfig();
throw new CliError(`You don't own any projects. Create one at ${dashboardUrl} or re-run and choose to create one.`);
}
const { dashboardUrl } = resolveLoginConfig();
const newProject = await createProjectInteractively(user, {
defaultDisplayName: path.basename(outputDir),
dashboardUrl,
});
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
projects = [newProject];
autoCreatedProjectId = newProject.id;
}
let projectId: string;
if (opts.selectProjectId) {
const found = projects.find((p) => p.id === opts.selectProjectId);
if (!found) {
throw new CliError(`Project '${opts.selectProjectId}' not found among your owned projects.`);
}
projectId = opts.selectProjectId;
} else if (autoCreatedProjectId) {
projectId = autoCreatedProjectId;
} else {
projectId = await select({
message: "Select a project:",
choices: projects.map((p) => ({
name: `${p.displayName} (${p.id})`,
value: p.id,
})),
});
}
const project = projects.find((p) => p.id === projectId)
?? throwErr(`Project not found: ${projectId}`);
await writeProjectKeysToEnv(project, outputDir);
return { projectId };
}
async function performLogin() {
const config = resolveLoginConfig();
const app = new StackClientApp({
projectId: "internal",
publishableClientKey: DEFAULT_PUBLISHABLE_CLIENT_KEY,
baseUrl: config.apiUrl,
tokenStore: "memory",
noAutomaticPrefetch: true,
});
console.log("Waiting for browser authentication...");
const result = await app.promptCliLogin({
appUrl: config.dashboardUrl,
});
if (result.status === "error") {
throw new CliError(`Login failed: ${result.error.message}`);
}
writeConfigValue("STACK_CLI_REFRESH_TOKEN", result.data);
console.log("Login successful!\n");
}
async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ configPath: string }> {
// Hexclave rebrand: new projects get the `hexclave.config.ts` filename.
const configPath = path.resolve(outputDir, "hexclave.config.ts");
console.log(`\nCreating a new config file at ${configPath}!\n`);
let selectedApps: string[];
if (opts.apps) {
selectedApps = opts.apps.split(",").map((s) => s.trim()).filter(Boolean);
const validAppIds = Object.keys(ALL_APPS);
const invalidApps = selectedApps.filter((id) => !validAppIds.includes(id));
if (invalidApps.length > 0) {
throw new CliError(`Unknown app IDs: ${invalidApps.join(", ")}. Valid IDs: ${validAppIds.join(", ")}`);
}
} else {
const stageOrder = { stable: 0, beta: 1 } as const;
const appEntries = Object.entries(ALL_APPS)
.filter(([, app]) => app.stage !== "alpha")
.sort((a, b) => stageOrder[a[1].stage as keyof typeof stageOrder] - stageOrder[b[1].stage as keyof typeof stageOrder]);
selectedApps = await checkbox({
message: "Select apps to enable:",
choices: appEntries.map(([id, app]) => ({
name: `${app.displayName} - ${app.subtitle}${app.stage !== "stable" ? ` (${app.stage})` : ""}`,
value: id,
checked: id === "authentication",
})),
});
}
const installed = Object.fromEntries(
selectedApps.map((appId) => [appId, { enabled: true }])
);
const config = {
apps: {
installed,
},
};
const importPackage = detectImportPackageFromDir(path.dirname(configPath));
const content = renderConfigFileContent(config, importPackage);
fs.mkdirSync(path.dirname(configPath), { recursive: true });
if (fs.existsSync(configPath)) {
if (isNonInteractiveEnv()) {
throw new CliError(`Config file already exists at ${configPath}. Refusing to overwrite in non-interactive mode.`);
}
const shouldOverwrite = await confirm({
message: `Config file already exists at ${configPath}. Overwrite?`,
default: false,
});
if (!shouldOverwrite) {
console.log("\nLeaving existing config file unchanged.");
return { configPath };
}
}
fs.writeFileSync(configPath, content);
console.log(`\nConfig file written to ${configPath}`);
return { configPath };
}