Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 79 additions & 12 deletions design/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,67 @@
import fs from "fs";
import path from "path";

const CONFIG_PATH = path.join(process.env.HOME || "~", ".gstack", "openai.json");
type ApiKeySource = "config" | "env";

export function resolveApiKey(): string | null {
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 {
if (fs.existsSync(CONFIG_PATH)) {
const content = fs.readFileSync(CONFIG_PATH, "utf-8");
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 config.api_key;
return { key: config.api_key, source: "config" };
}
}
} catch {
Expand All @@ -28,28 +79,42 @@ export function resolveApiKey(): string | null {

// 2. Check environment variable
if (process.env.OPENAI_API_KEY) {
return 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(CONFIG_PATH);
const dir = path.dirname(configPath());
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify({ api_key: key }, null, 2));
fs.chmodSync(CONFIG_PATH, 0o600);
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 key = resolveApiKey();
if (!key) {
const resolution = resolveApiKeyInfo();
if (!resolution) {
console.error("No OpenAI API key found.");
console.error("");
console.error("Run: $D setup");
Expand All @@ -59,5 +124,7 @@ export function requireApiKey(): string {
console.error("Get a key at: https://platform.openai.com/api-keys");
process.exit(1);
}
return key;
console.error(`Using OpenAI key from ${describeApiKeySource(resolution)}.`);
if (resolution.warning) console.error(resolution.warning);
return resolution.key;
}
3 changes: 2 additions & 1 deletion design/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ function printUsage(): void {
console.log(` ${name.padEnd(12)} ${info.description}`);
console.log(` ${"".padEnd(12)} ${info.usage}`);
}
console.log("\nAuth: ~/.gstack/openai.json or OPENAI_API_KEY env var");
console.log("\nAuth: ~/.gstack/openai.json, then OPENAI_API_KEY env var");
console.log("If OPENAI_API_KEY matches a current-directory .env file, the source is reported before billing.");
console.log("Setup: $D setup");
}

Expand Down
128 changes: 128 additions & 0 deletions design/test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Tests for $D OpenAI auth source reporting.
*/

import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import {
describeApiKeySource,
requireApiKey,
resolveApiKey,
resolveApiKeyInfo,
saveApiKey,
} from "../src/auth";

let tmpDir: string;
let tmpHome: string;
let originalHome: string | undefined;
let originalKey: string | undefined;
let originalNodeEnv: string | undefined;
let originalCwd: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gstack-design-auth-"));
tmpHome = path.join(tmpDir, "home");
fs.mkdirSync(tmpHome, { recursive: true });

originalHome = process.env.HOME;
originalKey = process.env.OPENAI_API_KEY;
originalNodeEnv = process.env.NODE_ENV;
originalCwd = process.cwd();

process.env.HOME = tmpHome;
delete process.env.OPENAI_API_KEY;
delete process.env.NODE_ENV;
process.chdir(tmpDir);
});

afterEach(() => {
process.chdir(originalCwd);
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalKey === undefined) delete process.env.OPENAI_API_KEY;
else process.env.OPENAI_API_KEY = originalKey;
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalNodeEnv;
fs.rmSync(tmpDir, { recursive: true, force: true });
});

describe("resolveApiKeyInfo", () => {
test("uses ~/.gstack/openai.json before OPENAI_API_KEY", () => {
saveApiKey("sk-config");
process.env.OPENAI_API_KEY = "sk-env";

const resolution = resolveApiKeyInfo();

expect(resolution?.key).toBe("sk-config");
expect(resolution?.source).toBe("config");
expect(describeApiKeySource(resolution!)).toBe("~/.gstack/openai.json");
expect(resolveApiKey()).toBe("sk-config");
});

test("uses OPENAI_API_KEY when no config file exists", () => {
process.env.OPENAI_API_KEY = "sk-env";

const resolution = resolveApiKeyInfo();

expect(resolution?.key).toBe("sk-env");
expect(resolution?.source).toBe("env");
expect(resolution?.envFile).toBeUndefined();
expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable");
});

test("reports when OPENAI_API_KEY matches current-directory .env", () => {
fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-project\n");
process.env.OPENAI_API_KEY = "sk-project";

const resolution = resolveApiKeyInfo();

expect(resolution?.key).toBe("sk-project");
expect(resolution?.envFile).toBe(".env");
expect(describeApiKeySource(resolution!)).toBe("OPENAI_API_KEY environment variable (matches .env in current directory)");
expect(resolution?.warning).toContain("may bill that project's OpenAI account");
});

test("detects quoted and exported env-file values", () => {
fs.writeFileSync(path.join(tmpDir, ".env.local"), "export OPENAI_API_KEY=\"sk-local\"\n");
process.env.OPENAI_API_KEY = "sk-local";

const resolution = resolveApiKeyInfo();

expect(resolution?.envFile).toBe(".env.local");
expect(resolution?.warning).toContain(".env.local");
});

test("does not claim env-file source when values differ", () => {
fs.writeFileSync(path.join(tmpDir, ".env"), "OPENAI_API_KEY=sk-other\n");
process.env.OPENAI_API_KEY = "sk-shell";

const resolution = resolveApiKeyInfo();

expect(resolution?.key).toBe("sk-shell");
expect(resolution?.envFile).toBeUndefined();
expect(resolution?.warning).toBeUndefined();
});
});

describe("requireApiKey", () => {
test("prints source disclosure without leaking the key", () => {
process.env.OPENAI_API_KEY = "sk-secret-value";
const messages: string[] = [];
const originalError = console.error;
console.error = (...args: unknown[]) => {
messages.push(args.map(String).join(" "));
};

try {
expect(requireApiKey()).toBe("sk-secret-value");
} finally {
console.error = originalError;
}

const stderr = messages.join("\n");
expect(stderr).toContain("Using OpenAI key from OPENAI_API_KEY environment variable.");
expect(stderr).not.toContain("sk-secret-value");
});
});