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.
- package/LICENSE +21 -0
- package/NOTICE +5 -0
- package/README.md +107 -0
- package/dist/adapters/feishu-adapter.d.ts +44 -0
- package/dist/adapters/feishu-adapter.js +1 -0
- package/dist/adapters/lark-cli-adapter.d.ts +45 -0
- package/dist/adapters/lark-cli-adapter.js +229 -0
- package/dist/cli/commands/core.d.ts +2 -0
- package/dist/cli/commands/core.js +135 -0
- package/dist/cli/commands/publish.d.ts +2 -0
- package/dist/cli/commands/publish.js +63 -0
- package/dist/cli/env.d.ts +27 -0
- package/dist/cli/env.js +113 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +52 -0
- package/dist/cli/output.d.ts +4 -0
- package/dist/cli/output.js +18 -0
- package/dist/config/sync-config.d.ts +13 -0
- package/dist/config/sync-config.js +60 -0
- package/dist/core/diff.d.ts +1 -0
- package/dist/core/diff.js +20 -0
- package/dist/core/doc-id.d.ts +12 -0
- package/dist/core/doc-id.js +41 -0
- package/dist/core/hash.d.ts +6 -0
- package/dist/core/hash.js +84 -0
- package/dist/core/markdown-canonical.d.ts +2 -0
- package/dist/core/markdown-canonical.js +11 -0
- package/dist/diff/run-diff.d.ts +23 -0
- package/dist/diff/run-diff.js +33 -0
- package/dist/feishu/types.d.ts +101 -0
- package/dist/feishu/types.js +1 -0
- package/dist/markdown/blocks.d.ts +5 -0
- package/dist/markdown/blocks.js +305 -0
- package/dist/markdown/from-blocks.d.ts +2 -0
- package/dist/markdown/from-blocks.js +110 -0
- package/dist/markdown/links.d.ts +3 -0
- package/dist/markdown/links.js +44 -0
- package/dist/merge/line-merge.d.ts +21 -0
- package/dist/merge/line-merge.js +270 -0
- package/dist/merge/merge-state.d.ts +33 -0
- package/dist/merge/merge-state.js +44 -0
- package/dist/merge/run-merge.d.ts +38 -0
- package/dist/merge/run-merge.js +139 -0
- package/dist/profiles/publish-profile.d.ts +9 -0
- package/dist/profiles/publish-profile.js +9 -0
- package/dist/publish/block-patch-plan.d.ts +34 -0
- package/dist/publish/block-patch-plan.js +223 -0
- package/dist/publish/block-state.d.ts +8 -0
- package/dist/publish/block-state.js +82 -0
- package/dist/publish/block-update.d.ts +4 -0
- package/dist/publish/block-update.js +40 -0
- package/dist/publish/profile-transform.d.ts +5 -0
- package/dist/publish/profile-transform.js +6 -0
- package/dist/publish/publish-plan.d.ts +35 -0
- package/dist/publish/publish-plan.js +113 -0
- package/dist/publish/run-publish.d.ts +25 -0
- package/dist/publish/run-publish.js +299 -0
- package/dist/publish/title.d.ts +9 -0
- package/dist/publish/title.js +20 -0
- package/dist/pull/run-pull.d.ts +23 -0
- package/dist/pull/run-pull.js +57 -0
- package/dist/receipts/publish-receipt.d.ts +49 -0
- package/dist/receipts/publish-receipt.js +52 -0
- package/dist/receipts/pull-receipt.d.ts +31 -0
- package/dist/receipts/pull-receipt.js +30 -0
- package/dist/status/run-status.d.ts +56 -0
- package/dist/status/run-status.js +131 -0
- package/dist/transform/include-tags.d.ts +6 -0
- package/dist/transform/include-tags.js +22 -0
- package/dist/transform/zilliz-publish.d.ts +5 -0
- package/dist/transform/zilliz-publish.js +42 -0
- package/dist/transform/zilliz-pull.d.ts +6 -0
- package/dist/transform/zilliz-pull.js +34 -0
- 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
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,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,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
|
+
}
|