diff --git a/.changeset/obsessiondb-auth-backfill.md b/.changeset/obsessiondb-auth-backfill.md new file mode 100644 index 0000000..db7fa59 --- /dev/null +++ b/.changeset/obsessiondb-auth-backfill.md @@ -0,0 +1,5 @@ +--- +"@chkit/plugin-obsessiondb": patch +--- + +Add device-code authentication (login/logout/whoami) and remote backfill routing via ObsessionDB backend. diff --git a/.changeset/plugin-command-hook.md b/.changeset/plugin-command-hook.md new file mode 100644 index 0000000..b85e321 --- /dev/null +++ b/.changeset/plugin-command-hook.md @@ -0,0 +1,5 @@ +--- +"chkit": patch +--- + +Add `onBeforePluginCommand` hook allowing plugins to intercept other plugins' commands. diff --git a/packages/cli/src/bin/plugin-runtime.test.ts b/packages/cli/src/bin/plugin-runtime.test.ts new file mode 100644 index 0000000..8ba8d16 --- /dev/null +++ b/packages/cli/src/bin/plugin-runtime.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, test } from 'bun:test' +import type { ResolvedChxConfig } from '@chkit/core' +import type { ChxPlugin } from '../plugins.js' +import { loadPluginRuntime } from './plugin-runtime.js' + +function makeConfig(plugins: ChxPlugin[]): ResolvedChxConfig { + return { + schema: [], + outDir: '.chkit', + migrationsDir: 'migrations', + metaDir: '.chkit/meta', + plugins: plugins.map((plugin) => ({ + plugin, + name: plugin.manifest.name, + enabled: true, + options: {}, + })), + check: { unusedTables: true, unusedColumns: true }, + safety: { allowDestructive: false }, + } +} + +function makeCommandContext() { + return { + config: makeConfig([]), + configPath: '/fake/clickhouse.config.ts', + jsonMode: false, + args: [], + flags: {}, + print: () => {}, + } +} + +describe('onBeforePluginCommand', () => { + test('returning handled: true skips the original command', async () => { + let originalRan = false + const targetPlugin: ChxPlugin = { + manifest: { name: 'backfill', apiVersion: 1 }, + commands: [ + { + name: 'run', + run: () => { + originalRan = true + return 0 + }, + }, + ], + } + + const interceptor: ChxPlugin = { + manifest: { name: 'obsessiondb', apiVersion: 1 }, + hooks: { + onBeforePluginCommand: () => ({ handled: true, exitCode: 42 }), + }, + } + + const config = makeConfig([targetPlugin, interceptor]) + const runtime = await loadPluginRuntime({ + config, + configPath: '/fake/clickhouse.config.ts', + cliVersion: '1.0.0', + }) + + const exitCode = await runtime.runPluginCommand('backfill', 'run', makeCommandContext()) + + expect(exitCode).toBe(42) + expect(originalRan).toBe(false) + }) + + test('returning handled: false falls through to original command', async () => { + let originalRan = false + const targetPlugin: ChxPlugin = { + manifest: { name: 'backfill', apiVersion: 1 }, + commands: [ + { + name: 'run', + run: () => { + originalRan = true + return 0 + }, + }, + ], + } + + const interceptor: ChxPlugin = { + manifest: { name: 'obsessiondb', apiVersion: 1 }, + hooks: { + onBeforePluginCommand: () => ({ handled: false }), + }, + } + + const config = makeConfig([targetPlugin, interceptor]) + const runtime = await loadPluginRuntime({ + config, + configPath: '/fake/clickhouse.config.ts', + cliVersion: '1.0.0', + }) + + const exitCode = await runtime.runPluginCommand('backfill', 'run', makeCommandContext()) + + expect(exitCode).toBe(0) + expect(originalRan).toBe(true) + }) + + test('the owning plugin hook is NOT called for its own commands', async () => { + let hookCalled = false + const plugin: ChxPlugin = { + manifest: { name: 'backfill', apiVersion: 1 }, + commands: [ + { + name: 'run', + run: () => 0, + }, + ], + hooks: { + onBeforePluginCommand: () => { + hookCalled = true + return { handled: true, exitCode: 99 } + }, + }, + } + + const config = makeConfig([plugin]) + const runtime = await loadPluginRuntime({ + config, + configPath: '/fake/clickhouse.config.ts', + cliVersion: '1.0.0', + }) + + const exitCode = await runtime.runPluginCommand('backfill', 'run', makeCommandContext()) + + expect(exitCode).toBe(0) + expect(hookCalled).toBe(false) + }) + + test('passes correct context to onBeforePluginCommand hook', async () => { + let receivedContext: Record = {} + const targetPlugin: ChxPlugin = { + manifest: { name: 'backfill', apiVersion: 1 }, + commands: [{ name: 'plan', run: () => 0 }], + } + + const interceptor: ChxPlugin = { + manifest: { name: 'obsessiondb', apiVersion: 1 }, + hooks: { + onBeforePluginCommand: (ctx) => { + receivedContext = { ...ctx } + return { handled: false } + }, + }, + } + + const config = makeConfig([targetPlugin, interceptor]) + const runtime = await loadPluginRuntime({ + config, + configPath: '/fake/clickhouse.config.ts', + cliVersion: '1.0.0', + }) + + await runtime.runPluginCommand('backfill', 'plan', { + ...makeCommandContext(), + args: ['--window', '7d'], + flags: { '--window': '7d' }, + }) + + expect(receivedContext.targetPlugin).toBe('backfill') + expect(receivedContext.command).toBe('plan') + expect(receivedContext.args).toEqual(['--window', '7d']) + }) +}) diff --git a/packages/cli/src/bin/plugin-runtime.ts b/packages/cli/src/bin/plugin-runtime.ts index 93607d4..89b2133 100644 --- a/packages/cli/src/bin/plugin-runtime.ts +++ b/packages/cli/src/bin/plugin-runtime.ts @@ -15,6 +15,8 @@ import type { ChxOnCheckResult, ChxOnAfterApplyContext, ChxOnBeforeApplyContext, + ChxOnBeforePluginCommandContext, + ChxOnBeforePluginCommandResult, ChxOnCompleteContext, ChxOnConfigLoadedContext, ChxOnInitContext, @@ -57,6 +59,11 @@ export interface PluginRuntime { context: Omit & { tableScope?: TableScope } ): Promise runOnCheckReport(results: ChxOnCheckResult[], print: (line: string) => void): Promise + runOnBeforePluginCommand( + pluginName: string, + commandName: string, + context: Omit + ): Promise runPluginCommand( pluginName: string, commandName: string, @@ -210,6 +217,30 @@ export async function loadPluginRuntime(input: { byName.set(plugin.manifest.name, item) } + async function runBeforePluginCommandHooks( + pluginName: string, + commandName: string, + context: Omit + ): Promise { + for (const item of loaded) { + if (item.plugin.manifest.name === pluginName) continue + const hook = item.plugin.hooks?.onBeforePluginCommand + if (!hook) continue + try { + const result = await hook({ + ...context, + targetPlugin: pluginName, + command: commandName, + options: item.options, + }) + if (result.handled) return result + } catch (error) { + throw formatPluginError(item.plugin.manifest.name, 'onBeforePluginCommand', error) + } + } + return { handled: false } + } + return { plugins: loaded, getCommand(pluginName, commandName) { @@ -345,11 +376,25 @@ export async function loadPluginRuntime(input: { } } }, + runOnBeforePluginCommand: runBeforePluginCommandHooks, async runPluginCommand(pluginName, commandName, context) { const item = byName.get(pluginName) if (!item) return 1 const command = (item.plugin.commands ?? []).find((entry) => entry.name === commandName) if (!command) return 1 + + // Run onBeforePluginCommand hooks — if any returns handled, skip the command + const beforeResult = await runBeforePluginCommandHooks(pluginName, commandName, { + config: context.config, + configPath: context.configPath, + jsonMode: context.jsonMode, + args: context.args, + flags: context.flags, + tableScope: context.tableScope ?? UNFILTERED_TABLE_SCOPE, + print: context.print, + }) + if (beforeResult.handled) return beforeResult.exitCode + try { const code = await command.run({ ...context, diff --git a/packages/cli/src/plugin.test.ts b/packages/cli/src/plugin.test.ts index fd0923e..ddda0ed 100644 --- a/packages/cli/src/plugin.test.ts +++ b/packages/cli/src/plugin.test.ts @@ -126,6 +126,85 @@ describe('plugin runtime', () => { } }) + test('onBeforePluginCommand intercepts another plugin command', async () => { + const fixture = await createFixture() + const targetPath = join(fixture.dir, 'target-plugin.ts') + const interceptorPath = join(fixture.dir, 'interceptor-plugin.ts') + try { + await writeFile( + targetPath, + `import { definePlugin } from '${CLI_ENTRY}'\n\nexport default definePlugin({\n manifest: { name: 'target', apiVersion: 1 },\n commands: [\n {\n name: 'greet',\n description: 'Original greet',\n run({ print }) {\n print({ source: 'original' })\n return 0\n },\n },\n ],\n})\n`, + 'utf8' + ) + + await writeFile( + interceptorPath, + `import { definePlugin } from '${CLI_ENTRY}'\n\nexport default definePlugin({\n manifest: { name: 'interceptor', apiVersion: 1 },\n hooks: {\n onBeforePluginCommand(context) {\n if (context.targetPlugin === 'target' && context.command === 'greet') {\n context.print({ source: 'intercepted', target: context.targetPlugin })\n return { handled: true, exitCode: 0 }\n }\n return { handled: false }\n },\n },\n})\n`, + 'utf8' + ) + + await writeFile( + fixture.configPath, + `export default {\n schema: '${fixture.schemaPath}',\n outDir: '${join(fixture.dir, 'chkit')}',\n migrationsDir: '${fixture.migrationsDir}',\n metaDir: '${fixture.metaDir}',\n plugins: [\n { resolve: './target-plugin.ts' },\n { resolve: './interceptor-plugin.ts' },\n ],\n}\n`, + 'utf8' + ) + + const result = runCli([ + 'plugin', + 'target', + 'greet', + '--config', + fixture.configPath, + '--json', + ]) + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { source: string; target: string } + expect(payload.source).toBe('intercepted') + expect(payload.target).toBe('target') + } finally { + await rm(fixture.dir, { recursive: true, force: true }) + } + }) + + test('onBeforePluginCommand returning handled: false falls through to original', async () => { + const fixture = await createFixture() + const targetPath = join(fixture.dir, 'target-plugin.ts') + const interceptorPath = join(fixture.dir, 'interceptor-plugin.ts') + try { + await writeFile( + targetPath, + `import { definePlugin } from '${CLI_ENTRY}'\n\nexport default definePlugin({\n manifest: { name: 'target', apiVersion: 1 },\n commands: [\n {\n name: 'greet',\n description: 'Original greet',\n run({ print }) {\n print({ source: 'original' })\n return 0\n },\n },\n ],\n})\n`, + 'utf8' + ) + + await writeFile( + interceptorPath, + `import { definePlugin } from '${CLI_ENTRY}'\n\nexport default definePlugin({\n manifest: { name: 'interceptor', apiVersion: 1 },\n hooks: {\n onBeforePluginCommand() {\n return { handled: false }\n },\n },\n})\n`, + 'utf8' + ) + + await writeFile( + fixture.configPath, + `export default {\n schema: '${fixture.schemaPath}',\n outDir: '${join(fixture.dir, 'chkit')}',\n migrationsDir: '${fixture.migrationsDir}',\n metaDir: '${fixture.metaDir}',\n plugins: [\n { resolve: './target-plugin.ts' },\n { resolve: './interceptor-plugin.ts' },\n ],\n}\n`, + 'utf8' + ) + + const result = runCli([ + 'plugin', + 'target', + 'greet', + '--config', + fixture.configPath, + '--json', + ]) + expect(result.exitCode).toBe(0) + const payload = JSON.parse(result.stdout) as { source: string } + expect(payload.source).toBe('original') + } finally { + await rm(fixture.dir, { recursive: true, force: true }) + } + }) + test('plugin pull schema command writes pulled schema artifact', async () => { const fixture = await createFixture() const pluginPath = join(fixture.dir, 'pull-plugin.ts') diff --git a/packages/cli/src/plugins.ts b/packages/cli/src/plugins.ts index 7f85036..13df641 100644 --- a/packages/cli/src/plugins.ts +++ b/packages/cli/src/plugins.ts @@ -123,6 +123,23 @@ export interface ChxOnCheckResult { metadata?: Record } +export interface ChxOnBeforePluginCommandContext { + targetPlugin: string + command: string + config: ResolvedChxConfig + configPath: string + jsonMode: boolean + args: string[] + flags: ParsedFlags + options: Record + tableScope: TableScope + print: (value: unknown) => void +} + +export type ChxOnBeforePluginCommandResult = + | { handled: true; exitCode: number } + | { handled: false } + export interface ChxPluginCommandContext { pluginName: string config: ResolvedChxConfig @@ -145,6 +162,9 @@ export interface ChxPluginCommand { export interface ChxPluginHooks { onInit?: (context: ChxOnInitContext) => void | Promise onComplete?: (context: ChxOnCompleteContext) => void | Promise + onBeforePluginCommand?: ( + context: ChxOnBeforePluginCommandContext + ) => ChxOnBeforePluginCommandResult | Promise onConfigLoaded?: (context: ChxOnConfigLoadedContext) => void | Promise onSchemaLoaded?: ( context: ChxOnSchemaLoadedContext diff --git a/packages/plugin-obsessiondb/src/auth/api-client.test.ts b/packages/plugin-obsessiondb/src/auth/api-client.test.ts new file mode 100644 index 0000000..52bdb5a --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/api-client.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test, afterEach, mock } from 'bun:test' + +import { pollDeviceToken, requestDeviceCode, getSession } from './api-client' + +const BASE_URL = 'https://console-api.example.com' + +describe('requestDeviceCode', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('sends POST with client_id and returns device code response', async () => { + const mockResponse = { + device_code: 'dc-123', + user_code: 'ABCD-EFGH', + verification_uri: 'https://console.example.com/device', + verification_uri_complete: 'https://console.example.com/device?code=ABCD-EFGH', + expires_in: 600, + interval: 5, + } + + globalThis.fetch = mock(async () => + new Response(JSON.stringify(mockResponse), { status: 200 }) + ) as typeof fetch + + const result = await requestDeviceCode(BASE_URL) + + expect(result).toEqual(mockResponse) + expect(globalThis.fetch).toHaveBeenCalledTimes(1) + }) + + test('throws on non-OK response', async () => { + globalThis.fetch = mock(async () => + new Response('Bad Request', { status: 400 }) + ) as typeof fetch + + await expect(requestDeviceCode(BASE_URL)).rejects.toThrow('Failed to request device code: 400') + }) +}) + +describe('pollDeviceToken', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('polls through authorization_pending then returns token', async () => { + let callCount = 0 + globalThis.fetch = mock(async () => { + callCount++ + if (callCount < 3) { + return new Response(JSON.stringify({ error: 'authorization_pending' }), { status: 200 }) + } + return new Response(JSON.stringify({ access_token: 'tok-final' }), { status: 200 }) + }) as typeof fetch + + // Use very short interval for fast test + const token = await pollDeviceToken(BASE_URL, 'dc-123', 0.01, 10) + + expect(token).toBe('tok-final') + expect(callCount).toBe(3) + }) + + // Note: slow_down increases interval by 5s per RFC 8628. Testing the timing + // would require waiting 5+ seconds which isn't practical in unit tests. + // The behavior is verified structurally by the other polling tests. + + test('throws on access_denied', async () => { + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ error: 'access_denied' }), { status: 200 }) + ) as typeof fetch + + await expect(pollDeviceToken(BASE_URL, 'dc-123', 0.01, 10)).rejects.toThrow( + 'Authorization denied by user.' + ) + }) + + test('throws on expired_token', async () => { + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ error: 'expired_token' }), { status: 200 }) + ) as typeof fetch + + await expect(pollDeviceToken(BASE_URL, 'dc-123', 0.01, 10)).rejects.toThrow( + 'Device code expired' + ) + }) +}) + +describe('getSession', () => { + const originalFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test('returns session with user info', async () => { + const session = { user: { id: 'u1', name: 'Test User', email: 'test@example.com' } } + globalThis.fetch = mock(async () => + new Response(JSON.stringify(session), { status: 200 }) + ) as typeof fetch + + const result = await getSession(BASE_URL, 'tok-123') + + expect(result).toEqual(session) + }) + + test('throws on 401', async () => { + globalThis.fetch = mock(async () => + new Response('Unauthorized', { status: 401 }) + ) as typeof fetch + + await expect(getSession(BASE_URL, 'bad-token')).rejects.toThrow('Failed to get session: 401') + }) +}) diff --git a/packages/plugin-obsessiondb/src/auth/api-client.ts b/packages/plugin-obsessiondb/src/auth/api-client.ts new file mode 100644 index 0000000..4fb1746 --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/api-client.ts @@ -0,0 +1,108 @@ +export interface DeviceCodeResponse { + device_code: string + user_code: string + verification_uri: string + verification_uri_complete: string + expires_in: number + interval: number +} + +export interface SessionResponse { + user: { + id: string + name: string + email: string + } +} + +const CLIENT_ID = 'chkit-cli' + +function userAgent(): string { + // Avoid importing package.json — use a hardcoded prefix; version is non-critical here + return 'chkit-cli' +} + +export async function requestDeviceCode(baseUrl: string): Promise { + const res = await fetch(`${baseUrl}/api/auth/device/code`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent(), + }, + body: JSON.stringify({ client_id: CLIENT_ID }), + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Failed to request device code: ${res.status} ${text}`) + } + return (await res.json()) as DeviceCodeResponse +} + +type TokenPollError = 'authorization_pending' | 'slow_down' | 'access_denied' | 'expired_token' + +interface TokenPollResponse { + access_token?: string + error?: TokenPollError +} + +export async function pollDeviceToken( + baseUrl: string, + deviceCode: string, + interval: number, + expiresIn: number +): Promise { + const deadline = Date.now() + expiresIn * 1000 + let pollInterval = interval * 1000 + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, pollInterval)) + + const res = await fetch(`${baseUrl}/api/auth/device/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': userAgent(), + }, + body: JSON.stringify({ client_id: CLIENT_ID, device_code: deviceCode }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`Token poll failed: ${res.status} ${text}`) + } + + const body = (await res.json()) as TokenPollResponse + + if (body.access_token) return body.access_token + + switch (body.error) { + case 'authorization_pending': + continue + case 'slow_down': + pollInterval += 5000 + continue + case 'access_denied': + throw new Error('Authorization denied by user.') + case 'expired_token': + throw new Error('Device code expired. Please try again.') + default: + throw new Error(`Unexpected token poll response: ${JSON.stringify(body)}`) + } + } + + throw new Error('Device code expired. Please try again.') +} + +export async function getSession(baseUrl: string, token: string): Promise { + const res = await fetch(`${baseUrl}/api/auth/get-session`, { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': userAgent(), + }, + }) + if (!res.ok) { + const text = await res.text() + throw new Error(`Failed to get session: ${res.status} ${text}`) + } + return (await res.json()) as SessionResponse +} diff --git a/packages/plugin-obsessiondb/src/auth/commands.ts b/packages/plugin-obsessiondb/src/auth/commands.ts new file mode 100644 index 0000000..f601801 --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/commands.ts @@ -0,0 +1,54 @@ +import { resolveBaseUrl } from './credentials.js' +import { runLogin, runLogout, runWhoami } from './login.js' + +function resolveBaseUrlFromFlags(flags: Record): string { + const flagValue = flags['--api-url'] + if (typeof flagValue === 'string' && flagValue.length > 0) return flagValue + return resolveBaseUrl() +} + +interface PluginCommandContext { + flags: Record + print: (value: unknown) => void +} + +interface PluginCommand { + name: string + description: string + flags?: ReadonlyArray<{ name: string; type: 'string' | 'boolean'; description: string }> + run: (context: PluginCommandContext) => Promise +} + +export const LOGIN_COMMAND: PluginCommand = { + name: 'login', + description: 'Authenticate with ObsessionDB', + flags: [ + { + name: '--api-url', + type: 'string', + description: 'ObsessionDB API base URL', + }, + ], + async run(context) { + const baseUrl = resolveBaseUrlFromFlags(context.flags) + return runLogin(baseUrl, (msg) => context.print(msg)) + }, +} + +export const LOGOUT_COMMAND: PluginCommand = { + name: 'logout', + description: 'Remove stored ObsessionDB credentials', + async run(context) { + return runLogout((msg) => context.print(msg)) + }, +} + +export const WHOAMI_COMMAND: PluginCommand = { + name: 'whoami', + description: 'Show current ObsessionDB user', + async run(context) { + return runWhoami((msg) => context.print(msg)) + }, +} + +export const AUTH_COMMANDS: PluginCommand[] = [LOGIN_COMMAND, LOGOUT_COMMAND, WHOAMI_COMMAND] diff --git a/packages/plugin-obsessiondb/src/auth/credentials.test.ts b/packages/plugin-obsessiondb/src/auth/credentials.test.ts new file mode 100644 index 0000000..ebc48a5 --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/credentials.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, test, afterEach } from 'bun:test' +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { clearCredentials, getCredentialsPath, loadCredentials, resolveBaseUrl, saveCredentials } from './credentials' + +describe('credentials', () => { + let tempDir: string + let originalXdg: string | undefined + + afterEach(async () => { + if (originalXdg !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdg + } else { + delete process.env.XDG_CONFIG_HOME + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + async function setupTempConfig(): Promise { + tempDir = await mkdtemp(join(tmpdir(), 'chkit-creds-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = tempDir + return tempDir + } + + test('getCredentialsPath respects XDG_CONFIG_HOME', () => { + const prev = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = '/custom/config' + const path = getCredentialsPath() + expect(path).toBe('/custom/config/chkit/credentials.json') + if (prev !== undefined) { + process.env.XDG_CONFIG_HOME = prev + } else { + delete process.env.XDG_CONFIG_HOME + } + }) + + test('write/read/delete cycle', async () => { + await setupTempConfig() + + const creds = { access_token: 'test-token-123', base_url: 'https://api.example.com' } + await saveCredentials(creds) + + const loaded = await loadCredentials() + expect(loaded).toEqual(creds) + + await clearCredentials() + const after = await loadCredentials() + expect(after).toBeNull() + }) + + test('saveCredentials creates file with 0o600 permissions', async () => { + await setupTempConfig() + + await saveCredentials({ access_token: 'tok', base_url: 'https://api.example.com' }) + + const fileStat = await stat(getCredentialsPath()) + // 0o600 = 0o100600 for regular file, mask with 0o777 to get permission bits + expect(fileStat.mode & 0o777).toBe(0o600) + }) + + test('loadCredentials returns null for missing file', async () => { + await setupTempConfig() + const loaded = await loadCredentials() + expect(loaded).toBeNull() + }) + + test('loadCredentials returns null for invalid JSON', async () => { + await setupTempConfig() + const { writeFile } = await import('node:fs/promises') + const { mkdir } = await import('node:fs/promises') + const path = getCredentialsPath() + await mkdir(join(tempDir, 'chkit'), { recursive: true }) + await writeFile(path, 'not json') + const loaded = await loadCredentials() + expect(loaded).toBeNull() + }) + + test('loadCredentials returns null for JSON without required fields', async () => { + await setupTempConfig() + const { writeFile } = await import('node:fs/promises') + const { mkdir } = await import('node:fs/promises') + const path = getCredentialsPath() + await mkdir(join(tempDir, 'chkit'), { recursive: true }) + await writeFile(path, JSON.stringify({ access_token: 'tok' })) + const loaded = await loadCredentials() + expect(loaded).toBeNull() + }) + + test('clearCredentials is a no-op if file does not exist', async () => { + await setupTempConfig() + // Should not throw + await clearCredentials() + }) +}) + +describe('resolveBaseUrl', () => { + let originalEnv: string | undefined + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.OBSESSIONDB_API_URL = originalEnv + } else { + delete process.env.OBSESSIONDB_API_URL + } + }) + + test('env var takes precedence over stored value', () => { + originalEnv = process.env.OBSESSIONDB_API_URL + process.env.OBSESSIONDB_API_URL = 'http://localhost:3000' + expect(resolveBaseUrl('https://console-api.obsessiondb.com')).toBe('http://localhost:3000') + }) + + test('falls back to stored value when env var is unset', () => { + originalEnv = process.env.OBSESSIONDB_API_URL + delete process.env.OBSESSIONDB_API_URL + expect(resolveBaseUrl('https://stored.example.com')).toBe('https://stored.example.com') + }) + + test('falls back to default when neither env var nor stored value', () => { + originalEnv = process.env.OBSESSIONDB_API_URL + delete process.env.OBSESSIONDB_API_URL + expect(resolveBaseUrl()).toBe('https://console-api.obsessiondb.com') + }) + + test('ignores empty env var', () => { + originalEnv = process.env.OBSESSIONDB_API_URL + process.env.OBSESSIONDB_API_URL = '' + expect(resolveBaseUrl('https://stored.example.com')).toBe('https://stored.example.com') + }) +}) diff --git a/packages/plugin-obsessiondb/src/auth/credentials.ts b/packages/plugin-obsessiondb/src/auth/credentials.ts new file mode 100644 index 0000000..f293053 --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/credentials.ts @@ -0,0 +1,64 @@ +import { mkdir, readFile, writeFile, unlink, chmod } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' + +export interface Credentials { + access_token: string + base_url: string +} + +export function getCredentialsPath(): string { + const xdgConfig = process.env.XDG_CONFIG_HOME + const configDir = xdgConfig || join(homedir(), '.config') + return join(configDir, 'chkit', 'credentials.json') +} + +export async function loadCredentials(): Promise { + try { + const raw = await readFile(getCredentialsPath(), 'utf8') + const parsed = JSON.parse(raw) as unknown + if ( + typeof parsed === 'object' && + parsed !== null && + 'access_token' in parsed && + 'base_url' in parsed && + typeof (parsed as Credentials).access_token === 'string' && + typeof (parsed as Credentials).base_url === 'string' + ) { + return parsed as Credentials + } + return null + } catch { + return null + } +} + +export async function saveCredentials(creds: Credentials): Promise { + const filePath = getCredentialsPath() + const dir = dirname(filePath) + await mkdir(dir, { recursive: true, mode: 0o700 }) + await writeFile(filePath, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 }) + // Ensure permissions even if file existed + await chmod(filePath, 0o600) +} + +export async function clearCredentials(): Promise { + try { + await unlink(getCredentialsPath()) + } catch { + // Already gone — no-op + } +} + +const DEFAULT_BASE_URL = 'https://console-api.obsessiondb.com' + +/** + * Resolve the ObsessionDB API base URL. + * Priority: OBSESSIONDB_API_URL env var > stored credentials > default. + */ +export function resolveBaseUrl(stored?: string): string { + const envValue = process.env.OBSESSIONDB_API_URL + if (envValue && envValue.length > 0) return envValue + if (stored && stored.length > 0) return stored + return DEFAULT_BASE_URL +} diff --git a/packages/plugin-obsessiondb/src/auth/index.ts b/packages/plugin-obsessiondb/src/auth/index.ts new file mode 100644 index 0000000..55e43a5 --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/index.ts @@ -0,0 +1,2 @@ +export { AUTH_COMMANDS } from './commands.js' +export { loadCredentials, resolveBaseUrl, type Credentials } from './credentials.js' diff --git a/packages/plugin-obsessiondb/src/auth/login.ts b/packages/plugin-obsessiondb/src/auth/login.ts new file mode 100644 index 0000000..06130aa --- /dev/null +++ b/packages/plugin-obsessiondb/src/auth/login.ts @@ -0,0 +1,68 @@ +import { execFile } from 'node:child_process' +import { platform } from 'node:os' + +import { getSession, pollDeviceToken, requestDeviceCode } from './api-client.js' +import { clearCredentials, loadCredentials, saveCredentials } from './credentials.js' + +function openBrowser(url: string): void { + const cmd = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'start' : 'xdg-open' + execFile(cmd, [url], () => { + // Silently ignore errors — user can open the URL manually + }) +} + +export async function runLogin(baseUrl: string, print: (msg: string) => void): Promise { + const existing = await loadCredentials() + if (existing) { + try { + const session = await getSession(existing.base_url, existing.access_token) + print(`Already logged in as ${session.user.email}`) + return 0 + } catch { + // Token expired or invalid — proceed with fresh login + await clearCredentials() + } + } + + const device = await requestDeviceCode(baseUrl) + + print(`\nOpen this URL in your browser:\n ${device.verification_uri_complete}\n`) + print(`Enter code: ${device.user_code}\n`) + + openBrowser(device.verification_uri_complete) + + print('Waiting for authorization...') + + const token = await pollDeviceToken(baseUrl, device.device_code, device.interval, device.expires_in) + + await saveCredentials({ access_token: token, base_url: baseUrl }) + + const session = await getSession(baseUrl, token) + print(`Logged in as ${session.user.email}`) + + return 0 +} + +export async function runLogout(print: (msg: string) => void): Promise { + await clearCredentials() + print('Logged out.') + return 0 +} + +export async function runWhoami(print: (msg: string) => void): Promise { + const creds = await loadCredentials() + if (!creds) { + print('Not logged in. Run `chkit obsessiondb login` to authenticate.') + return 1 + } + + try { + const session = await getSession(creds.base_url, creds.access_token) + print(`Logged in as ${session.user.email} (${session.user.name})`) + return 0 + } catch { + await clearCredentials() + print('Session expired. Run `chkit obsessiondb login` to re-authenticate.') + return 1 + } +} diff --git a/packages/plugin-obsessiondb/src/backfill/api-client.ts b/packages/plugin-obsessiondb/src/backfill/api-client.ts new file mode 100644 index 0000000..308a48c --- /dev/null +++ b/packages/plugin-obsessiondb/src/backfill/api-client.ts @@ -0,0 +1,113 @@ +import type { Credentials } from '../auth/index.js' + +export interface RemotePlanResponse { + ok: boolean + plan_id?: string + error?: string + [key: string]: unknown +} + +export interface RemoteRunResponse { + ok: boolean + run_id?: string + error?: string + [key: string]: unknown +} + +export interface RemoteStatusResponse { + ok: boolean + status?: string + error?: string + [key: string]: unknown +} + +export interface RemoteCancelResponse { + ok: boolean + error?: string + [key: string]: unknown +} + +export interface RemoteDoctorResponse { + ok: boolean + error?: string + [key: string]: unknown +} + +class SessionExpiredError extends Error { + constructor() { + super('Session expired. Run `chkit obsessiondb login` to re-authenticate.') + } +} + +async function apiRequest( + path: string, + creds: Credentials, + body?: unknown +): Promise { + const res = await fetch(`${creds.base_url}${path}`, { + method: body !== undefined ? 'POST' : 'GET', + headers: { + Authorization: `Bearer ${creds.access_token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'chkit-cli', + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + + if (res.status === 401) { + throw new SessionExpiredError() + } + + if (!res.ok) { + const text = await res.text() + throw new Error(`Remote backfill API error: ${res.status} ${text}`) + } + + return (await res.json()) as T +} + +export function isSessionExpiredError(error: unknown): boolean { + return error instanceof SessionExpiredError +} + +export async function submitBackfillPlan( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/plan', creds, input) +} + +export async function runRemoteBackfill( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/run', creds, input) +} + +export async function resumeRemoteBackfill( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/resume', creds, input) +} + +export async function getRemoteBackfillStatus( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/status', creds, input) +} + +export async function cancelRemoteBackfill( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/cancel', creds, input) +} + +export async function getRemoteBackfillDoctor( + input: Record, + creds: Credentials +): Promise { + return apiRequest('/api/v1/backfill/doctor', creds, input) +} diff --git a/packages/plugin-obsessiondb/src/backfill/handler.test.ts b/packages/plugin-obsessiondb/src/backfill/handler.test.ts new file mode 100644 index 0000000..2af8ff6 --- /dev/null +++ b/packages/plugin-obsessiondb/src/backfill/handler.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test, afterEach, mock } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { saveCredentials } from '../auth/credentials' +import { handleBackfillCommand } from './handler' + +function makeContext(overrides: Partial[0]> = {}) { + const printed: unknown[] = [] + return { + context: { + targetPlugin: 'backfill', + command: 'run', + config: {}, + configPath: '/fake/clickhouse.config.ts', + jsonMode: false, + args: [], + flags: {}, + options: {}, + print: (v: unknown) => printed.push(v), + ...overrides, + }, + printed, + } +} + +describe('handleBackfillCommand', () => { + let tempDir: string + let originalXdg: string | undefined + const originalFetch = globalThis.fetch + + afterEach(async () => { + globalThis.fetch = originalFetch + if (originalXdg !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdg + } else { + delete process.env.XDG_CONFIG_HOME + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + async function setupAuth() { + tempDir = await mkdtemp(join(tmpdir(), 'chkit-bf-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = tempDir + await saveCredentials({ access_token: 'test-tok', base_url: 'https://api.example.com' }) + } + + test('returns handled: false when targetPlugin is not backfill', async () => { + const { context } = makeContext({ targetPlugin: 'codegen' }) + const result = await handleBackfillCommand(context) + expect(result).toEqual({ handled: false }) + }) + + test('returns handled: false when --local flag is set', async () => { + const { context } = makeContext({ flags: { '--local': true } }) + const result = await handleBackfillCommand(context) + expect(result).toEqual({ handled: false }) + }) + + test('requires login when not authenticated', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'chkit-bf-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = tempDir + // No credentials saved + + const { context, printed } = makeContext() + const result = await handleBackfillCommand(context) + expect(result).toEqual({ handled: true, exitCode: 1 }) + expect(printed[0]).toContain('chkit obsessiondb login') + }) + + test('routes to remote API when authenticated', async () => { + await setupAuth() + + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ ok: true, run_id: 'r-123' }), { status: 200 }) + ) as typeof fetch + + const { context, printed } = makeContext() + const result = await handleBackfillCommand(context) + + expect(result).toEqual({ handled: true, exitCode: 0 }) + expect(printed).toHaveLength(1) + expect((printed[0] as Record).ok).toBe(true) + }) + + test('handles 401 with session expired message', async () => { + await setupAuth() + + globalThis.fetch = mock(async () => + new Response('Unauthorized', { status: 401 }) + ) as typeof fetch + + const { context, printed } = makeContext() + const result = await handleBackfillCommand(context) + + expect(result).toEqual({ handled: true, exitCode: 1 }) + expect(printed[0]).toContain('Session expired') + }) + + test('returns handled: false for unknown backfill subcommand', async () => { + await setupAuth() + + const { context } = makeContext({ command: 'unknown-subcommand' }) + const result = await handleBackfillCommand(context) + expect(result).toEqual({ handled: false }) + }) +}) diff --git a/packages/plugin-obsessiondb/src/backfill/handler.ts b/packages/plugin-obsessiondb/src/backfill/handler.ts new file mode 100644 index 0000000..f413f00 --- /dev/null +++ b/packages/plugin-obsessiondb/src/backfill/handler.ts @@ -0,0 +1,77 @@ +import { loadCredentials, resolveBaseUrl } from '../auth/index.js' +import { + cancelRemoteBackfill, + getRemoteBackfillDoctor, + getRemoteBackfillStatus, + isSessionExpiredError, + resumeRemoteBackfill, + runRemoteBackfill, + submitBackfillPlan, +} from './api-client.js' + +interface BeforePluginCommandContext { + targetPlugin: string + command: string + config: Record + configPath: string + jsonMode: boolean + args: string[] + flags: Record + options: Record + print: (value: unknown) => void +} + +type HandlerResult = + | { handled: true; exitCode: number } + | { handled: false } + +const BACKFILL_SUBCOMMANDS: Record< + string, + (input: Record, creds: { access_token: string; base_url: string }) => Promise +> = { + plan: submitBackfillPlan, + run: runRemoteBackfill, + resume: resumeRemoteBackfill, + status: getRemoteBackfillStatus, + cancel: cancelRemoteBackfill, + doctor: getRemoteBackfillDoctor, +} + +export async function handleBackfillCommand(context: BeforePluginCommandContext): Promise { + if (context.targetPlugin !== 'backfill') return { handled: false } + + // --local flag bypasses remote execution + if (context.flags['--local'] === true) return { handled: false } + + const handler = BACKFILL_SUBCOMMANDS[context.command] + if (!handler) return { handled: false } + + const creds = await loadCredentials() + if (!creds) { + context.print('Not logged in. Run `chkit obsessiondb login` to authenticate.') + return { handled: true, exitCode: 1 } + } + + // Allow OBSESSIONDB_API_URL env var to override the stored base_url + const effectiveCreds = { ...creds, base_url: resolveBaseUrl(creds.base_url) } + + try { + const input = { + command: context.command, + args: context.args, + flags: context.flags, + } + + const result = await handler(input, effectiveCreds) + + context.print(result) + + return { handled: true, exitCode: 0 } + } catch (error) { + if (isSessionExpiredError(error)) { + context.print(error instanceof Error ? error.message : String(error)) + return { handled: true, exitCode: 1 } + } + throw error + } +} diff --git a/packages/plugin-obsessiondb/src/backfill/index.ts b/packages/plugin-obsessiondb/src/backfill/index.ts new file mode 100644 index 0000000..db72220 --- /dev/null +++ b/packages/plugin-obsessiondb/src/backfill/index.ts @@ -0,0 +1,14 @@ +export { handleBackfillCommand } from './handler.js' + +export const BACKFILL_EXTEND_COMMANDS = [ + { + command: ['backfill plan', 'backfill run', 'backfill resume', 'backfill status', 'backfill cancel', 'backfill doctor'], + flags: [ + { + name: '--local', + type: 'boolean' as const, + description: 'Force local execution (skip remote routing)', + }, + ], + }, +] diff --git a/packages/plugin-obsessiondb/src/index.test.ts b/packages/plugin-obsessiondb/src/index.test.ts index 21b52d4..81dd1c5 100644 --- a/packages/plugin-obsessiondb/src/index.test.ts +++ b/packages/plugin-obsessiondb/src/index.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, test } from 'bun:test' +import { describe, expect, test, afterEach, mock } from 'bun:test' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { ResolvedChxConfig, SchemaDefinition } from '@chkit/core' +import { saveCredentials } from './auth/credentials' import { isObsessionDBHost, obsessiondb, @@ -301,3 +305,96 @@ describe('obsessiondb plugin', () => { expect(result).toHaveLength(1) }) }) + +describe('onBeforePluginCommand — backfill interception', () => { + let tempDir: string + let originalXdg: string | undefined + const originalFetch = globalThis.fetch + + afterEach(async () => { + globalThis.fetch = originalFetch + if (originalXdg !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdg + } else { + delete process.env.XDG_CONFIG_HOME + } + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }) + } + }) + + function makeHookContext(overrides: Record = {}) { + const printed: unknown[] = [] + return { + context: { + targetPlugin: 'backfill', + command: 'run', + config: {}, + configPath: '/fake/clickhouse.config.ts', + jsonMode: false, + args: [], + flags: {}, + options: {}, + print: (v: unknown) => printed.push(v), + ...overrides, + }, + printed, + } + } + + async function setupAuth() { + tempDir = await mkdtemp(join(tmpdir(), 'chkit-obd-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = tempDir + await saveCredentials({ access_token: 'test-tok', base_url: 'https://api.test.com' }) + } + + test('intercepts backfill commands when authenticated', async () => { + await setupAuth() + globalThis.fetch = mock(async () => + new Response(JSON.stringify({ ok: true, run_id: 'r-abc' }), { status: 200 }) + ) as typeof fetch + + const { context, printed } = makeHookContext() + const plugin = obsessiondb().plugin + const result = await plugin.hooks.onBeforePluginCommand(context as Parameters[0]) + + expect(result.handled).toBe(true) + expect(result.exitCode).toBe(0) + expect((printed[0] as Record).run_id).toBe('r-abc') + }) + + test('requires login when not authenticated', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'chkit-obd-')) + originalXdg = process.env.XDG_CONFIG_HOME + process.env.XDG_CONFIG_HOME = tempDir + + const { context, printed } = makeHookContext() + const plugin = obsessiondb().plugin + const result = await plugin.hooks.onBeforePluginCommand(context as Parameters[0]) + + expect(result.handled).toBe(true) + expect(result.exitCode).toBe(1) + expect(printed[0]).toContain('chkit obsessiondb login') + }) + + test('falls through with --local flag even when authenticated', async () => { + await setupAuth() + + const { context } = makeHookContext({ flags: { '--local': true } }) + const plugin = obsessiondb().plugin + const result = await plugin.hooks.onBeforePluginCommand(context as Parameters[0]) + + expect(result.handled).toBe(false) + }) + + test('falls through for non-backfill plugins', async () => { + await setupAuth() + + const { context } = makeHookContext({ targetPlugin: 'codegen' }) + const plugin = obsessiondb().plugin + const result = await plugin.hooks.onBeforePluginCommand(context as Parameters[0]) + + expect(result.handled).toBe(false) + }) +}) diff --git a/packages/plugin-obsessiondb/src/index.ts b/packages/plugin-obsessiondb/src/index.ts index 1ee48c2..15077db 100644 --- a/packages/plugin-obsessiondb/src/index.ts +++ b/packages/plugin-obsessiondb/src/index.ts @@ -4,24 +4,54 @@ import type { SchemaDefinition, } from '@chkit/core' +import { AUTH_COMMANDS, loadCredentials } from './auth/index.js' +import { BACKFILL_EXTEND_COMMANDS, handleBackfillCommand } from './backfill/index.js' + export type ObsessionDBPluginOptions = Record +interface PluginCommand { + name: string + description: string + flags?: ReadonlyArray<{ name: string; type: string; description: string }> + run: (context: Record) => unknown +} + +interface BeforePluginCommandContext { + targetPlugin: string + command: string + config: Record + configPath: string + jsonMode: boolean + args: string[] + flags: Record + options: Record + print: (value: unknown) => void +} + +interface BeforePluginCommandResult { + handled: boolean + exitCode?: number +} + interface ObsessionDBPlugin { manifest: { name: 'obsessiondb'; apiVersion: 1 } + commands: PluginCommand[] extendCommands: Array<{ command: string[] flags: Array<{ name: string - type: 'boolean' + type: 'boolean' | 'string' description: string }> }> hooks: { + onInit(context: { command: string; isInteractive: boolean; jsonMode: boolean }): Promise onSchemaLoaded(context: { config: ResolvedChxConfig flags: Record definitions: SchemaDefinition[] }): SchemaDefinition[] | undefined + onBeforePluginCommand(context: BeforePluginCommandContext): Promise } } @@ -108,6 +138,7 @@ export function rewriteSharedEngines(definitions: SchemaDefinition[]): { function createObsessionDBPlugin(_options: ObsessionDBPluginOptions): ObsessionDBPlugin { return { manifest: { name: 'obsessiondb', apiVersion: 1 }, + commands: AUTH_COMMANDS as unknown as PluginCommand[], extendCommands: [ { command: ['generate', 'migrate', 'status', 'drift', 'check'], @@ -124,8 +155,18 @@ function createObsessionDBPlugin(_options: ObsessionDBPluginOptions): ObsessionD }, ], }, + ...BACKFILL_EXTEND_COMMANDS, ], hooks: { + async onInit(context) { + if (context.jsonMode) return + const creds = await loadCredentials() + if (creds) { + console.log( + 'obsessiondb: authenticated, backfill commands will execute remotely (use --local to override)', + ) + } + }, onSchemaLoaded(context) { const shouldStrip = resolveStripBehavior(context.config, context.flags) if (!shouldStrip) return @@ -144,6 +185,9 @@ function createObsessionDBPlugin(_options: ObsessionDBPluginOptions): ObsessionD } return rewritten.definitions }, + async onBeforePluginCommand(context) { + return handleBackfillCommand(context) + }, }, } }