From 82125253dfbd95ff70109a616741aab5882c251f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:26:39 +0000 Subject: [PATCH 1/2] feat(cli): add login, whoami, and import commands with API auth - loginCommand: validates nomark_sk_ token against process-import endpoint, saves to ~/.nomark/config.json with chmod 0600 - whoamiCommand: reads stored token, validates against API, shows masked key - importCommand: supports chatgpt, claude, gemini platforms; reads file, POSTs to process-import with Bearer auth, prints ImportResult summary - config.ts: replace module-level constants with runtime functions so os.homedir() is evaluated at call time (fixes test isolation) - index.ts: wire login/whoami routes, update HELP text, bump VERSION 0.2.1 - 13 new tests for import, 5 for login, 5 for whoami, 7 for config - README: add Install, Authenticate, Import, Managing API Keys sections All 291 tests pass. Build succeeds (tsup). TypeScript clean. --- packages/engine/README.md | 52 +++- packages/engine/src/cli/config.ts | 41 ++- packages/engine/src/cli/import.ts | 140 +++++----- packages/engine/src/cli/index.ts | 24 +- packages/engine/src/cli/login.ts | 60 +++++ packages/engine/src/cli/whoami.ts | 36 +++ packages/engine/tests/cli/config.test.ts | 80 ++++++ packages/engine/tests/cli/import-api.test.ts | 255 +++++++++++++++++++ packages/engine/tests/cli/login.test.ts | 102 ++++++++ packages/engine/tests/cli/whoami.test.ts | 93 +++++++ 10 files changed, 806 insertions(+), 77 deletions(-) create mode 100644 packages/engine/src/cli/login.ts create mode 100644 packages/engine/src/cli/whoami.ts create mode 100644 packages/engine/tests/cli/config.test.ts create mode 100644 packages/engine/tests/cli/import-api.test.ts create mode 100644 packages/engine/tests/cli/login.test.ts create mode 100644 packages/engine/tests/cli/whoami.test.ts diff --git a/packages/engine/README.md b/packages/engine/README.md index a5b1256..c77b9ff 100644 --- a/packages/engine/README.md +++ b/packages/engine/README.md @@ -109,12 +109,58 @@ Includes offline queue, conflict resolution, privacy filtering, and merge strate ## CLI +The `nomark` CLI lets you import conversation exports into your NOMARK preference ledger and inspect the resulting profile. + +### Install + +```bash +npm install -g @nomark-ai/engine +# or use without installing: +npx nomark +``` + +Requires Node.js 18+. + +### Authenticate + +Get an API key from the NOMARK panel under **Settings → API Keys**, then run: + ```bash -npx nomark profile # Show resolved preference profile -npx nomark import # Import from ChatGPT/Claude exports -npx nomark review # Review and manage ledger entries +export NOMARK_TOKEN=nomark_sk_... +nomark login ``` +Your key is saved to `~/.nomark/config.json` (mode 0600). To verify it's working: + +```bash +nomark whoami +``` + +### Import + +```bash +# ChatGPT — export from chat.openai.com → Settings → Export +nomark import --platform chatgpt --file conversations.json + +# Claude — export from claude.ai → Settings → Export data +nomark import --platform claude --file claude-export.json + +# Gemini — export from Google Takeout (My Activity → Gemini) +nomark import --platform gemini --file takeout.json +``` + +Each import prints a summary: conversations analyzed, signals extracted, and how many were promoted into your ledger. + +### Managing API Keys + +| Command | Description | +|---------|-------------| +| `nomark login` | Save an API key from `NOMARK_TOKEN` env or interactive prompt | +| `nomark whoami` | Verify the saved key and show its last four characters | +| `nomark config --model ` | Set the default model | + +Keys are stored as `api_key` in `~/.nomark/config.json`. The file is created with mode 0600 and is never logged or transmitted anywhere except the NOMARK API. + ## API Reference ### Core diff --git a/packages/engine/src/cli/config.ts b/packages/engine/src/cli/config.ts index dc731a3..500dc6e 100644 --- a/packages/engine/src/cli/config.ts +++ b/packages/engine/src/cli/config.ts @@ -6,38 +6,55 @@ export type NomarkConfig = { model?: string apiKey?: string ledgerPath?: string + token?: string } -const CONFIG_DIR = path.join(os.homedir(), '.nomark') -const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json') +function getConfigDir(): string { + return path.join(os.homedir(), '.nomark') +} + +function getConfigPath(): string { + return path.join(getConfigDir(), 'config.json') +} export function loadConfig(): NomarkConfig { const config: NomarkConfig = {} + const configPath = getConfigPath() - // File config - if (fs.existsSync(CONFIG_PATH)) { + if (fs.existsSync(configPath)) { try { - const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8')) as Record + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record if (typeof raw['model'] === 'string') config.model = raw['model'] if (typeof raw['apiKey'] === 'string') config.apiKey = raw['apiKey'] if (typeof raw['ledgerPath'] === 'string') config.ledgerPath = raw['ledgerPath'] + if (typeof raw['api_key'] === 'string') config.token = raw['api_key'] } catch { // ignore malformed config } } - // Env overrides if (process.env['NOMARK_MODEL']) config.model = process.env['NOMARK_MODEL'] if (process.env['NOMARK_API_KEY']) config.apiKey = process.env['NOMARK_API_KEY'] + if (process.env['NOMARK_TOKEN']) config.token = process.env['NOMARK_TOKEN'] return config } export function saveConfig(config: NomarkConfig): void { - fs.mkdirSync(CONFIG_DIR, { recursive: true }) + const configDir = getConfigDir() + const configPath = getConfigPath() + fs.mkdirSync(configDir, { recursive: true }) const existing = loadConfig() const merged = { ...existing, ...config } - fs.writeFileSync(CONFIG_PATH, JSON.stringify(merged, null, 2) + '\n') + + const json: Record = {} + if (merged.model !== undefined) json['model'] = merged.model + if (merged.apiKey !== undefined) json['apiKey'] = merged.apiKey + if (merged.ledgerPath !== undefined) json['ledgerPath'] = merged.ledgerPath + if (merged.token !== undefined) json['api_key'] = merged.token + + fs.writeFileSync(configPath, JSON.stringify(json, null, 2) + '\n') + fs.chmodSync(configPath, 0o600) } export function configCommand(flags: Record): void { @@ -48,9 +65,13 @@ export function configCommand(flags: Record): void { if (typeof flags['ledger'] === 'string') updates.ledgerPath = flags['ledger'] if (Object.keys(updates).length === 0) { - // Show current config const config = loadConfig() - console.log(JSON.stringify(config, null, 2)) + const display: Record = {} + if (config.model !== undefined) display['model'] = config.model + if (config.apiKey !== undefined) display['apiKey'] = `***${config.apiKey.slice(-4)}` + if (config.ledgerPath !== undefined) display['ledgerPath'] = config.ledgerPath + if (config.token !== undefined) display['api_key'] = `***${config.token.slice(-4)}` + console.log(JSON.stringify(display, null, 2)) return } diff --git a/packages/engine/src/cli/import.ts b/packages/engine/src/cli/import.ts index 59da3b2..2c1d5fd 100644 --- a/packages/engine/src/cli/import.ts +++ b/packages/engine/src/cli/import.ts @@ -1,84 +1,110 @@ import * as fs from 'node:fs' -import * as path from 'node:path' -import { parseChatGPTExport } from '../importers/chatgpt.js' -import { parseClaudeExport } from '../importers/claude.js' -import { runMigration } from '../importers/pipeline.js' -import { writeLedger, parseLedger } from '../ledger.js' -import type { Conversation } from '../importers/types.js' +import { loadConfig } from './config.js' import { requireFlag } from './args.js' -const DEFAULT_LEDGER = './nomark-ledger.jsonl' +const PROCESS_IMPORT_URL = 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' -export function importCommand(flags: Record): void { - const platform = requireFlag(flags, 'platform') +type Platform = 'chatgpt' | 'claude' | 'gemini' + +const SUPPORTED_PLATFORMS: Platform[] = ['chatgpt', 'claude', 'gemini'] + +type ImportResult = { + platform: string + conversationsAnalyzed: number + signalsExtracted: number + signalsPromoted: number + byConfidence: { + high: number + medium: number + low: number + } +} + +export async function importCommand(flags: Record): Promise { + const platform = requireFlag(flags, 'platform') as Platform const filePath = requireFlag(flags, 'file') - const dryRun = flags['dry-run'] !== false && flags['dry-run'] !== 'false' - const ledgerPath = typeof flags['ledger'] === 'string' ? flags['ledger'] : DEFAULT_LEDGER - const maxConversations = typeof flags['max'] === 'string' ? parseInt(flags['max'], 10) : undefined - // Read file + if (!SUPPORTED_PLATFORMS.includes(platform)) { + console.error(`Unsupported platform: ${platform}. Supported: ${SUPPORTED_PLATFORMS.join(', ')}`) + process.exit(1) + } + if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`) process.exit(1) } - const raw = fs.readFileSync(filePath, 'utf8') - - const parsers: Record Conversation[]> = { - chatgpt: parseChatGPTExport, - claude: parseClaudeExport, + let raw: string + try { + raw = fs.readFileSync(filePath, 'utf8') + } catch { + console.error(`Error: Could not read file: ${filePath}`) + process.exit(1) + return } - const parser = parsers[platform] - if (!parser) { - console.error(`Unsupported platform: ${platform}. Supported: chatgpt, claude`) + let data: unknown + try { + data = JSON.parse(raw) + } catch { + console.error(`Error: Invalid JSON in file: ${filePath}`) process.exit(1) - return // unreachable but satisfies TS + return } - const conversations = parser(raw) + const config = loadConfig() + const token = config.token - if (conversations.length === 0) { - console.log('No conversations found in export file.') - return + if (!token) { + console.error('Not authenticated. Run `nomark login` first.') + process.exit(1) } - // Load existing ledger for dedup - let existingLedger = '' - if (fs.existsSync(ledgerPath)) { - existingLedger = fs.readFileSync(ledgerPath, 'utf8') + let response: Response + try { + response = await fetch(PROCESS_IMPORT_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform, data }), + }) + } catch { + console.error('Error: Could not reach NOMARK API. Check your network connection.') + process.exit(1) + return } - // Run pipeline - const report = runMigration(conversations, { - existingLedger: existingLedger || undefined, - dryRun, - maxConversations, - }) - - // Display report - console.log() - console.log(` Analyzing ${report.conversationsAnalyzed} conversations...`) - console.log(` Extracted ${report.signalsExtracted} signals (${report.byConfidence.high} high, ${report.byConfidence.medium} medium, ${report.byConfidence.low} low confidence)`) - console.log(` Promoted ${report.signalsPromoted} to ledger.`) - - if (report.byConfidence.low > 0) { - console.log(` ${report.byConfidence.low} need review. Run: npx nomark review`) + if (!response.ok) { + let body = '' + try { + body = await response.text() + } catch { + // ignore read failure + } + if (response.status === 401) { + console.error('Error: Authentication failed (401). Run `nomark login` to re-authenticate.') + } else { + console.error(`Error: API request failed (${response.status}): ${body}`) + } + process.exit(1) + return } - if (dryRun) { - console.log() - console.log(' [DRY RUN] No changes written. Run with --dry-run=false to apply.') - } else { - // Write ledger - const existingEntries = existingLedger ? parseLedger(existingLedger) : [] - const allEntries = [...existingEntries, ...report.ledgerEntries] - const content = writeLedger(allEntries) - fs.mkdirSync(path.dirname(path.resolve(ledgerPath)), { recursive: true }) - fs.writeFileSync(ledgerPath, content) - console.log(` Written to ${ledgerPath}`) + let result: ImportResult + try { + result = (await response.json()) as ImportResult + } catch { + console.error('Error: Invalid response from NOMARK API.') + process.exit(1) + return } - console.log(' No account needed. Everything runs locally.') + console.log() + console.log(`Import complete. Platform: ${result.platform}.`) + console.log(`Conversations analyzed: ${result.conversationsAnalyzed}.`) + console.log(`Signals extracted: ${result.signalsExtracted} (${result.byConfidence.high} high, ${result.byConfidence.medium} medium, ${result.byConfidence.low} low confidence).`) + console.log(`Signals promoted to ledger: ${result.signalsPromoted}.`) console.log() } diff --git a/packages/engine/src/cli/index.ts b/packages/engine/src/cli/index.ts index be734de..ec193c8 100644 --- a/packages/engine/src/cli/index.ts +++ b/packages/engine/src/cli/index.ts @@ -3,10 +3,12 @@ import { parseArgs } from './args.js' import { configCommand } from './config.js' import { importCommand } from './import.js' +import { loginCommand } from './login.js' import { profileCommand } from './profile.js' import { reviewCommand } from './review.js' +import { whoamiCommand } from './whoami.js' -const VERSION = '0.1.0' +const VERSION = '0.2.1' const HELP = ` nomark — agent outcome quality engine @@ -15,6 +17,8 @@ const HELP = ` npx nomark [options] Commands: + login Authenticate with your NOMARK API key + whoami Show current authentication status config Configure model and API key import Import AI conversation history profile View your preference profile @@ -25,13 +29,13 @@ const HELP = ` --version Show version Examples: - npx nomark config --model claude --api-key $ANTHROPIC_API_KEY - npx nomark import --platform chatgpt --file export.json - npx nomark profile - npx nomark review + npx nomark login + npx nomark import --platform chatgpt --file conversations.json + npx nomark import --platform claude --file claude-export.json + npx nomark import --platform gemini --file takeout.json ` -function main(): void { +async function main(): Promise { const { command, flags } = parseArgs(process.argv) if (flags['version']) { @@ -46,11 +50,17 @@ function main(): void { try { switch (command) { + case 'login': + await loginCommand() + break + case 'whoami': + await whoamiCommand() + break case 'config': configCommand(flags) break case 'import': - importCommand(flags) + await importCommand(flags) break case 'profile': profileCommand(flags) diff --git a/packages/engine/src/cli/login.ts b/packages/engine/src/cli/login.ts new file mode 100644 index 0000000..fa9d872 --- /dev/null +++ b/packages/engine/src/cli/login.ts @@ -0,0 +1,60 @@ +import * as readline from 'node:readline' +import { saveConfig } from './config.js' + +const PROCESS_IMPORT_URL = 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' + +async function validateToken(token: string): Promise { + const response = await fetch(PROCESS_IMPORT_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform: 'chatgpt', data: [] }), + }) + return response.status !== 401 +} + +function promptApiKey(): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + rl.question('Enter your NOMARK API key (nomark_sk_...): ', (answer) => { + rl.close() + resolve(answer.trim()) + }) + }) +} + +export async function loginCommand(): Promise { + const token = process.env['NOMARK_TOKEN'] ?? (await promptApiKey()) + + if (!token) { + console.error('Error: No API key provided.') + process.exit(1) + } + + if (!token.startsWith('nomark_sk_')) { + console.error('Error: Invalid API key format. Expected key starting with nomark_sk_') + process.exit(1) + } + + console.log('Validating API key...') + + let valid: boolean + try { + valid = await validateToken(token) + } catch { + console.error('Error: Could not reach NOMARK API. Check your network connection.') + process.exit(1) + return + } + + if (!valid) { + console.error('Error: Invalid API key (401). Check your key in the NOMARK panel under Settings → API Keys.') + process.exit(1) + } + + saveConfig({ token }) + console.log('Authenticated successfully.') + console.log(`API key saved to ~/.nomark/config.json (key: ***${token.slice(-4)})`) +} diff --git a/packages/engine/src/cli/whoami.ts b/packages/engine/src/cli/whoami.ts new file mode 100644 index 0000000..fc1cd79 --- /dev/null +++ b/packages/engine/src/cli/whoami.ts @@ -0,0 +1,36 @@ +import { loadConfig } from './config.js' + +const PROCESS_IMPORT_URL = 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import' + +export async function whoamiCommand(): Promise { + const config = loadConfig() + const token = config.token + + if (!token) { + console.error('Not authenticated. Run `nomark login`.') + process.exit(1) + } + + let response: Response + try { + response = await fetch(PROCESS_IMPORT_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ platform: 'chatgpt', data: [] }), + }) + } catch { + console.error('Error: Could not reach NOMARK API. Check your network connection.') + process.exit(1) + return + } + + if (response.status === 401) { + console.error('Error: API key is invalid or revoked (401). Run `nomark login` to re-authenticate.') + process.exit(1) + } + + console.log(`Authenticated. API key: ***${token.slice(-4)}`) +} diff --git a/packages/engine/tests/cli/config.test.ts b/packages/engine/tests/cli/config.test.ts new file mode 100644 index 0000000..69c5072 --- /dev/null +++ b/packages/engine/tests/cli/config.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { loadConfig, saveConfig } from '../../src/cli/config.js' + +let tmpDir: string +let originalHome: string | undefined +let originalNomarkToken: string | undefined + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-cfg-test-')) + originalHome = process.env['HOME'] + originalNomarkToken = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalNomarkToken !== undefined) { + process.env['NOMARK_TOKEN'] = originalNomarkToken + } else { + delete process.env['NOMARK_TOKEN'] + } + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('saveConfig / loadConfig — token field', () => { + it('saves token as api_key in JSON', () => { + saveConfig({ token: 'nomark_sk_abc123' }) + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(raw['api_key']).toBe('nomark_sk_abc123') + expect(raw['token']).toBeUndefined() + }) + + it('reads api_key from JSON into token field', () => { + saveConfig({ token: 'nomark_sk_xyz987' }) + const config = loadConfig() + expect(config.token).toBe('nomark_sk_xyz987') + }) + + it('chmod 0600 on config file after save', () => { + saveConfig({ token: 'nomark_sk_test' }) + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const stat = fs.statSync(configPath) + const mode = stat.mode & 0o777 + expect(mode).toBe(0o600) + }) + + it('NOMARK_TOKEN env var overrides file token', () => { + saveConfig({ token: 'nomark_sk_from_file' }) + process.env['NOMARK_TOKEN'] = 'nomark_sk_from_env' + const config = loadConfig() + expect(config.token).toBe('nomark_sk_from_env') + }) + + it('merges token with existing config fields', () => { + saveConfig({ model: 'claude' }) + saveConfig({ token: 'nomark_sk_new' }) + const config = loadConfig() + expect(config.model).toBe('claude') + expect(config.token).toBe('nomark_sk_new') + }) + + it('returns empty config when no file exists', () => { + const config = loadConfig() + expect(config.token).toBeUndefined() + expect(config.model).toBeUndefined() + }) + + it('does not write token key when token is undefined', () => { + saveConfig({ model: 'gpt-4' }) + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const raw = JSON.parse(fs.readFileSync(configPath, 'utf8')) as Record + expect(raw['api_key']).toBeUndefined() + expect(raw['model']).toBe('gpt-4') + }) +}) diff --git a/packages/engine/tests/cli/import-api.test.ts b/packages/engine/tests/cli/import-api.test.ts new file mode 100644 index 0000000..144263c --- /dev/null +++ b/packages/engine/tests/cli/import-api.test.ts @@ -0,0 +1,255 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { saveConfig } from '../../src/cli/config.js' + +let tmpDir: string +let originalHome: string | undefined +let originalNomarkToken: string | undefined + +const MOCK_RESULT = { + platform: 'chatgpt', + conversationsAnalyzed: 3, + signalsExtracted: 12, + signalsPromoted: 8, + byConfidence: { high: 4, medium: 5, low: 3 }, +} + +function writeTmpFile(content: string): string { + const filePath = path.join(tmpDir, 'export.json') + fs.writeFileSync(filePath, content) + return filePath +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-import-test-')) + originalHome = process.env['HOME'] + originalNomarkToken = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalNomarkToken !== undefined) { + process.env['NOMARK_TOKEN'] = originalNomarkToken + } else { + delete process.env['NOMARK_TOKEN'] + } + vi.restoreAllMocks() + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('importCommand', () => { + it('posts to process-import with chatgpt platform', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[{"id": "1"}]') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...MOCK_RESULT, platform: 'chatgpt' }), + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'chatgpt', file: filePath }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer nomark_sk_test', + 'Content-Type': 'application/json', + }), + }) + ) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) as Record + expect(body['platform']).toBe('chatgpt') + }) + + it('posts to process-import with claude platform', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[{"uuid": "abc"}]') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...MOCK_RESULT, platform: 'claude' }), + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'claude', file: filePath }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) as Record + expect(body['platform']).toBe('claude') + }) + + it('posts to process-import with gemini platform', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('{"conversations": []}') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ ...MOCK_RESULT, platform: 'gemini' }), + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await importCommand({ platform: 'gemini', file: filePath }) + + const body = JSON.parse(mockFetch.mock.calls[0][1].body as string) as Record + expect(body['platform']).toBe('gemini') + }) + + it('exits 1 when file not found', async () => { + saveConfig({ token: 'nomark_sk_test' }) + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: '/nonexistent/path.json' })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on invalid JSON in file', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('not valid json {{{') + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on unsupported platform', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[]') + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'unsupported', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 when not authenticated', async () => { + const filePath = writeTmpFile('[]') + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on HTTP 401 response', async () => { + saveConfig({ token: 'nomark_sk_expired' }) + const filePath = writeTmpFile('[]') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + text: async () => 'Unauthorized', + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on non-2xx response', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[]') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on network error', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[]') + + const mockFetch = vi.fn().mockRejectedValue(new Error('ENOTFOUND')) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(importCommand({ platform: 'chatgpt', file: filePath })) + .rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('prints ImportResult summary on success', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[{"id": "1"}, {"id": "2"}, {"id": "3"}]') + + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => MOCK_RESULT, + } as unknown as Response) + vi.stubGlobal('fetch', mockFetch) + + const { importCommand } = await import('../../src/cli/import.js') + const logs: string[] = [] + vi.spyOn(console, 'log').mockImplementation((...args) => { logs.push(args.join(' ')) }) + + await importCommand({ platform: 'chatgpt', file: filePath }) + + const output = logs.join('\n') + expect(output).toContain('Import complete') + expect(output).toContain('chatgpt') + expect(output).toContain('3') + expect(output).toContain('12') + }) + + it('missing --platform flag throws', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const filePath = writeTmpFile('[]') + + const { importCommand } = await import('../../src/cli/import.js') + + await expect(importCommand({ file: filePath })).rejects.toThrow('Missing required flag: --platform') + }) + + it('missing --file flag throws', async () => { + saveConfig({ token: 'nomark_sk_test' }) + + const { importCommand } = await import('../../src/cli/import.js') + + await expect(importCommand({ platform: 'chatgpt' })).rejects.toThrow('Missing required flag: --file') + }) +}) diff --git a/packages/engine/tests/cli/login.test.ts b/packages/engine/tests/cli/login.test.ts new file mode 100644 index 0000000..b2c877e --- /dev/null +++ b/packages/engine/tests/cli/login.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { loadConfig } from '../../src/cli/config.js' + +let tmpDir: string +let originalHome: string | undefined +let originalNomarkToken: string | undefined + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-login-test-')) + originalHome = process.env['HOME'] + originalNomarkToken = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalNomarkToken !== undefined) { + process.env['NOMARK_TOKEN'] = originalNomarkToken + } else { + delete process.env['NOMARK_TOKEN'] + } + vi.restoreAllMocks() + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('loginCommand', () => { + it('saves valid token from NOMARK_TOKEN env', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_validkey' + const mockFetch = vi.fn().mockResolvedValue({ status: 200, ok: true } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { loginCommand } = await import('../../src/cli/login.js') + await loginCommand() + + const config = loadConfig() + expect(config.token).toBe('nomark_sk_validkey') + + const configPath = path.join(tmpDir, '.nomark', 'config.json') + const stat = fs.statSync(configPath) + expect(stat.mode & 0o777).toBe(0o600) + }) + + it('exits non-zero on 401', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_badkey' + const mockFetch = vi.fn().mockResolvedValue({ status: 401, ok: false } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { loginCommand } = await import('../../src/cli/login.js') + + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + await expect(loginCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits non-zero on invalid key format', async () => { + process.env['NOMARK_TOKEN'] = 'not_a_nomark_key' + const mockFetch = vi.fn() + vi.stubGlobal('fetch', mockFetch) + + const { loginCommand } = await import('../../src/cli/login.js') + + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + await expect(loginCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + expect(mockFetch).not.toHaveBeenCalled() + }) + + it('exits non-zero on network error', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_validformat' + const mockFetch = vi.fn().mockRejectedValue(new Error('ENOTFOUND')) + vi.stubGlobal('fetch', mockFetch) + + const { loginCommand } = await import('../../src/cli/login.js') + + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + await expect(loginCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('posts to process-import with correct headers', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_abc' + const mockFetch = vi.fn().mockResolvedValue({ status: 200, ok: true } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { loginCommand } = await import('../../src/cli/login.js') + await loginCommand() + + expect(mockFetch).toHaveBeenCalledWith( + 'https://cnwiskdzeygqxezmazoq.supabase.co/functions/v1/process-import', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer nomark_sk_abc', + }), + }) + ) + }) +}) diff --git a/packages/engine/tests/cli/whoami.test.ts b/packages/engine/tests/cli/whoami.test.ts new file mode 100644 index 0000000..b02dbd3 --- /dev/null +++ b/packages/engine/tests/cli/whoami.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { saveConfig } from '../../src/cli/config.js' + +let tmpDir: string +let originalHome: string | undefined +let originalNomarkToken: string | undefined + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nomark-whoami-test-')) + originalHome = process.env['HOME'] + originalNomarkToken = process.env['NOMARK_TOKEN'] + process.env['HOME'] = tmpDir + delete process.env['NOMARK_TOKEN'] +}) + +afterEach(() => { + process.env['HOME'] = originalHome + if (originalNomarkToken !== undefined) { + process.env['NOMARK_TOKEN'] = originalNomarkToken + } else { + delete process.env['NOMARK_TOKEN'] + } + vi.restoreAllMocks() + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe('whoamiCommand', () => { + it('exits 1 with message when not authenticated', async () => { + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + const mockError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + + await expect(whoamiCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + expect(mockError).toHaveBeenCalledWith(expect.stringContaining('Not authenticated')) + }) + + it('shows authenticated status on valid token', async () => { + saveConfig({ token: 'nomark_sk_mytoken' }) + const mockFetch = vi.fn().mockResolvedValue({ status: 200, ok: true } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const mockLog = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await whoamiCommand() + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining('Authenticated')) + }) + + it('exits 1 on 401 response', async () => { + saveConfig({ token: 'nomark_sk_badtoken' }) + const mockFetch = vi.fn().mockResolvedValue({ status: 401, ok: false } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(whoamiCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('exits 1 on network error', async () => { + saveConfig({ token: 'nomark_sk_test' }) + const mockFetch = vi.fn().mockRejectedValue(new Error('ENOTFOUND')) + vi.stubGlobal('fetch', mockFetch) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + const mockExit = vi.spyOn(process, 'exit').mockImplementation((_code) => { throw new Error('process.exit') }) + + await expect(whoamiCommand()).rejects.toThrow('process.exit') + expect(mockExit).toHaveBeenCalledWith(1) + }) + + it('reads token from NOMARK_TOKEN env', async () => { + process.env['NOMARK_TOKEN'] = 'nomark_sk_envtoken' + const mockFetch = vi.fn().mockResolvedValue({ status: 200, ok: true } as Response) + vi.stubGlobal('fetch', mockFetch) + + const { whoamiCommand } = await import('../../src/cli/whoami.js') + vi.spyOn(console, 'log').mockImplementation(() => undefined) + + await whoamiCommand() + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ 'Authorization': 'Bearer nomark_sk_envtoken' }), + }) + ) + }) +}) From b3a511a8a38adf86c05030aa2b10cffe483d62d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 14:29:07 +0000 Subject: [PATCH 2/2] chore: add progress tracking for BRF-2026-04-12-nomark-cli --- progress.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 progress.md diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..87c6fd2 --- /dev/null +++ b/progress.md @@ -0,0 +1,47 @@ +# nomark CLI — BRF-2026-04-12-nomark-cli + +## Feature: F-API-KEYS-CLI (nomark import CLI) +Branch: claude/festive-davinci-ysm7md + +### STORY-CLI-001: Config — token field, NOMARK_TOKEN env, chmod 0600 +- **Status:** DONE +- **Goal:** NomarkConfig stores token; saveConfig writes api_key in JSON and chmods 0600 +- **Done when:** Tests in tests/cli/config.test.ts pass + +### STORY-CLI-002: login command +- **Status:** DONE +- **Goal:** `nomark login` reads NOMARK_TOKEN or prompts, validates vs process-import, saves to config +- **Done when:** Tests in tests/cli/login.test.ts pass + +### STORY-CLI-003: whoami command +- **Status:** DONE +- **Goal:** `nomark whoami` reads token, shows auth status or "Not authenticated" + exit 1 +- **Done when:** Tests in tests/cli/whoami.test.ts pass + +### STORY-CLI-004: import command — API-backed, 3 platforms +- **Status:** DONE +- **Goal:** `nomark import --platform chatgpt|claude|gemini --file X` POSTs to process-import +- **Done when:** Tests in tests/cli/import-api.test.ts pass + +### STORY-CLI-005: index.ts — async main, login/whoami routes, version +- **Status:** DONE +- **Goal:** main() is async, routes login+whoami, VERSION = '0.2.1' +- **Done when:** tsc --noEmit passes + +### STORY-CLI-006: Test coverage ≥80% +- **Status:** DONE +- **Goal:** npm test exits 0, ≥80% line coverage on src/cli/ +- **Done when:** npm test -- --coverage shows ≥80% + +### STORY-CLI-007: publish.yml +- **Status:** DONE +- **Goal:** .github/workflows/publish.yml publishes on v*.*.* tag push +- **Done when:** File exists and YAML is valid + +### STORY-CLI-008: README +- **Status:** DONE +- **Goal:** README.md has ## Install, ## Authenticate, ## Import, ## Managing API Keys +- **Done when:** Sections exist with runnable examples + +## Current state +All stories done. PR created on claude/festive-davinci-ysm7md.