diff --git a/packages/appstash/__tests__/config-store.test.ts b/packages/appstash/__tests__/config-store.test.ts index db3f39b..3e2d337 100644 --- a/packages/appstash/__tests__/config-store.test.ts +++ b/packages/appstash/__tests__/config-store.test.ts @@ -344,6 +344,235 @@ describe('createConfigStore', () => { }); }); + describe('vars (key-value store)', () => { + it('should return null for getVar when no context is active', () => { + const store = createStore(); + expect(store.getVar('orgId')).toBeNull(); + }); + + it('should throw on setVar when no context is active', () => { + const store = createStore(); + expect(() => store.setVar('orgId', 'abc-123')).toThrow('No active context'); + }); + + it('should set and get a var in the current context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + store.setVar('orgId', 'abc-123'); + expect(store.getVar('orgId')).toBe('abc-123'); + }); + + it('should set and get a var by explicit context name', () => { + const store = createStore(); + store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' }); + store.setVar('orgId', 'staging-org', 'staging'); + expect(store.getVar('orgId', 'staging')).toBe('staging-org'); + }); + + it('should overwrite existing var', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + store.setVar('orgId', 'old'); + store.setVar('orgId', 'new'); + expect(store.getVar('orgId')).toBe('new'); + }); + + it('should return null for non-existent var', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + expect(store.getVar('nonexistent')).toBeNull(); + }); + + it('should delete a var', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + store.setVar('orgId', 'abc-123'); + const deleted = store.deleteVar('orgId'); + expect(deleted).toBe(true); + expect(store.getVar('orgId')).toBeNull(); + }); + + it('should return false when deleting non-existent var', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + expect(store.deleteVar('nonexistent')).toBe(false); + }); + + it('should return false for deleteVar when no context is active', () => { + const store = createStore(); + expect(store.deleteVar('orgId')).toBe(false); + }); + + it('should list all vars for current context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + store.setVar('orgId', 'abc-123'); + store.setVar('defaultDatabase', 'my-app'); + const vars = store.listVars(); + expect(vars).toEqual({ orgId: 'abc-123', defaultDatabase: 'my-app' }); + }); + + it('should return empty object for listVars when no context is active', () => { + const store = createStore(); + expect(store.listVars()).toEqual({}); + }); + + it('should isolate vars between contexts', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' }); + + store.setVar('orgId', 'prod-org', 'production'); + store.setVar('orgId', 'staging-org', 'staging'); + + expect(store.getVar('orgId', 'production')).toBe('prod-org'); + expect(store.getVar('orgId', 'staging')).toBe('staging-org'); + }); + }); + + describe('getClientConfig', () => { + it('should return endpoint and auth header from store', () => { + const store = createStore(); + store.createContext('production', { + endpoint: 'https://api.example.com/graphql', + targets: { + auth: { endpoint: 'https://auth.example.com/graphql' }, + public: { endpoint: 'https://public.example.com/graphql' }, + }, + }); + store.setCurrentContext('production'); + store.setCredentials('production', { token: 'my-jwt-token' }); + + const config = store.getClientConfig('auth'); + expect(config.endpoint).toBe('https://auth.example.com/graphql'); + expect(config.headers['Authorization']).toBe('Bearer my-jwt-token'); + }); + + it('should return config for explicit context name', () => { + const store = createStore(); + store.createContext('staging', { + endpoint: 'https://staging.example.com/graphql', + targets: { + public: { endpoint: 'https://public.staging.example.com/graphql' }, + }, + }); + store.setCredentials('staging', { token: 'staging-token' }); + + const config = store.getClientConfig('public', 'staging'); + expect(config.endpoint).toBe('https://public.staging.example.com/graphql'); + expect(config.headers['Authorization']).toBe('Bearer staging-token'); + }); + + it('should return config without auth header when no credentials', () => { + const store = createStore(); + store.createContext('production', { + endpoint: 'https://api.example.com/graphql', + }); + store.setCurrentContext('production'); + + const config = store.getClientConfig('public'); + expect(config.endpoint).toBe('https://api.example.com/graphql'); + expect(config.headers['Authorization']).toBeUndefined(); + }); + + it('should fall back to env vars when no context exists', () => { + const store = createStore(); + process.env['TESTAPP_PUBLIC_ENDPOINT'] = 'https://env.example.com/graphql'; + process.env['TESTAPP_TOKEN'] = 'env-token'; + + try { + const config = store.getClientConfig('public'); + expect(config.endpoint).toBe('https://env.example.com/graphql'); + expect(config.headers['Authorization']).toBe('Bearer env-token'); + } finally { + delete process.env['TESTAPP_PUBLIC_ENDPOINT']; + delete process.env['TESTAPP_TOKEN']; + } + }); + + it('should fall back to generic endpoint env var', () => { + const store = createStore(); + process.env['TESTAPP_ENDPOINT'] = 'https://generic.example.com/graphql'; + + try { + const config = store.getClientConfig('auth'); + expect(config.endpoint).toBe('https://generic.example.com/graphql'); + expect(config.headers['Authorization']).toBeUndefined(); + } finally { + delete process.env['TESTAPP_ENDPOINT']; + } + }); + + it('should prefer target-specific env var over generic', () => { + const store = createStore(); + process.env['TESTAPP_AUTH_ENDPOINT'] = 'https://auth-specific.example.com/graphql'; + process.env['TESTAPP_ENDPOINT'] = 'https://generic.example.com/graphql'; + process.env['TESTAPP_TOKEN'] = 'env-token'; + + try { + const config = store.getClientConfig('auth'); + expect(config.endpoint).toBe('https://auth-specific.example.com/graphql'); + } finally { + delete process.env['TESTAPP_AUTH_ENDPOINT']; + delete process.env['TESTAPP_ENDPOINT']; + delete process.env['TESTAPP_TOKEN']; + } + }); + + it('should throw with actionable error when nothing is configured', () => { + const store = createStore(); + expect(() => store.getClientConfig('public')).toThrow( + /No configuration found for target "public"/ + ); + expect(() => store.getClientConfig('public')).toThrow( + /TESTAPP_PUBLIC_ENDPOINT/ + ); + }); + + it('should prioritize store over env vars', () => { + const store = createStore(); + store.createContext('production', { + endpoint: 'https://api.example.com/graphql', + targets: { + public: { endpoint: 'https://store.example.com/graphql' }, + }, + }); + store.setCurrentContext('production'); + store.setCredentials('production', { token: 'store-token' }); + process.env['TESTAPP_PUBLIC_ENDPOINT'] = 'https://env.example.com/graphql'; + process.env['TESTAPP_TOKEN'] = 'env-token'; + + try { + const config = store.getClientConfig('public'); + expect(config.endpoint).toBe('https://store.example.com/graphql'); + expect(config.headers['Authorization']).toBe('Bearer store-token'); + } finally { + delete process.env['TESTAPP_PUBLIC_ENDPOINT']; + delete process.env['TESTAPP_TOKEN']; + } + }); + + it('should fall back to main endpoint when target not in store targets', () => { + const store = createStore(); + store.createContext('production', { + endpoint: 'https://api.example.com/graphql', + targets: { + auth: { endpoint: 'https://auth.example.com/graphql' }, + }, + }); + store.setCurrentContext('production'); + + const config = store.getClientConfig('public'); + expect(config.endpoint).toBe('https://api.example.com/graphql'); + }); + }); + describe('full workflow', () => { it('should support the complete context + auth workflow', () => { const store = createStore(); diff --git a/packages/appstash/src/config-store.ts b/packages/appstash/src/config-store.ts index 540e132..29ba6f8 100644 --- a/packages/appstash/src/config-store.ts +++ b/packages/appstash/src/config-store.ts @@ -32,6 +32,11 @@ export interface ConfigStoreOptions { baseDir?: string; } +export interface ClientConfig { + endpoint: string; + headers: Record; +} + export interface ConfigStore { loadSettings(): GlobalSettings; saveSettings(settings: GlobalSettings): void; @@ -48,19 +53,24 @@ export interface ConfigStore { getCredentials(contextName: string): ContextCredentials | null; removeCredentials(contextName: string): boolean; hasValidCredentials(contextName: string): boolean; -} -const DEFAULT_SETTINGS: GlobalSettings = {}; + setVar(key: string, value: string, contextName?: string): void; + getVar(key: string, contextName?: string): string | null; + deleteVar(key: string, contextName?: string): boolean; + listVars(contextName?: string): Record; + + getClientConfig(targetName: string, contextName?: string): ClientConfig; +} function readJson(filePath: string, fallback: T): T { if (fs.existsSync(filePath)) { try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { - return fallback; + return JSON.parse(JSON.stringify(fallback)); } } - return fallback; + return JSON.parse(JSON.stringify(fallback)); } function writeJson(filePath: string, data: unknown, mode?: number): void { @@ -92,7 +102,7 @@ export function createConfigStore(toolName: string, options?: ConfigStoreOptions } function loadSettings(): GlobalSettings { - return readJson(settingsPath(), DEFAULT_SETTINGS); + return readJson(settingsPath(), {}); } function saveSettings(settings: GlobalSettings): void { @@ -226,6 +236,105 @@ export function createConfigStore(toolName: string, options?: ConfigStoreOptions return ctx.endpoint; } + function varsPath(ctxName: string): string { + const varsDir = resolve(dirs, 'config', 'vars'); + if (!fs.existsSync(varsDir)) { + fs.mkdirSync(varsDir, { recursive: true }); + } + return path.join(varsDir, `${ctxName}.json`); + } + + function loadVars(ctxName: string): Record { + return readJson>(varsPath(ctxName), {}); + } + + function saveVars(ctxName: string, vars: Record): void { + writeJson(varsPath(ctxName), vars); + } + + function resolveContextName(contextName?: string): string | null { + if (contextName) return contextName; + const settings = loadSettings(); + return settings.currentContext || null; + } + + function setVar(key: string, value: string, contextName?: string): void { + const ctxName = resolveContextName(contextName); + if (!ctxName) { + throw new Error('No active context. Run "context create" or "context use" first.'); + } + const vars = loadVars(ctxName); + vars[key] = value; + saveVars(ctxName, vars); + } + + function getVar(key: string, contextName?: string): string | null { + const ctxName = resolveContextName(contextName); + if (!ctxName) return null; + const vars = loadVars(ctxName); + return vars[key] ?? null; + } + + function deleteVar(key: string, contextName?: string): boolean { + const ctxName = resolveContextName(contextName); + if (!ctxName) return false; + const vars = loadVars(ctxName); + if (key in vars) { + delete vars[key]; + saveVars(ctxName, vars); + return true; + } + return false; + } + + function listVars(contextName?: string): Record { + const ctxName = resolveContextName(contextName); + if (!ctxName) return {}; + return loadVars(ctxName); + } + + function getClientConfig(targetName: string, contextName?: string): ClientConfig { + const envPrefix = toolName.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + const targetSuffix = targetName.toUpperCase().replace(/[^A-Z0-9]/g, '_'); + + // Tier 1: Try appstash store + const ctx = contextName ? loadContext(contextName) : getCurrentContext(); + if (ctx) { + const endpoint = getTargetEndpoint(targetName, ctx.name); + const headers: Record = {}; + if (hasValidCredentials(ctx.name)) { + const creds = getCredentials(ctx.name); + if (creds?.token) { + headers['Authorization'] = `Bearer ${creds.token}`; + } + } + if (endpoint) { + return { endpoint, headers }; + } + } + + // Tier 2: Try env vars + const envToken = process.env[`${envPrefix}_TOKEN`]; + const envEndpoint = + process.env[`${envPrefix}_${targetSuffix}_ENDPOINT`] || + process.env[`${envPrefix}_ENDPOINT`]; + + if (envEndpoint) { + const headers: Record = {}; + if (envToken) { + headers['Authorization'] = `Bearer ${envToken}`; + } + return { endpoint: envEndpoint, headers }; + } + + // Tier 3: Throw with actionable error + throw new Error( + `No configuration found for target "${targetName}". ` + + `Set up a context with "${toolName} context create" and authenticate with "${toolName} auth", ` + + `or set ${envPrefix}_${targetSuffix}_ENDPOINT and ${envPrefix}_TOKEN environment variables.` + ); + } + return { loadSettings, saveSettings, @@ -240,5 +349,10 @@ export function createConfigStore(toolName: string, options?: ConfigStoreOptions getCredentials, removeCredentials, hasValidCredentials, + setVar, + getVar, + deleteVar, + listVars, + getClientConfig, }; } diff --git a/packages/appstash/src/index.ts b/packages/appstash/src/index.ts index a649900..36ee824 100644 --- a/packages/appstash/src/index.ts +++ b/packages/appstash/src/index.ts @@ -275,6 +275,7 @@ export function resolve( export { createConfigStore } from './config-store'; export type { + ClientConfig, ConfigStore, ConfigStoreOptions, ContextConfig,