/** * Configuration resolution for the Deepline SDK and SDK CLI. * * The public SDK CLI env contract is deliberately small: * * - `DEEPLINE_HOST_URL`: Deepline API/app host, for example `https://code.deepline.com` * - `DEEPLINE_API_KEY`: API key for that host and workspace * * The CLI also stores the same two keys in per-host files under * `~/.local/deepline//.env`, created by `deepline auth register`. * * Resolution order: * * Base URL: * 1. `options.baseUrl` * 2. `DEEPLINE_HOST_URL` * 3. nearest project `.env.deepline` * 4. Cowork mounted workspace `.env.deepline` * 5. production host auth file * 6. production fallback: `https://code.deepline.com` * * API key: * 1. `options.apiKey` * 2. `DEEPLINE_API_KEY` * 3. nearest project `.env.deepline` * 4. Cowork mounted workspace `.env.deepline` * 5. host auth file for the resolved base URL * * App/runtime env files such as `.env`, `.env.local`, and `.env.worktree` do * not route the SDK CLI. Put CLI routing in `.env.deepline`. * * @module */ import { existsSync, mkdirSync, readdirSync, realpathSync, readFileSync, statSync, writeFileSync, } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import type { DeeplineClientOptions, ResolvedConfig } from './types.js'; import { ConfigError } from './errors.js'; export const HOST_URL_ENV = 'DEEPLINE_HOST_URL'; export const API_KEY_ENV = 'DEEPLINE_API_KEY'; /** Production API base URL. */ const PROD_URL = 'https://code.deepline.com'; /** Default request timeout: 60 seconds. */ const DEFAULT_TIMEOUT = 60_000; /** Default retry count for transient failures. */ const DEFAULT_MAX_RETRIES = 3; const PROJECT_DEEPLINE_ENV_FILE = '.env.deepline'; const COWORK_IGNORED_WORKSPACE_DIRS = new Set([ '.auto-memory', '.claude', '.remote-plugins', 'outputs', 'plugins', 'uploads', ]); const COWORK_PROJECT_MARKERS = [ '.deepline', '.env.deepline', '.git', 'AGENTS.md', 'package.json', 'pyproject.toml', ]; type EnvValues = Record; type ProjectEnvCandidate = { filePath: string; env: EnvValues; source: 'nearest' | 'cowork'; }; export type ProjectAuthSource = ProjectEnvCandidate; export type ProjectPinTarget = | { ok: true; dir: string; source: 'nearest' | 'cowork' | 'cwd'; } | { ok: false; reason: 'ambiguous_cowork_project'; candidates: string[]; }; /** * Convert a base URL to a filesystem-safe slug for per-host config storage. * * @example * ```typescript * baseUrlSlug('http://localhost:3000') // "localhost-3000" * baseUrlSlug('https://code.deepline.com') // "code-deepline-com" * baseUrlSlug('https://example.com:8080') // "example-com-8080" * ``` */ function baseUrlSlug(baseUrl: string): string { let url: URL; try { url = new URL(baseUrl); } catch { return 'unknown'; } const host = url.hostname || 'unknown'; const port = url.port ? Number.parseInt(url.port, 10) : null; let slug = host.replace(/[^a-zA-Z0-9]/g, '-'); if (port && port !== 80 && port !== 443) { slug = `${slug}-${port}`; } return slug.toLowerCase().replace(/^-+|-+$/g, ''); } /** * Parse a simple `KEY=VALUE` env file. Handles `#` comments and quoted values. */ function parseEnvFile(filePath: string): EnvValues { if (!existsSync(filePath)) return {}; const env: EnvValues = {}; const content = readFileSync(filePath, 'utf-8'); for (const line of content.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const eqIndex = trimmed.indexOf('='); if (eqIndex < 0) continue; const key = trimmed.slice(0, eqIndex).trim(); let value = trimmed.slice(eqIndex + 1).trim(); if ( value.length >= 2 && ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) ) { value = value.slice(1, -1); } if (key && value) { env[key] = value; } } return env; } function findNearestEnvFile( name: string, startDir: string = process.cwd(), ): string | null { let current = resolve(startDir); while (true) { const filePath = join(current, name); if (existsSync(filePath)) return filePath; const parent = dirname(current); if (parent === current) return null; current = parent; } } function isDirectory(path: string): boolean { try { return statSync(path).isDirectory(); } catch { return false; } } function canonicalPath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } } function isTruthy(value: string | undefined): boolean { return /^(1|true|yes|on)$/i.test(value?.trim() ?? ''); } function sessionRootFromPath(path: string | undefined): string | null { const trimmed = path?.trim(); if (!trimmed) return null; const match = /^\/sessions\/[^/]+(?=\/|$)/.exec(trimmed); return match?.[0] ?? null; } function coworkSessionRoot(): string | null { const home = process.env.HOME?.trim(); const homeSessionRoot = sessionRootFromPath(home); if (homeSessionRoot && isDirectory(join(homeSessionRoot, 'mnt'))) { return homeSessionRoot; } const cwdSessionRoot = sessionRootFromPath(process.cwd()); if (cwdSessionRoot && isDirectory(join(cwdSessionRoot, 'mnt'))) { return cwdSessionRoot; } if (isTruthy(process.env.CLAUDE_CODE_REMOTE) && home) { const mountedRoot = join(home, 'mnt'); if (isDirectory(mountedRoot)) return resolve(home); } return null; } function isCoworkLikeSandbox(): boolean { const home = process.env.HOME?.trim(); return ( isTruthy(process.env.CLAUDE_CODE_REMOTE) || sessionRootFromPath(home) !== null || sessionRootFromPath(process.cwd()) !== null ); } function coworkProjectScore(path: string): number { let score = 0; for (const marker of COWORK_PROJECT_MARKERS) { if (existsSync(join(path, marker))) score += 1; } return score; } function listCoworkWorkspaceDirCandidates(): string[] { if (!isCoworkLikeSandbox()) { return []; } const explicitProjectDir = process.env.CLAUDE_PROJECT_DIR?.trim(); if (explicitProjectDir && isDirectory(explicitProjectDir)) { return [resolve(explicitProjectDir)]; } const sessionRoot = coworkSessionRoot(); if (!sessionRoot) return []; const mountedRoot = join(sessionRoot, 'mnt'); if (!isDirectory(mountedRoot)) return []; let names: string[]; try { names = readdirSync(mountedRoot).sort(); } catch { return []; } const candidates: string[] = []; for (const name of names) { if (name.startsWith('.') || COWORK_IGNORED_WORKSPACE_DIRS.has(name)) { continue; } const candidate = join(mountedRoot, name); if (isDirectory(candidate)) candidates.push(candidate); } if (candidates.length <= 1) return candidates; const projectLike = candidates.filter( (candidate) => coworkProjectScore(candidate) > 0, ); return projectLike.length > 0 ? projectLike : candidates; } function isInIgnoredCoworkMount(path: string): boolean { const sessionRoot = coworkSessionRoot(); if (!sessionRoot) return false; const mountedRoot = canonicalPath(join(sessionRoot, 'mnt')); const resolvedPath = canonicalPath(path); const prefix = `${mountedRoot}/`; if (!resolvedPath.startsWith(prefix)) return false; const relativePath = resolvedPath.slice(prefix.length); const mountName = relativePath.split('/')[0]; return ( mountName.startsWith('.') || COWORK_IGNORED_WORKSPACE_DIRS.has(mountName) ); } function detectCoworkWorkspaceDir(): string | null { const candidates = listCoworkWorkspaceDirCandidates(); return candidates.length === 1 ? candidates[0] : null; } function loadProjectEnvCandidates( startDir: string = process.cwd(), ): ProjectEnvCandidate[] { const filePaths: string[] = []; const sources = new Map(); const nearestFile = findNearestEnvFile(PROJECT_DEEPLINE_ENV_FILE, startDir); if (nearestFile && !isInIgnoredCoworkMount(nearestFile)) { filePaths.push(nearestFile); sources.set(resolve(nearestFile), 'nearest'); } const coworkWorkspaceDir = detectCoworkWorkspaceDir(); if (coworkWorkspaceDir) { const coworkFile = join(coworkWorkspaceDir, PROJECT_DEEPLINE_ENV_FILE); if ( existsSync(coworkFile) && !filePaths.some((filePath) => resolve(filePath) === resolve(coworkFile)) ) { filePaths.push(coworkFile); sources.set(resolve(coworkFile), 'cowork'); } } return filePaths.map((filePath) => ({ filePath, env: parseEnvFile(filePath), source: sources.get(resolve(filePath)) ?? 'nearest', })); } function loadProjectDeeplineEnv(startDir = process.cwd()): EnvValues { return loadProjectEnvCandidates(startDir)[0]?.env ?? {}; } function normalizeBaseUrl(baseUrl: string | null | undefined): string { const trimmed = baseUrl?.trim().replace(/\/+$/, '') ?? ''; if (!trimmed) return ''; try { const parsed = new URL(trimmed); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { return ''; } return parsed.toString().replace(/\/+$/, ''); } catch { return ''; } } function firstNonEmpty(...values: Array): string { for (const value of values) { const trimmed = value?.trim(); if (trimmed) return trimmed; } return ''; } function sdkCliConfigDir(baseUrl: string): string { const home = process.env.HOME?.trim() || homedir(); return join(home, '.local', 'deepline', baseUrlSlug(baseUrl || PROD_URL)); } export function sdkCliStateDirPath( baseUrl: string, homeDir: string = process.env.HOME?.trim() || homedir(), ): string { return join( homeDir, '.local', 'deepline', baseUrlSlug(baseUrl || PROD_URL), 'sdk-cli', ); } function sdkCliEnvFilePath(baseUrl: string): string { return join(sdkCliConfigDir(baseUrl), '.env'); } function loadCliEnv(baseUrl = PROD_URL): EnvValues { return parseEnvFile(sdkCliEnvFilePath(baseUrl)); } export function hostConfigDirPath(baseUrl: string): string { return sdkCliConfigDir(baseUrl); } export function hostEnvFilePath(baseUrl: string): string { return sdkCliEnvFilePath(baseUrl); } export function saveHostEnvValues(baseUrl: string, values: EnvValues): void { const filePath = sdkCliEnvFilePath(baseUrl); const dir = dirname(filePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } const existing = parseEnvFile(filePath); const merged = { ...existing, ...values }; const allowedKeys = new Set([HOST_URL_ENV, API_KEY_ENV]); const lines = Object.entries(merged) .filter(([key, value]) => allowedKeys.has(key) && value !== '') .map(([key, value]) => `${key}=${value}`); writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf-8'); } function loadGlobalCliEnv(): EnvValues { return loadCliEnv(PROD_URL); } /** * Auto-detect the best base URL when none is explicitly provided. */ function autoDetectBaseUrl(): string { const projectEnvs = loadProjectEnvCandidates(); const globalEnv = loadGlobalCliEnv(); return ( normalizeBaseUrl(process.env[HOST_URL_ENV] ?? '') || firstNonEmpty( ...projectEnvs.map(({ env }) => normalizeBaseUrl(env[HOST_URL_ENV])), ) || normalizeBaseUrl(globalEnv[HOST_URL_ENV] ?? '') || PROD_URL ); } export function resolveApiKeyForBaseUrl( baseUrl: string, explicitApiKey?: string | null, ): string { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); const projectEnvs = loadProjectEnvCandidates(); const cliEnv = loadCliEnv(normalizedBaseUrl || baseUrl); return firstNonEmpty( explicitApiKey, process.env[API_KEY_ENV], ...projectEnvs.map(({ env }) => { const projectBaseUrl = normalizeBaseUrl(env[HOST_URL_ENV] ?? ''); return projectBaseUrl === normalizedBaseUrl ? env[API_KEY_ENV] : ''; }), cliEnv[API_KEY_ENV], ); } export function resolveProjectApiKeyForBaseUrl( baseUrl: string, startDir: string = process.cwd(), ): string { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); return firstNonEmpty( ...loadProjectEnvCandidates(startDir).map(({ env }) => { const projectBaseUrl = normalizeBaseUrl(env[HOST_URL_ENV] ?? ''); return projectBaseUrl === normalizedBaseUrl ? env[API_KEY_ENV] : ''; }), ); } export function resolveGlobalApiKeyForBaseUrl(baseUrl: string): string { return firstNonEmpty( process.env[API_KEY_ENV], loadCliEnv(normalizeBaseUrl(baseUrl) || baseUrl)[API_KEY_ENV], ); } function getResolvedProjectAuthSource( baseUrl: string, apiKey: string, startDir: string = process.cwd(), ): ProjectAuthSource | null { const normalizedBaseUrl = normalizeBaseUrl(baseUrl); const normalizedApiKey = apiKey.trim(); if (!normalizedBaseUrl || !normalizedApiKey) return null; return ( loadProjectEnvCandidates(startDir).find(({ env }) => { const projectBaseUrl = normalizeBaseUrl(env[HOST_URL_ENV] ?? ''); return ( projectBaseUrl === normalizedBaseUrl && (env[API_KEY_ENV] ?? '').trim() === normalizedApiKey ); }) ?? null ); } /** * Resolve SDK configuration from the public SDK CLI env contract. */ export function resolveConfig(options?: DeeplineClientOptions): ResolvedConfig { const baseUrl = normalizeBaseUrl( options?.baseUrl?.trim() || autoDetectBaseUrl(), ); if (!baseUrl) { throw new ConfigError( `Invalid ${HOST_URL_ENV}. Expected an http(s) URL such as https://code.deepline.com.`, ); } const apiKey = resolveApiKeyForBaseUrl(baseUrl, options?.apiKey); if (!apiKey) { throw new ConfigError( `No API key found. Set ${API_KEY_ENV}, add it to .env.deepline, or run: deepline auth register`, ); } return { apiKey, baseUrl, timeout: options?.timeout ?? DEFAULT_TIMEOUT, maxRetries: options?.maxRetries ?? DEFAULT_MAX_RETRIES, }; } function mergeProjectEnvFile(filePath: string, values: EnvValues): void { const existing = parseEnvFile(filePath); const merged = { ...existing, ...values }; const dir = dirname(filePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); ensureProjectEnvIsIgnored(dir); const allowedKeys = new Set([HOST_URL_ENV, API_KEY_ENV]); const lines = Object.entries(merged) .filter(([key, value]) => allowedKeys.has(key) && value !== '') .map(([key, value]) => `${key}=${value}`); writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf-8'); } function ensureProjectEnvIsIgnored(dir: string): void { ensureProjectPrivatePathsIgnored(dir, [ PROJECT_DEEPLINE_ENV_FILE, '.deepline/', ]); } export function ensureProjectPrivatePathsIgnored( dir: string, entries: readonly string[], ): void { const gitDir = findNearestGitCommonDir(dir); if (gitDir) { const excludePath = join(gitDir, 'info', 'exclude'); const existing = existsSync(excludePath) ? readFileSync(excludePath, 'utf-8') : ''; const existingEntries = new Set( existing .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean), ); const missing = entries.filter( (entry) => !existingEntries.has(entry) && !existingEntries.has(`/${entry}`), ); if (missing.length === 0) return; mkdirSync(dirname(excludePath), { recursive: true }); const prefix = existing && !existing.endsWith('\n') ? '\n' : ''; writeFileSync( excludePath, `${existing}${prefix}${missing.join('\n')}\n`, 'utf-8', ); return; } const gitignorePath = join(dir, '.gitignore'); const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; const existingEntries = new Set( existing .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean), ); const missing = entries.filter( (entry) => !existingEntries.has(entry) && !existingEntries.has(`/${entry}`), ); if (missing.length === 0) return; const prefix = existing && !existing.endsWith('\n') ? '\n' : ''; writeFileSync( gitignorePath, `${existing}${prefix}${missing.join('\n')}\n`, 'utf-8', ); } function findNearestGitCommonDir(startDir: string): string | null { let current = resolve(startDir); while (true) { const candidate = join(current, '.git'); if (existsSync(candidate)) { try { const stat = statSync(candidate); if (stat.isDirectory()) return candidate; if (stat.isFile()) { const match = readFileSync(candidate, 'utf8').match( /^gitdir:\s*(.+)$/m, ); const rawGitDir = match?.[1]?.trim(); if (rawGitDir) { const gitDir = isAbsolute(rawGitDir) ? rawGitDir : resolve(dirname(candidate), rawGitDir); const commonDirPath = join(gitDir, 'commondir'); if (!existsSync(commonDirPath)) return gitDir; const rawCommonDir = readFileSync(commonDirPath, 'utf8').trim(); return rawCommonDir ? isAbsolute(rawCommonDir) ? rawCommonDir : resolve(gitDir, rawCommonDir) : gitDir; } } } catch { return null; } } const parent = dirname(current); if (parent === current) return null; current = parent; } } export function saveProjectDeeplineEnvValues( values: EnvValues, startDir: string = process.cwd(), ): string[] { const target = resolveProjectPinTarget(startDir); if (!target.ok) { throw new ConfigError( `Cowork project folder is ambiguous. Candidate folders: ${target.candidates.join( ', ', )}. Set CLAUDE_PROJECT_DIR or cd into the intended project folder before running this command.`, ); } const filePath = join(target.dir, PROJECT_DEEPLINE_ENV_FILE); mergeProjectEnvFile(filePath, values); return [filePath]; } export function resolveProjectPinDir(startDir: string = process.cwd()): string { const target = resolveProjectPinTarget(startDir); if (!target.ok) { throw new ConfigError( `Cowork project folder is ambiguous. Candidate folders: ${target.candidates.join( ', ', )}. Set CLAUDE_PROJECT_DIR or cd into the intended project folder before running this command.`, ); } return target.dir; } export function resolveProjectPinTarget( startDir: string = process.cwd(), ): ProjectPinTarget { const nearestFile = findNearestEnvFile(PROJECT_DEEPLINE_ENV_FILE, startDir); if (nearestFile && !isInIgnoredCoworkMount(nearestFile)) { return { ok: true, dir: dirname(nearestFile), source: 'nearest' }; } const coworkCandidates = listCoworkWorkspaceDirCandidates(); if (coworkCandidates.length === 1) { return { ok: true, dir: coworkCandidates[0], source: 'cowork' }; } if (coworkCandidates.length > 1) { const resolvedStartDir = canonicalPath(startDir); const cwdCandidate = coworkCandidates.find((candidate) => { const resolvedCandidate = canonicalPath(candidate); return ( resolvedStartDir === resolvedCandidate || resolvedStartDir.startsWith(`${resolvedCandidate}/`) ); }); if (cwdCandidate) { return { ok: true, dir: cwdCandidate, source: 'cowork' }; } return { ok: false, reason: 'ambiguous_cowork_project', candidates: coworkCandidates, }; } return { ok: true, dir: resolve(startDir), source: 'cwd' }; } export function getActiveProjectAuthSource( startDir: string = process.cwd(), ): ProjectAuthSource | null { return loadProjectEnvCandidates(startDir)[0] ?? null; } export { baseUrlSlug, loadCliEnv, loadGlobalCliEnv, loadProjectDeeplineEnv, getResolvedProjectAuthSource, listCoworkWorkspaceDirCandidates, parseEnvFile, detectCoworkWorkspaceDir, autoDetectBaseUrl, PROD_URL, };