diff --git a/apps/cli/src/commands/notion/connect.ts b/apps/cli/src/commands/notion/connect.ts new file mode 100644 index 00000000..5c9a70fc --- /dev/null +++ b/apps/cli/src/commands/notion/connect.ts @@ -0,0 +1,360 @@ +import { Flags } from '@oclif/core' +import inquirer from 'inquirer' +import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js' +import { colors } from '../../lib/colors.js' +import { + shouldOutputJson, + outputSuccessAsJson, + outputErrorAsJson, + outputPromptAsJson, + buildPromptConfig, + createMetadata, +} from '../../lib/prompt-json.js' +import { + NotionClient, + isNotionConfigured, + loadNotionConfig, + saveNotionApiKey, + saveNotionDefaultDatabase, + getNotionApiKey, +} from '../../lib/notion/index.js' +import type { NotionDatabase } from '../../lib/notion/index.js' +import { upsertProviderSource } from '../../lib/work-source/provider-sources.js' + +function databaseTitle(db: NotionDatabase): string { + if (db.title && db.title.length > 0) { + return db.title.map((t) => t.plain_text).join('').trim() || '(Untitled)' + } + return '(Untitled)' +} + +export default class NotionConnect extends PMOCommand { + static description = 'Connect to Notion: authenticate and select a default database' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --check', + '<%= config.bin %> <%= command.id %> --force', + '<%= config.bin %> <%= command.id %> --database ', + 'PRLT_NOTION_API_KEY=secret_... <%= config.bin %> <%= command.id %>', + ] + + static flags = { + ...pmoBaseFlags, + check: Flags.boolean({ + description: 'Only check if Notion credentials are valid (do not prompt)', + default: false, + }), + force: Flags.boolean({ + description: 'Force re-authentication even if credentials exist', + default: false, + }), + database: Flags.string({ + description: 'Default Notion database ID (skips interactive picker)', + }), + } + + async execute(): Promise { + const { flags } = await this.parse(NotionConnect) + const jsonMode = shouldOutputJson(flags) + const db = this.storage.getDatabase() + + if (flags.check) { + return this.handleCheck(flags, jsonMode, db) + } + + const existingConfig = loadNotionConfig(db) + if (existingConfig && !flags.force && !flags.database) { + try { + const client = new NotionClient(existingConfig.apiKey) + const info = await client.verify() + + if (jsonMode) { + outputSuccessAsJson({ + authenticated: true, + workspace: info.workspaceName, + defaultDatabaseId: existingConfig.defaultDatabaseId ?? null, + defaultDatabaseName: existingConfig.defaultDatabaseName ?? null, + message: 'Already connected. Use --force to re-authenticate.', + }, createMetadata('notion connect', flags)) + return + } + + this.log(colors.success('Already connected to Notion')) + if (info.workspaceName) { + this.log(colors.textMuted(` Workspace: ${info.workspaceName}`)) + } + if (existingConfig.defaultDatabaseName) { + this.log(colors.textMuted(` Database: ${existingConfig.defaultDatabaseName}`)) + } + this.log('') + this.log(colors.textMuted('Use --force to re-authenticate, or --database to change the default database.')) + return + } catch { + // Stored key invalid — proceed with re-auth + } + } + + let apiKey = existingConfig && !flags.force ? existingConfig.apiKey : getNotionApiKey(db) + + if (!apiKey || flags.force) { + if (jsonMode) { + outputErrorAsJson( + 'API_KEY_REQUIRED', + 'Notion API key required. Set PRLT_NOTION_API_KEY or run interactively.', + createMetadata('notion connect', flags), + ) + return + } + + this.log('') + this.log(colors.primary('Notion Authentication')) + this.log('') + this.log('Create an internal integration and copy its secret token at:') + this.log(colors.textSecondary(' https://www.notion.so/profile/integrations')) + this.log('') + this.log(colors.textMuted('Then share the database you want to use with the integration:')) + this.log(colors.textMuted(' Open the database → ⋯ menu → Connections → add your integration.')) + this.log('') + + const { inputKey } = await inquirer.prompt([{ + type: 'password', + name: 'inputKey', + message: 'Enter your Notion integration token:', + mask: '*', + validate: (input: string) => { + if (!input.trim()) return 'Token is required' + return true + }, + }]) + apiKey = inputKey + } + + if (!apiKey) { + if (jsonMode) { + outputErrorAsJson( + 'API_KEY_REQUIRED', + 'Notion API key required.', + createMetadata('notion connect', flags), + ) + return + } + this.error('Notion API key is required.') + } + + if (!jsonMode) { + this.log('') + this.log(colors.textMuted('Verifying Notion token...')) + } + + let client: NotionClient + let workspaceName = '' + try { + client = new NotionClient(apiKey) + const info = await client.verify() + workspaceName = info.workspaceName + saveNotionApiKey(db, apiKey) + + if (!jsonMode) { + const labelSuffix = workspaceName ? ` to ${workspaceName}` : '' + this.log(colors.success(`Connected${labelSuffix}`)) + } + } catch (error) { + const message = `Authentication failed: ${error instanceof Error ? error.message : String(error)}` + if (jsonMode) { + outputErrorAsJson('NOTION_CONNECT_FAILED', message, createMetadata('notion connect', flags)) + return + } + this.error(message) + } + + await this.handleDatabaseSelection(client, flags, jsonMode, db, workspaceName) + } + + private async handleCheck( + flags: Record, + jsonMode: boolean, + db: import('better-sqlite3').Database, + ): Promise { + if (!isNotionConfigured(db)) { + if (jsonMode) { + outputErrorAsJson( + 'NOTION_NOT_CONFIGURED', + 'Notion is not configured. Run "prlt notion connect" to authenticate.', + createMetadata('notion connect', flags), + ) + return + } + this.log(colors.warning('Notion is not configured.')) + this.log(colors.textMuted('Run "prlt notion connect" to authenticate.')) + this.exit(1) + return + } + + const apiKey = getNotionApiKey(db) + if (!apiKey) { + if (jsonMode) { + outputErrorAsJson('NOTION_NOT_CONFIGURED', 'Notion API key missing.', createMetadata('notion connect', flags)) + return + } + this.log(colors.warning('Notion API key missing.')) + this.exit(1) + return + } + + const config = loadNotionConfig(db) + try { + const client = new NotionClient(apiKey) + const info = await client.verify() + if (jsonMode) { + outputSuccessAsJson({ + authenticated: true, + connected: true, + workspace: info.workspaceName, + defaultDatabaseId: config?.defaultDatabaseId ?? null, + defaultDatabaseName: config?.defaultDatabaseName ?? null, + }, createMetadata('notion connect', flags)) + return + } + this.log(colors.success('Notion connection is active')) + if (info.workspaceName) { + this.log(colors.textMuted(` Workspace: ${info.workspaceName}`)) + } + if (config?.defaultDatabaseName) { + this.log(colors.textMuted(` Database: ${config.defaultDatabaseName}`)) + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (jsonMode) { + outputErrorAsJson( + 'NOTION_AUTH_INVALID', + `Stored Notion token is invalid or expired: ${message}`, + createMetadata('notion connect', flags), + ) + return + } + this.log(colors.error('Stored Notion token is invalid or expired.')) + this.log(colors.textMuted(` Error: ${message}`)) + this.log(colors.textMuted('Run "prlt notion connect --force" to re-authenticate.')) + this.exit(1) + } + } + + private async handleDatabaseSelection( + client: NotionClient, + flags: { database?: string } & Record, + jsonMode: boolean, + db: import('better-sqlite3').Database, + workspaceName: string, + ): Promise { + // Explicit database flag — fetch and confirm + if (flags.database) { + try { + const database = await client.getDatabase(flags.database) + const name = databaseTitle(database) + saveNotionDefaultDatabase(db, database.id, name) + upsertProviderSource(db, { + id: 'notion', + provider: 'notion', + apiKeyRef: 'notion.api_key', + teamProjectId: database.id, + prefix: 'NOT-', + label: name, + }) + + if (jsonMode) { + outputSuccessAsJson({ + authenticated: true, + workspace: workspaceName, + defaultDatabaseId: database.id, + defaultDatabaseName: name, + }, createMetadata('notion connect', flags)) + return + } + this.log(colors.textMuted(` Default database: ${name}`)) + this.log('') + this.log(colors.success('Notion integration configured!')) + return + } catch (error) { + const message = `Could not access database "${flags.database}": ${error instanceof Error ? error.message : String(error)}` + if (jsonMode) { + outputErrorAsJson('NOTION_DATABASE_NOT_FOUND', message, createMetadata('notion connect', flags)) + return + } + this.error(message) + } + } + + // List databases the integration can see + let databases: NotionDatabase[] + try { + databases = await client.searchDatabases() + } catch (error) { + const message = `Failed to list Notion databases: ${error instanceof Error ? error.message : String(error)}` + if (jsonMode) { + outputErrorAsJson('NOTION_SEARCH_FAILED', message, createMetadata('notion connect', flags)) + return + } + this.error(message) + } + + if (databases.length === 0) { + const hint = 'No databases visible to this integration. Open the database in Notion → ⋯ → Connections → add your integration, then re-run.' + if (jsonMode) { + outputErrorAsJson('NOTION_NO_DATABASES', hint, createMetadata('notion connect', flags)) + return + } + this.log(colors.warning(hint)) + this.exit(1) + return + } + + if (jsonMode) { + const choices = databases.map((d) => ({ + name: databaseTitle(d), + value: d.id, + })) + outputPromptAsJson( + buildPromptConfig('list', 'database', 'Select default Notion database:', choices), + createMetadata('notion connect', flags), + ) + return + } + + let selectedId: string + let selectedName: string + if (databases.length === 1) { + selectedId = databases[0].id + selectedName = databaseTitle(databases[0]) + this.log(colors.textMuted(` Default database: ${selectedName}`)) + } else { + const choices = databases.map((d) => ({ + name: databaseTitle(d), + value: d.id, + })) + const { picked } = await inquirer.prompt([{ + type: 'list', + name: 'picked', + message: 'Select default Notion database:', + choices, + }]) + selectedId = picked + selectedName = databaseTitle(databases.find((d) => d.id === picked)!) + this.log(colors.textMuted(` Default database: ${selectedName}`)) + } + + saveNotionDefaultDatabase(db, selectedId, selectedName) + upsertProviderSource(db, { + id: 'notion', + provider: 'notion', + apiKeyRef: 'notion.api_key', + teamProjectId: selectedId, + prefix: 'NOT-', + label: selectedName, + }) + + this.log('') + this.log(colors.success('Notion integration configured!')) + this.log(colors.textMuted(' Run "prlt ticket list" to see Notion pages.')) + } +} diff --git a/apps/cli/src/commands/notion/disconnect.ts b/apps/cli/src/commands/notion/disconnect.ts new file mode 100644 index 00000000..5bbbf51f --- /dev/null +++ b/apps/cli/src/commands/notion/disconnect.ts @@ -0,0 +1,41 @@ +import { PMOCommand, pmoBaseFlags } from '../../lib/pmo/index.js' +import { colors } from '../../lib/colors.js' +import { + shouldOutputJson, + outputSuccessAsJson, + createMetadata, +} from '../../lib/prompt-json.js' +import { clearNotionConfig } from '../../lib/notion/index.js' +import { removeProviderSourcesByProvider } from '../../lib/work-source/provider-sources.js' + +export default class NotionDisconnect extends PMOCommand { + static description = 'Disconnect from Notion: remove stored credentials and database default' + + static examples = [ + '<%= config.bin %> <%= command.id %>', + '<%= config.bin %> <%= command.id %> --json', + ] + + static flags = { + ...pmoBaseFlags, + } + + async execute(): Promise { + const { flags } = await this.parse(NotionDisconnect) + const jsonMode = shouldOutputJson(flags) + const db = this.storage.getDatabase() + + clearNotionConfig(db) + removeProviderSourcesByProvider(db, 'notion') + + if (jsonMode) { + outputSuccessAsJson({ + disconnected: true, + message: 'Notion credentials and configuration removed.', + }, createMetadata('notion disconnect', flags)) + return + } + + this.log(colors.success('Notion credentials and configuration removed.')) + } +} diff --git a/apps/cli/src/lib/notion/client.ts b/apps/cli/src/lib/notion/client.ts index 19494374..25eb9f61 100644 --- a/apps/cli/src/lib/notion/client.ts +++ b/apps/cli/src/lib/notion/client.ts @@ -36,6 +36,16 @@ export class NotionClient { return this.request('GET', `/databases/${databaseId}`) } + async searchDatabases(query?: string): Promise { + const body: Record = { + filter: { property: 'object', value: 'database' }, + page_size: 100, + } + if (query) body.query = query + const response = await this.request<{ results: NotionDatabase[] }>('POST', '/search', body) + return response.results + } + async queryDatabase(databaseId: string, options?: { filter?: Record sorts?: Array> diff --git a/apps/cli/test/unit/notion-connect.test.ts b/apps/cli/test/unit/notion-connect.test.ts new file mode 100644 index 00000000..4d48528a --- /dev/null +++ b/apps/cli/test/unit/notion-connect.test.ts @@ -0,0 +1,200 @@ +import { expect } from 'chai' +import Database from 'better-sqlite3' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' + +import { + isNotionConfigured, + loadNotionConfig, + saveNotionApiKey, + saveNotionDefaultDatabase, + clearNotionConfig, + getNotionApiKey, +} from '../../src/lib/notion/config.js' +import { NotionClient } from '../../src/lib/notion/client.js' +import { + loadProviderSources, + removeProviderSourcesByProvider, + upsertProviderSource, +} from '../../src/lib/work-source/provider-sources.js' +import { closeAllCredentialStores } from '../../src/lib/database/credential-store.js' + +function createTempWorkspace(): { workspacePath: string; db: Database.Database } { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), 'prlt-notion-test-')) + const proletariatDir = path.join(workspacePath, '.proletariat') + fs.mkdirSync(proletariatDir, { recursive: true }) + + const dbPath = path.join(proletariatDir, 'workspace.db') + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.exec(` + CREATE TABLE IF NOT EXISTS workspace_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + `) + return { workspacePath, db } +} + +function cleanupWorkspace(workspacePath: string, db: Database.Database): void { + try { db.close() } catch { /* */ } + closeAllCredentialStores() + try { fs.rmSync(workspacePath, { recursive: true, force: true }) } catch { /* */ } +} + +describe('Notion connect/disconnect config (PRLT-1326)', () => { + let workspacePath: string + let db: Database.Database + + beforeEach(() => { + const ws = createTempWorkspace() + workspacePath = ws.workspacePath + db = ws.db + }) + + afterEach(() => { + cleanupWorkspace(workspacePath, db) + delete process.env.PRLT_NOTION_API_KEY + delete process.env.NOTION_API_KEY + }) + + describe('config persistence', () => { + it('isNotionConfigured returns false before connecting', () => { + expect(isNotionConfigured(db)).to.equal(false) + }) + + it('saveNotionApiKey + saveNotionDefaultDatabase persists config', () => { + saveNotionApiKey(db, 'secret_test_key') + saveNotionDefaultDatabase(db, 'db-uuid-123', 'My Tickets DB') + + expect(isNotionConfigured(db)).to.equal(true) + const config = loadNotionConfig(db) + expect(config).to.not.be.null + expect(config!.apiKey).to.equal('secret_test_key') + expect(config!.defaultDatabaseId).to.equal('db-uuid-123') + expect(config!.defaultDatabaseName).to.equal('My Tickets DB') + }) + + it('getNotionApiKey returns the stored credential', () => { + saveNotionApiKey(db, 'secret_persisted') + expect(getNotionApiKey(db)).to.equal('secret_persisted') + }) + + it('getNotionApiKey prefers env var when set', () => { + saveNotionApiKey(db, 'secret_stored') + process.env.PRLT_NOTION_API_KEY = 'secret_env' + expect(getNotionApiKey(db)).to.equal('secret_env') + }) + + it('clearNotionConfig removes credentials and settings', () => { + saveNotionApiKey(db, 'secret_test') + saveNotionDefaultDatabase(db, 'db-id', 'Name') + expect(isNotionConfigured(db)).to.equal(true) + + clearNotionConfig(db) + expect(isNotionConfigured(db)).to.equal(false) + expect(loadNotionConfig(db)).to.be.null + }) + }) + + describe('disconnect flow', () => { + it('clears Notion provider source when disconnecting', () => { + saveNotionApiKey(db, 'secret_test') + saveNotionDefaultDatabase(db, 'db-id', 'My DB') + upsertProviderSource(db, { + id: 'notion', + provider: 'notion', + apiKeyRef: 'notion.api_key', + teamProjectId: 'db-id', + prefix: 'NOT-', + label: 'My DB', + }) + + expect(loadProviderSources(db).some((s) => s.provider === 'notion')).to.equal(true) + + // Simulate disconnect + clearNotionConfig(db) + removeProviderSourcesByProvider(db, 'notion') + + expect(isNotionConfigured(db)).to.equal(false) + expect(loadProviderSources(db).some((s) => s.provider === 'notion')).to.equal(false) + }) + }) + + describe('NotionClient.searchDatabases', () => { + it('posts a database-only search filter to /v1/search', async () => { + const calls: Array<{ url: string; body: unknown; headers: Record }> = [] + const originalFetch = globalThis.fetch + globalThis.fetch = (async (url: string, init: RequestInit) => { + calls.push({ + url: String(url), + body: init.body ? JSON.parse(String(init.body)) : null, + headers: init.headers as Record, + }) + return new Response( + JSON.stringify({ + results: [ + { id: 'db-1', title: [{ plain_text: 'Engineering' }], properties: {} }, + { id: 'db-2', title: [{ plain_text: 'Product' }], properties: {} }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ) + }) as typeof fetch + + try { + const client = new NotionClient('secret_x') + const databases = await client.searchDatabases() + expect(databases).to.have.lengthOf(2) + expect(databases[0].id).to.equal('db-1') + + expect(calls).to.have.lengthOf(1) + expect(calls[0].url).to.include('/v1/search') + const body = calls[0].body as Record + expect(body.filter).to.deep.equal({ property: 'object', value: 'database' }) + expect(calls[0].headers.Authorization).to.equal('Bearer secret_x') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('passes query string when provided', async () => { + let bodySeen: Record | null = null + const originalFetch = globalThis.fetch + globalThis.fetch = (async (_url: string, init: RequestInit) => { + bodySeen = init.body ? JSON.parse(String(init.body)) : null + return new Response(JSON.stringify({ results: [] }), { status: 200 }) + }) as typeof fetch + + try { + const client = new NotionClient('secret_y') + await client.searchDatabases('roadmap') + expect(bodySeen).to.not.be.null + expect(bodySeen!.query).to.equal('roadmap') + } finally { + globalThis.fetch = originalFetch + } + }) + + it('throws on non-OK response', async () => { + const originalFetch = globalThis.fetch + globalThis.fetch = (async () => + new Response('unauthorized', { status: 401 })) as typeof fetch + + try { + const client = new NotionClient('secret_bad') + let threw = false + try { + await client.searchDatabases() + } catch (error) { + threw = true + expect((error as Error).message).to.include('401') + } + expect(threw).to.equal(true) + } finally { + globalThis.fetch = originalFetch + } + }) + }) +})