diff --git a/packages/appstash/__tests__/config-store.test.ts b/packages/appstash/__tests__/config-store.test.ts new file mode 100644 index 0000000..516b12c --- /dev/null +++ b/packages/appstash/__tests__/config-store.test.ts @@ -0,0 +1,298 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createConfigStore } from '../src'; + +describe('createConfigStore', () => { + let tempBase: string; + + beforeEach(() => { + tempBase = fs.mkdtempSync(path.join(os.tmpdir(), 'appstash-config-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempBase)) { + fs.rmSync(tempBase, { recursive: true, force: true }); + } + }); + + function createStore(toolName = 'testapp') { + return createConfigStore(toolName, { baseDir: tempBase }); + } + + describe('settings', () => { + it('should return default settings when none exist', () => { + const store = createStore(); + const settings = store.loadSettings(); + expect(settings).toEqual({}); + }); + + it('should save and load settings', () => { + const store = createStore(); + store.saveSettings({ currentContext: 'production' }); + const settings = store.loadSettings(); + expect(settings.currentContext).toBe('production'); + }); + + it('should overwrite existing settings', () => { + const store = createStore(); + store.saveSettings({ currentContext: 'staging' }); + store.saveSettings({ currentContext: 'production' }); + const settings = store.loadSettings(); + expect(settings.currentContext).toBe('production'); + }); + }); + + describe('context management', () => { + it('should create a context', () => { + const store = createStore(); + const ctx = store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + expect(ctx.name).toBe('production'); + expect(ctx.endpoint).toBe('https://api.example.com/graphql'); + expect(ctx.createdAt).toBeDefined(); + expect(ctx.updatedAt).toBeDefined(); + }); + + it('should load a created context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + const ctx = store.loadContext('production'); + expect(ctx).not.toBeNull(); + expect(ctx!.name).toBe('production'); + expect(ctx!.endpoint).toBe('https://api.example.com/graphql'); + }); + + it('should return null for non-existent context', () => { + const store = createStore(); + const ctx = store.loadContext('nonexistent'); + expect(ctx).toBeNull(); + }); + + it('should list all contexts', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' }); + const contexts = store.listContexts(); + expect(contexts).toHaveLength(2); + const names = contexts.map(c => c.name).sort(); + expect(names).toEqual(['production', 'staging']); + }); + + it('should return empty list when no contexts exist', () => { + const store = createStore(); + const contexts = store.listContexts(); + expect(contexts).toEqual([]); + }); + + it('should delete a context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + const deleted = store.deleteContext('production'); + expect(deleted).toBe(true); + expect(store.loadContext('production')).toBeNull(); + }); + + it('should return false when deleting non-existent context', () => { + const store = createStore(); + const deleted = store.deleteContext('nonexistent'); + expect(deleted).toBe(false); + }); + + it('should clear currentContext when deleting the active context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.setCurrentContext('production'); + store.deleteContext('production'); + const settings = store.loadSettings(); + expect(settings.currentContext).toBeUndefined(); + }); + }); + + describe('current context', () => { + it('should return null when no current context is set', () => { + const store = createStore(); + const ctx = store.getCurrentContext(); + expect(ctx).toBeNull(); + }); + + it('should set and get current context', () => { + const store = createStore(); + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + const result = store.setCurrentContext('production'); + expect(result).toBe(true); + const ctx = store.getCurrentContext(); + expect(ctx).not.toBeNull(); + expect(ctx!.name).toBe('production'); + }); + + it('should return false when setting non-existent context as current', () => { + const store = createStore(); + const result = store.setCurrentContext('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('credentials', () => { + it('should return null for non-existent credentials', () => { + const store = createStore(); + const creds = store.getCredentials('production'); + expect(creds).toBeNull(); + }); + + it('should set and get credentials', () => { + const store = createStore(); + store.setCredentials('production', { token: 'abc123' }); + const creds = store.getCredentials('production'); + expect(creds).not.toBeNull(); + expect(creds!.token).toBe('abc123'); + }); + + it('should store optional credential fields', () => { + const store = createStore(); + store.setCredentials('production', { + token: 'abc123', + expiresAt: '2099-12-31T23:59:59Z', + refreshToken: 'refresh456', + }); + const creds = store.getCredentials('production'); + expect(creds!.expiresAt).toBe('2099-12-31T23:59:59Z'); + expect(creds!.refreshToken).toBe('refresh456'); + }); + + it('should overwrite existing credentials', () => { + const store = createStore(); + store.setCredentials('production', { token: 'old' }); + store.setCredentials('production', { token: 'new' }); + const creds = store.getCredentials('production'); + expect(creds!.token).toBe('new'); + }); + + it('should remove credentials', () => { + const store = createStore(); + store.setCredentials('production', { token: 'abc123' }); + const removed = store.removeCredentials('production'); + expect(removed).toBe(true); + expect(store.getCredentials('production')).toBeNull(); + }); + + it('should return false when removing non-existent credentials', () => { + const store = createStore(); + const removed = store.removeCredentials('production'); + expect(removed).toBe(false); + }); + + it('should keep credentials for other contexts when removing one', () => { + const store = createStore(); + store.setCredentials('production', { token: 'prod-token' }); + store.setCredentials('staging', { token: 'staging-token' }); + store.removeCredentials('production'); + expect(store.getCredentials('production')).toBeNull(); + expect(store.getCredentials('staging')!.token).toBe('staging-token'); + }); + }); + + describe('hasValidCredentials', () => { + it('should return false when no credentials exist', () => { + const store = createStore(); + expect(store.hasValidCredentials('production')).toBe(false); + }); + + it('should return true for valid non-expiring token', () => { + const store = createStore(); + store.setCredentials('production', { token: 'abc123' }); + expect(store.hasValidCredentials('production')).toBe(true); + }); + + it('should return true for token with future expiry', () => { + const store = createStore(); + store.setCredentials('production', { + token: 'abc123', + expiresAt: '2099-12-31T23:59:59Z', + }); + expect(store.hasValidCredentials('production')).toBe(true); + }); + + it('should return false for expired token', () => { + const store = createStore(); + store.setCredentials('production', { + token: 'abc123', + expiresAt: '2020-01-01T00:00:00Z', + }); + expect(store.hasValidCredentials('production')).toBe(false); + }); + + it('should return false for empty token', () => { + const store = createStore(); + store.setCredentials('production', { token: '' }); + expect(store.hasValidCredentials('production')).toBe(false); + }); + }); + + describe('isolation between tools', () => { + it('should isolate config between different tool names', () => { + const store1 = createStore('app1'); + const store2 = createStore('app2'); + + store1.createContext('production', { endpoint: 'https://app1.example.com/graphql' }); + store2.createContext('production', { endpoint: 'https://app2.example.com/graphql' }); + + const ctx1 = store1.loadContext('production'); + const ctx2 = store2.loadContext('production'); + + expect(ctx1!.endpoint).toBe('https://app1.example.com/graphql'); + expect(ctx2!.endpoint).toBe('https://app2.example.com/graphql'); + }); + + it('should isolate credentials between different tool names', () => { + const store1 = createStore('app1'); + const store2 = createStore('app2'); + + store1.setCredentials('production', { token: 'token1' }); + store2.setCredentials('production', { token: 'token2' }); + + expect(store1.getCredentials('production')!.token).toBe('token1'); + expect(store2.getCredentials('production')!.token).toBe('token2'); + }); + }); + + describe('full workflow', () => { + it('should support the complete context + auth workflow', () => { + const store = createStore(); + + // Create contexts + store.createContext('production', { endpoint: 'https://api.example.com/graphql' }); + store.createContext('staging', { endpoint: 'https://staging.example.com/graphql' }); + + // Set current context + store.setCurrentContext('production'); + expect(store.getCurrentContext()!.name).toBe('production'); + + // Set credentials + store.setCredentials('production', { token: 'prod-token' }); + expect(store.hasValidCredentials('production')).toBe(true); + + // Switch context + store.setCurrentContext('staging'); + expect(store.getCurrentContext()!.name).toBe('staging'); + expect(store.hasValidCredentials('staging')).toBe(false); + + // Auth staging + store.setCredentials('staging', { token: 'staging-token' }); + expect(store.hasValidCredentials('staging')).toBe(true); + + // List contexts + const contexts = store.listContexts(); + expect(contexts).toHaveLength(2); + + // Logout from production + store.removeCredentials('production'); + expect(store.hasValidCredentials('production')).toBe(false); + expect(store.hasValidCredentials('staging')).toBe(true); + + // Delete staging + store.deleteContext('staging'); + expect(store.listContexts()).toHaveLength(1); + expect(store.getCurrentContext()).toBeNull(); + }); + }); +}); diff --git a/packages/appstash/src/config-store.ts b/packages/appstash/src/config-store.ts new file mode 100644 index 0000000..f417f2c --- /dev/null +++ b/packages/appstash/src/config-store.ts @@ -0,0 +1,220 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { appstash, resolve } from './index'; + +export interface ContextConfig { + name: string; + endpoint: string; + createdAt: string; + updatedAt: string; +} + +export interface ContextCredentials { + token: string; + expiresAt?: string; + refreshToken?: string; +} + +export interface Credentials { + tokens: Record; +} + +export interface GlobalSettings { + currentContext?: string; +} + +export interface ConfigStoreOptions { + baseDir?: string; +} + +export interface ConfigStore { + loadSettings(): GlobalSettings; + saveSettings(settings: GlobalSettings): void; + + createContext(name: string, options: { endpoint: string }): ContextConfig; + loadContext(name: string): ContextConfig | null; + listContexts(): ContextConfig[]; + deleteContext(name: string): boolean; + getCurrentContext(): ContextConfig | null; + setCurrentContext(name: string): boolean; + + setCredentials(contextName: string, creds: ContextCredentials): void; + getCredentials(contextName: string): ContextCredentials | null; + removeCredentials(contextName: string): boolean; + hasValidCredentials(contextName: string): boolean; +} + +const DEFAULT_SETTINGS: GlobalSettings = {}; + +function readJson(filePath: string, fallback: T): T { + if (fs.existsSync(filePath)) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch { + return fallback; + } + } + return fallback; +} + +function writeJson(filePath: string, data: unknown, mode?: number): void { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + const options: fs.WriteFileOptions = mode ? { mode } : {}; + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), options); +} + +export function createConfigStore(toolName: string, options?: ConfigStoreOptions): ConfigStore { + const dirs = appstash(toolName, { ensure: true, baseDir: options?.baseDir }); + + function settingsPath(): string { + return resolve(dirs, 'config', 'settings.json'); + } + + function credentialsPath(): string { + return resolve(dirs, 'config', 'credentials.json'); + } + + function contextPath(name: string): string { + const contextsDir = resolve(dirs, 'config', 'contexts'); + if (!fs.existsSync(contextsDir)) { + fs.mkdirSync(contextsDir, { recursive: true }); + } + return path.join(contextsDir, `${name}.json`); + } + + function loadSettings(): GlobalSettings { + return readJson(settingsPath(), DEFAULT_SETTINGS); + } + + function saveSettings(settings: GlobalSettings): void { + writeJson(settingsPath(), settings); + } + + function loadContext(name: string): ContextConfig | null { + return readJson(contextPath(name), null); + } + + function createContext(name: string, options: { endpoint: string }): ContextConfig { + const now = new Date().toISOString(); + const context: ContextConfig = { + name, + endpoint: options.endpoint, + createdAt: now, + updatedAt: now, + }; + writeJson(contextPath(name), context); + return context; + } + + function listContexts(): ContextConfig[] { + const contextsDir = resolve(dirs, 'config', 'contexts'); + if (!fs.existsSync(contextsDir)) { + return []; + } + const files = fs.readdirSync(contextsDir).filter(f => f.endsWith('.json')); + const contexts: ContextConfig[] = []; + for (const file of files) { + try { + const content = fs.readFileSync(path.join(contextsDir, file), 'utf8'); + contexts.push(JSON.parse(content)); + } catch { + // skip invalid files + } + } + return contexts; + } + + function deleteContext(name: string): boolean { + const p = contextPath(name); + if (fs.existsSync(p)) { + fs.unlinkSync(p); + const settings = loadSettings(); + if (settings.currentContext === name) { + settings.currentContext = undefined; + saveSettings(settings); + } + return true; + } + return false; + } + + function getCurrentContext(): ContextConfig | null { + const settings = loadSettings(); + if (settings.currentContext) { + return loadContext(settings.currentContext); + } + return null; + } + + function setCurrentContext(name: string): boolean { + const context = loadContext(name); + if (!context) { + return false; + } + const settings = loadSettings(); + settings.currentContext = name; + saveSettings(settings); + return true; + } + + function loadCredentials(): Credentials { + return readJson(credentialsPath(), { tokens: {} }); + } + + function saveCredentials(credentials: Credentials): void { + writeJson(credentialsPath(), credentials, 0o600); + } + + function setCredentials(contextName: string, creds: ContextCredentials): void { + const credentials = loadCredentials(); + credentials.tokens[contextName] = creds; + saveCredentials(credentials); + } + + function getCredentials(contextName: string): ContextCredentials | null { + const credentials = loadCredentials(); + return credentials.tokens[contextName] || null; + } + + function removeCredentials(contextName: string): boolean { + const credentials = loadCredentials(); + if (credentials.tokens[contextName]) { + delete credentials.tokens[contextName]; + saveCredentials(credentials); + return true; + } + return false; + } + + function hasValidCredentials(contextName: string): boolean { + const creds = getCredentials(contextName); + if (!creds || !creds.token) { + return false; + } + if (creds.expiresAt) { + const expiresAt = new Date(creds.expiresAt); + if (expiresAt <= new Date()) { + return false; + } + } + return true; + } + + return { + loadSettings, + saveSettings, + createContext, + loadContext, + listContexts, + deleteContext, + getCurrentContext, + setCurrentContext, + setCredentials, + getCredentials, + removeCredentials, + hasValidCredentials, + }; +} diff --git a/packages/appstash/src/index.ts b/packages/appstash/src/index.ts index b5772df..6070721 100644 --- a/packages/appstash/src/index.ts +++ b/packages/appstash/src/index.ts @@ -272,3 +272,13 @@ export function resolve( ): string { return path.join(dirs[kind], ...parts); } + +export { createConfigStore } from './config-store'; +export type { + ConfigStore, + ConfigStoreOptions, + ContextConfig, + ContextCredentials, + Credentials, + GlobalSettings, +} from './config-store';