From 74ed78edec0221fcf7fbd25c3efb31acefe683d6 Mon Sep 17 00:00:00 2001 From: byte-n Date: Wed, 3 Jun 2026 14:53:38 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20init=20?= =?UTF-8?q?=E5=91=BD=E4=BB=A4=E4=B8=8E=20macOS=20Keychain=20=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E5=AD=98=E5=82=A8=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 47 +++++++++ src/cli/handlers.ts | 12 +-- src/cli/init-handler.ts | 124 ++++++++++++++++++++++++ src/cli/options.ts | 15 --- src/cli/program.ts | 10 ++ src/lib/auth-store.ts | 93 ++++++++++++++++++ src/lib/client.ts | 31 +++++- src/lib/converter.ts | 2 +- src/lib/get-titles.ts | 6 +- src/lib/keychain.ts | 50 ++++++++++ src/lib/one-click-app.ts | 198 ++++++++++++++++++++++++++++++++++++++ src/lib/types.ts | 4 +- tests/cli-options.test.ts | 2 - 14 files changed, 562 insertions(+), 33 deletions(-) create mode 100644 src/cli/init-handler.ts create mode 100644 src/lib/auth-store.ts create mode 100644 src/lib/keychain.ts create mode 100644 src/lib/one-click-app.ts diff --git a/package.json b/package.json index 0a7fe32..35ce06b 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ }, "packageManager": "pnpm@10.30.1", "dependencies": { + "@clack/prompts": "^1.5.0", "@larksuiteoapi/node-sdk": "^1.60.0", "commander": "^14.0.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d1c61d..41079e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@clack/prompts': + specifier: ^1.5.0 + version: 1.5.0 '@larksuiteoapi/node-sdk': specifier: ^1.60.0 version: 1.60.0 @@ -64,6 +67,14 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@clack/core@1.4.0': + resolution: {integrity: sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.5.0': + resolution: {integrity: sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==} + engines: {node: '>= 20.12.0'} + '@cspotcode/source-map-support@0.8.1': resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} @@ -722,6 +733,15 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1024,6 +1044,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1248,6 +1271,18 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@clack/core@1.4.0': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.5.0': + dependencies: + '@clack/core': 1.4.0 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cspotcode/source-map-support@0.8.1': dependencies: '@jridgewell/trace-mapping': 0.3.9 @@ -1764,6 +1799,16 @@ snapshots: expect-type@1.3.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2061,6 +2106,8 @@ snapshots: siginfo@2.0.0: {} + sisteransi@1.0.5: {} + source-map-js@1.2.1: {} stackback@0.0.2: {} diff --git a/src/cli/handlers.ts b/src/cli/handlers.ts index bc4ed00..ac7f88e 100644 --- a/src/cli/handlers.ts +++ b/src/cli/handlers.ts @@ -12,7 +12,6 @@ import { setLogLevel } from '../lib/logger.js'; import { parseWikiUrl } from '../lib/url.js'; import { serializeYaml } from '../whiteboard/yaml/serialize.js'; import { - missingCredentialsMessage, resolveCommandUrl, resolveDownloadOptions, resolveGetTitlesOptions, @@ -37,11 +36,10 @@ export async function handleDownloadCommand ( const agentEnabled = agentLocal || agentStdout; if (agentEnabled) setLogLevel(LoggerLevel.error); - if (!opts.appId || !opts.appSecret) fail(missingCredentialsMessage(agentEnabled)); const result = await convert({ - appId: opts.appId, - appSecret: opts.appSecret, + appId: rawOptions.appId, + appSecret: rawOptions.appSecret, url, output: opts.output, imageMode: opts.imageMode, @@ -85,11 +83,9 @@ export async function handleGetTitlesCommand ( throw new Error(GET_TITLES_NON_DOCUMENT_HINT); } - if (!opts.appId || !opts.appSecret) fail(missingCredentialsMessage(agentEnabled)); - const result = await getTitles({ - appId: opts.appId, - appSecret: opts.appSecret, + appId: rawOptions.appId, + appSecret: rawOptions.appSecret, url, agent: opts.agent, }); diff --git a/src/cli/init-handler.ts b/src/cli/init-handler.ts new file mode 100644 index 0000000..f3e5174 --- /dev/null +++ b/src/cli/init-handler.ts @@ -0,0 +1,124 @@ +import * as p from '@clack/prompts'; +import { readAuthConfig, removeCredentials, saveCredentials } from '../lib/auth-store.js'; +import { beginRegistration, pollRegistration, probeCredentials } from '../lib/one-click-app.js'; +import type { CliErrorHandler } from './handlers.js'; + +export interface InitRawOptions { + noProbe?: boolean; + timeout?: string; +} + +export async function handleInitCommand ( + rawOptions: InitRawOptions, + fail: CliErrorHandler, +): Promise { + const timeoutMs = rawOptions.timeout ? parseTimeout(rawOptions.timeout) : 30_000; + if (timeoutMs === null) fail('无效的 --timeout 值,必须为正整数(毫秒)'); + + p.intro('lark-docx2md init'); + + const existing = readAuthConfig(); + + if (existing) { + // Already configured: show replace / remove options + p.note(`当前 App ID: ${existing.appId}`); + const action = await p.select({ + message: '已存在凭证配置,请选择操作:', + options: [ + { value: 'replace', label: '替换', hint: '注册新应用并替换现有凭证' }, + { value: 'remove', label: '移除', hint: '删除已存储的凭证' }, + ], + }); + + if (p.isCancel(action)) { + p.cancel('操作已取消。'); + process.exit(0); + } + + if (action === 'remove') { + await removeCredentials(); + p.outro('凭证已移除。'); + return; + } + + // action === 'replace': fall through to registration flow + } else { + // Not configured: show add option + const action = await p.select({ + message: '尚未配置凭证,请选择操作:', + options: [ + { value: 'add', label: '新增', hint: '注册新的飞书应用并保存凭证' }, + ], + }); + + if (p.isCancel(action)) { + p.cancel('操作已取消。'); + process.exit(0); + } + } + + // Registration flow + const s = p.spinner(); + s.start('正在启动一键应用配置(飞书)...'); + + let beginResult; + try { + beginResult = await beginRegistration(timeoutMs); + } catch (err: any) { + s.stop('失败'); + fail(`启动注册失败: ${err.message}`); + } + s.stop('注册已发起。'); + + // Show verification URL + p.note( + `请在浏览器中打开以下链接完成应用配置:\n\n ${beginResult.verificationUriComplete}`, + '验证', + ); + + // Poll for completion + const pollSpinner = p.spinner(); + pollSpinner.start('等待应用配置完成...'); + + let result; + try { + result = await pollRegistration( + beginResult.deviceCode, + beginResult.interval, + beginResult.expiresIn, + timeoutMs, + ); + } catch (err: any) { + pollSpinner.stop('失败'); + fail(`注册失败: ${err.message}`); + } + pollSpinner.stop('应用配置完成!'); + + // Probe credentials (optional) + if (!rawOptions.noProbe) { + const probeSpinner = p.spinner(); + probeSpinner.start('正在验证凭证...'); + try { + await probeCredentials(result.appId, result.appSecret, timeoutMs); + probeSpinner.stop('凭证验证通过。'); + } catch (err: any) { + probeSpinner.stop('失败'); + fail(`凭证验证失败: ${err.message}`); + } + } + + // Save credentials + try { + await saveCredentials(result.appId, result.appSecret); + } catch (err: any) { + fail(`保存凭证失败: ${err.message}`); + } + + p.outro(`应用配置完成: ${result.appId}。现在可以直接使用 download/get-titles 命令,无需 --app-id/--app-secret。`); +} + +function parseTimeout (raw: string): number | null { + const n = parseInt(raw, 10); + if (!Number.isInteger(n) || n <= 0 || `${n}` !== raw.trim()) return null; + return n; +} diff --git a/src/cli/options.ts b/src/cli/options.ts index b8c7d6e..e479a12 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -27,8 +27,6 @@ export interface GetTitlesRawOptions { } export interface ResolvedDownloadOptions { - appId?: string; - appSecret?: string; output: string; agent: AgentMode | false; imageMode: ImageMode; @@ -41,8 +39,6 @@ export interface ResolvedDownloadOptions { } export interface ResolvedGetTitlesOptions { - appId?: string; - appSecret?: string; output: string; agent: AgentMode | false; maxLevel: number; @@ -92,13 +88,6 @@ export function resolveAgentMode ( return { value: false, error: `Invalid --agent value "${raw}", must be "stdout" or "local"` }; } -export function missingCredentialsMessage (agentEnabled: boolean): string { - if (agentEnabled) { - return 'Missing credentials: pass --app-id/--app-secret or configure credentials in the environment.'; - } - return 'Missing credentials: pass --app-id/--app-secret or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET'; -} - export function resolveDownloadOptions ( raw: DownloadRawOptions, env: Env = process.env, @@ -162,8 +151,6 @@ export function resolveDownloadOptions ( return { value: { - appId: raw.appId ?? env.LARK_DOCX2MD_APP_ID, - appSecret: raw.appSecret ?? env.LARK_DOCX2MD_APP_SECRET, output: raw.output ?? env.LARK_DOCX2MD_OUTPUT ?? DEFAULT_OUTPUT, agent, imageMode, @@ -196,8 +183,6 @@ export function resolveGetTitlesOptions ( return { value: { - appId: raw.appId ?? env.LARK_DOCX2MD_APP_ID, - appSecret: raw.appSecret ?? env.LARK_DOCX2MD_APP_SECRET, output: raw.output ?? env.LARK_DOCX2MD_OUTPUT ?? DEFAULT_OUTPUT, agent: agentResolved.value, maxLevel, diff --git a/src/cli/program.ts b/src/cli/program.ts index 4f4a1fc..3288171 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -1,5 +1,6 @@ import { Command } from 'commander'; import { handleDownloadCommand, handleGetTitlesCommand } from './handlers.js'; +import { handleInitCommand, type InitRawOptions } from './init-handler.js'; import type { DownloadRawOptions, GetTitlesRawOptions } from './options.js'; export function createProgram (): Command { @@ -42,6 +43,15 @@ export function createProgram (): Command { handleGetTitlesCommand(positionalUrl, opts, message => program.error(message)) )); + program + .command('init') + .description('管理应用凭证:新增、替换或移除已存储的飞书应用凭证') + .option('--no-probe', '注册后跳过 tenant_access_token 验证') + .option('--timeout ', '每次请求超时时间(毫秒,默认 30000)') + .action((opts: InitRawOptions) => ( + handleInitCommand(opts, message => program.error(message)) + )); + return program; } diff --git a/src/lib/auth-store.ts b/src/lib/auth-store.ts new file mode 100644 index 0000000..699e7e8 --- /dev/null +++ b/src/lib/auth-store.ts @@ -0,0 +1,93 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { createHash } from 'node:crypto'; +import { homedir } from 'node:os'; +import { keychainGet, keychainSet, keychainDelete, assertMacOS } from './keychain.js'; + +const AUTH_DIR = path.join(homedir(), '.lark-docx2md'); +const AUTH_FILE = path.join(AUTH_DIR, 'auth.json'); + +export interface AuthConfig { + appId: string; + key: string; // md5 of appSecret, used as keychain account +} + +const KEYCHAIN_ACCOUNT_PREFIX = 'app-secret-'; + +function computeSecretKey (appSecret: string): string { + return KEYCHAIN_ACCOUNT_PREFIX + createHash('md5').update(appSecret + Date.now()).digest('hex'); +} + +/** Read auth.json. Returns null if file does not exist or is invalid. */ +export function readAuthConfig (): AuthConfig | null { + try { + const raw = fs.readFileSync(AUTH_FILE, 'utf-8'); + const data = JSON.parse(raw); + if (data && typeof data.appId === 'string' && typeof data.key === 'string') { + return { appId: data.appId, key: data.key }; + } + return null; + } catch { + return null; + } +} + +/** Write auth.json, creating directory if needed. */ +export function writeAuthConfig (config: AuthConfig): void { + fs.mkdirSync(AUTH_DIR, { recursive: true }); + fs.writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8'); +} + +/** Remove auth.json file. */ +export function removeAuthConfig (): void { + try { + fs.unlinkSync(AUTH_FILE); + } catch { + // ignore if not exists + } +} + +/** + * Save credentials: appId goes to auth.json, appSecret goes to keychain. + * The keychain account name is md5(appSecret). + */ +export async function saveCredentials (appId: string, appSecret: string): Promise { + assertMacOS(); + const key = computeSecretKey(appSecret); + await keychainSet(key, appSecret); + writeAuthConfig({ appId, key }); +} + +/** + * Read credentials: appId from auth.json, appSecret from keychain using stored key. + * Returns undefined values if not configured. + */ +export async function getStoredCredentials (): Promise<{ appId?: string; appSecret?: string }> { + const config = readAuthConfig(); + if (!config) return {}; + + try { + const appSecret = await keychainGet(config.key); + return { + appId: config.appId, + appSecret: appSecret || undefined, + }; + } catch { + return { appId: config.appId }; + } +} + +/** + * Remove stored credentials: delete keychain entry and auth.json. + */ +export async function removeCredentials (): Promise { + const config = readAuthConfig(); + if (config) { + try { + await keychainDelete(config.key); + } catch { + // ignore if not found + } + } + removeAuthConfig(); +} diff --git a/src/lib/client.ts b/src/lib/client.ts index 542251d..dee36d5 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -2,13 +2,40 @@ import * as lark from '@larksuiteoapi/node-sdk'; import { LoggerLevel } from '@larksuiteoapi/node-sdk'; import * as fs from 'node:fs'; import * as path from 'node:path'; +import { platform } from 'node:os'; +import { getStoredCredentials } from './auth-store.js'; import type { DocInfo, DocxBlock, WhiteboardNode } from './types.js'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); const RATE_LIMIT_MAX_RETRIES = 3; const RATE_LIMIT_RETRY_DELAY = 500; -export function createClient (appId: string, appSecret: string, loggerLevel: LoggerLevel = LoggerLevel.warn) { +/** + * 创建飞书 API 客户端。 + * 凭证优先级:传入参数(CLI)> macOS Keychain(auth.json + keychain)> 环境变量。 + */ +export async function createClient (cliAppId?: string, cliAppSecret?: string, loggerLevel: LoggerLevel = LoggerLevel.warn) { + let appId = cliAppId; + let appSecret = cliAppSecret; + + if (!appId || !appSecret) { + const stored = await getStoredCredentials(); + appId = appId || stored.appId; + appSecret = appSecret || stored.appSecret; + } + + if (!appId || !appSecret) { + appId = appId || process.env.LARK_DOCX2MD_APP_ID; + appSecret = appSecret || process.env.LARK_DOCX2MD_APP_SECRET; + } + + if (!appId || !appSecret) { + const keychainHint = platform() === 'darwin' ? ', run "lark-docx2md init" to store in macOS Keychain,' : ''; + throw new Error( + `Missing credentials: pass --app-id/--app-secret${keychainHint} or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET`, + ); + } + const client = new lark.Client({ appId, appSecret, loggerLevel }); async function call (name: string, fn: () => Promise<{ code?: number; msg?: string; data?: T }>): Promise { @@ -195,4 +222,4 @@ export function createClient (appId: string, appSecret: string, loggerLevel: Log }; } -export type LarkClient = ReturnType; +export type LarkClient = Awaited>; diff --git a/src/lib/converter.ts b/src/lib/converter.ts index af91908..39cc2c2 100644 --- a/src/lib/converter.ts +++ b/src/lib/converter.ts @@ -28,7 +28,7 @@ export async function convert (opts: ConvertOptions): Promise { logger.info('Captured document token:', rawToken, sheetId ? `sheetId: ${sheetId}` : ''); const sdkLoggerLevel = opts.agent ? LoggerLevel.error : LoggerLevel.warn; - const client = createClient(opts.appId, opts.appSecret, sdkLoggerLevel); + const client = await createClient(opts.appId, opts.appSecret, sdkLoggerLevel); // 1. 解析文档 token 与类型 let docToken = rawToken; diff --git a/src/lib/get-titles.ts b/src/lib/get-titles.ts index 48aa64d..a2e96a9 100644 --- a/src/lib/get-titles.ts +++ b/src/lib/get-titles.ts @@ -11,8 +11,8 @@ export const GET_TITLES_NON_DOCUMENT_HINT = 'The input link is not a document. T // ─── Options & Result ─────────────────────────────────────────────────────── export interface GetTitlesOptions { - appId: string; - appSecret: string; + appId?: string; + appSecret?: string; url: string; /** 仅影响 SDK 日志级别(与 dl 保持一致) */ agent?: AgentMode | false; @@ -34,7 +34,7 @@ export async function getTitles (opts: GetTitlesOptions): Promise { + return new Promise((resolve, reject) => { + execFile('/usr/bin/security', args, { timeout: 5000 }, (err, stdout, stderr) => { + if (err) { + // security returns exit code 44 when item not found + if ((err as any).code === 44 || stderr?.includes('could not be found')) { + resolve(''); + return; + } + reject(new Error(`security command failed: ${stderr || err.message}`)); + return; + } + resolve(stdout.trim()); + }); + }); +} + +export function assertMacOS (): void { + if (!isMacOS()) { + throw new Error('Keychain storage is only supported on macOS.'); + } +} + +export async function keychainGet (account: string): Promise { + assertMacOS(); + return execSecurity(['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w']); +} + +export async function keychainSet (account: string, password: string): Promise { + assertMacOS(); + // Delete first to avoid "already exists" error, ignore if not found + try { + await execSecurity(['delete-generic-password', '-s', SERVICE_NAME, '-a', account]); + } catch { /* ignore */ } + await execSecurity(['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', password]); +} + +export async function keychainDelete (account: string): Promise { + assertMacOS(); + await execSecurity(['delete-generic-password', '-s', SERVICE_NAME, '-a', account]); +} diff --git a/src/lib/one-click-app.ts b/src/lib/one-click-app.ts new file mode 100644 index 0000000..926219c --- /dev/null +++ b/src/lib/one-click-app.ts @@ -0,0 +1,198 @@ +/** + * One-click app registration via Feishu Device-Code OAuth flow. + * Only supports feishu (not lark). + */ +import * as https from 'node:https'; + +const ACCOUNTS_BASE = 'https://accounts.feishu.cn'; +const OPEN_BASE = 'https://open.feishu.cn'; +const PATH_APP_REGISTRATION = '/oauth/v1/app/registration'; +const PATH_TAT = '/open-apis/auth/v3/tenant_access_token/internal'; + +const DEFAULT_TIMEOUT_MS = 30_000; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface InitOptions { + /** Request timeout in ms (default: 30000) */ + timeoutMs?: number; + /** Skip tenant_access_token validation (default: false) */ + noProbe?: boolean; +} + +export interface RegistrationBeginResult { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number; + interval: number; +} + +export interface RegistrationResult { + appId: string; + appSecret: string; +} + +// ─── HTTP Helpers ──────────────────────────────────────────────────────────── + +function postBody (url: string, body: string, contentType: string, timeoutMs: number): Promise<{ statusCode: number; data: any }> { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = https.request({ + method: 'POST', + hostname: parsed.hostname, + port: parsed.port || 443, + path: parsed.pathname + parsed.search, + headers: { + 'content-type': contentType, + 'content-length': Buffer.byteLength(body), + }, + timeout: timeoutMs, + }, (resp) => { + const chunks: Buffer[] = []; + resp.on('data', (chunk: Buffer) => chunks.push(chunk)); + resp.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let data: any = {}; + if (raw.trim()) { + try { data = JSON.parse(raw); } catch (e: any) { + reject(new Error(`response was not JSON: ${e.message}`)); + return; + } + } + resolve({ statusCode: resp.statusCode ?? 0, data }); + }); + }); + req.on('timeout', () => req.destroy(new Error(`request timed out after ${timeoutMs}ms`))); + req.on('error', reject); + req.end(body); + }); +} + +function postForm (url: string, form: Record, timeoutMs: number) { + return postBody(url, new URLSearchParams(form).toString(), 'application/x-www-form-urlencoded', timeoutMs); +} + +function postJSON (url: string, body: Record, timeoutMs: number) { + return postBody(url, JSON.stringify(body), 'application/json', timeoutMs); +} + +function sleep (ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function getString (obj: any, key: string): string { + const value = obj?.[key]; + return typeof value === 'string' ? value : ''; +} + +function getInt (obj: any, key: string, fallback: number): number { + const value = obj?.[key]; + if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value); + if (typeof value === 'string' && value !== '' && Number.isFinite(Number(value))) return Math.trunc(Number(value)); + return fallback; +} + +// ─── Core Flow ─────────────────────────────────────────────────────────────── + +/** Step 1: Begin device-code registration, returns verification URL for user. */ +export async function beginRegistration (timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + const endpoint = ACCOUNTS_BASE + PATH_APP_REGISTRATION; + const resp = await postForm(endpoint, { + action: 'begin', + archetype: 'PersonalAgent', + auth_method: 'client_secret', + request_user_info: 'open_id tenant_brand', + }, timeoutMs); + + const errorText = getString(resp.data, 'error_description') || getString(resp.data, 'error'); + if (resp.statusCode >= 400 || errorText) { + throw new Error(`app registration begin failed: ${errorText || `HTTP ${resp.statusCode}`}`); + } + + const deviceCode = getString(resp.data, 'device_code'); + const userCode = getString(resp.data, 'user_code'); + if (!deviceCode || !userCode) { + throw new Error('app registration begin response is missing device_code or user_code'); + } + + return { + deviceCode, + userCode, + verificationUri: getString(resp.data, 'verification_uri'), + verificationUriComplete: `${OPEN_BASE}/page/cli?user_code=${encodeURIComponent(userCode)}`, + expiresIn: getInt(resp.data, 'expires_in', 300), + interval: getInt(resp.data, 'interval', 5), + }; +} + +/** Step 2: Poll until user completes the app configuration in browser. */ +export async function pollRegistration ( + deviceCode: string, + interval: number, + expiresIn: number, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + const endpoint = ACCOUNTS_BASE + PATH_APP_REGISTRATION; + const deadline = Date.now() + expiresIn * 1000; + let currentInterval = Math.max(1, interval || 2); + let attempts = 0; + + while (Date.now() < deadline && attempts < 200) { + attempts++; + await sleep(currentInterval * 1000); + + let resp; + try { + resp = await postForm(endpoint, { action: 'poll', device_code: deviceCode }, timeoutMs); + } catch (err: any) { + process.stderr.write(`[WARN] poll request failed: ${err.message}\n`); + currentInterval = Math.min(currentInterval + 1, 60); + continue; + } + + const errStr = getString(resp.data, 'error'); + const clientID = getString(resp.data, 'client_id'); + + if (!errStr && clientID) { + const clientSecret = getString(resp.data, 'client_secret'); + if (!clientSecret) throw new Error('app registration succeeded but missing client_secret'); + return { appId: clientID, appSecret: clientSecret }; + } + + if (errStr === 'authorization_pending') continue; + if (errStr === 'slow_down') { + currentInterval = Math.min(currentInterval + 5, 60); + continue; + } + if (errStr === 'access_denied') { + throw new Error('app registration denied by user'); + } + if (errStr === 'expired_token' || errStr === 'invalid_grant') { + throw new Error('device code expired, please try again'); + } + + const desc = getString(resp.data, 'error_description') || errStr || `HTTP ${resp.statusCode}`; + throw new Error(`app registration poll failed: ${desc}`); + } + + throw new Error('app registration timed out, please try again'); +} + +/** Step 3 (optional): Validate credentials by requesting a tenant_access_token. */ +export async function probeCredentials (appId: string, appSecret: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + const endpoint = OPEN_BASE + PATH_TAT; + const resp = await postJSON(endpoint, { app_id: appId, app_secret: appSecret }, timeoutMs); + + if (resp.statusCode !== 200) { + throw new Error(`credential probe failed: HTTP ${resp.statusCode}`); + } + const code = getInt(resp.data, 'code', -1); + if (code !== 0) { + throw new Error(`credential probe failed: [${code}] ${getString(resp.data, 'msg') || 'unknown error'}`); + } + if (!getString(resp.data, 'tenant_access_token')) { + throw new Error('credential probe: tenant_access_token missing in response'); + } +} diff --git a/src/lib/types.ts b/src/lib/types.ts index e714ef0..6eece9d 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -40,8 +40,8 @@ export type SvgBackground = 'none' | 'dot' | (string & {}); export type AgentMode = 'stdout' | 'local'; export interface ConvertOptions { - appId: string; - appSecret: string; + appId?: string; + appSecret?: string; url: string; output: string; imageMode: ImageMode; diff --git a/tests/cli-options.test.ts b/tests/cli-options.test.ts index 7fccf3d..2aae911 100644 --- a/tests/cli-options.test.ts +++ b/tests/cli-options.test.ts @@ -36,8 +36,6 @@ describe('cli options', () => { expect(resolved.error).toBeUndefined(); expect(resolved.value).toMatchObject({ - appId: 'app-id', - appSecret: 'app-secret', output: './out', agent: 'stdout', imageMode: 'online', From c8ede8cb2159e3704c68afcfee87d8bb5da41d08 Mon Sep 17 00:00:00 2001 From: byte-n Date: Wed, 3 Jun 2026 14:56:56 +0800 Subject: [PATCH 2/2] release: 0.7.0 --- CHANGELOG.md | 8 +++++ README.md | 25 +++++++++++++ package.json | 2 +- public/index.html | 11 ++++++ src/cli/init-handler.ts | 8 ++++- src/lib/auth-store.ts | 78 ++++++++++++++++++++++++++++++----------- src/lib/client.ts | 8 +++-- src/lib/keychain.ts | 4 +-- 8 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 public/index.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a1ca73..968f9d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 0.7.0 + +### Features + +- **`init` 命令**:新增交互式初始化命令,引导用户完成飞书应用 App ID 与 App Secret 的配置 +- **macOS Keychain 凭证存储**:macOS 平台将飞书应用凭证安全存储至系统钥匙串,免去重复输入或环境变量配置 +- **跨平台凭证回退**:非 macOS 系统凭证以明文存储至 `~/.lark-docx2md/auth.json`;macOS 读取时若 Keychain 无记录则自动回退至明文,兼顾安全与兼容 + ## 0.6.0 ### Features diff --git a/README.md b/README.md index 914e9cd..cf860bb 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,16 @@ export LARK_DOCX2MD_APP_SECRET= npx -y lark-docx2md@latest download --url ``` +或使用 `init` 命令一键配置并持久化凭证(macOS 存入系统 Keychain,其他系统存入 `~/.lark-docx2md/auth.json`): + +```bash +npx -y lark-docx2md@latest init +``` + +完成后即可直接使用 `download` / `get-titles`,无需每次传入 `--app-id` / `--app-secret` 或设置环境变量。 + +> **凭证优先级**:命令行参数 > 已存储凭证(macOS: Keychain;其他: auth.json)> 环境变量 + ## 参数 | 参数 | 说明 | 环境变量 | 默认值 | @@ -61,6 +71,21 @@ npx -y lark-docx2md@latest download --url > - `--max-output-lines`:仅在未指定 `--filter-title` / `--filter-title-block-id` 时生效;纯 Markdown 阶段先检查一次,图片、画板、表格等解析完成后再检查一次。超过限制时直接报错,提示先用 `get-titles` 获取标题,再补充标题过滤参数重试。 > - **命中深层标题时自动注入父级标题(仅 heading 块本身)**:两个过滤参数均会按文档顺序补齐包含路径上的顶层→该标题的所有祖先标题,以保留章节层级上下文;不会引入旁支兄弟或伪造跳级。 +## 子命令:`init` + +交互式配置飞书应用凭证。通过飞书一键应用流程注册并验证凭证,macOS 保存至系统 Keychain,其他系统保存至 `~/.lark-docx2md/auth.json`。 + +```bash +npx -y lark-docx2md@latest init +``` + +| 参数 | 说明 | 默认值 | +|------|------|--------| +| `--no-probe` | 跳过凭证验证步骤 | `false` | +| `--timeout ` | 请求超时(毫秒) | `30000` | + +已配置时再次运行可替换或移除现有凭证。 + ## 子命令:`get-titles` 列出 docx/wiki 文档全部标题(不支持 sheets),配合 `--filter-title-block-id` 使用可避开同名歧义。 diff --git a/package.json b/package.json index 35ce06b..a92ac92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lark-docx2md", - "version": "0.6.0", + "version": "0.7.0", "description": "Convert Lark/Feishu documents to Markdown", "type": "module", "main": "./dist/converter.js", diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c5db600 --- /dev/null +++ b/public/index.html @@ -0,0 +1,11 @@ + + + + + + Document + + + + + diff --git a/src/cli/init-handler.ts b/src/cli/init-handler.ts index f3e5174..e9af651 100644 --- a/src/cli/init-handler.ts +++ b/src/cli/init-handler.ts @@ -1,4 +1,5 @@ import * as p from '@clack/prompts'; +import { platform } from 'node:os'; import { readAuthConfig, removeCredentials, saveCredentials } from '../lib/auth-store.js'; import { beginRegistration, pollRegistration, probeCredentials } from '../lib/one-click-app.js'; import type { CliErrorHandler } from './handlers.js'; @@ -17,6 +18,10 @@ export async function handleInitCommand ( p.intro('lark-docx2md init'); + if (platform() !== 'darwin') { + p.log.warn('当前系统不支持 macOS Keychain,凭证将以明文存储在 ~/.lark-docx2md/auth.json 中。'); + } + const existing = readAuthConfig(); if (existing) { @@ -114,7 +119,8 @@ export async function handleInitCommand ( fail(`保存凭证失败: ${err.message}`); } - p.outro(`应用配置完成: ${result.appId}。现在可以直接使用 download/get-titles 命令,无需 --app-id/--app-secret。`); + const storageDesc = platform() === 'darwin' ? 'macOS Keychain' : '~/.lark-docx2md/auth.json'; + p.outro(`应用配置完成: ${result.appId}(凭证已存储至 ${storageDesc})。现在可以直接使用 download/get-titles 命令,无需 --app-id/--app-secret。`); } function parseTimeout (raw: string): number | null { diff --git a/src/lib/auth-store.ts b/src/lib/auth-store.ts index 699e7e8..34c0fd1 100644 --- a/src/lib/auth-store.ts +++ b/src/lib/auth-store.ts @@ -2,14 +2,21 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; -import { keychainGet, keychainSet, keychainDelete, assertMacOS } from './keychain.js'; +import { keychainGet, keychainSet, keychainDelete, isMacOS } from './keychain.js'; + +/** Warning message when auth.json contains a Keychain reference but platform is non-macOS. */ +const CROSS_PLATFORM_WARN = + 'auth.json 中包含 Keychain 凭证引用,但当前系统不支持 macOS Keychain。请运行 "lark-docx2md init" 重新配置。'; const AUTH_DIR = path.join(homedir(), '.lark-docx2md'); const AUTH_FILE = path.join(AUTH_DIR, 'auth.json'); export interface AuthConfig { appId: string; - key: string; // md5 of appSecret, used as keychain account + /** macOS only: keychain account name for retrieving appSecret */ + key?: string; + /** non-macOS only: appSecret stored in plaintext as file fallback */ + appSecret?: string; } const KEYCHAIN_ACCOUNT_PREFIX = 'app-secret-'; @@ -23,8 +30,12 @@ export function readAuthConfig (): AuthConfig | null { try { const raw = fs.readFileSync(AUTH_FILE, 'utf-8'); const data = JSON.parse(raw); - if (data && typeof data.appId === 'string' && typeof data.key === 'string') { - return { appId: data.appId, key: data.key }; + if (data && typeof data.appId === 'string') { + return { + appId: data.appId, + key: typeof data.key === 'string' ? data.key : undefined, + appSecret: typeof data.appSecret === 'string' ? data.appSecret : undefined, + }; } return null; } catch { @@ -35,7 +46,11 @@ export function readAuthConfig (): AuthConfig | null { /** Write auth.json, creating directory if needed. */ export function writeAuthConfig (config: AuthConfig): void { fs.mkdirSync(AUTH_DIR, { recursive: true }); - fs.writeFileSync(AUTH_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8'); + // Only write fields that are defined (omit undefined) + const toWrite: Record = { appId: config.appId }; + if (config.key !== undefined) toWrite.key = config.key; + if (config.appSecret !== undefined) toWrite.appSecret = config.appSecret; + fs.writeFileSync(AUTH_FILE, JSON.stringify(toWrite, null, 2) + '\n', 'utf-8'); } /** Remove auth.json file. */ @@ -48,41 +63,62 @@ export function removeAuthConfig (): void { } /** - * Save credentials: appId goes to auth.json, appSecret goes to keychain. - * The keychain account name is md5(appSecret). + * Save credentials. + * - macOS: appId → auth.json, appSecret → Keychain + * - non-macOS: appId + appSecret → auth.json (file fallback) */ export async function saveCredentials (appId: string, appSecret: string): Promise { - assertMacOS(); - const key = computeSecretKey(appSecret); - await keychainSet(key, appSecret); - writeAuthConfig({ appId, key }); + if (isMacOS()) { + const key = computeSecretKey(appSecret); + await keychainSet(key, appSecret); + writeAuthConfig({ appId, key }); + } else { + writeAuthConfig({ appId, appSecret }); + } } /** - * Read credentials: appId from auth.json, appSecret from keychain using stored key. + * Read credentials. + * - macOS: prefer Keychain (key field), fall back to plaintext (appSecret field). + * - non-macOS: prefer plaintext (appSecret field); warn if only key field exists. * Returns undefined values if not configured. */ export async function getStoredCredentials (): Promise<{ appId?: string; appSecret?: string }> { const config = readAuthConfig(); if (!config) return {}; - try { - const appSecret = await keychainGet(config.key); - return { - appId: config.appId, - appSecret: appSecret || undefined, - }; - } catch { + if (isMacOS()) { + // macOS: Keychain first, then plaintext fallback + if (config.key !== undefined) { + try { + const appSecret = await keychainGet(config.key); + if (appSecret) return { appId: config.appId, appSecret }; + } catch { /* fall through */ } + } + if (config.appSecret !== undefined) { + return { appId: config.appId, appSecret: config.appSecret }; + } return { appId: config.appId }; } + + // non-macOS: plaintext first + if (config.appSecret !== undefined) { + return { appId: config.appId, appSecret: config.appSecret }; + } + if (config.key !== undefined) { + console.warn(CROSS_PLATFORM_WARN); + } + return { appId: config.appId }; } /** - * Remove stored credentials: delete keychain entry and auth.json. + * Remove stored credentials. + * - macOS: delete Keychain entry + auth.json + * - non-macOS: delete auth.json */ export async function removeCredentials (): Promise { const config = readAuthConfig(); - if (config) { + if (config?.key) { try { await keychainDelete(config.key); } catch { diff --git a/src/lib/client.ts b/src/lib/client.ts index dee36d5..c17cb4e 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -12,7 +12,7 @@ const RATE_LIMIT_RETRY_DELAY = 500; /** * 创建飞书 API 客户端。 - * 凭证优先级:传入参数(CLI)> macOS Keychain(auth.json + keychain)> 环境变量。 + * 凭证优先级:传入参数(CLI)> 已存储凭证(macOS: Keychain; 其他: auth.json 明文)> 环境变量。 */ export async function createClient (cliAppId?: string, cliAppSecret?: string, loggerLevel: LoggerLevel = LoggerLevel.warn) { let appId = cliAppId; @@ -30,9 +30,11 @@ export async function createClient (cliAppId?: string, cliAppSecret?: string, lo } if (!appId || !appSecret) { - const keychainHint = platform() === 'darwin' ? ', run "lark-docx2md init" to store in macOS Keychain,' : ''; + const initHint = platform() === 'darwin' + ? ', run "lark-docx2md init" to store in macOS Keychain,' + : ', run "lark-docx2md init" to store in auth.json,'; throw new Error( - `Missing credentials: pass --app-id/--app-secret${keychainHint} or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET`, + `Missing credentials: pass --app-id/--app-secret${initHint} or set LARK_DOCX2MD_APP_ID/LARK_DOCX2MD_APP_SECRET`, ); } diff --git a/src/lib/keychain.ts b/src/lib/keychain.ts index 7d09e9c..2b63b7c 100644 --- a/src/lib/keychain.ts +++ b/src/lib/keychain.ts @@ -3,7 +3,7 @@ import { platform } from 'node:os'; const SERVICE_NAME = 'lark-docx2md'; -function isMacOS (): boolean { +export function isMacOS (): boolean { return platform() === 'darwin'; } @@ -24,7 +24,7 @@ function execSecurity (args: string[]): Promise { }); } -export function assertMacOS (): void { +function assertMacOS (): void { if (!isMacOS()) { throw new Error('Keychain storage is only supported on macOS.'); }