mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Merge branch 'dev' into promptless/document-developer-tools
This commit is contained in:
commit
c320331ab5
@ -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}`);
|
||||
|
||||
@ -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) {
|
||||
|
||||
36
packages/stack-cli/src/lib/create-project.ts
Normal file
36
packages/stack-cli/src/lib/create-project.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user