Merge branch 'dev' into promptless/document-custom-dashboards

This commit is contained in:
promptless[bot] 2026-04-27 18:46:25 +00:00
commit 71d4781b3e
3 changed files with 225 additions and 100 deletions

View File

@ -10,11 +10,13 @@ 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 { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
type InitOptions = {
mode?: "create" | "link-config" | "link-cloud",
mode?: "create" | "create-cloud" | "link-config" | "link-cloud",
apps?: string,
configFile?: string,
selectProjectId?: string,
@ -26,7 +28,7 @@ export function registerInitCommand(program: Command) {
program
.command("init")
.description("Initialize Stack Auth in your project")
.option("--mode <mode>", "Mode: create, link-config, or link-cloud (skips interactive prompts)")
.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)")
@ -51,32 +53,94 @@ export function registerInitCommand(program: Command) {
});
}
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");
const mode: string = "link";
// TODO: re-enable local emulator option
// const mode: string = opts.mode ?? await select({
// message: "Would you like to link to an existing project, or create a new one?",
// choices: [
// { name: "Create a new project (local emulator)", value: "create" as const },
// { name: "Link an existing project", value: "link" as const },
// ],
// });
let mode: "create" | "create-cloud" | "link" | "link-config" | "link-cloud";
if (opts.mode) {
mode = opts.mode;
} else if (opts.selectProjectId) {
mode = "link-cloud";
} else if (opts.configFile) {
mode = "link-config";
} else {
const action = await select({
message: "Would you like to link to an existing project, or create a new one?",
choices: [
{ name: "Create a new project", value: "create" as const },
{ name: "Link an existing project", value: "link" as const },
],
});
if (action === "link") {
mode = "link";
} else {
const location = await select({
message: "Where would you like to create the project?",
choices: [
{ name: "Stack Auth Cloud", value: "hosted" as const },
{ name: "Local (requires local emulator installation, ~1.3gb storage required)", value: "local" as const },
],
});
mode = location === "local" ? "create" : "create-cloud";
}
}
let configPath: string | undefined;
if (mode === "link" || mode === "link-config" || mode === "link-cloud") {
const result = await handleLink(flags, opts, outputDir);
configPath = result.configPath;
} else if (mode === "create") {
const result = await handleCreate(opts, outputDir);
configPath = result.configPath;
} else {
throw new CliError(`Unknown mode: ${mode}`);
switch (mode) {
case "link":
case "link-config":
case "link-cloud": {
const result = await handleLink(flags, opts, outputDir, mode);
configPath = result.configPath;
break;
}
case "create": {
const result = await handleCreate(opts, outputDir);
configPath = result.configPath;
break;
}
case "create-cloud": {
const result = await handleCreateCloud(flags, opts, outputDir);
configPath = result.configPath;
break;
}
}
const initPrompt = createInitPrompt(false, configPath);
@ -96,23 +160,21 @@ async function runInit(program: Command, opts: InitOptions) {
}
}
async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
async function handleLink(flags: Record<string, unknown>, opts: InitOptions, outputDir: string, resolvedMode: "link" | "link-config" | "link-cloud"): Promise<{ configPath?: string }> {
let source: "config-file" | "cloud";
if (opts.mode === "link-config") {
if (resolvedMode === "link-config") {
source = "config-file";
} else if (opts.mode === "link-cloud") {
} else if (resolvedMode === "link-cloud") {
source = "cloud";
} else {
source = "cloud";
// TODO: re-enable config file linking option
// source = await select({
// message: "How would you like to link your project?",
// choices: [
// { name: "Link from config file", value: "config-file" as const },
// { name: "Link from app.stack-auth.com", value: "cloud" as const },
// ],
// });
source = await select({
message: "How would you like to link your project?",
choices: [
{ name: "Link from config file", value: "config-file" as const },
{ name: "Link from app.stack-auth.com", value: "cloud" as const },
],
});
}
if (source === "config-file") {
@ -142,10 +204,9 @@ async function handleLinkFromConfigFile(opts: InitOptions): Promise<{ configPath
return { configPath };
}
async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
let sessionAuth;
async function ensureLoggedInSession(flags: Record<string, unknown>) {
try {
sessionAuth = resolveSessionAuth(flags as { projectId?: string });
return resolveSessionAuth(flags as { projectId?: string });
} catch (e) {
if (e instanceof AuthError) {
if (isNonInteractiveEnv()) {
@ -153,37 +214,16 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
}
console.log("You need to log in first.\n");
await performLogin(flags);
sessionAuth = resolveSessionAuth(flags as { projectId?: string });
} else {
throw e;
return resolveSessionAuth(flags as { projectId?: string });
}
throw e;
}
}
const user = await getInternalUser(sessionAuth);
const projects = await user.listOwnedProjects();
if (projects.length === 0) {
throw new CliError("You don't own any projects. Create one at app.stack-auth.com first.");
}
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 {
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)!;
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,
) {
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
@ -192,11 +232,14 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
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 envLines = [
"# Stack Auth",
`NEXT_PUBLIC_STACK_PROJECT_ID=${projectId}`,
`NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${apiKey.publishableClientKey ?? ""}`,
`STACK_SECRET_SERVER_KEY=${apiKey.secretServerKey ?? ""}`,
`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");
@ -226,7 +269,70 @@ async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOpt
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 }> {
const sessionAuth = await ensureLoggedInSession(flags);
const user = await getInternalUser(sessionAuth);
const newProject = await createProjectInteractively(user, {
defaultDisplayName: path.basename(outputDir),
});
console.log(`\nCreated project: ${newProject.displayName} (${newProject.id})\n`);
await writeProjectKeysToEnv(newProject, outputDir);
return {};
}
async function handleLinkFromCloud(flags: Record<string, unknown>, opts: InitOptions, outputDir: string): Promise<{ configPath?: string }> {
const sessionAuth = await ensureLoggedInSession(flags);
const user = await getInternalUser(sessionAuth);
let projects = await user.listOwnedProjects();
let autoCreatedProjectId: string | null = null;
if (projects.length === 0) {
if (isNonInteractiveEnv()) {
throw new CliError("No projects found. Run `stack project create --display-name <name>` first, or set --select-project-id.");
}
const shouldCreate = await confirm({
message: "You don't have any Stack Auth projects yet. Would you like to create one?",
default: true,
});
if (!shouldCreate) {
throw new CliError("You don't own any projects. Create one at app.stack-auth.com or re-run and choose to create one.");
}
const newProject = await createProjectInteractively(user, {
defaultDisplayName: path.basename(outputDir),
});
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)!;
await writeProjectKeysToEnv(project, outputDir);
return {};
}
@ -298,6 +404,21 @@ async function handleCreate(opts: InitOptions, outputDir: string): Promise<{ con
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}`);

View File

@ -1,22 +1,7 @@
import { Command } from "commander";
import * as readline from "readline";
import { resolveSessionAuth } from "../lib/auth.js";
import { getInternalUser } from "../lib/app.js";
import { isNonInteractiveEnv } from "../lib/interactive.js";
import { CliError } from "../lib/errors.js";
function prompt(question: string): Promise<string> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise((resolve) => {
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
import { createProjectInteractively } from "../lib/create-project.js";
export function registerProjectCommand(program: Command) {
const project = program
@ -54,25 +39,8 @@ export function registerProjectCommand(program: Command) {
const auth = resolveSessionAuth(flags);
const user = await getInternalUser(auth);
let displayName: string = opts.displayName;
if (!displayName) {
if (isNonInteractiveEnv()) {
throw new CliError("--display-name is required in non-interactive environments (CI).");
}
displayName = await prompt("Project display name: ");
if (!displayName.trim()) {
throw new CliError("Display name cannot be empty.");
}
}
const teams = await user.listTeams();
if (teams.length === 0) {
throw new CliError("No teams found. You need a team to create a project.");
}
const newProject = await user.createProject({
displayName,
teamId: teams[0].id,
const newProject = await createProjectInteractively(user, {
displayName: opts.displayName,
});
if (program.opts().json) {

View File

@ -0,0 +1,36 @@
import { input } from "@inquirer/prompts";
import type { CurrentInternalUser } from "@stackframe/js";
import { CliError } from "./errors.js";
import { isNonInteractiveEnv } from "./interactive.js";
type CreateProjectOptions = {
displayName?: string,
defaultDisplayName?: string,
};
export async function createProjectInteractively(
user: CurrentInternalUser,
opts: CreateProjectOptions = {},
) {
let displayName = opts.displayName;
if (!displayName) {
if (isNonInteractiveEnv()) {
throw new CliError("--display-name is required in non-interactive environments (CI).");
}
displayName = await input({
message: "Project display name:",
default: opts.defaultDisplayName,
validate: (v) => v.trim().length > 0 || "Display name cannot be empty.",
});
}
const teams = await user.listTeams();
if (teams.length === 0) {
throw new CliError("No teams found on your account. Create a team at app.stack-auth.com first.");
}
return await user.createProject({
displayName: displayName.trim(),
teamId: teams[0].id,
});
}