Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ export LARK_DOCX2MD_APP_SECRET=<APP_SECRET>
npx -y lark-docx2md@latest download --url <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)> 环境变量

## 参数

| 参数 | 说明 | 环境变量 | 默认值 |
Expand Down Expand Up @@ -61,6 +71,21 @@ npx -y lark-docx2md@latest download --url <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 <ms>` | 请求超时(毫秒) | `30000` |

已配置时再次运行可替换或移除现有凭证。

## 子命令:`get-titles`

列出 docx/wiki 文档全部标题(不支持 sheets),配合 `--filter-title-block-id` 使用可避开同名歧义。
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
47 changes: 47 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Document</title>
</head>
<body>
<iframe src="https://open.feishu.cn/page/cli"></iframe>
</body>
</html>
12 changes: 4 additions & 8 deletions src/cli/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
});
Expand Down
130 changes: 130 additions & 0 deletions src/cli/init-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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';

export interface InitRawOptions {
noProbe?: boolean;
timeout?: string;
}

export async function handleInitCommand (
rawOptions: InitRawOptions,
fail: CliErrorHandler,
): Promise<void> {
const timeoutMs = rawOptions.timeout ? parseTimeout(rawOptions.timeout) : 30_000;
if (timeoutMs === null) fail('无效的 --timeout 值,必须为正整数(毫秒)');

p.intro('lark-docx2md init');

if (platform() !== 'darwin') {
p.log.warn('当前系统不支持 macOS Keychain,凭证将以明文存储在 ~/.lark-docx2md/auth.json 中。');
}

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}`);
}

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 {
const n = parseInt(raw, 10);
if (!Number.isInteger(n) || n <= 0 || `${n}` !== raw.trim()) return null;
return n;
}
15 changes: 0 additions & 15 deletions src/cli/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,6 @@ export interface GetTitlesRawOptions {
}

export interface ResolvedDownloadOptions {
appId?: string;
appSecret?: string;
output: string;
agent: AgentMode | false;
imageMode: ImageMode;
Expand All @@ -41,8 +39,6 @@ export interface ResolvedDownloadOptions {
}

export interface ResolvedGetTitlesOptions {
appId?: string;
appSecret?: string;
output: string;
agent: AgentMode | false;
maxLevel: number;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading