@trydying/opencode-feishu-notifier 0.3.1 → 0.3.3
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/README.md +13 -0
- package/package.json +5 -20
- package/src/config.ts +176 -0
- package/src/context/progress.ts +174 -0
- package/src/context/project.ts +202 -0
- package/src/feishu/client.ts +202 -0
- package/src/feishu/messages.ts +322 -0
- package/src/feishu/templates.ts +539 -0
- package/src/hooks.ts +40 -0
- package/src/index.ts +144 -0
- package/src/types.ts +102 -0
- package/dist/chunk-DGUM43GV.js +0 -11
- package/dist/chunk-DGUM43GV.js.map +0 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -550
- package/dist/index.js.map +0 -1
- package/dist/templates-PQN4V7CV.js +0 -564
- package/dist/templates-PQN4V7CV.js.map +0 -1
package/README.md
CHANGED
|
@@ -107,6 +107,19 @@ gh api --method PUT /user/starred/Thrimbda/opencode-feishu-notifier
|
|
|
107
107
|
}
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
+
也支持将 `appId` 和 `appSecret` 写成环境变量占位符(变量名可自定义):
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"appId": "${FEISHU_CUSTOM_APP_ID}",
|
|
115
|
+
"appSecret": "${FEISHU_CUSTOM_APP_SECRET}",
|
|
116
|
+
"receiverType": "user_id",
|
|
117
|
+
"receiverId": "your_user_id"
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
当配置使用 `${ENV_NAME}` 格式时,插件会在启动时读取对应环境变量;如果变量未设置会报错。
|
|
122
|
+
|
|
110
123
|
### 2. OpenCode 插件配置
|
|
111
124
|
|
|
112
125
|
在 `~/.config/opencode/opencode.json` 中启用插件:
|
package/package.json
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@trydying/opencode-feishu-notifier",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "OpenCode plugin for Feishu notifications",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "
|
|
7
|
-
"module": "dist/index.js",
|
|
8
|
-
"types": "dist/index.d.ts",
|
|
6
|
+
"main": "src/index.ts",
|
|
9
7
|
"files": [
|
|
10
|
-
"
|
|
8
|
+
"src"
|
|
11
9
|
],
|
|
12
10
|
"scripts": {
|
|
13
|
-
"typecheck": "tsc --noEmit"
|
|
14
|
-
"build": "tsup"
|
|
11
|
+
"typecheck": "tsc --noEmit"
|
|
15
12
|
},
|
|
16
13
|
"dependencies": {
|
|
17
14
|
"@opencode-ai/plugin": "^1.0.0"
|
|
18
15
|
},
|
|
19
16
|
"devDependencies": {
|
|
20
17
|
"@types/node": "^22.0.0",
|
|
21
|
-
"tsup": "^8.3.5",
|
|
22
18
|
"typescript": "^5.5.0"
|
|
23
19
|
},
|
|
24
|
-
"publishConfig": {
|
|
25
|
-
"access": "public"
|
|
26
|
-
},
|
|
27
|
-
"keywords": [
|
|
28
|
-
"opencode",
|
|
29
|
-
"plugin",
|
|
30
|
-
"feishu",
|
|
31
|
-
"notification"
|
|
32
|
-
],
|
|
33
|
-
"author": "trydying@mail.ustc.edu.cn",
|
|
34
|
-
"license": "MIT",
|
|
35
20
|
"engines": {
|
|
36
21
|
"bun": ">=1.0.0"
|
|
37
22
|
},
|
|
38
23
|
"repository": {
|
|
39
24
|
"type": "git",
|
|
40
|
-
"url": "
|
|
25
|
+
"url": "https://github.com/Thrimbda/opencode-feishu-notifier"
|
|
41
26
|
}
|
|
42
27
|
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from "fs"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import path from "path"
|
|
4
|
+
|
|
5
|
+
export type ReceiverType = "user_id" | "open_id" | "chat_id"
|
|
6
|
+
|
|
7
|
+
export interface FeishuConfig {
|
|
8
|
+
appId: string
|
|
9
|
+
appSecret: string
|
|
10
|
+
receiverType: ReceiverType
|
|
11
|
+
receiverId: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface LoadConfigOptions {
|
|
15
|
+
directory?: string
|
|
16
|
+
configPath?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type ConfigSource =
|
|
20
|
+
| { type: "file"; detail: string }
|
|
21
|
+
| { type: "env"; detail: string }
|
|
22
|
+
|
|
23
|
+
const receiverTypes: ReceiverType[] = ["user_id", "open_id", "chat_id"]
|
|
24
|
+
|
|
25
|
+
function getConfigPaths(options: LoadConfigOptions): string[] {
|
|
26
|
+
if (options.configPath) {
|
|
27
|
+
return [options.configPath]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const paths: string[] = []
|
|
31
|
+
const directory = options.directory ?? process.cwd()
|
|
32
|
+
const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
|
|
33
|
+
|
|
34
|
+
paths.push(path.join(xdgConfig, "opencode", "feishu-notifier.json"))
|
|
35
|
+
paths.push(path.join(directory, ".opencode", "feishu-notifier.json"))
|
|
36
|
+
|
|
37
|
+
return paths
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function readConfigFile(filePath: string): Partial<FeishuConfig> | null {
|
|
41
|
+
if (!fs.existsSync(filePath)) {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const raw = fs.readFileSync(filePath, "utf8").trim()
|
|
46
|
+
if (!raw) {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(raw) as Partial<FeishuConfig>
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new Error(`Invalid JSON in ${filePath}: ${String(error)}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveEnvPlaceholder(value: string | undefined, fieldName: keyof FeishuConfig): string | undefined {
|
|
58
|
+
if (!value) {
|
|
59
|
+
return value
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const envReferenceMatch = value.match(/^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/)
|
|
63
|
+
if (!envReferenceMatch) {
|
|
64
|
+
return value
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const envName = envReferenceMatch[1]
|
|
68
|
+
const envValue = process.env[envName]
|
|
69
|
+
|
|
70
|
+
if (!envValue) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`Config field ${fieldName} references environment variable ${envName}, but it is not set.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return envValue
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveConfigPlaceholders(config: Partial<FeishuConfig>): Partial<FeishuConfig> {
|
|
80
|
+
return {
|
|
81
|
+
...config,
|
|
82
|
+
appId: resolveEnvPlaceholder(config.appId, "appId"),
|
|
83
|
+
appSecret: resolveEnvPlaceholder(config.appSecret, "appSecret"),
|
|
84
|
+
receiverType: resolveEnvPlaceholder(config.receiverType, "receiverType") as
|
|
85
|
+
| ReceiverType
|
|
86
|
+
| undefined,
|
|
87
|
+
receiverId: resolveEnvPlaceholder(config.receiverId, "receiverId")
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readEnvConfig(): Partial<FeishuConfig> {
|
|
92
|
+
return {
|
|
93
|
+
appId: process.env.FEISHU_APP_ID,
|
|
94
|
+
appSecret: process.env.FEISHU_APP_SECRET,
|
|
95
|
+
receiverType: process.env.FEISHU_RECEIVER_TYPE as ReceiverType | undefined,
|
|
96
|
+
receiverId: process.env.FEISHU_RECEIVER_ID
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveConfig(options: LoadConfigOptions): {
|
|
101
|
+
mergedConfig: Partial<FeishuConfig>
|
|
102
|
+
sources: ConfigSource[]
|
|
103
|
+
} {
|
|
104
|
+
const configPaths = getConfigPaths(options)
|
|
105
|
+
let mergedConfig: Partial<FeishuConfig> = {}
|
|
106
|
+
const sources: ConfigSource[] = []
|
|
107
|
+
|
|
108
|
+
for (const configPath of configPaths) {
|
|
109
|
+
const config = readConfigFile(configPath)
|
|
110
|
+
if (config) {
|
|
111
|
+
const resolvedConfig = resolveConfigPlaceholders(config)
|
|
112
|
+
mergedConfig = {
|
|
113
|
+
...mergedConfig,
|
|
114
|
+
...resolvedConfig
|
|
115
|
+
}
|
|
116
|
+
sources.push({ type: "file", detail: configPath })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const envConfig = readEnvConfig()
|
|
121
|
+
if (envConfig.appId || envConfig.appSecret || envConfig.receiverType || envConfig.receiverId) {
|
|
122
|
+
mergedConfig = {
|
|
123
|
+
...mergedConfig,
|
|
124
|
+
...envConfig
|
|
125
|
+
}
|
|
126
|
+
sources.push({ type: "env", detail: "FEISHU_*" })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return { mergedConfig, sources }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function finalizeConfig(mergedConfig: Partial<FeishuConfig>, sources: ConfigSource[]): FeishuConfig {
|
|
133
|
+
if (sources.length === 0) {
|
|
134
|
+
throw new Error(
|
|
135
|
+
"Missing Feishu configuration. Use FEISHU_* environment variables or create feishu-notifier.json."
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const missing: string[] = []
|
|
140
|
+
if (!mergedConfig.appId) missing.push("appId")
|
|
141
|
+
if (!mergedConfig.appSecret) missing.push("appSecret")
|
|
142
|
+
if (!mergedConfig.receiverType) missing.push("receiverType")
|
|
143
|
+
if (!mergedConfig.receiverId) missing.push("receiverId")
|
|
144
|
+
|
|
145
|
+
if (missing.length > 0) {
|
|
146
|
+
throw new Error(`Missing config fields: ${missing.join(", ")}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const receiverType = mergedConfig.receiverType as ReceiverType
|
|
150
|
+
if (!receiverTypes.includes(receiverType)) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`Invalid receiverType: ${mergedConfig.receiverType}. Expected one of ${receiverTypes.join(", ")}`
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
appId: mergedConfig.appId!,
|
|
158
|
+
appSecret: mergedConfig.appSecret!,
|
|
159
|
+
receiverType,
|
|
160
|
+
receiverId: mergedConfig.receiverId!
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function loadConfig(options: LoadConfigOptions = {}): FeishuConfig {
|
|
165
|
+
return loadConfigWithSource(options).config
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function loadConfigWithSource(
|
|
169
|
+
options: LoadConfigOptions = {}
|
|
170
|
+
): { config: FeishuConfig; sources: ConfigSource[] } {
|
|
171
|
+
const { mergedConfig, sources } = resolveConfig(options)
|
|
172
|
+
return {
|
|
173
|
+
config: finalizeConfig(mergedConfig, sources),
|
|
174
|
+
sources
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { ProgressInfo } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 从事件负载中提取进度信息
|
|
5
|
+
* @param eventPayload 事件负载
|
|
6
|
+
* @returns 进度信息
|
|
7
|
+
*/
|
|
8
|
+
export function extractProgressInfo(eventPayload?: unknown): ProgressInfo {
|
|
9
|
+
const timestamp = new Date().toISOString();
|
|
10
|
+
|
|
11
|
+
// 尝试从事件负载中提取信息
|
|
12
|
+
let lastAction: string | undefined;
|
|
13
|
+
let currentTask: string | undefined;
|
|
14
|
+
|
|
15
|
+
if (eventPayload && typeof eventPayload === "object") {
|
|
16
|
+
const payload = eventPayload as Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
// 尝试从常见字段提取信息
|
|
19
|
+
if (typeof payload.message === "string") {
|
|
20
|
+
lastAction = payload.message;
|
|
21
|
+
} else if (typeof payload.description === "string") {
|
|
22
|
+
lastAction = payload.description;
|
|
23
|
+
} else if (typeof payload.action === "string") {
|
|
24
|
+
lastAction = payload.action;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (typeof payload.task === "string") {
|
|
28
|
+
currentTask = payload.task;
|
|
29
|
+
} else if (typeof payload.currentTask === "string") {
|
|
30
|
+
currentTask = payload.currentTask;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 如果没有从负载中提取到信息,提供默认值
|
|
35
|
+
if (!lastAction) {
|
|
36
|
+
lastAction = "OpenCode 正在处理任务";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
lastAction,
|
|
41
|
+
currentTask,
|
|
42
|
+
timestamp,
|
|
43
|
+
// 文件变更信息需要从 Git 或其他来源获取,这里暂时留空
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 从 Git 状态中提取文件变更信息
|
|
49
|
+
* @param directory 工作目录
|
|
50
|
+
* @returns 文件变更信息
|
|
51
|
+
*/
|
|
52
|
+
export function extractFileChanges(directory: string):
|
|
53
|
+
| {
|
|
54
|
+
added?: number;
|
|
55
|
+
modified?: number;
|
|
56
|
+
deleted?: number;
|
|
57
|
+
}
|
|
58
|
+
| undefined {
|
|
59
|
+
try {
|
|
60
|
+
const { execSync } = require("child_process");
|
|
61
|
+
|
|
62
|
+
// 获取 Git 状态摘要
|
|
63
|
+
const statusOutput = execSync("git status --porcelain", {
|
|
64
|
+
cwd: directory,
|
|
65
|
+
encoding: "utf-8",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
let added = 0;
|
|
69
|
+
let modified = 0;
|
|
70
|
+
let deleted = 0;
|
|
71
|
+
|
|
72
|
+
const lines = statusOutput.trim().split("\n");
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
if (!line.trim()) continue;
|
|
75
|
+
|
|
76
|
+
const status = line.substring(0, 2).trim();
|
|
77
|
+
if (status === "A" || status.startsWith("A")) {
|
|
78
|
+
added++;
|
|
79
|
+
} else if (status === "M" || status.startsWith("M")) {
|
|
80
|
+
modified++;
|
|
81
|
+
} else if (status === "D" || status.startsWith("D")) {
|
|
82
|
+
deleted++;
|
|
83
|
+
} else if (status === "??") {
|
|
84
|
+
added++; // 未跟踪的文件视为新增
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (added > 0 || modified > 0 || deleted > 0) {
|
|
89
|
+
return { added, modified, deleted };
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// Git 命令失败或不是 Git 仓库
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 创建带文件变更信息的进度信息
|
|
100
|
+
* @param eventPayload 事件负载
|
|
101
|
+
* @param directory 工作目录
|
|
102
|
+
* @returns 完整的进度信息
|
|
103
|
+
*/
|
|
104
|
+
export function createProgressInfo(
|
|
105
|
+
eventPayload?: unknown,
|
|
106
|
+
directory?: string
|
|
107
|
+
): ProgressInfo {
|
|
108
|
+
const baseInfo = extractProgressInfo(eventPayload);
|
|
109
|
+
|
|
110
|
+
// 如果提供了目录,尝试获取文件变更信息
|
|
111
|
+
if (directory) {
|
|
112
|
+
const fileChanges = extractFileChanges(directory);
|
|
113
|
+
if (fileChanges) {
|
|
114
|
+
return {
|
|
115
|
+
...baseInfo,
|
|
116
|
+
fileChanges,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return baseInfo;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 格式化进度信息为可读文本
|
|
126
|
+
* @param progress 进度信息
|
|
127
|
+
* @returns 格式化后的文本
|
|
128
|
+
*/
|
|
129
|
+
export function formatProgressInfo(progress: ProgressInfo): string {
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
|
|
132
|
+
// 格式化时间戳
|
|
133
|
+
const time = new Date(progress.timestamp);
|
|
134
|
+
const timeStr = time.toLocaleString("zh-CN", {
|
|
135
|
+
year: "numeric",
|
|
136
|
+
month: "2-digit",
|
|
137
|
+
day: "2-digit",
|
|
138
|
+
hour: "2-digit",
|
|
139
|
+
minute: "2-digit",
|
|
140
|
+
second: "2-digit",
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
lines.push(`• 时间:${timeStr}`);
|
|
144
|
+
|
|
145
|
+
if (progress.lastAction) {
|
|
146
|
+
lines.push(`• 最近操作:${progress.lastAction}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (progress.currentTask) {
|
|
150
|
+
lines.push(`• 当前任务:${progress.currentTask}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 添加文件变更信息
|
|
154
|
+
if (progress.fileChanges) {
|
|
155
|
+
const changes = progress.fileChanges;
|
|
156
|
+
const changeParts: string[] = [];
|
|
157
|
+
|
|
158
|
+
if (changes.added && changes.added > 0) {
|
|
159
|
+
changeParts.push(`新增 ${changes.added} 个文件`);
|
|
160
|
+
}
|
|
161
|
+
if (changes.modified && changes.modified > 0) {
|
|
162
|
+
changeParts.push(`修改 ${changes.modified} 个文件`);
|
|
163
|
+
}
|
|
164
|
+
if (changes.deleted && changes.deleted > 0) {
|
|
165
|
+
changeParts.push(`删除 ${changes.deleted} 个文件`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (changeParts.length > 0) {
|
|
169
|
+
lines.push(`• 文件变更:${changeParts.join(",")}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import os from "os";
|
|
5
|
+
import type { ProjectContext } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 提取项目上下文信息
|
|
9
|
+
* @param directory 工作目录路径
|
|
10
|
+
* @returns 项目上下文信息
|
|
11
|
+
*/
|
|
12
|
+
export async function extractProjectContext(
|
|
13
|
+
directory: string
|
|
14
|
+
): Promise<ProjectContext> {
|
|
15
|
+
const workingDir = path.resolve(directory);
|
|
16
|
+
|
|
17
|
+
// 从 package.json 提取项目名称,否则使用目录名
|
|
18
|
+
const projectName = await extractProjectName(workingDir);
|
|
19
|
+
|
|
20
|
+
// 检查是否为 Git 仓库并提取信息
|
|
21
|
+
const gitInfo = await extractGitInfo(workingDir);
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
projectName,
|
|
25
|
+
branch: gitInfo.branch,
|
|
26
|
+
workingDir,
|
|
27
|
+
repoUrl: gitInfo.repoUrl,
|
|
28
|
+
isGitRepo: gitInfo.isGitRepo,
|
|
29
|
+
hostname: os.hostname(),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 从 package.json 或目录名提取项目名称
|
|
35
|
+
*/
|
|
36
|
+
async function extractProjectName(directory: string): Promise<string> {
|
|
37
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
38
|
+
|
|
39
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
40
|
+
try {
|
|
41
|
+
const content = await fs.promises.readFile(packageJsonPath, "utf-8");
|
|
42
|
+
const packageJson = JSON.parse(content);
|
|
43
|
+
if (packageJson.name) {
|
|
44
|
+
return packageJson.name;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// 如果读取失败,使用目录名
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 使用目录名作为项目名
|
|
52
|
+
return path.basename(directory);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 提取 Git 仓库信息
|
|
57
|
+
*/
|
|
58
|
+
async function extractGitInfo(directory: string): Promise<{
|
|
59
|
+
isGitRepo: boolean;
|
|
60
|
+
branch?: string;
|
|
61
|
+
repoUrl?: string;
|
|
62
|
+
}> {
|
|
63
|
+
const gitDir = path.join(directory, ".git");
|
|
64
|
+
|
|
65
|
+
if (!fs.existsSync(gitDir)) {
|
|
66
|
+
return { isGitRepo: false };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
// 获取当前分支
|
|
71
|
+
const branch = execSync("git branch --show-current", {
|
|
72
|
+
cwd: directory,
|
|
73
|
+
encoding: "utf-8",
|
|
74
|
+
}).trim();
|
|
75
|
+
|
|
76
|
+
// 获取远程仓库 URL
|
|
77
|
+
let repoUrl: string | undefined;
|
|
78
|
+
try {
|
|
79
|
+
const remoteUrl = execSync("git config --get remote.origin.url", {
|
|
80
|
+
cwd: directory,
|
|
81
|
+
encoding: "utf-8",
|
|
82
|
+
}).trim();
|
|
83
|
+
|
|
84
|
+
if (remoteUrl) {
|
|
85
|
+
// 转换 SSH URL 为 HTTPS URL(如果适用)
|
|
86
|
+
repoUrl = convertGitUrlToWeb(remoteUrl);
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// 无法获取远程 URL,忽略
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
isGitRepo: true,
|
|
94
|
+
branch: branch || undefined,
|
|
95
|
+
repoUrl,
|
|
96
|
+
};
|
|
97
|
+
} catch (error) {
|
|
98
|
+
// Git 命令失败,但目录中有 .git 文件夹
|
|
99
|
+
return { isGitRepo: true };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 转换 Git URL 为可访问的 Web URL
|
|
105
|
+
*/
|
|
106
|
+
function convertGitUrlToWeb(gitUrl: string): string {
|
|
107
|
+
// 移除 .git 后缀
|
|
108
|
+
let url = gitUrl.replace(/\.git$/, "");
|
|
109
|
+
|
|
110
|
+
// 转换 SSH URL 为 HTTPS URL
|
|
111
|
+
// git@github.com:user/repo -> https://github.com/user/repo
|
|
112
|
+
if (url.startsWith("git@")) {
|
|
113
|
+
url = url.replace(":", "/").replace("git@", "https://");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return url;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 简化版:同步提取项目上下文(用于简单场景)
|
|
121
|
+
*/
|
|
122
|
+
export function extractProjectContextSync(directory: string): ProjectContext {
|
|
123
|
+
const workingDir = path.resolve(directory);
|
|
124
|
+
const projectName = extractProjectNameSync(workingDir);
|
|
125
|
+
const gitInfo = extractGitInfoSync(workingDir);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
projectName,
|
|
129
|
+
branch: gitInfo.branch,
|
|
130
|
+
workingDir,
|
|
131
|
+
repoUrl: gitInfo.repoUrl,
|
|
132
|
+
isGitRepo: gitInfo.isGitRepo,
|
|
133
|
+
hostname: os.hostname(),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 同步提取项目名称
|
|
139
|
+
*/
|
|
140
|
+
function extractProjectNameSync(directory: string): string {
|
|
141
|
+
const packageJsonPath = path.join(directory, "package.json");
|
|
142
|
+
|
|
143
|
+
if (fs.existsSync(packageJsonPath)) {
|
|
144
|
+
try {
|
|
145
|
+
const content = fs.readFileSync(packageJsonPath, "utf-8");
|
|
146
|
+
const packageJson = JSON.parse(content);
|
|
147
|
+
if (packageJson.name) {
|
|
148
|
+
return packageJson.name;
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// 如果读取失败,使用目录名
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return path.basename(directory);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* 同步提取 Git 信息
|
|
160
|
+
*/
|
|
161
|
+
function extractGitInfoSync(directory: string): {
|
|
162
|
+
isGitRepo: boolean;
|
|
163
|
+
branch?: string;
|
|
164
|
+
repoUrl?: string;
|
|
165
|
+
} {
|
|
166
|
+
const gitDir = path.join(directory, ".git");
|
|
167
|
+
|
|
168
|
+
if (!fs.existsSync(gitDir)) {
|
|
169
|
+
return { isGitRepo: false };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// 获取当前分支
|
|
174
|
+
const branch = execSync("git branch --show-current", {
|
|
175
|
+
cwd: directory,
|
|
176
|
+
encoding: "utf-8",
|
|
177
|
+
}).trim();
|
|
178
|
+
|
|
179
|
+
// 获取远程仓库 URL
|
|
180
|
+
let repoUrl: string | undefined;
|
|
181
|
+
try {
|
|
182
|
+
const remoteUrl = execSync("git config --get remote.origin.url", {
|
|
183
|
+
cwd: directory,
|
|
184
|
+
encoding: "utf-8",
|
|
185
|
+
}).trim();
|
|
186
|
+
|
|
187
|
+
if (remoteUrl) {
|
|
188
|
+
repoUrl = convertGitUrlToWeb(remoteUrl);
|
|
189
|
+
}
|
|
190
|
+
} catch {
|
|
191
|
+
// 无法获取远程 URL,忽略
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
isGitRepo: true,
|
|
196
|
+
branch: branch || undefined,
|
|
197
|
+
repoUrl,
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
return { isGitRepo: true };
|
|
201
|
+
}
|
|
202
|
+
}
|