@fatdoge/wtree 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 (65) hide show
  1. package/README.en.md +113 -0
  2. package/README.md +136 -0
  3. package/api/app.ts +19 -0
  4. package/api/cli/wtree.ts +809 -0
  5. package/api/core/config.ts +26 -0
  6. package/api/core/exec.ts +55 -0
  7. package/api/core/git.ts +35 -0
  8. package/api/core/id.ts +8 -0
  9. package/api/core/open.ts +58 -0
  10. package/api/core/worktree.test.ts +33 -0
  11. package/api/core/worktree.ts +72 -0
  12. package/api/createApiApp.ts +33 -0
  13. package/api/index.ts +9 -0
  14. package/api/routes/worktrees.ts +255 -0
  15. package/api/server.ts +34 -0
  16. package/api/ui/startUiDev.ts +82 -0
  17. package/dist/assets/index-D9inyPb3.js +179 -0
  18. package/dist/assets/index-W34LSHWF.css +1 -0
  19. package/dist/favicon.svg +4 -0
  20. package/dist/index.html +354 -0
  21. package/dist-node/api/app.js +17 -0
  22. package/dist-node/api/cli/wtree.js +722 -0
  23. package/dist-node/api/cli/wtui.js +722 -0
  24. package/dist-node/api/core/config.js +21 -0
  25. package/dist-node/api/core/exec.js +24 -0
  26. package/dist-node/api/core/git.js +24 -0
  27. package/dist-node/api/core/id.js +6 -0
  28. package/dist-node/api/core/open.js +51 -0
  29. package/dist-node/api/core/worktree.js +58 -0
  30. package/dist-node/api/core/worktree.test.js +30 -0
  31. package/dist-node/api/createApiApp.js +26 -0
  32. package/dist-node/api/routes/worktrees.js +213 -0
  33. package/dist-node/api/server.js +29 -0
  34. package/dist-node/api/ui/startUiDev.js +65 -0
  35. package/dist-node/shared/wtui-types.js +1 -0
  36. package/index.html +24 -0
  37. package/package.json +89 -0
  38. package/postcss.config.js +10 -0
  39. package/shared/wtui-types.ts +36 -0
  40. package/src/App.tsx +28 -0
  41. package/src/assets/react.svg +1 -0
  42. package/src/components/Button.tsx +34 -0
  43. package/src/components/Empty.tsx +8 -0
  44. package/src/components/Input.tsx +16 -0
  45. package/src/components/Modal.tsx +33 -0
  46. package/src/components/ToastHost.tsx +42 -0
  47. package/src/hooks/useTheme.ts +29 -0
  48. package/src/i18n/index.ts +22 -0
  49. package/src/i18n/locales/en.json +145 -0
  50. package/src/i18n/locales/zh.json +145 -0
  51. package/src/index.css +24 -0
  52. package/src/lib/utils.ts +6 -0
  53. package/src/main.tsx +11 -0
  54. package/src/pages/CreateWorktree.tsx +181 -0
  55. package/src/pages/HelpPage.tsx +67 -0
  56. package/src/pages/Home.tsx +3 -0
  57. package/src/pages/SettingsPage.tsx +218 -0
  58. package/src/pages/Worktrees.tsx +354 -0
  59. package/src/stores/themeStore.ts +44 -0
  60. package/src/stores/toastStore.ts +29 -0
  61. package/src/stores/worktreeStore.ts +93 -0
  62. package/src/utils/api.ts +36 -0
  63. package/src/vite-env.d.ts +1 -0
  64. package/tailwind.config.js +13 -0
  65. package/vite.config.ts +46 -0
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import type { WtuiConfig } from '../../shared/wtui-types.js'
6
+
7
+ const CONFIG_DIR = path.join(os.homedir(), '.config', 'wtree')
8
+ const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json')
9
+
10
+ export function readConfig(): WtuiConfig {
11
+ try {
12
+ const raw = fs.readFileSync(CONFIG_PATH, 'utf-8')
13
+ return JSON.parse(raw) as WtuiConfig
14
+ } catch {
15
+ return {}
16
+ }
17
+ }
18
+
19
+ export function writeConfig(next: WtuiConfig) {
20
+ fs.mkdirSync(CONFIG_DIR, { recursive: true })
21
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2), 'utf-8')
22
+ }
23
+
24
+ export function getConfigPaths() {
25
+ return { dir: CONFIG_DIR, path: CONFIG_PATH }
26
+ }
@@ -0,0 +1,55 @@
1
+ import { spawnSync } from 'node:child_process'
2
+
3
+ export type ExecResult = {
4
+ ok: boolean
5
+ stdout: string
6
+ stderr: string
7
+ exitCode: number | null
8
+ }
9
+
10
+ export type ExecError = Error & {
11
+ exec?: {
12
+ command: string
13
+ args: string[]
14
+ ok: boolean
15
+ stdout: string
16
+ stderr: string
17
+ exitCode: number | null
18
+ }
19
+ }
20
+
21
+ export function exec(
22
+ command: string,
23
+ args: string[],
24
+ options: { cwd?: string } = {},
25
+ ): ExecResult {
26
+ const r = spawnSync(command, args, {
27
+ cwd: options.cwd,
28
+ encoding: 'utf-8',
29
+ })
30
+
31
+ const stdout = (r.stdout || '').toString()
32
+ const stderr = (r.stderr || '').toString()
33
+ const ok = r.status === 0
34
+
35
+ return {
36
+ ok,
37
+ stdout: stdout.trimEnd(),
38
+ stderr: stderr.trimEnd(),
39
+ exitCode: r.status,
40
+ }
41
+ }
42
+
43
+ export function execOrThrow(
44
+ command: string,
45
+ args: string[],
46
+ options: { cwd?: string; errorCode?: string } = {},
47
+ ) {
48
+ const r = exec(command, args, { cwd: options.cwd })
49
+ if (r.ok) return r
50
+ const error: ExecError = new Error(
51
+ `${options.errorCode ? `[${options.errorCode}] ` : ''}${command} ${args.join(' ')}\n${r.stderr || r.stdout}`,
52
+ )
53
+ error.exec = { command, args, ...r }
54
+ throw error
55
+ }
@@ -0,0 +1,35 @@
1
+ import path from 'node:path'
2
+ import { exec, execOrThrow } from './exec.js'
3
+
4
+ const withSafeArgs = (args: string[]) => ['-c', 'safe.directory=*', ...args]
5
+
6
+ export function getRepoRoot(cwd: string) {
7
+ const common = exec('git', withSafeArgs(['rev-parse', '--path-format=absolute', '--git-common-dir']), { cwd })
8
+ if (common.ok && common.stdout.trim()) {
9
+ return path.dirname(common.stdout.trim())
10
+ }
11
+
12
+ const top = exec('git', withSafeArgs(['rev-parse', '--show-toplevel']), { cwd })
13
+ if (top.ok && top.stdout.trim()) {
14
+ return top.stdout.trim()
15
+ }
16
+
17
+ throw new Error('无法确定 Git 根目录。请确保在 Git 仓库中运行。')
18
+ }
19
+
20
+ export function getGitDirAbsolute(rootDir: string) {
21
+ const r = execOrThrow(
22
+ 'git',
23
+ withSafeArgs(['rev-parse', '--path-format=absolute', '--git-dir']),
24
+ { cwd: rootDir, errorCode: 'GIT_DIR' },
25
+ )
26
+ return r.stdout.trim()
27
+ }
28
+
29
+ export function git(rootDir: string, args: string[]) {
30
+ return exec('git', withSafeArgs(args), { cwd: rootDir })
31
+ }
32
+
33
+ export function gitOrThrow(rootDir: string, args: string[], errorCode?: string) {
34
+ return execOrThrow('git', withSafeArgs(args), { cwd: rootDir, errorCode })
35
+ }
package/api/core/id.ts ADDED
@@ -0,0 +1,8 @@
1
+ export function idFromPath(p: string) {
2
+ return Buffer.from(p, 'utf-8').toString('base64url')
3
+ }
4
+
5
+ export function pathFromId(id: string) {
6
+ return Buffer.from(id, 'base64url').toString('utf-8')
7
+ }
8
+
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import process from 'node:process'
3
+
4
+ function hasCommand(cmd: string) {
5
+ try {
6
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which'
7
+ const r = spawnSync(checkCmd, [cmd], { stdio: 'ignore' })
8
+ return r.status === 0
9
+ } catch {
10
+ return false
11
+ }
12
+ }
13
+
14
+ export function openPath(targetPath: string, openCommand?: string) {
15
+ const platform = process.platform
16
+ if (openCommand) {
17
+ const r = spawnSync(openCommand, [targetPath], { stdio: 'ignore' })
18
+ return r.status === 0
19
+ }
20
+
21
+ if (platform === 'darwin') {
22
+ const r = spawnSync('open', [targetPath], { stdio: 'ignore' })
23
+ return r.status === 0
24
+ }
25
+
26
+ if (platform === 'win32') {
27
+ const r = spawnSync('cmd', ['/c', 'start', '', targetPath], {
28
+ stdio: 'ignore',
29
+ windowsVerbatimArguments: true,
30
+ })
31
+ return r.status === 0
32
+ }
33
+
34
+ const r = spawnSync('xdg-open', [targetPath], { stdio: 'ignore' })
35
+ return r.status === 0
36
+ }
37
+
38
+ export function openEditor(targetPath: string, editorCommand?: string) {
39
+ if (editorCommand) {
40
+ // Split command and args? For now assume command is single word or handled by spawnSync if passed as array.
41
+ // Actually spawnSync(cmd, args) expects cmd to be executable.
42
+ // If editorCommand is "code -r", it might fail if passed as cmd.
43
+ // We should probably split by space if user provided args, but let's keep it simple: assume user provides executable name.
44
+ // Or we can use shell: true for custom commands.
45
+ const r = spawnSync(editorCommand, [targetPath], { stdio: 'ignore', shell: true })
46
+ return r.status === 0
47
+ }
48
+
49
+ const editors = ['trae', 'cursor', 'code']
50
+ for (const ed of editors) {
51
+ if (hasCommand(ed)) {
52
+ const r = spawnSync(ed, [targetPath], { stdio: 'ignore' })
53
+ return r.status === 0
54
+ }
55
+ }
56
+ return false
57
+ }
58
+
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { parseWorktreePorcelain } from './worktree.js'
3
+
4
+ describe('parseWorktreePorcelain', () => {
5
+ it('parses multiple worktrees with branch and locked', () => {
6
+ const output = [
7
+ 'worktree /repo',
8
+ 'HEAD 1111111111111111111111111111111111111111',
9
+ 'branch refs/heads/main',
10
+ 'worktree /repo/worktrees/feature-a',
11
+ 'HEAD 2222222222222222222222222222222222222222',
12
+ 'branch refs/heads/feature/a',
13
+ 'locked',
14
+ '',
15
+ ].join('\n')
16
+
17
+ const items = parseWorktreePorcelain(output)
18
+ expect(items).toHaveLength(2)
19
+ expect(items[0]).toEqual({
20
+ path: '/repo',
21
+ head: '1111111111111111111111111111111111111111',
22
+ branch: 'main',
23
+ isLocked: false,
24
+ })
25
+ expect(items[1]).toEqual({
26
+ path: '/repo/worktrees/feature-a',
27
+ head: '2222222222222222222222222222222222222222',
28
+ branch: 'feature/a',
29
+ isLocked: true,
30
+ })
31
+ })
32
+ })
33
+
@@ -0,0 +1,72 @@
1
+ import path from 'node:path'
2
+ import { gitOrThrow } from './git.js'
3
+ import { idFromPath } from './id.js'
4
+ import type { WorktreeItem } from '../../shared/wtui-types.js'
5
+
6
+ export type WorktreeRaw = {
7
+ path: string
8
+ head: string
9
+ branch?: string
10
+ isLocked: boolean
11
+ }
12
+
13
+ export function parseWorktreePorcelain(output: string): WorktreeRaw[] {
14
+ const lines = output.split('\n')
15
+ const items: WorktreeRaw[] = []
16
+ let current: Partial<WorktreeRaw> = {}
17
+
18
+ const flush = () => {
19
+ if (!current.path) return
20
+ items.push({
21
+ path: current.path,
22
+ head: current.head || '',
23
+ branch: current.branch,
24
+ isLocked: Boolean(current.isLocked),
25
+ })
26
+ current = {}
27
+ }
28
+
29
+ for (const line of lines) {
30
+ if (line.startsWith('worktree ')) {
31
+ flush()
32
+ current.path = line.slice('worktree '.length).trim()
33
+ continue
34
+ }
35
+ if (line.startsWith('HEAD ')) {
36
+ current.head = line.slice('HEAD '.length).trim()
37
+ continue
38
+ }
39
+ if (line.startsWith('branch ')) {
40
+ const b = line.slice('branch '.length).trim()
41
+ current.branch = b.replace(/^refs\/heads\//, '')
42
+ continue
43
+ }
44
+ if (line.startsWith('locked')) {
45
+ current.isLocked = true
46
+ continue
47
+ }
48
+ }
49
+ flush()
50
+
51
+ return items
52
+ }
53
+
54
+ export function listWorktrees(rootDir: string): WorktreeItem[] {
55
+ const r = gitOrThrow(rootDir, ['worktree', 'list', '--porcelain'], 'WORKTREE_LIST')
56
+ const raw = parseWorktreePorcelain(r.stdout)
57
+
58
+ return raw
59
+ .filter((wt) => wt.path)
60
+ .map((wt) => {
61
+ const normalized = path.resolve(wt.path)
62
+ return {
63
+ id: idFromPath(normalized),
64
+ path: normalized,
65
+ head: wt.head,
66
+ branch: wt.branch,
67
+ isMain: path.resolve(normalized) === path.resolve(rootDir),
68
+ isLocked: wt.isLocked,
69
+ }
70
+ })
71
+ }
72
+
@@ -0,0 +1,33 @@
1
+ import express, { type Request, type Response, type NextFunction } from 'express'
2
+ import cors from 'cors'
3
+ import { createWorktreeRouter } from './routes/worktrees.js'
4
+
5
+ export function createApiApp(getRepoRoot: () => string) {
6
+ const app: express.Application = express()
7
+
8
+ app.use(cors())
9
+ app.use(express.json({ limit: '10mb' }))
10
+ app.use(express.urlencoded({ extended: true, limit: '10mb' }))
11
+
12
+ app.use('/api', createWorktreeRouter(getRepoRoot))
13
+
14
+ app.get('/api/health', (req: Request, res: Response) => {
15
+ res.status(200).json({ ok: true, data: { status: 'ok' } })
16
+ })
17
+
18
+ app.use((error: Error, req: Request, res: Response, next: NextFunction) => {
19
+ void error
20
+ void req
21
+ void next
22
+ res.status(500).json({
23
+ ok: false,
24
+ error: { code: 'INTERNAL', message: 'Server internal error' },
25
+ })
26
+ })
27
+
28
+ app.use((req: Request, res: Response) => {
29
+ res.status(404).json({ ok: false, error: { code: 'NOT_FOUND', message: 'API not found' } })
30
+ })
31
+
32
+ return app
33
+ }
package/api/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Vercel deploy entry handler, for serverless deployment, please don't modify this file
3
+ */
4
+ import type { VercelRequest, VercelResponse } from '@vercel/node';
5
+ import app from './app.js';
6
+
7
+ export default function handler(req: VercelRequest, res: VercelResponse) {
8
+ return app(req, res);
9
+ }
@@ -0,0 +1,255 @@
1
+ import express, { type Request, type Response } from 'express'
2
+ import path from 'node:path'
3
+ import { getGitDirAbsolute } from '../core/git.js'
4
+ import { listWorktrees } from '../core/worktree.js'
5
+ import { gitOrThrow } from '../core/git.js'
6
+ import { pathFromId } from '../core/id.js'
7
+ import { openPath, openEditor } from '../core/open.js'
8
+ import { readConfig, writeConfig } from '../core/config.js'
9
+ import type {
10
+ ApiResult,
11
+ CreateWorktreeRequest,
12
+ RepoInfo,
13
+ WtuiConfig,
14
+ WorktreeItem,
15
+ } from '../../shared/wtui-types.js'
16
+
17
+ export function createWorktreeRouter(getRepoRoot: () => string) {
18
+ const router = express.Router()
19
+
20
+ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
21
+
22
+ router.get('/repo', (req: Request, res: Response<ApiResult<RepoInfo>>) => {
23
+ try {
24
+ const rootPath = getRepoRoot()
25
+ const gitDirPath = getGitDirAbsolute(rootPath)
26
+ res.json({ ok: true, data: { rootPath, gitDirPath } })
27
+ } catch (e: unknown) {
28
+ res.status(400).json({
29
+ ok: false,
30
+ error: { code: 'REPO_NOT_FOUND', message: errMsg(e) || 'Repo not found' },
31
+ })
32
+ }
33
+ })
34
+
35
+ router.get(
36
+ '/worktrees',
37
+ (req: Request, res: Response<ApiResult<WorktreeItem[]>>) => {
38
+ try {
39
+ const root = getRepoRoot()
40
+ const items = listWorktrees(root)
41
+ res.json({ ok: true, data: items })
42
+ } catch (e: unknown) {
43
+ res.status(500).json({
44
+ ok: false,
45
+ error: { code: 'WORKTREE_LIST_FAILED', message: errMsg(e) || 'List failed' },
46
+ })
47
+ }
48
+ },
49
+ )
50
+
51
+ router.post(
52
+ '/worktrees',
53
+ (
54
+ req: Request<Record<string, never>, ApiResult<WorktreeItem>, CreateWorktreeRequest>,
55
+ res: Response<ApiResult<WorktreeItem>>,
56
+ ) => {
57
+ try {
58
+ const root = getRepoRoot()
59
+ const body = req.body
60
+
61
+ if (!body || !body.ref || !body.path) {
62
+ res.status(400).json({
63
+ ok: false,
64
+ error: { code: 'INVALID_INPUT', message: 'ref 与 path 不能为空' },
65
+ })
66
+ return
67
+ }
68
+
69
+ const targetDir = path.isAbsolute(body.path)
70
+ ? body.path
71
+ : path.resolve(root, body.path)
72
+
73
+ if (body.newBranch && body.newBranch.trim()) {
74
+ gitOrThrow(root, ['branch', body.newBranch.trim(), body.ref.trim()], 'BRANCH_CREATE')
75
+ gitOrThrow(root, ['worktree', 'add', targetDir, body.newBranch.trim()], 'WORKTREE_ADD')
76
+ } else {
77
+ gitOrThrow(root, ['worktree', 'add', targetDir, body.ref.trim()], 'WORKTREE_ADD')
78
+ }
79
+
80
+ const items = listWorktrees(root)
81
+ const created = items.find((x) => path.resolve(x.path) === path.resolve(targetDir))
82
+ if (!created) {
83
+ res.json({
84
+ ok: false,
85
+ error: { code: 'WORKTREE_CREATE_UNKNOWN', message: 'Worktree 创建成功但未能读取到列表' },
86
+ })
87
+ return
88
+ }
89
+ res.json({ ok: true, data: created })
90
+ } catch (e: unknown) {
91
+ res.status(500).json({
92
+ ok: false,
93
+ error: {
94
+ code: 'WORKTREE_CREATE_FAILED',
95
+ message: '创建失败',
96
+ details: errMsg(e),
97
+ },
98
+ })
99
+ }
100
+ },
101
+ )
102
+
103
+ router.delete(
104
+ '/worktrees/:id',
105
+ (req: Request<{ id: string }>, res: Response<ApiResult<{ removed: true }>>) => {
106
+ const force = req.query.force === '1' || req.query.force === 'true'
107
+ try {
108
+ const root = getRepoRoot()
109
+ const p = pathFromId(req.params.id)
110
+ const args = force ? ['worktree', 'remove', '--force', p] : ['worktree', 'remove', p]
111
+ gitOrThrow(root, args, 'WORKTREE_REMOVE')
112
+ res.json({ ok: true, data: { removed: true } })
113
+ } catch (e: unknown) {
114
+ res.status(500).json({
115
+ ok: false,
116
+ error: {
117
+ code: 'WORKTREE_REMOVE_FAILED',
118
+ message: '删除失败',
119
+ details: errMsg(e),
120
+ },
121
+ })
122
+ }
123
+ },
124
+ )
125
+
126
+ router.post(
127
+ '/worktrees/:id/open',
128
+ (req: Request<{ id: string }, ApiResult<{ launched: true }>, { type?: 'folder' | 'editor' }>, res: Response<ApiResult<{ launched: true }>>) => {
129
+ try {
130
+ const p = pathFromId(req.params.id)
131
+ const cfg = readConfig()
132
+ const type = req.body.type || 'folder'
133
+
134
+ let ok = false
135
+ if (type === 'editor') {
136
+ ok = openEditor(p, cfg.editorCommand)
137
+ } else {
138
+ ok = openPath(p, cfg.openCommand)
139
+ }
140
+
141
+ if (!ok) {
142
+ res.status(500).json({
143
+ ok: false,
144
+ error: {
145
+ code: 'OPEN_FAILED',
146
+ message:
147
+ type === 'editor'
148
+ ? '无法打开编辑器 (未找到 Trae/Cursor/VSCode,请在设置中配置 editorCommand)'
149
+ : '打开失败',
150
+ },
151
+ })
152
+ return
153
+ }
154
+ res.json({ ok: true, data: { launched: true } })
155
+ } catch (e: unknown) {
156
+ res.status(500).json({
157
+ ok: false,
158
+ error: { code: 'OPEN_FAILED', message: errMsg(e) || '打开失败' },
159
+ })
160
+ }
161
+ },
162
+ )
163
+
164
+ router.post(
165
+ '/worktrees/:id/lock',
166
+ (req: Request<{ id: string }>, res: Response<ApiResult<{ locked: true }>>) => {
167
+ try {
168
+ const root = getRepoRoot()
169
+ const p = pathFromId(req.params.id)
170
+ gitOrThrow(root, ['worktree', 'lock', p], 'WORKTREE_LOCK')
171
+ res.json({ ok: true, data: { locked: true } })
172
+ } catch (e: unknown) {
173
+ res.status(500).json({
174
+ ok: false,
175
+ error: { code: 'LOCK_FAILED', message: errMsg(e) || 'Lock failed' },
176
+ })
177
+ }
178
+ },
179
+ )
180
+
181
+ router.post(
182
+ '/worktrees/:id/unlock',
183
+ (req: Request<{ id: string }>, res: Response<ApiResult<{ unlocked: true }>>) => {
184
+ try {
185
+ const root = getRepoRoot()
186
+ const p = pathFromId(req.params.id)
187
+ gitOrThrow(root, ['worktree', 'unlock', p], 'WORKTREE_UNLOCK')
188
+ res.json({ ok: true, data: { unlocked: true } })
189
+ } catch (e: unknown) {
190
+ res.status(500).json({
191
+ ok: false,
192
+ error: { code: 'UNLOCK_FAILED', message: errMsg(e) || 'Unlock failed' },
193
+ })
194
+ }
195
+ },
196
+ )
197
+
198
+ router.post('/worktrees/prune', (req: Request, res: Response<ApiResult<{ pruned: true }>>) => {
199
+ try {
200
+ const root = getRepoRoot()
201
+ gitOrThrow(root, ['worktree', 'prune'], 'WORKTREE_PRUNE')
202
+ res.json({ ok: true, data: { pruned: true } })
203
+ } catch (e: unknown) {
204
+ res.status(500).json({
205
+ ok: false,
206
+ error: { code: 'PRUNE_FAILED', message: errMsg(e) || 'Prune failed' },
207
+ })
208
+ }
209
+ })
210
+
211
+ router.get('/branches', (req: Request, res: Response<ApiResult<string[]>>) => {
212
+ try {
213
+ const root = getRepoRoot()
214
+ const local = gitOrThrow(root, ['branch', '--format=%(refname:short)']).stdout
215
+ .split('\n')
216
+ .map((b) => b.trim())
217
+ .filter(Boolean)
218
+
219
+ const remote = gitOrThrow(root, ['branch', '-r', '--format=%(refname:short)']).stdout
220
+ .split('\n')
221
+ .map((b) => b.trim())
222
+ .filter((b) => b && !b.includes('/HEAD'))
223
+
224
+ const all = Array.from(new Set([...local, ...remote])).sort()
225
+ res.json({ ok: true, data: all })
226
+ } catch (e: unknown) {
227
+ res.status(500).json({
228
+ ok: false,
229
+ error: { code: 'BRANCH_LIST_FAILED', message: errMsg(e) || 'List branches failed' },
230
+ })
231
+ }
232
+ })
233
+
234
+ router.get('/config', (req: Request, res: Response<ApiResult<WtuiConfig>>) => {
235
+ res.json({ ok: true, data: readConfig() })
236
+ })
237
+
238
+ router.put(
239
+ '/config',
240
+ (req: Request<Record<string, never>, ApiResult<WtuiConfig>, WtuiConfig>, res: Response<ApiResult<WtuiConfig>>) => {
241
+ try {
242
+ const next = req.body || {}
243
+ writeConfig(next)
244
+ res.json({ ok: true, data: readConfig() })
245
+ } catch (e: unknown) {
246
+ res.status(500).json({
247
+ ok: false,
248
+ error: { code: 'CONFIG_WRITE_FAILED', message: '保存失败', details: errMsg(e) },
249
+ })
250
+ }
251
+ },
252
+ )
253
+
254
+ return router
255
+ }
package/api/server.ts ADDED
@@ -0,0 +1,34 @@
1
+ /**
2
+ * local server entry file, for local development
3
+ */
4
+ import app from './app.js';
5
+
6
+ /**
7
+ * start server with port
8
+ */
9
+ const PORT = process.env.PORT || 3001;
10
+
11
+ const server = app.listen(PORT, () => {
12
+ console.log(`Server ready on port ${PORT}`);
13
+ });
14
+
15
+ /**
16
+ * close server
17
+ */
18
+ process.on('SIGTERM', () => {
19
+ console.log('SIGTERM signal received');
20
+ server.close(() => {
21
+ console.log('Server closed');
22
+ process.exit(0);
23
+ });
24
+ });
25
+
26
+ process.on('SIGINT', () => {
27
+ console.log('SIGINT signal received');
28
+ server.close(() => {
29
+ console.log('Server closed');
30
+ process.exit(0);
31
+ });
32
+ });
33
+
34
+ export default app;