feishu-md-sync 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +5 -0
  3. package/README.md +107 -0
  4. package/dist/adapters/feishu-adapter.d.ts +44 -0
  5. package/dist/adapters/feishu-adapter.js +1 -0
  6. package/dist/adapters/lark-cli-adapter.d.ts +45 -0
  7. package/dist/adapters/lark-cli-adapter.js +229 -0
  8. package/dist/cli/commands/core.d.ts +2 -0
  9. package/dist/cli/commands/core.js +135 -0
  10. package/dist/cli/commands/publish.d.ts +2 -0
  11. package/dist/cli/commands/publish.js +63 -0
  12. package/dist/cli/env.d.ts +27 -0
  13. package/dist/cli/env.js +113 -0
  14. package/dist/cli/index.d.ts +2 -0
  15. package/dist/cli/index.js +52 -0
  16. package/dist/cli/output.d.ts +4 -0
  17. package/dist/cli/output.js +18 -0
  18. package/dist/config/sync-config.d.ts +13 -0
  19. package/dist/config/sync-config.js +60 -0
  20. package/dist/core/diff.d.ts +1 -0
  21. package/dist/core/diff.js +20 -0
  22. package/dist/core/doc-id.d.ts +12 -0
  23. package/dist/core/doc-id.js +41 -0
  24. package/dist/core/hash.d.ts +6 -0
  25. package/dist/core/hash.js +84 -0
  26. package/dist/core/markdown-canonical.d.ts +2 -0
  27. package/dist/core/markdown-canonical.js +11 -0
  28. package/dist/diff/run-diff.d.ts +23 -0
  29. package/dist/diff/run-diff.js +33 -0
  30. package/dist/feishu/types.d.ts +101 -0
  31. package/dist/feishu/types.js +1 -0
  32. package/dist/markdown/blocks.d.ts +5 -0
  33. package/dist/markdown/blocks.js +305 -0
  34. package/dist/markdown/from-blocks.d.ts +2 -0
  35. package/dist/markdown/from-blocks.js +110 -0
  36. package/dist/markdown/links.d.ts +3 -0
  37. package/dist/markdown/links.js +44 -0
  38. package/dist/merge/line-merge.d.ts +21 -0
  39. package/dist/merge/line-merge.js +270 -0
  40. package/dist/merge/merge-state.d.ts +33 -0
  41. package/dist/merge/merge-state.js +44 -0
  42. package/dist/merge/run-merge.d.ts +38 -0
  43. package/dist/merge/run-merge.js +139 -0
  44. package/dist/profiles/publish-profile.d.ts +9 -0
  45. package/dist/profiles/publish-profile.js +9 -0
  46. package/dist/publish/block-patch-plan.d.ts +34 -0
  47. package/dist/publish/block-patch-plan.js +223 -0
  48. package/dist/publish/block-state.d.ts +8 -0
  49. package/dist/publish/block-state.js +82 -0
  50. package/dist/publish/block-update.d.ts +4 -0
  51. package/dist/publish/block-update.js +40 -0
  52. package/dist/publish/profile-transform.d.ts +5 -0
  53. package/dist/publish/profile-transform.js +6 -0
  54. package/dist/publish/publish-plan.d.ts +35 -0
  55. package/dist/publish/publish-plan.js +113 -0
  56. package/dist/publish/run-publish.d.ts +25 -0
  57. package/dist/publish/run-publish.js +299 -0
  58. package/dist/publish/title.d.ts +9 -0
  59. package/dist/publish/title.js +20 -0
  60. package/dist/pull/run-pull.d.ts +23 -0
  61. package/dist/pull/run-pull.js +57 -0
  62. package/dist/receipts/publish-receipt.d.ts +49 -0
  63. package/dist/receipts/publish-receipt.js +52 -0
  64. package/dist/receipts/pull-receipt.d.ts +31 -0
  65. package/dist/receipts/pull-receipt.js +30 -0
  66. package/dist/status/run-status.d.ts +56 -0
  67. package/dist/status/run-status.js +131 -0
  68. package/dist/transform/include-tags.d.ts +6 -0
  69. package/dist/transform/include-tags.js +22 -0
  70. package/dist/transform/zilliz-publish.d.ts +5 -0
  71. package/dist/transform/zilliz-publish.js +42 -0
  72. package/dist/transform/zilliz-pull.d.ts +6 -0
  73. package/dist/transform/zilliz-pull.js +34 -0
  74. package/package.json +61 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 liyun95
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/NOTICE ADDED
@@ -0,0 +1,5 @@
1
+ This project is an independent Markdown-to-Feishu CLI.
2
+
3
+ Feishu API endpoint shapes and block-conversion behavior were informed by
4
+ separate internal reference tooling. No runtime dependency on that tooling is
5
+ used.
package/README.md ADDED
@@ -0,0 +1,107 @@
1
+ # feishu-md-sync
2
+
3
+ `feishu-md-sync` is a dry-run-first sync bridge between local Markdown and Feishu/Lark online documents. It uses the official `lark-cli` for authentication and Feishu document IO, then adds profiles, receipts, safety gates, status, diff, pull, merge, and publish UX.
4
+
5
+ Docs site: <https://liyun95.github.io/feishu-md-sync/>
6
+
7
+ ## Install
8
+
9
+ Install and authenticate the official Lark CLI:
10
+
11
+ ```bash
12
+ npx @larksuite/cli@latest install
13
+ lark-cli auth login --domain docs,wiki,drive
14
+ lark-cli auth status --verify
15
+ ```
16
+
17
+ Install `feishu-md-sync` globally:
18
+
19
+ ```bash
20
+ npm install --global feishu-md-sync
21
+ feishu-md-sync --help
22
+ ```
23
+
24
+ The selected `lark-cli` identity must have access to the target document, Drive folder, or Wiki parent.
25
+
26
+ ## Main Commands
27
+
28
+ | Need | Command |
29
+ | --- | --- |
30
+ | Publish local Markdown to Feishu | `feishu-md-sync publish` |
31
+ | Create a new Feishu document from Markdown | `feishu-md-sync publish --create` |
32
+ | Pull a remote Markdown snapshot | `feishu-md-sync pull` |
33
+ | Check local/remote state | `feishu-md-sync status` |
34
+ | Inspect publish diff | `feishu-md-sync diff` |
35
+ | Merge remote edits into local Markdown | `feishu-md-sync merge` |
36
+
37
+ ## Common Flow
38
+
39
+ The default profile is `none`. Start with a read-only status check and diff:
40
+
41
+ ```bash
42
+ feishu-md-sync status ./doc.md --target DocToken
43
+ feishu-md-sync diff ./doc.md --target DocToken
44
+ ```
45
+
46
+ Preview a publish without writing to Feishu:
47
+
48
+ ```bash
49
+ feishu-md-sync publish ./doc.md --target DocToken
50
+ ```
51
+
52
+ Write to an existing remote document for the first time after reviewing the plan:
53
+
54
+ ```bash
55
+ feishu-md-sync publish ./doc.md --target DocToken --write --confirm-untracked-remote
56
+ feishu-md-sync status ./doc.md --target DocToken
57
+ ```
58
+
59
+ If the remote document changed, pull a reviewable snapshot and merge it locally:
60
+
61
+ ```bash
62
+ feishu-md-sync pull --target DocToken --output doc.remote.md --write-receipt
63
+ feishu-md-sync merge ./doc.md --target DocToken
64
+ feishu-md-sync publish ./doc.md --target DocToken --write
65
+ ```
66
+
67
+ When the merge already makes the publish draft match Feishu, the final write is a no-op remote update that refreshes the local receipt and merge base.
68
+
69
+ Create a new document under a Drive folder or Wiki parent:
70
+
71
+ ```bash
72
+ feishu-md-sync publish ./doc.md --target FolderOrWikiToken --create --write
73
+ ```
74
+
75
+ Inspect local auth loading without printing secrets:
76
+
77
+ ```bash
78
+ feishu-md-sync doctor auth --format json
79
+ ```
80
+
81
+ ## Profiles
82
+
83
+ Omit `--profile` or use `--profile none` for general Markdown sync.
84
+
85
+ Use `--profile zilliz` when local Markdown uses Milvus wording but the Feishu document is a Zilliz Cloud publishing draft. Use `--profile milvus` mainly when pulling or merging that content back into the local Milvus-shaped source.
86
+
87
+ ## Safety Model
88
+
89
+ - Remote writes require `publish --write`.
90
+ - The first write to an existing untracked document also requires `--confirm-untracked-remote`.
91
+ - Block-patch updates or deletions that may affect comments, anchors, or block identity require `--confirm-collaboration-risk`.
92
+ - Whole-document replacement requires `--strategy document-replace --confirm-destructive`.
93
+ - `status` and `diff` are read-only; `merge` writes only local files and supports `--abort`.
94
+
95
+ ## Development
96
+
97
+ From a repository checkout:
98
+
99
+ ```bash
100
+ npm install
101
+ npm run build
102
+ npm run typecheck
103
+ npm test
104
+ npm run test:package
105
+ ```
106
+
107
+ Generated outputs such as `dist/` and `coverage/` should not be committed.
@@ -0,0 +1,44 @@
1
+ import type { FeishuBlock } from '../feishu/types.js';
2
+ export type RemoteMarkdown = {
3
+ markdown: string;
4
+ revision?: string;
5
+ };
6
+ export type RemoteBlocks = {
7
+ blocks: FeishuBlock[];
8
+ };
9
+ export type CreatedDocument = {
10
+ documentId: string;
11
+ url?: string;
12
+ revision?: string;
13
+ };
14
+ export type FeishuAdapter = {
15
+ fetchDocMarkdown(input: {
16
+ doc: string;
17
+ }): Promise<RemoteMarkdown>;
18
+ fetchDocBlocks?(input: {
19
+ doc: string;
20
+ }): Promise<RemoteBlocks>;
21
+ replaceDocument(input: {
22
+ doc: string;
23
+ markdown: string;
24
+ }): Promise<void>;
25
+ replaceBlock?(input: {
26
+ doc: string;
27
+ blockId: string;
28
+ markdown: string;
29
+ }): Promise<void>;
30
+ insertBlocksAfter?(input: {
31
+ doc: string;
32
+ blockId: string;
33
+ markdown: string;
34
+ }): Promise<void>;
35
+ deleteBlocks?(input: {
36
+ doc: string;
37
+ blockIds: string[];
38
+ }): Promise<void>;
39
+ createDocument(input: {
40
+ title: string;
41
+ markdown: string;
42
+ parentToken: string;
43
+ }): Promise<CreatedDocument>;
44
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import type { CreatedDocument, FeishuAdapter, RemoteBlocks, RemoteMarkdown } from './feishu-adapter.js';
2
+ export type LarkCliExecResult = {
3
+ stdout: string;
4
+ stderr: string;
5
+ };
6
+ export type LarkCliExecutor = (args: string[]) => Promise<LarkCliExecResult>;
7
+ export type LarkCliIdentity = 'auto' | 'bot' | 'user';
8
+ export declare class LarkCliAdapter implements FeishuAdapter {
9
+ private readonly exec;
10
+ private readonly identity;
11
+ constructor(input?: {
12
+ exec?: LarkCliExecutor;
13
+ identity?: LarkCliIdentity;
14
+ });
15
+ fetchDocMarkdown(input: {
16
+ doc: string;
17
+ }): Promise<RemoteMarkdown>;
18
+ fetchDocBlocks(input: {
19
+ doc: string;
20
+ }): Promise<RemoteBlocks>;
21
+ replaceDocument(input: {
22
+ doc: string;
23
+ markdown: string;
24
+ }): Promise<void>;
25
+ replaceBlock(input: {
26
+ doc: string;
27
+ blockId: string;
28
+ markdown: string;
29
+ }): Promise<void>;
30
+ insertBlocksAfter(input: {
31
+ doc: string;
32
+ blockId: string;
33
+ markdown: string;
34
+ }): Promise<void>;
35
+ deleteBlocks(input: {
36
+ doc: string;
37
+ blockIds: string[];
38
+ }): Promise<void>;
39
+ createDocument(input: {
40
+ title: string;
41
+ markdown: string;
42
+ parentToken: string;
43
+ }): Promise<CreatedDocument>;
44
+ private updateMarkdownBlock;
45
+ }
@@ -0,0 +1,229 @@
1
+ import { execFile } from 'node:child_process';
2
+ export class LarkCliAdapter {
3
+ exec;
4
+ identity;
5
+ constructor(input = {}) {
6
+ this.exec = input.exec ?? runLarkCli;
7
+ this.identity = input.identity ?? larkCliIdentityFromEnv();
8
+ }
9
+ async fetchDocMarkdown(input) {
10
+ const result = await this.exec(withIdentity([
11
+ 'docs',
12
+ '+fetch',
13
+ '--doc',
14
+ input.doc,
15
+ '--doc-format',
16
+ 'markdown',
17
+ '--format',
18
+ 'json'
19
+ ], this.identity));
20
+ const data = parseLarkCliJson(result);
21
+ const content = markdownContentFromData(data.data);
22
+ if (typeof content !== 'string') {
23
+ throw new Error('lark-cli docs +fetch did not return document content.');
24
+ }
25
+ const revision = revisionFromData(data.data);
26
+ return { markdown: content, revision };
27
+ }
28
+ async fetchDocBlocks(input) {
29
+ const blocks = [];
30
+ let pageToken;
31
+ do {
32
+ const params = {
33
+ page_size: 500,
34
+ document_revision_id: -1
35
+ };
36
+ if (pageToken)
37
+ params.page_token = pageToken;
38
+ const result = await this.exec(withIdentity([
39
+ 'api',
40
+ 'GET',
41
+ `/open-apis/docx/v1/documents/${input.doc}/blocks`,
42
+ '--params',
43
+ JSON.stringify(params),
44
+ '--format',
45
+ 'json'
46
+ ], this.identity));
47
+ const parsed = parseLarkCliJson(result);
48
+ const page = blockPageFromData(parsed.data);
49
+ blocks.push(...page.items);
50
+ pageToken = page.hasMore ? page.pageToken : undefined;
51
+ } while (pageToken);
52
+ return { blocks };
53
+ }
54
+ async replaceDocument(input) {
55
+ parseLarkCliJson(await this.exec(withIdentity([
56
+ 'docs',
57
+ '+update',
58
+ '--doc',
59
+ input.doc,
60
+ '--command',
61
+ 'overwrite',
62
+ '--doc-format',
63
+ 'markdown',
64
+ '--content',
65
+ input.markdown,
66
+ '--format',
67
+ 'json'
68
+ ], this.identity)));
69
+ }
70
+ async replaceBlock(input) {
71
+ await this.updateMarkdownBlock({
72
+ doc: input.doc,
73
+ command: 'block_replace',
74
+ blockId: input.blockId,
75
+ markdown: input.markdown
76
+ });
77
+ }
78
+ async insertBlocksAfter(input) {
79
+ await this.updateMarkdownBlock({
80
+ doc: input.doc,
81
+ command: 'block_insert_after',
82
+ blockId: input.blockId,
83
+ markdown: input.markdown
84
+ });
85
+ }
86
+ async deleteBlocks(input) {
87
+ parseLarkCliJson(await this.exec(withIdentity([
88
+ 'docs',
89
+ '+update',
90
+ '--doc',
91
+ input.doc,
92
+ '--command',
93
+ 'block_delete',
94
+ '--block-id',
95
+ input.blockIds.join(','),
96
+ '--format',
97
+ 'json'
98
+ ], this.identity)));
99
+ }
100
+ async createDocument(input) {
101
+ const data = parseLarkCliJson(await this.exec(withIdentity([
102
+ 'docs',
103
+ '+create',
104
+ '--title',
105
+ input.title,
106
+ '--doc-format',
107
+ 'markdown',
108
+ '--content',
109
+ input.markdown,
110
+ '--parent-token',
111
+ input.parentToken,
112
+ '--format',
113
+ 'json'
114
+ ], this.identity)));
115
+ const document = documentFromData(data.data);
116
+ if (!document) {
117
+ throw new Error('lark-cli docs +create did not return data.document.');
118
+ }
119
+ const documentId = typeof document.document_id === 'string' ? document.document_id : undefined;
120
+ if (!documentId) {
121
+ throw new Error('lark-cli docs +create did not return document.document_id.');
122
+ }
123
+ return {
124
+ documentId,
125
+ url: typeof document.url === 'string' ? document.url : undefined,
126
+ revision: typeof document.revision_id === 'string' || typeof document.revision_id === 'number'
127
+ ? String(document.revision_id)
128
+ : undefined
129
+ };
130
+ }
131
+ async updateMarkdownBlock(input) {
132
+ parseLarkCliJson(await this.exec(withIdentity([
133
+ 'docs',
134
+ '+update',
135
+ '--doc',
136
+ input.doc,
137
+ '--command',
138
+ input.command,
139
+ '--block-id',
140
+ input.blockId,
141
+ '--doc-format',
142
+ 'markdown',
143
+ '--content',
144
+ input.markdown,
145
+ '--format',
146
+ 'json'
147
+ ], this.identity)));
148
+ }
149
+ }
150
+ function withIdentity(args, identity) {
151
+ return identity === 'auto' ? args : [...args, '--as', identity];
152
+ }
153
+ function larkCliIdentityFromEnv() {
154
+ const value = process.env.FEISHU_MD_SYNC_LARK_AS;
155
+ return value === 'bot' || value === 'user' ? value : 'auto';
156
+ }
157
+ function runLarkCli(args) {
158
+ return new Promise((resolve, reject) => {
159
+ execFile('lark-cli', args, { encoding: 'utf8' }, (error, stdout, stderr) => {
160
+ if (error) {
161
+ reject(new Error(stderr.trim() || error.message));
162
+ return;
163
+ }
164
+ resolve({ stdout, stderr });
165
+ });
166
+ });
167
+ }
168
+ function parseLarkCliJson(result) {
169
+ const raw = result.stdout.trim() || result.stderr.trim();
170
+ let parsed;
171
+ try {
172
+ parsed = JSON.parse(raw);
173
+ }
174
+ catch {
175
+ throw new Error(`lark-cli returned non-JSON output: ${raw}`);
176
+ }
177
+ if (parsed.ok === false) {
178
+ throw new Error(`lark-cli failed: ${parsed.error?.message ?? 'unknown error'}`);
179
+ }
180
+ return parsed;
181
+ }
182
+ function markdownContentFromData(data) {
183
+ if (!data || typeof data !== 'object')
184
+ return undefined;
185
+ if ('content' in data && typeof data.content === 'string') {
186
+ return data.content;
187
+ }
188
+ if ('document' in data) {
189
+ const document = data.document;
190
+ if (document && typeof document === 'object' && typeof document.content === 'string') {
191
+ return document.content;
192
+ }
193
+ }
194
+ return undefined;
195
+ }
196
+ function revisionFromData(data) {
197
+ if (!data || typeof data !== 'object')
198
+ return undefined;
199
+ if (typeof data.revision === 'string') {
200
+ return data.revision;
201
+ }
202
+ if ('document' in data) {
203
+ const document = data.document;
204
+ const revision = document && typeof document === 'object'
205
+ ? document.revision_id
206
+ : undefined;
207
+ return typeof revision === 'string' || typeof revision === 'number' ? String(revision) : undefined;
208
+ }
209
+ return undefined;
210
+ }
211
+ function documentFromData(data) {
212
+ if (!data || typeof data !== 'object' || !('document' in data))
213
+ return undefined;
214
+ const document = data.document;
215
+ return document && typeof document === 'object' ? document : undefined;
216
+ }
217
+ function blockPageFromData(data) {
218
+ if (!data || typeof data !== 'object') {
219
+ return { items: [], hasMore: false };
220
+ }
221
+ const record = data;
222
+ const items = Array.isArray(record.items) ? record.items.filter(isFeishuBlock) : [];
223
+ const hasMore = record.has_more === true;
224
+ const pageToken = typeof record.page_token === 'string' ? record.page_token : undefined;
225
+ return { items, hasMore, pageToken };
226
+ }
227
+ function isFeishuBlock(value) {
228
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value) && 'block_type' in value);
229
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerCoreCommands(program: Command): void;
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ import { LarkCliAdapter } from '../../adapters/lark-cli-adapter.js';
3
+ import { loadSyncConfig, resolvePublishProfile } from '../../config/sync-config.js';
4
+ import { parseFeishuTarget } from '../../core/doc-id.js';
5
+ import { diffSummaryLines, runDiff } from '../../diff/run-diff.js';
6
+ import { runMerge } from '../../merge/run-merge.js';
7
+ import { runPull } from '../../pull/run-pull.js';
8
+ import { runStatus } from '../../status/run-status.js';
9
+ import { printFormatted } from '../output.js';
10
+ export function registerCoreCommands(program) {
11
+ program
12
+ .command('status')
13
+ .description('show local/remote publish status without writing')
14
+ .argument('<markdown-file>', 'local Markdown file')
15
+ .requiredOption('--target <url-or-token>', 'Feishu/Lark docx or wiki URL/token')
16
+ .option('--profile <profile>', 'publish profile: zilliz | milvus | none')
17
+ .option('--format <format>', 'output format: pretty | json', 'pretty')
18
+ .action(async (markdownFile, opts) => {
19
+ const cwd = process.cwd();
20
+ const target = parseNonFolderTarget(opts.target ?? '', 'status');
21
+ const config = await loadSyncConfig({ cwd });
22
+ const profile = resolvePublishProfile({ cliProfile: opts.profile, config });
23
+ const result = await runStatus({
24
+ cwd,
25
+ sourcePath: path.resolve(cwd, markdownFile),
26
+ target,
27
+ profile,
28
+ adapter: new LarkCliAdapter()
29
+ });
30
+ printFormatted(result, opts.format);
31
+ });
32
+ program
33
+ .command('pull')
34
+ .description('export current Feishu/Lark content as a local Markdown snapshot')
35
+ .requiredOption('--target <url-or-token>', 'Feishu/Lark docx or wiki URL/token')
36
+ .requiredOption('-o, --output <file>', 'write remote Markdown to a local file')
37
+ .option('--profile <profile>', 'pull profile: zilliz | milvus | none')
38
+ .option('--overwrite', 'allow pull to replace an existing output file')
39
+ .option('--write-receipt', 'write a local pull snapshot receipt')
40
+ .option('--format <format>', 'output format: pretty | json', 'pretty')
41
+ .action(async (opts) => {
42
+ const cwd = process.cwd();
43
+ const target = parseNonFolderTarget(opts.target ?? '', 'pull');
44
+ const config = await loadSyncConfig({ cwd });
45
+ const profile = resolvePublishProfile({ cliProfile: opts.profile, config });
46
+ const result = await runPull({
47
+ cwd,
48
+ target,
49
+ outputPath: path.resolve(cwd, opts.output ?? ''),
50
+ profile,
51
+ overwrite: opts.overwrite === true,
52
+ writeReceipt: opts.writeReceipt === true,
53
+ adapter: new LarkCliAdapter()
54
+ });
55
+ printFormatted(result, opts.format);
56
+ });
57
+ program
58
+ .command('diff')
59
+ .description('show a publish-draft diff against current Feishu/Lark content')
60
+ .argument('<markdown-file>', 'local Markdown file')
61
+ .requiredOption('--target <url-or-token>', 'Feishu/Lark docx or wiki URL/token')
62
+ .option('--profile <profile>', 'publish profile: zilliz | milvus | none')
63
+ .option('--format <format>', 'output format: pretty | json', 'pretty')
64
+ .action(async (markdownFile, opts) => {
65
+ const cwd = process.cwd();
66
+ const target = parseNonFolderTarget(opts.target ?? '', 'diff');
67
+ const config = await loadSyncConfig({ cwd });
68
+ const profile = resolvePublishProfile({ cliProfile: opts.profile, config });
69
+ const result = await runDiff({
70
+ cwd,
71
+ sourcePath: path.resolve(cwd, markdownFile),
72
+ target,
73
+ profile,
74
+ adapter: new LarkCliAdapter()
75
+ });
76
+ if (opts.format === 'json') {
77
+ printFormatted(result, opts.format);
78
+ return;
79
+ }
80
+ for (const line of diffSummaryLines(result))
81
+ console.log(line);
82
+ });
83
+ program
84
+ .command('merge')
85
+ .description('merge remote Feishu/Lark Markdown changes into a local Markdown file')
86
+ .argument('<markdown-file>', 'local Markdown file')
87
+ .option('--target <url-or-token>', 'Feishu/Lark docx or wiki URL/token to fetch before merging')
88
+ .option('--remote <file>', 'local remote snapshot Markdown file')
89
+ .option('--base <file>', 'explicit merge base Markdown file')
90
+ .option('--profile <profile>', 'local authoring profile: milvus | zilliz | none')
91
+ .option('--check', 'check whether merge would conflict without writing')
92
+ .option('--dry-run', 'show merge metadata without writing')
93
+ .option('--abort', 'restore the local file from the previous merge state')
94
+ .option('--save-remote <file>', 'save fetched remote snapshot when using --target')
95
+ .option('--format <format>', 'output format: pretty | json', 'pretty')
96
+ .action(async (markdownFile, opts) => {
97
+ const cwd = process.cwd();
98
+ const config = await loadSyncConfig({ cwd });
99
+ const profile = resolvePublishProfile({ cliProfile: opts.profile, config });
100
+ const target = opts.target ? parseNonFolderTarget(opts.target, 'merge') : undefined;
101
+ const result = await runMerge({
102
+ cwd,
103
+ filePath: path.resolve(cwd, markdownFile),
104
+ target,
105
+ remotePath: opts.remote ? path.resolve(cwd, opts.remote) : undefined,
106
+ basePath: opts.base ? path.resolve(cwd, opts.base) : undefined,
107
+ saveRemotePath: opts.saveRemote ? path.resolve(cwd, opts.saveRemote) : undefined,
108
+ profile,
109
+ mode: resolveMergeMode(opts),
110
+ adapter: new LarkCliAdapter()
111
+ });
112
+ printFormatted(result, opts.format);
113
+ if (result.state === 'conflict')
114
+ process.exitCode = 1;
115
+ });
116
+ }
117
+ function parseNonFolderTarget(raw, command) {
118
+ const target = parseFeishuTarget(raw);
119
+ if (target.kind === 'folder') {
120
+ throw new Error(`${command} --target does not support Drive folder targets.`);
121
+ }
122
+ return target;
123
+ }
124
+ function resolveMergeMode(opts) {
125
+ const selected = [opts.check, opts.dryRun, opts.abort].filter(Boolean).length;
126
+ if (selected > 1)
127
+ throw new Error('Choose only one of --check, --dry-run, or --abort.');
128
+ if (opts.abort)
129
+ return 'abort';
130
+ if (opts.check)
131
+ return 'check';
132
+ if (opts.dryRun)
133
+ return 'dry-run';
134
+ return 'write';
135
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerPublishCommand(program: Command): void;
@@ -0,0 +1,63 @@
1
+ import path from 'node:path';
2
+ import { LarkCliAdapter } from '../../adapters/lark-cli-adapter.js';
3
+ import { loadSyncConfig, resolvePublishProfile } from '../../config/sync-config.js';
4
+ import { parseFeishuTarget } from '../../core/doc-id.js';
5
+ import { runPublish } from '../../publish/run-publish.js';
6
+ import { printFormatted } from '../output.js';
7
+ export function registerPublishCommand(program) {
8
+ program
9
+ .command('publish')
10
+ .description('publish local Markdown to an existing Feishu/Lark online document')
11
+ .argument('<markdown-file>', 'local Markdown file')
12
+ .requiredOption('--target <url-or-token>', 'existing Feishu/Lark docx URL or token')
13
+ .option('--profile <profile>', 'publish profile: zilliz | milvus | none')
14
+ .option('--write', 'write to Feishu/Lark; omitted means dry-run')
15
+ .option('--create', 'create a new document under a folder or wiki target')
16
+ .option('--strategy <strategy>', 'write strategy: auto | block-patch | document-replace', 'auto')
17
+ .option('--confirm-destructive', 'confirm destructive document replacement in non-interactive mode')
18
+ .option('--confirm-collaboration-risk', 'confirm block replacement/deletion may affect comments or block identity')
19
+ .option('--confirm-untracked-remote', 'confirm adopting an existing remote document without a publish receipt')
20
+ .option('--format <format>', 'output format: pretty | json', 'pretty')
21
+ .action(async (markdownFile, opts) => {
22
+ const requested = publishRequestFromArgv(opts);
23
+ if (requested.write && requested.strategy === 'document-replace' && !requested.confirmDestructive) {
24
+ throw new Error('--confirm-destructive is required with --strategy document-replace --write');
25
+ }
26
+ const target = parseFeishuTarget(opts.target ?? '');
27
+ const cwd = process.cwd();
28
+ const config = await loadSyncConfig({ cwd });
29
+ const profile = resolvePublishProfile({ cliProfile: opts.profile, config });
30
+ const result = await runPublish({
31
+ cwd,
32
+ file: path.resolve(cwd, markdownFile),
33
+ target,
34
+ profile,
35
+ write: requested.write,
36
+ create: requested.create,
37
+ strategy: requested.strategy,
38
+ confirmDestructive: requested.confirmDestructive,
39
+ confirmCollaborationRisk: requested.confirmCollaborationRisk,
40
+ confirmUntrackedRemote: requested.confirmUntrackedRemote,
41
+ adapter: new LarkCliAdapter()
42
+ });
43
+ printFormatted(result, opts.format);
44
+ });
45
+ }
46
+ function publishRequestFromArgv(opts) {
47
+ const strategy = optionValueFromArgv('--strategy') ?? opts.strategy ?? 'auto';
48
+ if (strategy !== 'auto' && strategy !== 'block-patch' && strategy !== 'document-replace') {
49
+ throw new Error(`Invalid --strategy ${strategy}. Expected auto, block-patch, or document-replace.`);
50
+ }
51
+ return {
52
+ write: opts.write === true || process.argv.includes('--write'),
53
+ create: opts.create === true || process.argv.includes('--create'),
54
+ strategy,
55
+ confirmDestructive: opts.confirmDestructive === true || process.argv.includes('--confirm-destructive'),
56
+ confirmCollaborationRisk: opts.confirmCollaborationRisk === true || process.argv.includes('--confirm-collaboration-risk'),
57
+ confirmUntrackedRemote: opts.confirmUntrackedRemote === true || process.argv.includes('--confirm-untracked-remote')
58
+ };
59
+ }
60
+ function optionValueFromArgv(name) {
61
+ const index = process.argv.indexOf(name);
62
+ return index === -1 ? undefined : process.argv[index + 1];
63
+ }