mirror of https://github.com/garrytan/gstack.git
137 lines
4.3 KiB
TypeScript
137 lines
4.3 KiB
TypeScript
/**
|
|
* Auth resolution for OpenAI API access.
|
|
*
|
|
* Resolution order:
|
|
* 1. ~/.gstack/openai.json → { "api_key": "sk-..." }
|
|
* 2. OPENAI_API_KEY environment variable
|
|
* 3. null (caller handles guided setup or fallback)
|
|
*
|
|
* When OPENAI_API_KEY is in use AND its value matches an OPENAI_API_KEY entry
|
|
* in the current directory's .env / .env.<NODE_ENV> / .env.local, we disclose
|
|
* the source on stderr before the run. Catches the silent-billing surface
|
|
* reported in #1248: design generation inside someone else's project would
|
|
* silently bill their OpenAI account if their .env was loaded into the shell.
|
|
*/
|
|
|
|
import fs from "fs";
|
|
import path from "path";
|
|
|
|
type ApiKeySource = "config" | "env";
|
|
|
|
export interface ApiKeyResolution {
|
|
key: string;
|
|
source: ApiKeySource;
|
|
envFile?: string;
|
|
warning?: string;
|
|
}
|
|
|
|
function configPath(): string {
|
|
return path.join(process.env.HOME || "~", ".gstack", "openai.json");
|
|
}
|
|
|
|
function readEnvValue(filePath: string, key: string): string | null {
|
|
let content: string;
|
|
try {
|
|
content = fs.readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
for (const line of content.split(/\r?\n/)) {
|
|
const match = line.match(new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=\\s*(.*)\\s*$`));
|
|
if (!match) continue;
|
|
|
|
let value = match[1].trim();
|
|
if (
|
|
(value.startsWith('"') && value.endsWith('"')) ||
|
|
(value.startsWith("'") && value.endsWith("'"))
|
|
) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function matchingCwdEnvFile(key: string, value: string): string | null {
|
|
const candidates = [".env"];
|
|
const nodeEnv = process.env.NODE_ENV;
|
|
if (nodeEnv) candidates.push(`.env.${nodeEnv}`);
|
|
candidates.push(".env.local");
|
|
|
|
for (const fileName of candidates) {
|
|
const fileValue = readEnvValue(path.join(process.cwd(), fileName), key);
|
|
if (fileValue === value) return fileName;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveApiKeyInfo(): ApiKeyResolution | null {
|
|
// 1. Check ~/.gstack/openai.json
|
|
try {
|
|
const authPath = configPath();
|
|
if (fs.existsSync(authPath)) {
|
|
const content = fs.readFileSync(authPath, "utf-8");
|
|
const config = JSON.parse(content);
|
|
if (config.api_key && typeof config.api_key === "string") {
|
|
return { key: config.api_key, source: "config" };
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall through to env var
|
|
}
|
|
|
|
// 2. Check environment variable
|
|
if (process.env.OPENAI_API_KEY) {
|
|
const envFile = matchingCwdEnvFile("OPENAI_API_KEY", process.env.OPENAI_API_KEY);
|
|
const warning = envFile
|
|
? `Warning: OPENAI_API_KEY matches ${envFile} in the current directory. Design generation may bill that project's OpenAI account. Run $D setup to store a gstack-specific key in ~/.gstack/openai.json.`
|
|
: undefined;
|
|
return { key: process.env.OPENAI_API_KEY, source: "env", envFile: envFile ?? undefined, warning };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function resolveApiKey(): string | null {
|
|
return resolveApiKeyInfo()?.key ?? null;
|
|
}
|
|
|
|
export function describeApiKeySource(resolution: ApiKeyResolution): string {
|
|
if (resolution.source === "config") return "~/.gstack/openai.json";
|
|
if (resolution.envFile) return `OPENAI_API_KEY environment variable (matches ${resolution.envFile} in current directory)`;
|
|
return "OPENAI_API_KEY environment variable";
|
|
}
|
|
|
|
/**
|
|
* Save an API key to ~/.gstack/openai.json with 0600 permissions.
|
|
*/
|
|
export function saveApiKey(key: string): void {
|
|
const dir = path.dirname(configPath());
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(configPath(), JSON.stringify({ api_key: key }, null, 2));
|
|
fs.chmodSync(configPath(), 0o600);
|
|
}
|
|
|
|
/**
|
|
* Get API key or exit with setup instructions.
|
|
*/
|
|
export function requireApiKey(): string {
|
|
const resolution = resolveApiKeyInfo();
|
|
if (!resolution) {
|
|
console.error("No OpenAI API key found.");
|
|
console.error("");
|
|
console.error("Run: $D setup");
|
|
console.error(" or save to ~/.gstack/openai.json: { \"api_key\": \"sk-...\" }");
|
|
console.error(" or set OPENAI_API_KEY environment variable");
|
|
console.error("");
|
|
console.error("Get a key at: https://platform.openai.com/api-keys");
|
|
process.exit(1);
|
|
}
|
|
console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`);
|
|
if (resolution.warning) console.error(resolution.warning);
|
|
return resolution.key;
|
|
}
|