growork 1.0.1 → 1.1.2
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/.claude/commands/review.md +62 -0
- package/.claude/settings.local.json +2 -1
- package/README.md +108 -105
- package/claude.md +103 -2
- package/dist/index.js +211 -71
- package/docs/architecture.md +175 -0
- package/docs/developer-notes.md +67 -0
- package/docs/prd-1.0.0.md +86 -0
- package/docs/prd-1.1.1.md +215 -0
- package/docs/prd-1.1.2.md +136 -0
- package/docs/prd-template.md +65 -0
- package/growork.config.yaml +36 -33
- package/package.json +1 -1
- package/src/commands/init.ts +1 -5
- package/src/commands/list.ts +52 -19
- package/src/commands/sync.ts +69 -26
- package/src/index.ts +13 -4
- package/src/utils/config.ts +159 -40
- package/tests/config.test.ts +383 -3
- package/tests/feishu.test.ts +187 -0
- package/tests/notion.test.ts +81 -0
- package/tests/sync.test.ts +65 -0
- package/{docs/test → tests}/test-cases.md +76 -26
- package/docs/product/prd-v1.0.md +0 -418
- package/test/backend-ai.md +0 -539
- package/test/backend-api.md +0 -1236
- package/test/prd-5spread.md +0 -74
- package/test/push-prd-notion.md +0 -126
- package/test/push.md +0 -119
package/src/commands/sync.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as fs from 'fs';
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import chalk from 'chalk';
|
|
4
|
-
import {
|
|
4
|
+
import { loadConfigV2, normalizeConfig, sanitizeFileName, SyncOptions, NormalizedDoc } from '../utils/config.js';
|
|
5
5
|
import { FeishuService } from '../services/feishu.js';
|
|
6
6
|
import { NotionService } from '../services/notion.js';
|
|
7
7
|
|
|
@@ -14,44 +14,74 @@ function clearLine(): void {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
const
|
|
17
|
+
function extractTitleFromMarkdown(content: string): string {
|
|
18
|
+
const match = content.match(/^#\s+(.+)$/m);
|
|
19
|
+
return match ? match[1].trim() : '未命名文档';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function syncCommand(options: SyncOptions = {}): Promise<void> {
|
|
23
|
+
const config = loadConfigV2();
|
|
24
|
+
const docs = normalizeConfig(config, options);
|
|
25
|
+
|
|
26
|
+
if (docs.length === 0) {
|
|
27
|
+
console.log(chalk.yellow('⚠️ 没有找到匹配的文档'));
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 分组:普通文档 vs Figma 文档
|
|
32
|
+
const normalDocs = docs.filter(d => d.type !== 'figma');
|
|
33
|
+
const figmaDocs = docs.filter(d => d.type === 'figma');
|
|
34
|
+
|
|
19
35
|
const feishuService = config.feishu ? new FeishuService(config.feishu) : null;
|
|
20
36
|
const notionService = config.notion ? new NotionService(config.notion) : null;
|
|
21
37
|
|
|
22
|
-
|
|
38
|
+
// 检查凭证(只针对普通文档)
|
|
39
|
+
const hasFeishuDocs = normalDocs.some(d => d.type === 'feishu');
|
|
40
|
+
const hasNotionDocs = normalDocs.some(d => d.type === 'notion');
|
|
23
41
|
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
process.exit(1);
|
|
30
|
-
}
|
|
31
|
-
docsToSync = [doc];
|
|
42
|
+
if (hasFeishuDocs && !feishuService) {
|
|
43
|
+
throw new Error('配置文件缺少飞书凭证 (feishu.appId, feishu.appSecret)');
|
|
44
|
+
}
|
|
45
|
+
if (hasNotionDocs && !notionService) {
|
|
46
|
+
throw new Error('配置文件缺少 Notion 凭证 (notion.token)');
|
|
32
47
|
}
|
|
33
48
|
|
|
34
49
|
console.log(chalk.blue('📄 开始同步文档...\n'));
|
|
35
50
|
|
|
36
51
|
let successCount = 0;
|
|
37
52
|
|
|
38
|
-
|
|
39
|
-
|
|
53
|
+
// Figma 文档按目录分组
|
|
54
|
+
const figmaGroups = new Map<string, { feature: string; urls: string[] }>();
|
|
55
|
+
for (const doc of figmaDocs) {
|
|
56
|
+
const dir = path.dirname(doc.outputPath);
|
|
57
|
+
const feature = path.basename(path.dirname(dir));
|
|
58
|
+
if (!figmaGroups.has(dir)) {
|
|
59
|
+
figmaGroups.set(dir, { feature, urls: [] });
|
|
60
|
+
}
|
|
61
|
+
figmaGroups.get(dir)!.urls.push(doc.url);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 处理普通文档(feishu/notion)
|
|
65
|
+
for (const doc of normalDocs) {
|
|
66
|
+
const displayName = doc.name || doc.url.slice(-20);
|
|
67
|
+
process.stdout.write(chalk.gray(` ⏳ ${displayName}`));
|
|
40
68
|
|
|
41
69
|
try {
|
|
42
70
|
let content: string;
|
|
43
71
|
if (doc.type === 'feishu') {
|
|
44
|
-
|
|
45
|
-
content = await feishuService.getDocumentAsMarkdown(doc.url);
|
|
46
|
-
} else if (doc.type === 'notion') {
|
|
47
|
-
if (!notionService) throw new Error('Notion 服务未配置');
|
|
48
|
-
content = await notionService.getPageAsMarkdown(doc.url);
|
|
72
|
+
content = await feishuService!.getDocumentAsMarkdown(doc.url);
|
|
49
73
|
} else {
|
|
50
|
-
|
|
74
|
+
content = await notionService!.getPageAsMarkdown(doc.url);
|
|
51
75
|
}
|
|
52
76
|
|
|
53
|
-
|
|
77
|
+
// 从 markdown 提取标题
|
|
78
|
+
const title = doc.name || extractTitleFromMarkdown(content);
|
|
79
|
+
const safeTitle = sanitizeFileName(title);
|
|
80
|
+
|
|
81
|
+
// 替换占位符生成最终路径
|
|
82
|
+
const outputPath = path.join(process.cwd(), doc.outputPath.replace('{title}', safeTitle));
|
|
54
83
|
const outputDir = path.dirname(outputPath);
|
|
84
|
+
|
|
55
85
|
if (!fs.existsSync(outputDir)) {
|
|
56
86
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
57
87
|
}
|
|
@@ -59,19 +89,32 @@ export async function syncCommand(docName?: string): Promise<void> {
|
|
|
59
89
|
fs.writeFileSync(outputPath, content, 'utf-8');
|
|
60
90
|
|
|
61
91
|
clearLine();
|
|
62
|
-
console.log(chalk.green(` ✓ ${
|
|
92
|
+
console.log(chalk.green(` ✓ ${safeTitle.padEnd(25)} → ${path.relative(process.cwd(), outputPath)}`));
|
|
63
93
|
successCount++;
|
|
64
94
|
} catch (error) {
|
|
65
95
|
clearLine();
|
|
66
96
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
67
|
-
console.log(chalk.red(` ✗ ${
|
|
97
|
+
console.log(chalk.red(` ✗ ${displayName.padEnd(25)} → ${errorMessage}`));
|
|
68
98
|
}
|
|
69
99
|
}
|
|
70
100
|
|
|
101
|
+
// 处理 Figma 文档:生成 design.md
|
|
102
|
+
for (const [dir, { feature, urls }] of figmaGroups) {
|
|
103
|
+
const content = `# ${feature} 设计稿\n\n${urls.map(u => `- ${u}`).join('\n')}\n`;
|
|
104
|
+
const outputPath = path.join(process.cwd(), dir, 'design.md');
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
107
|
+
fs.writeFileSync(outputPath, content, 'utf-8');
|
|
108
|
+
|
|
109
|
+
console.log(chalk.green(` ✓ design.md → ${path.relative(process.cwd(), outputPath)}`));
|
|
110
|
+
successCount++;
|
|
111
|
+
}
|
|
112
|
+
|
|
71
113
|
console.log('');
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
const totalCount = normalDocs.length + figmaGroups.size;
|
|
115
|
+
if (successCount === totalCount) {
|
|
116
|
+
console.log(chalk.green(`✅ 同步完成,共 ${totalCount} 个文档`));
|
|
74
117
|
} else {
|
|
75
|
-
console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${
|
|
118
|
+
console.log(chalk.yellow(`⚠️ 同步完成: ${successCount}/${totalCount} 个文档成功`));
|
|
76
119
|
}
|
|
77
120
|
}
|
package/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ const program = new Command();
|
|
|
10
10
|
program
|
|
11
11
|
.name('growork')
|
|
12
12
|
.description('将飞书文档同步到本地,为 AI Agent 提供完整上下文')
|
|
13
|
-
.version('
|
|
13
|
+
.version('2.0.0');
|
|
14
14
|
|
|
15
15
|
program
|
|
16
16
|
.command('init')
|
|
@@ -18,9 +18,18 @@ program
|
|
|
18
18
|
.action(initCommand);
|
|
19
19
|
|
|
20
20
|
program
|
|
21
|
-
.command('sync
|
|
22
|
-
.description('
|
|
23
|
-
.
|
|
21
|
+
.command('sync')
|
|
22
|
+
.description('同步文档')
|
|
23
|
+
.option('--ver <version>', '只同步指定版本')
|
|
24
|
+
.option('-f, --feature <feature>', '只同步指定 feature')
|
|
25
|
+
.option('-c, --custom', '只同步全局文档')
|
|
26
|
+
.action((options) => {
|
|
27
|
+
// 将 ver 映射到 version
|
|
28
|
+
if (options.ver) {
|
|
29
|
+
options.version = options.ver;
|
|
30
|
+
}
|
|
31
|
+
syncCommand(options);
|
|
32
|
+
});
|
|
24
33
|
|
|
25
34
|
program
|
|
26
35
|
.command('list')
|
package/src/utils/config.ts
CHANGED
|
@@ -2,13 +2,6 @@ import * as fs from 'fs';
|
|
|
2
2
|
import * as path from 'path';
|
|
3
3
|
import * as yaml from 'yaml';
|
|
4
4
|
|
|
5
|
-
export interface DocConfig {
|
|
6
|
-
name: string;
|
|
7
|
-
type: 'feishu' | 'notion';
|
|
8
|
-
url: string;
|
|
9
|
-
output: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
5
|
export interface NotionConfig {
|
|
13
6
|
token: string;
|
|
14
7
|
}
|
|
@@ -16,13 +9,40 @@ export interface NotionConfig {
|
|
|
16
9
|
export interface FeishuConfig {
|
|
17
10
|
appId: string;
|
|
18
11
|
appSecret: string;
|
|
19
|
-
domain?: 'feishu' | 'lark';
|
|
12
|
+
domain?: 'feishu' | 'lark';
|
|
20
13
|
}
|
|
14
|
+
export type DocInput = string | { url: string; name?: string };
|
|
15
|
+
|
|
16
|
+
export type FeatureValue = DocInput[] | {
|
|
17
|
+
prd?: DocInput[];
|
|
18
|
+
design?: DocInput[];
|
|
19
|
+
api?: DocInput[];
|
|
20
|
+
test?: DocInput[];
|
|
21
|
+
};
|
|
21
22
|
|
|
22
|
-
export interface
|
|
23
|
+
export interface GroworkConfigV2 {
|
|
23
24
|
feishu?: FeishuConfig;
|
|
24
25
|
notion?: NotionConfig;
|
|
25
|
-
|
|
26
|
+
outputDir?: string; // 输出根目录,默认 "docs"
|
|
27
|
+
custom?: DocInput[];
|
|
28
|
+
versions?: {
|
|
29
|
+
[version: string]: {
|
|
30
|
+
[feature: string]: FeatureValue;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NormalizedDoc {
|
|
36
|
+
url: string;
|
|
37
|
+
name?: string;
|
|
38
|
+
type: 'feishu' | 'notion' | 'figma';
|
|
39
|
+
outputPath: string; // 含 {title} 占位符
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncOptions {
|
|
43
|
+
version?: string;
|
|
44
|
+
feature?: string;
|
|
45
|
+
custom?: boolean;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
const CONFIG_FILE_NAME = 'growork.config.yaml';
|
|
@@ -35,38 +55,125 @@ export function configExists(): boolean {
|
|
|
35
55
|
return fs.existsSync(getConfigPath());
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
export function
|
|
39
|
-
|
|
58
|
+
export function inferDocType(url: string): 'feishu' | 'notion' | 'figma' {
|
|
59
|
+
if (url.includes('feishu.cn') || url.includes('larksuite.com')) {
|
|
60
|
+
return 'feishu';
|
|
61
|
+
}
|
|
62
|
+
if (url.includes('notion.so') || url.includes('notion.site')) {
|
|
63
|
+
return 'notion';
|
|
64
|
+
}
|
|
65
|
+
if (url.includes('figma.com')) {
|
|
66
|
+
return 'figma';
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`无法从 URL 推断文档类型: ${url}`);
|
|
69
|
+
}
|
|
40
70
|
|
|
41
|
-
|
|
42
|
-
|
|
71
|
+
export function sanitizeFileName(title: string): string {
|
|
72
|
+
return title
|
|
73
|
+
.replace(/[\/\\:*?"<>|]/g, '') // 移除文件系统保留字符
|
|
74
|
+
.replace(/\s+/g, '-') // 空格替换为 -
|
|
75
|
+
.replace(/-+/g, '-') // 连续 - 合并
|
|
76
|
+
.replace(/^-|-$/g, ''); // 首尾 - 去除
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseDocInput(input: DocInput): { url: string; name?: string } {
|
|
80
|
+
if (typeof input === 'string') {
|
|
81
|
+
return { url: input };
|
|
43
82
|
}
|
|
83
|
+
return input;
|
|
84
|
+
}
|
|
44
85
|
|
|
45
|
-
|
|
46
|
-
|
|
86
|
+
function isTypedFeature(value: FeatureValue): value is { prd?: DocInput[]; design?: DocInput[]; api?: DocInput[]; test?: DocInput[] } {
|
|
87
|
+
return !Array.isArray(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function normalizeConfig(config: GroworkConfigV2, options: SyncOptions = {}): NormalizedDoc[] {
|
|
91
|
+
const docs: NormalizedDoc[] = [];
|
|
92
|
+
const outputDir = config.outputDir || 'docs';
|
|
93
|
+
|
|
94
|
+
// 处理 custom 文档
|
|
95
|
+
if (config.custom && (options.custom || (!options.version && !options.feature))) {
|
|
96
|
+
for (const input of config.custom) {
|
|
97
|
+
const { url, name } = parseDocInput(input);
|
|
98
|
+
docs.push({
|
|
99
|
+
url,
|
|
100
|
+
name,
|
|
101
|
+
type: inferDocType(url),
|
|
102
|
+
outputPath: `${outputDir}/custom/{title}.md`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 如果只请求 custom,直接返回
|
|
108
|
+
if (options.custom) {
|
|
109
|
+
return docs;
|
|
110
|
+
}
|
|
47
111
|
|
|
48
|
-
//
|
|
49
|
-
if (
|
|
50
|
-
|
|
112
|
+
// 处理 versions
|
|
113
|
+
if (config.versions) {
|
|
114
|
+
for (const [version, features] of Object.entries(config.versions)) {
|
|
115
|
+
// 版本过滤
|
|
116
|
+
if (options.version && options.version !== version) continue;
|
|
117
|
+
|
|
118
|
+
for (const [feature, value] of Object.entries(features)) {
|
|
119
|
+
// feature 过滤
|
|
120
|
+
if (options.feature && options.feature !== feature) continue;
|
|
121
|
+
|
|
122
|
+
if (isTypedFeature(value)) {
|
|
123
|
+
// 分类型的 feature
|
|
124
|
+
for (const docType of ['prd', 'design', 'api', 'test'] as const) {
|
|
125
|
+
const docInputs = value[docType];
|
|
126
|
+
if (!docInputs) continue;
|
|
127
|
+
|
|
128
|
+
for (const input of docInputs) {
|
|
129
|
+
const { url, name } = parseDocInput(input);
|
|
130
|
+
docs.push({
|
|
131
|
+
url,
|
|
132
|
+
name,
|
|
133
|
+
type: inferDocType(url),
|
|
134
|
+
outputPath: `${outputDir}/${version}/${feature}/${docType}/{title}.md`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// 简单 feature(数组形式)
|
|
140
|
+
for (const input of value) {
|
|
141
|
+
const { url, name } = parseDocInput(input);
|
|
142
|
+
docs.push({
|
|
143
|
+
url,
|
|
144
|
+
name,
|
|
145
|
+
type: inferDocType(url),
|
|
146
|
+
outputPath: `${outputDir}/${version}/${feature}/{title}.md`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
51
152
|
}
|
|
52
153
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
154
|
+
return docs;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function loadConfigV2(): GroworkConfigV2 {
|
|
158
|
+
const configPath = getConfigPath();
|
|
56
159
|
|
|
57
|
-
if (
|
|
58
|
-
throw new Error(
|
|
160
|
+
if (!fs.existsSync(configPath)) {
|
|
161
|
+
throw new Error(`配置文件不存在: ${configPath}\n请先运行 growork init 初始化配置`);
|
|
59
162
|
}
|
|
60
163
|
|
|
61
|
-
|
|
62
|
-
|
|
164
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
165
|
+
const config = yaml.parse(content) as GroworkConfigV2;
|
|
166
|
+
|
|
167
|
+
// v2.0 至少需要 custom 或 versions
|
|
168
|
+
if (!config.custom && !config.versions) {
|
|
169
|
+
throw new Error('配置文件中没有配置任何文档(custom 或 versions)');
|
|
63
170
|
}
|
|
64
171
|
|
|
65
172
|
return config;
|
|
66
173
|
}
|
|
67
174
|
|
|
68
175
|
export function getDefaultConfig(): string {
|
|
69
|
-
return `# Growork 配置文件
|
|
176
|
+
return `# Growork v2.0 配置文件
|
|
70
177
|
|
|
71
178
|
# 飞书应用凭证 (使用飞书文档时需要)
|
|
72
179
|
feishu:
|
|
@@ -78,18 +185,30 @@ feishu:
|
|
|
78
185
|
notion:
|
|
79
186
|
token: "ntn_xxxx" # Notion Integration Token
|
|
80
187
|
|
|
81
|
-
#
|
|
82
|
-
docs
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
188
|
+
# 输出根目录(默认 "docs")
|
|
189
|
+
outputDir: "docs"
|
|
190
|
+
|
|
191
|
+
# 全局文档(不跟版本)
|
|
192
|
+
custom:
|
|
193
|
+
- "https://xxx.feishu.cn/docx/xxxxx" # 最简写法
|
|
194
|
+
# - url: "https://www.notion.so/xxxxx"
|
|
195
|
+
# name: "技术架构" # 可选:自定义名称
|
|
196
|
+
|
|
197
|
+
# 版本化文档
|
|
198
|
+
versions:
|
|
199
|
+
v1.0:
|
|
200
|
+
用户登录:
|
|
201
|
+
prd:
|
|
202
|
+
- "https://xxx.feishu.cn/docx/xxxxx"
|
|
203
|
+
# design:
|
|
204
|
+
# - "https://xxx.feishu.cn/docx/yyyyy"
|
|
205
|
+
# api:
|
|
206
|
+
# - "https://xxx.feishu.cn/docx/zzzzz"
|
|
207
|
+
# test:
|
|
208
|
+
# - "https://xxx.feishu.cn/docx/aaaaa"
|
|
209
|
+
|
|
210
|
+
# 简单 feature 可不分类
|
|
211
|
+
# 小优化:
|
|
212
|
+
# - "https://xxx.feishu.cn/docx/bbbbb"
|
|
94
213
|
`;
|
|
95
214
|
}
|