source-code-mgmt 1.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/lib/index.js ADDED
@@ -0,0 +1,1601 @@
1
+ /**
2
+ * source-code-mgmt — host half (Node).
3
+ *
4
+ * Provides /api routes for the browser half that wrap the git / gh / ssh
5
+ * command line for a "源代码管理" (source code management) sidebar tool:
6
+ *
7
+ * GET /api/source-code-mgmt/env — git & gh presence/version
8
+ * GET /api/source-code-mgmt/ssh — ssh key + config + gh auth status
9
+ * POST /api/source-code-mgmt/gen-key — generate ed25519 key (no passphrase)
10
+ * POST /api/source-code-mgmt/write-config — write ~/.ssh/config for github|gitee (443)
11
+ * POST /api/source-code-mgmt/ssh-test — ssh -T git@<host> connectivity (github|gitee)
12
+ * GET /api/source-code-mgmt/repo?dir= — status of the selected folder
13
+ * POST /api/source-code-mgmt/push — commit+push (auto .gitignore >100MB)
14
+ * POST /api/source-code-mgmt/create — gh repo create --private --source=. --push
15
+ *
16
+ * All routes are loopback-only (same-machine browsers only).
17
+ */
18
+
19
+ import { execFileSync, spawn } from 'node:child_process'
20
+ import {
21
+ existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync,
22
+ readdirSync, statSync,
23
+ } from 'node:fs'
24
+ import { join, dirname } from 'node:path'
25
+ import { homedir } from 'node:os'
26
+
27
+ /** Stable cordis plugin name (also the browser bundle id). */
28
+ export const name = 'source-code-mgmt'
29
+
30
+ /** Services required before the routes can mount. */
31
+ export const inject = ['webServer']
32
+
33
+ /** API base path. */
34
+ const BASE = '/api/source-code-mgmt'
35
+
36
+ /** GitHub hard limit for a single committed file. */
37
+ const GH_FILE_LIMIT = 100 * 1024 * 1024
38
+
39
+ /** Detected platform. */
40
+ const IS_WIN = process.platform === 'win32'
41
+
42
+ /**
43
+ * Cross-platform binary resolution.
44
+ *
45
+ * Every external command (git / gh / ssh / ssh-keygen) is resolved once at
46
+ * module load through {@link resolveBin}, so the plugin works the same on any
47
+ * machine regardless of whether the tool lives on PATH:
48
+ *
49
+ * 1. An explicit environment override wins (DSH_SCM_GIT / DSH_SCM_GH /
50
+ * DSH_SCM_SSH / DSH_SCM_SSH_KEYGEN).
51
+ * 2. Otherwise the PATH is searched for the bare name (+ `.exe` on Windows).
52
+ * 3. Windows Git bundles ssh/ssh-keygen inside its own prefix; when PATH
53
+ * has git but not ssh, the ssh tools fall back to that prefix first
54
+ * (`usr\bin`, then `bin`) and to common install locations.
55
+ * 4. Last resort is the bare name — `run` then reports a descriptive error
56
+ * instead of a cryptic ENOENT.
57
+ *
58
+ * Resolution only performs pure probe calls (existsSync / directory walks),
59
+ * never runs the tool, so it is safe at module scope.
60
+ */
61
+
62
+ /** Resolved path to the git binary. */
63
+ const GIT = resolveBin('git', 'DSH_SCM_GIT')
64
+ /** Resolved path to the GitHub CLI binary. */
65
+ const GH = resolveBin('gh', 'DSH_SCM_GH')
66
+ /** Resolved path to the ssh binary. */
67
+ const SSH = resolveBin('ssh', 'DSH_SCM_SSH')
68
+ /** Resolved path to the ssh-keygen binary. */
69
+ const SSH_KEYGEN = resolveBin('ssh-keygen', 'DSH_SCM_SSH_KEYGEN')
70
+ /** Resolved path to curl (used to call the Gitee OpenAPI). */
71
+ const CURL = resolveBin('curl', 'DSH_SCM_CURL')
72
+
73
+ /**
74
+ * Preferred ssh for git remote operations (injected via GIT_SSH).
75
+ *
76
+ * On Windows, Git for Windows ships an MSYS ssh (`usr\bin\ssh.exe`) that fails
77
+ * with "couldn't create signal pipe, Win32 error 5" when spawned from a
78
+ * detached/agent process, breaking `git push`/`git pull`. The Windows system
79
+ * OpenSSH does not have this problem, so it is preferred when present,
80
+ * regardless of PATH resolution order. Falls back to the resolved `SSH` (or
81
+ * the bare name) otherwise.
82
+ */
83
+ const WORKING_SSH = (() => {
84
+ if (IS_WIN) {
85
+ const sysOpenSsh = 'C:\\Windows\\System32\\OpenSSH\\ssh.exe'
86
+ if (isFile(sysOpenSsh)) return sysOpenSsh
87
+ }
88
+ return SSH
89
+ })()
90
+
91
+ /** Return whether a filename exists and is a file (follows symlinks). */
92
+ function isFile(p) {
93
+ try { return statSync(p).isFile() } catch { return false }
94
+ }
95
+
96
+ /** Split the platform PATH into absolute directories. */
97
+ function pathDirs() {
98
+ return String(process.env.PATH ?? '')
99
+ .split(IS_WIN ? ';' : ':')
100
+ .map((p) => p.trim())
101
+ .filter((p) => p !== '')
102
+ }
103
+
104
+ /** Resolve a bare command name against an explicit prefix directory. */
105
+ function probePrefix(prefix, name) {
106
+ if (!name || !prefix) return undefined
107
+ const candidates = [
108
+ ...(IS_WIN ? [name + '.exe', name + '.cmd', name + '.bat', name] : [name]),
109
+ ]
110
+ for (const c of candidates) {
111
+ const p = join(prefix, c)
112
+ if (isFile(p)) return p
113
+ }
114
+ return undefined
115
+ }
116
+
117
+ /** Try to find `name` on PATH (with `.exe` on Windows). */
118
+ function findOnPath(name) {
119
+ for (const dir of pathDirs()) {
120
+ const found = probePrefix(dir, name)
121
+ if (found) return found
122
+ }
123
+ return undefined
124
+ }
125
+
126
+ /** Git install prefix on Windows (where a bundled ssh / ssh-keygen lives). */
127
+ function windowsGitPrefixes() {
128
+ const prefixes = []
129
+ // Prefer a git that is already reachable: its install root is one level up
130
+ // from the git binary on PATH (…\Git\cmd\git.exe -> …\Git).
131
+ const gitOnPath = findOnPath('git')
132
+ if (gitOnPath) {
133
+ // …\Git\cmd\git.exe -> prefix …\Git
134
+ const prefix = sanitizeWindowsGitPrefix(dirname(dirname(gitOnPath)))
135
+ if (prefix) prefixes.push(prefix)
136
+ // …\Git\usr\bin\git.exe -> prefix …\Git (when git came from usr/bin)
137
+ const prefixUsr = sanitizeWindowsGitPrefix(dirname(dirname(dirname(gitOnPath))))
138
+ if (prefixUsr) prefixes.push(prefixUsr)
139
+ }
140
+ for (const base of [
141
+ 'C:\\Program Files\\Git',
142
+ 'C:\\Program Files (x86)\\Git',
143
+ join(homedir(), 'scoop', 'apps', 'git'),
144
+ join(homedir(), 'AppData', 'Local', 'Programs', 'Git'),
145
+ ]) {
146
+ prefixes.push(base)
147
+ }
148
+ return prefixes
149
+ }
150
+
151
+ /** Clean a Windows Git prefix candidate (must look like a Git root). */
152
+ function sanitizeWindowsGitPrefix(p) {
153
+ try {
154
+ if (!p) return undefined
155
+ // A Git root contains usr/bin; keep only if it seems plausible.
156
+ if (isFile(join(p, 'usr', 'bin', 'ssh.exe')) || isFile(join(p, 'bin', 'ssh.exe'))) return p
157
+ } catch { /* ignore */ }
158
+ return undefined
159
+ }
160
+
161
+ /**
162
+ * Resolve an external tool path. See the module doc on {@link resolveBin}.
163
+ * @param {string} name - bare command name, e.g. 'ssh'.
164
+ * @param {string} envVar - override env var, e.g. 'DSH_SCM_SSH'.
165
+ * @returns {string} a usable path or the bare name.
166
+ */
167
+ function resolveBin(name, envVar) {
168
+ const override = process.env[envVar]
169
+ if (override) return override
170
+ // 1) PATH hit is the most portable answer.
171
+ const onPath = findOnPath(name)
172
+ if (onPath) return onPath
173
+ // 2) Windows: git bundles ssh / ssh-keygen.
174
+ if (IS_WIN) {
175
+ for (const prefix of windowsGitPrefixes()) {
176
+ const probe = probePrefix(join(prefix, 'usr', 'bin'), name)
177
+ ?? probePrefix(join(prefix, 'bin'), name)
178
+ if (probe) return probe
179
+ }
180
+ }
181
+ // 3) Fall back to the bare name so `run` reports the tool by name.
182
+ return name
183
+ }
184
+
185
+ /** ~/.ssh path. */
186
+ function sshDir() {
187
+ return join(homedir(), '.ssh')
188
+ }
189
+
190
+ /** Full path to the ed25519 private key. */
191
+ function privateKeyPath() {
192
+ return join(sshDir(), 'id_ed25519')
193
+ }
194
+
195
+ /** Full path to the ed25519 public key. */
196
+ function publicKeyPath() {
197
+ return join(sshDir(), 'id_ed25519.pub')
198
+ }
199
+
200
+ /** Full path to ssh config. */
201
+ function configPath() {
202
+ return join(sshDir(), 'config')
203
+ }
204
+
205
+ /** Whether the host is a local (loopback) request. */
206
+ function isLoopbackRequest(req) {
207
+ const host = req.headers.host
208
+ if (typeof host !== 'string') return false
209
+ try {
210
+ const hostname = new URL(`http://${host}`).hostname
211
+ if (hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1') return false
212
+ } catch {
213
+ return false
214
+ }
215
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
216
+ const origin = req.headers.origin
217
+ if (origin === undefined) return true
218
+ try {
219
+ return new URL(origin).host === host
220
+ } catch {
221
+ return false
222
+ }
223
+ }
224
+
225
+ /** Read a JSON body (POST). */
226
+ async function readBody(req, limit = 1 << 20) {
227
+ return await new Promise((resolve, reject) => {
228
+ let data = ''
229
+ let settled = false
230
+ req.on('data', (chunk) => {
231
+ data += chunk
232
+ if (data.length > limit) {
233
+ settled = true
234
+ reject(new Error('body too large'))
235
+ req.destroy()
236
+ return
237
+ }
238
+ })
239
+ req.on('end', () => { if (!settled) resolve(data) })
240
+ req.on('error', (err) => reject(err))
241
+ })
242
+ }
243
+
244
+ /** Run a command, capture combined stdout/stderr + exit code (never throws). */
245
+ function run(cmd, args, opts = {}) {
246
+ // Git for Windows bundles an MSYS ssh (usr\bin\ssh.exe) that fails to create
247
+ // its signal pipe when spawned from a detached/agent process, making
248
+ // `git push`/`git pull` over SSH fail with "couldn't create signal pipe,
249
+ // Win32 error 5". Force git to use the resolved (working, usually the system
250
+ // OpenSSH) ssh via GIT_SSH so remote operations succeed on every platform.
251
+ const env = { ...process.env, ...(opts.env ?? {}) }
252
+ if (cmd === GIT && !env.GIT_SSH && WORKING_SSH) {
253
+ env.GIT_SSH = WORKING_SSH
254
+ }
255
+ try {
256
+ const out = execFileSync(cmd, args, {
257
+ encoding: 'utf8',
258
+ stdio: ['ignore', 'pipe', 'pipe'],
259
+ timeout: opts.timeout ?? 30_000,
260
+ cwd: opts.cwd,
261
+ env,
262
+ shell: opts.shell ?? (IS_WIN && (opts.forceShell || false)),
263
+ })
264
+ return { ok: true, code: 0, stdout: out, stderr: '' }
265
+ } catch (error) {
266
+ const code = typeof error.status === 'number' ? error.status : 1
267
+ const stdout = typeof error.stdout === 'string' ? error.stdout : ''
268
+ const stderr = typeof error.stderr === 'string' ? error.stderr : String(error.message ?? '')
269
+ return { ok: code === 0, code, stdout, stderr }
270
+ }
271
+ }
272
+
273
+ /** Check a tool (git/gh) — version when installed. */
274
+ function toolStatus(bin, args) {
275
+ const r = run(bin, args)
276
+ if (!r.ok) return { installed: false }
277
+ const version = (r.stdout || r.stderr).trim().split(/\r?\n/)[0]
278
+ return { installed: true, version }
279
+ }
280
+
281
+ /** Friendly OS label mapped from process.platform (win32 -> Windows, etc.). */
282
+ function platformLabel() {
283
+ switch (process.platform) {
284
+ case 'win32': return 'Windows'
285
+ case 'darwin': return 'macOS'
286
+ case 'linux': return 'Linux'
287
+ case 'freebsd': return 'FreeBSD'
288
+ default: return process.platform
289
+ }
290
+ }
291
+
292
+ /** Environment check: git + gh presence (plus resolved ssh whereabouts). */
293
+ function checkEnv() {
294
+ return {
295
+ platform: process.platform,
296
+ platformLabel: platformLabel(),
297
+ isWin: IS_WIN,
298
+ os: process.env.OS ?? '',
299
+ home: homedir(),
300
+ git: toolStatus(GIT, ['--version']),
301
+ gh: toolStatus(GH, ['--version']),
302
+ ssh: {
303
+ installed: SSH !== 'ssh' && SSH !== 'ssh.exe',
304
+ path: SSH,
305
+ sshKeygen: SSH_KEYGEN,
306
+ },
307
+ }
308
+ }
309
+
310
+ /**
311
+ * Configuration for the active codeforge provider (github or gitee).
312
+ * `port` 443 keeps SSH usable on many domestic networks that block port 22.
313
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
314
+ */
315
+ function providerCfg(provider) {
316
+ const name = provider === 'gitee' ? 'gitee' : 'github'
317
+ return {
318
+ name, // 'github' | 'gitee'
319
+ host: name === 'gitee' ? 'gitee.com' : 'github.com', // Host key in ~/.ssh/config
320
+ hostname: name === 'gitee' ? 'gitee.com' : 'ssh.github.com', // 443 SSH endpoint
321
+ port: 443,
322
+ gitUser: 'git',
323
+ testAddr: name === 'gitee' ? 'git@gitee.com' : 'git@github.com',
324
+ label: name === 'gitee' ? 'Gitee' : 'GitHub',
325
+ }
326
+ }
327
+
328
+ /** SSH key & config & gh auth summary. */
329
+ function checkSsh() {
330
+ const hasKey = existsSync(privateKeyPath())
331
+ const hasPub = existsSync(publicKeyPath())
332
+ const hasConfig = existsSync(configPath())
333
+ let pubContent = ''
334
+ if (hasPub) {
335
+ try { pubContent = readFileSync(publicKeyPath(), 'utf8').trim() } catch {}
336
+ }
337
+ let configContent = ''
338
+ if (hasConfig) {
339
+ try { configContent = readFileSync(configPath(), 'utf8') } catch {}
340
+ }
341
+ // gh auth status -> which account.
342
+ const ghAuth = run(GH, ['auth', 'status'], { timeout: 20_000 })
343
+ const ghLoggedIn = ghAuth.ok && /Logged in to github\.com/i.test(ghAuth.stdout + ghAuth.stderr)
344
+ const ghAccount = (() => {
345
+ const blob = ghAuth.stdout + ghAuth.stderr
346
+ const m = /account\s+(\S+)/i.exec(blob)
347
+ return m ? m[1] : undefined
348
+ })()
349
+
350
+ return {
351
+ hasKey,
352
+ hasPub,
353
+ hasConfig,
354
+ pubContent,
355
+ configContent,
356
+ sshDir: sshDir(),
357
+ ghLoggedIn,
358
+ ghAccount,
359
+ // Per-provider SSH config presence (used by the panel's provider selector).
360
+ sshGitHubConfigured: /\bHost\s+github\.com\b/.test(configContent),
361
+ sshGiteeConfigured: /\bHost\s+gitee\.com\b/.test(configContent),
362
+ }
363
+ }
364
+
365
+ /** Generate ed25519 key with no passphrase (non-interactive). */
366
+ function generateKey() {
367
+ if (existsSync(privateKeyPath())) {
368
+ return { ok: true, alreadyExists: true, path: privateKeyPath() }
369
+ }
370
+ try {
371
+ mkdirSync(sshDir(), { recursive: true })
372
+ const r = run(SSH_KEYGEN, ['-t', 'ed25519', '-N', '', '-f', privateKeyPath()], { timeout: 30_000 })
373
+ return {
374
+ ok: r.ok,
375
+ alreadyExists: false,
376
+ path: privateKeyPath(),
377
+ error: r.ok ? undefined : (r.stderr || r.stdout).trim(),
378
+ }
379
+ } catch (error) {
380
+ return { ok: false, alreadyExists: false, path: privateKeyPath(), error: String(error) }
381
+ }
382
+ }
383
+
384
+ /**
385
+ * Write the SSH config block (Host <host> on port 443) for github or gitee.
386
+ * Domestic 443 keeps SSH usable on networks that block port 22.
387
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
388
+ */
389
+ function writeSshConfig(provider) {
390
+ const cfg = providerCfg(provider)
391
+ try {
392
+ mkdirSync(sshDir(), { recursive: true })
393
+ let current = ''
394
+ if (existsSync(configPath())) {
395
+ current = readFileSync(configPath(), 'utf8')
396
+ }
397
+ if (new RegExp('\\bHost\\s+' + cfg.host + '\\b').test(current)) {
398
+ return { ok: true, alreadyConfigured: true, path: configPath(), provider: cfg.name, host: cfg.host }
399
+ }
400
+ const block = [
401
+ '',
402
+ 'Host ' + cfg.host,
403
+ ' Hostname ' + cfg.hostname,
404
+ ' Port ' + cfg.port,
405
+ ' User ' + cfg.gitUser,
406
+ ' IdentityFile ~/.ssh/id_ed25519',
407
+ '',
408
+ ].join('\n')
409
+ writeFileSync(configPath(), current + block, 'utf8')
410
+ if (!IS_WIN) {
411
+ try { chmodSync(configPath(), 0o600) } catch {}
412
+ }
413
+ return { ok: true, alreadyConfigured: false, path: configPath(), provider: cfg.name, host: cfg.host, block }
414
+ } catch (error) {
415
+ return { ok: false, error: String(error instanceof Error ? error.message : error) }
416
+ }
417
+ }
418
+
419
+ /**
420
+ * ssh -T git@<host> connectivity test for github or gitee
421
+ * (accept-new so a new host key is auto-trusted).
422
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
423
+ */
424
+ function sshTest(provider) {
425
+ const cfg = providerCfg(provider)
426
+ const r = run(SSH, ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=20', '-T', cfg.testAddr], { timeout: 45_000 })
427
+ const blob = (r.stdout + '\n' + r.stderr)
428
+ const authed = /Hi\s+(\S+?)[!\s]/.exec(blob)
429
+ return {
430
+ ok: authed !== null,
431
+ account: authed ? authed[1] : undefined,
432
+ connected: r.ok || authed !== null,
433
+ provider: cfg.name,
434
+ host: cfg.host,
435
+ detail: blob.trim(),
436
+ }
437
+ }
438
+
439
+ // ---------------------------------------------------------------------------
440
+ // Gitee (OpenAPI) support — ③ 代码管理 in Gitee mode uses the Gitee REST API
441
+ // (https://gitee.com/api/v5) with a personal access token the user enters in
442
+ // the panel. The token is stored only under ~/.dsh/storages (0600), never in
443
+ // the plugin directory. When no token is configured, GitHub-mode features
444
+ // simply report "需要配置 Gitee 令牌".
445
+ // ---------------------------------------------------------------------------
446
+
447
+ /** Persisted file holding the user-entered Gitee personal access token. */
448
+ function giteeTokenFile() {
449
+ return join(homedir(), '.dsh', 'storages', 'source-code-mgmt-gitee.json')
450
+ }
451
+
452
+ /** Read the stored Gitee token (trimmed, empty string when none). Never throws. */
453
+ function readGiteeToken() {
454
+ try {
455
+ const file = giteeTokenFile()
456
+ if (!existsSync(file)) return ''
457
+ const data = JSON.parse(readFileSync(file, 'utf8'))
458
+ return typeof data === 'object' && typeof data.token === 'string' ? data.token.trim() : ''
459
+ } catch {
460
+ return ''
461
+ }
462
+ }
463
+
464
+ /** Persist (or clear, when empty) the Gitee token. Best-effort, never throws. */
465
+ function saveGiteeToken(token) {
466
+ const value = String(token || '').trim()
467
+ try {
468
+ const file = giteeTokenFile()
469
+ mkdirSync(dirname(file), { recursive: true })
470
+ writeFileSync(file, JSON.stringify({ token: value }, null, 2), 'utf8')
471
+ if (!IS_WIN) {
472
+ try { chmodSync(file, 0o600) } catch {}
473
+ }
474
+ } catch { /* ignore persistence errors */ }
475
+ return value
476
+ }
477
+
478
+ /**
479
+ * Call the Gitee OpenAPI v5.
480
+ * @param {string} method - 'GET' | 'POST' | 'PATCH' | 'DELETE' | ...
481
+ * @param {string} path - API path (no leading slash), e.g. 'user', 'user/repos'.
482
+ * @param {object} [body] - JSON body for POST/PATCH (mutates nothing).
483
+ * @param {string} [token] - Gitee personal access token (uses stored one when omitted).
484
+ * @returns {{ ok: boolean, status?: number, data?: any, error?: string }}
485
+ */
486
+ function giteeApi(method, path, body, token) {
487
+ const tok = token || readGiteeToken()
488
+ if (!tok) return { ok: false, error: '尚未配置 Gitee 私人令牌(请打开 ③ 代码管理输入令牌)' }
489
+ const args = [
490
+ '-sS', '-L',
491
+ '-X', String(method || 'GET').toUpperCase(),
492
+ '-H', 'Authorization: token ' + tok,
493
+ '-H', 'Content-Type: application/json; charset=utf-8',
494
+ '-H', 'User-Agent: source-code-mgmt-dsh',
495
+ '--connect-timeout', '15',
496
+ '--max-time', '60',
497
+ ]
498
+ if (body !== undefined && body !== null) args.push('-d', JSON.stringify(body))
499
+ args.push('https://gitee.com/api/v5/' + String(path).replace(/^\/+/, ''))
500
+ const r = run(CURL, args, { timeout: 70_000 })
501
+ if (!r.ok) {
502
+ return { ok: false, error: (r.stderr || r.stdout || '').trim() || 'Gitee API 请求失败(需要 curl)' }
503
+ }
504
+ let data
505
+ try { data = JSON.parse(r.stdout || '{}') } catch { data = null }
506
+ return { ok: true, status: r.code, data }
507
+ }
508
+
509
+ /** The Gitee account login (owner) for the configured token, or undefined. */
510
+ function giteeOwner() {
511
+ const api = giteeApi('GET', 'user')
512
+ if (!api.ok || !api.data) return undefined
513
+ return typeof api.data.login === 'string' && api.data.login !== '' ? api.data.login : undefined
514
+ }
515
+
516
+ /** Whether the Gitee API returned an "auth required / repo not found" style error. */
517
+ function giteeApiFailed(data) {
518
+ return !!(data && typeof data === 'object' && data.message && /(auth|not found|forbidden|token)/i.test(String(data.message)))
519
+ }
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // Recursively find working-tree files >100MB (skips .git and node_modules).
523
+ // ---------------------------------------------------------------------------
524
+ /** Recursively find working-tree files >100MB (skips .git and node_modules). */
525
+ function findLargeFiles(dir) {
526
+ const large = []
527
+ const scan = (base, rel) => {
528
+ let entries
529
+ try { entries = readdirSync(base) } catch { return }
530
+ for (const entry of entries) {
531
+ if (entry === '.git' || entry === 'node_modules') continue
532
+ const full = join(base, entry)
533
+ const childRel = rel === '' ? entry : rel + '/' + entry
534
+ let stat
535
+ try { stat = statSync(full) } catch { continue }
536
+ if (stat.isDirectory()) {
537
+ scan(full, childRel)
538
+ } else if (stat.size > GH_FILE_LIMIT) {
539
+ large.push({ path: childRel, bytes: stat.size })
540
+ }
541
+ }
542
+ }
543
+ scan(dir, '')
544
+ return large
545
+ }
546
+
547
+ /**
548
+ * Merge a list of >100MB files into top-level ignore entries.
549
+ *
550
+ * Rule (per user decision):
551
+ * - A large file living inside some first-level sub-directory
552
+ * (relative path contains '/') is treated as part of a unified folder,
553
+ * so the WHOLE first-level directory is ignored (e.g.
554
+ * `dsh-desktop/binary/dsh-desktop.exe` -> ignore `dsh-desktop/`).
555
+ * - A large file sitting directly at the repo root (no '/') is ignored as
556
+ * a single file (e.g. `dsh-desktop.zip`).
557
+ *
558
+ * Returns an array of unique entries:
559
+ * { path, bytes, kind: 'dir'|'file', source }
560
+ * `bytes` is summed across every large file folded into the same entry.
561
+ */
562
+ function groupIgnoreEntries(largeFiles) {
563
+ const entries = new Map()
564
+ for (const f of largeFiles) {
565
+ const idx = f.path.indexOf('/')
566
+ if (idx === -1) {
567
+ // Root-level standalone large file -> ignore the single file.
568
+ const cur = entries.get(f.path)
569
+ entries.set(f.path, {
570
+ path: f.path,
571
+ bytes: f.bytes + (cur ? cur.bytes : 0),
572
+ kind: 'file',
573
+ source: f.path,
574
+ })
575
+ } else {
576
+ // Inside a first-level sub-directory -> ignore the whole top directory.
577
+ const top = f.path.slice(0, idx)
578
+ const cur = entries.get(top)
579
+ entries.set(top, {
580
+ path: top,
581
+ bytes: f.bytes + (cur ? cur.bytes : 0),
582
+ kind: 'dir',
583
+ source: f.path,
584
+ })
585
+ }
586
+ }
587
+ return [...entries.values()]
588
+ }
589
+
590
+ /** Whether a repo-relative path is already covered by .gitignore rules
591
+ * (git check-ignore exits 0 when ignored). */
592
+ function isIgnored(dir, rel) {
593
+ if (!rel) return false
594
+ const r = run(GIT, ['-C', dir, 'check-ignore', '-q', rel])
595
+ return r.ok
596
+ }
597
+
598
+ /**
599
+ * Plan the >100MB ignore strategy for a folder:
600
+ * merges the raw large-file list into top-level entries and flags each entry
601
+ * that is already covered by the existing .gitignore, so neither the status
602
+ * panel nor the push flow re-adds duplicate/conflicting rules.
603
+ */
604
+ function ignorePlan(dir) {
605
+ const large = findLargeFiles(dir)
606
+ const entries = groupIgnoreEntries(large).map((e) => ({
607
+ ...e,
608
+ ignored: isIgnored(dir, e.source),
609
+ }))
610
+ return { large, entries }
611
+ }
612
+
613
+ /**
614
+ * Repo status for a selected folder: whether it is a git repo, remote,
615
+ * branch, dirty count, tracked-over-100MB files, and new >100MB candidates.
616
+ */
617
+ function repoStatus(dir, provider = 'github') {
618
+ const base = { ok: false, dir, isGitRepo: false, provider }
619
+ if (!dir || !existsSync(dir)) return { ...base, error: 'folder does not exist' }
620
+ if (!existsSync(join(dir, '.git'))) {
621
+ // 不是 git 仓库:仍返回文件夹名和同名仓库检测,便于「新建仓库」直接使用。
622
+ const defaultRepoName = baseName(dir)
623
+ const existence = repoExists(defaultRepoName, provider)
624
+ return {
625
+ ...base,
626
+ defaultRepoName,
627
+ repoExists: existence.checked ? existence.exists : undefined,
628
+ repoOwner: existence.owner,
629
+ visibility: existence.checked && existence.exists ? repoVisibility(defaultRepoName, provider) : undefined,
630
+ provider,
631
+ error: 'not a git repository(尚未 git init,可用「新建仓库并推送」初始化为 git 仓库并上传)',
632
+ }
633
+ }
634
+
635
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
636
+ const status = run(GIT, ['-C', dir, 'status', '--porcelain'])
637
+ const statusLines = status.ok
638
+ ? status.stdout.split(/\r?\n/).filter((l) => l.trim() !== '')
639
+ : []
640
+ const dirtyFiles = statusLines.length
641
+
642
+ // Parse porcelain lines (two leading status letters then a path) into a
643
+ // readable list, e.g. { "M " -> modified, "??" -> untracked, "A " -> added,
644
+ // "D " -> deleted, "R " -> renamed }. Only the file/folder NAME is shown in
645
+ // the panel, never the diff content.
646
+ const changedFiles = statusLines.map((line) => {
647
+ const code = line.slice(0, 2)
648
+ let path = line.slice(3)
649
+ // Rename/copy lines read "R old -> new": keep the destination name.
650
+ if (/^(R|C)/.test(code)) {
651
+ const arrow = path.indexOf(' -> ')
652
+ if (arrow !== -1) path = path.slice(arrow + 4)
653
+ }
654
+ // Git may quote paths with special characters; strip surrounding quotes.
655
+ if (path.length >= 2 && path[0] === '"' && path[path.length - 1] === '"') {
656
+ path = path.slice(1, -1)
657
+ }
658
+ const type = /^\?\?/.test(code) ? 'untracked'
659
+ : /^A|^AM/.test(code) ? 'added'
660
+ : /^D|^AD/.test(code) ? 'deleted'
661
+ : /^R/.test(code) ? 'renamed'
662
+ : 'modified'
663
+ return { type, path }
664
+ })
665
+
666
+ // Tracked files that exceed 100MB (GitHub would reject a push of these).
667
+ let trackedOverLimit = []
668
+ const lsr = run(GIT, ['-C', dir, 'ls-files', '-z'])
669
+ if (lsr.ok) {
670
+ const tracked = lsr.stdout.split('\0').filter(Boolean)
671
+ for (const p of tracked) {
672
+ const full = join(dir, p)
673
+ try {
674
+ const s = statSync(full)
675
+ if (s.size > GH_FILE_LIMIT) trackedOverLimit.push({ path: p, bytes: s.size })
676
+ } catch {}
677
+ }
678
+ }
679
+ // Working-tree files >100MB, merged into top-level ignore entries. Only
680
+ // entries NOT yet covered by .gitignore are surfaced to the panel, so help
681
+ // is offered precisely for the files that would actually be rejected on push.
682
+ const plan = ignorePlan(dir)
683
+ const ignoredLarge = plan.entries.filter(
684
+ (e) => !e.ignored && !trackedOverLimit.some((t) => t.path === e.source)
685
+ )
686
+
687
+ // Default repo name = folder basename; and whether the same-name repo exists.
688
+ const defaultRepoName = baseName(dir)
689
+ const existence = repoExists(defaultRepoName, provider)
690
+
691
+ const branchName = branch.ok ? branch.stdout.trim() : undefined
692
+ const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
693
+
694
+ // ahead (unpushed local commits) / behind (remote commits not yet pulled),
695
+ // measured against origin/<branch>. A lightweight fetch keeps this fresh;
696
+ // a failed fetch is tolerated (falls back to the last known tracking ref).
697
+ let ahead = 0
698
+ let behind = 0
699
+ const upstream = hasRemote && branchName ? 'origin/' + branchName : undefined
700
+ if (upstream) {
701
+ if (run(GIT, ['-C', dir, 'fetch', 'origin', branchName, '--quiet'], { timeout: 20_000 }).ok) {
702
+ const rb = run(GIT, ['-C', dir, 'rev-list', '--left-right', '--count', upstream + '...HEAD'])
703
+ if (rb.ok) {
704
+ const m = rb.stdout.trim().split(/\s+/)
705
+ behind = parseInt(m[0], 10) || 0 // left side = origin-only commits
706
+ ahead = parseInt(m[1], 10) || 0 // right side = HEAD-only commits
707
+ }
708
+ }
709
+ }
710
+
711
+ // Short commit lists for the "同步" detail view: commits we have locally but
712
+ // not on origin (ahead), and commits origin has that we don't yet (behind).
713
+ const fmtLogLine = (l) => {
714
+ // porcelain <hash> <subject>
715
+ const sp = l.indexOf(' ')
716
+ return sp === -1 ? l : l.slice(0, sp) + ' ' + l.slice(sp + 1)
717
+ }
718
+ const logRange = (range) => {
719
+ if (!upstream) return []
720
+ const r = run(GIT, ['-C', dir, 'log', '--oneline', '-20', range])
721
+ return r.ok ? r.stdout.split(/\r?\n/).filter((l) => l.trim() !== '').map(fmtLogLine) : []
722
+ }
723
+ const aheadCommits = ahead > 0 ? logRange(upstream + '..HEAD') : []
724
+ const behindCommits = behind > 0 ? logRange('HEAD..' + upstream) : []
725
+
726
+ return {
727
+ ok: true,
728
+ isGitRepo: true,
729
+ dir,
730
+ defaultRepoName,
731
+ repoExists: existence.checked ? existence.exists : undefined,
732
+ repoOwner: existence.owner,
733
+ provider,
734
+ branch: branchName,
735
+ hasRemote,
736
+ remoteUrl: (() => {
737
+ const r = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin'])
738
+ return r.ok ? r.stdout.trim() : undefined
739
+ })(),
740
+ dirty: dirtyFiles > 0,
741
+ dirtyCount: dirtyFiles,
742
+ // Changed/added/deleted/renamed file names (with their status), empty when clean.
743
+ changedFiles,
744
+ ahead,
745
+ behind,
746
+ // Commit lists for the "同步" detail view.
747
+ aheadCommits,
748
+ behindCommits,
749
+ // Actual remote visibility (private/public) when the repo exists and the
750
+ // account can view it; undefined when unknown (not token/logged in or repo absent).
751
+ visibility: existence.checked && existence.exists ? repoVisibility(defaultRepoName, provider) : undefined,
752
+ trackedOverLimit,
753
+ ignoredLarge: ignoredLarge.slice(0, 200),
754
+ }
755
+ }
756
+
757
+ /**
758
+ * The push flow:
759
+ * 1. ensure it's a git repo (init if needed)
760
+ * 2. append explicit .gitignore entries for every >100MB file so they are not
761
+ * staged, then report those as "skipped + reason"
762
+ * 3. stage everything remaining, commit, push to origin
763
+ */
764
+ function pushFlow(dir) {
765
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
766
+ if (!existsSync(join(dir, '.git'))) {
767
+ const init = run(GIT, ['-C', dir, 'init'])
768
+ if (!init.ok) return { ok: false, error: 'git init failed' }
769
+ }
770
+
771
+ // Plan which >100MB entries to ignore (merged top-level dirs or single
772
+ // files). Entries already covered by .gitignore are left alone and reported
773
+ // as already-ignored; the rest are appended once, each with a reason.
774
+ const plan = ignorePlan(dir)
775
+ const skipped = plan.entries.map((e) => {
776
+ const reason = e.ignored
777
+ ? `超过 GitHub 100MB 单文件限制(${fmtMB(e.bytes)}),已被 .gitignore 的 "${e.path}" 排除`
778
+ : e.kind === 'dir'
779
+ ? `超过 GitHub 100MB 单文件限制(${fmtMB(e.bytes)}),${e.source} 所在的一级目录「${e.path}」整体忽略(该文件夹为一整体)`
780
+ : `超过 GitHub 100MB 单文件限制(${fmtMB(e.bytes)}),已忽略单个文件「${e.path}」`
781
+ return { path: e.kind === 'dir' ? e.path + '/' : e.path, reason }
782
+ })
783
+
784
+ // Append explicit ignore globs only for entries NOT already covered.
785
+ const gitignorePath = join(dir, '.gitignore')
786
+ const lines = []
787
+ if (existsSync(gitignorePath)) {
788
+ try { lines.push(...readFileSync(gitignorePath, 'utf8').split(/\r?\n/)) } catch {}
789
+ }
790
+ let changedIgnore = false
791
+ for (const e of plan.entries) {
792
+ if (e.ignored) continue
793
+ const pat = '/' + e.path + (e.kind === 'dir' ? '/' : '')
794
+ if (!lines.includes(pat)) { lines.push(pat); changedIgnore = true }
795
+ }
796
+ if (changedIgnore) {
797
+ try { writeFileSync(gitignorePath, lines.join('\n') + '\n', 'utf8') } catch {}
798
+ }
799
+
800
+ // Stage everything remaining.
801
+ run(GIT, ['-C', dir, 'add', '-A'])
802
+
803
+ // What got staged? Report for transparency.
804
+ const staged = run(GIT, ['-C', dir, 'diff', '--cached', '--name-only'])
805
+ const stagedFiles = staged.ok ? staged.stdout.split(/\r?\n/).filter(Boolean) : []
806
+
807
+ let committed = false
808
+ let commitHash
809
+ const hasChanges = run(GIT, ['-C', dir, 'diff', '--cached', '--quiet']).code !== 0
810
+ if (hasChanges) {
811
+ const ident = run(GIT, ['-C', dir, 'config', 'user.email'])
812
+ if (!ident.ok) {
813
+ run(GIT, ['-C', dir, 'config', 'user.name', 'DSH User'])
814
+ run(GIT, ['-C', dir, 'config', 'user.email', 'dsh@localhost'])
815
+ }
816
+ const commit = run(GIT, ['-C', dir, 'commit', '-m', 'chore: update workspace via DSH source-code-mgmt'])
817
+ if (commit.ok) {
818
+ committed = true
819
+ const rev = run(GIT, ['-C', dir, 'rev-parse', '--short', 'HEAD'])
820
+ commitHash = rev.ok ? rev.stdout.trim() : undefined
821
+ }
822
+ }
823
+
824
+ const curBranch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
825
+ const branchName = curBranch.ok ? curBranch.stdout.trim() : 'main'
826
+ const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
827
+
828
+ if (!hasRemote) {
829
+ return {
830
+ ok: false, needsRemote: true, branch: branchName,
831
+ committed, commitHash, skipped,
832
+ error: '尚未配置远程仓库 origin,请使用「新建仓库」创建远程仓库。',
833
+ }
834
+ }
835
+
836
+ const pushArgs = ['-C', dir, 'push', '-u', 'origin', branchName]
837
+ const push = run(GIT, pushArgs, { timeout: 120_000 })
838
+ const pushed = push.ok
839
+ return {
840
+ ok: pushed,
841
+ needsRemote: false,
842
+ committed,
843
+ commitHash,
844
+ pushed,
845
+ branch: branchName,
846
+ skipped,
847
+ pushError: pushed ? undefined : (push.stderr || push.stdout).trim(),
848
+ }
849
+ }
850
+
851
+ /**
852
+ * Pull the latest changes from the remote `origin` into the current branch.
853
+ * Uses `git pull --ff-only` so it never creates a surprise merge commit;
854
+ * returns structured feedback: already up to date, pulled new commits, or a
855
+ * conflict / error.
856
+ * @param {string} dir - local folder.
857
+ */
858
+ function pullFlow(dir) {
859
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
860
+ if (!existsSync(join(dir, '.git'))) {
861
+ return { ok: false, error: 'not a git repository(尚未 git init,无法拉取)' }
862
+ }
863
+ const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
864
+ if (!hasRemote) {
865
+ return { ok: false, error: '尚未配置远程仓库 origin,无法拉取。' }
866
+ }
867
+
868
+ // Was already up to date? First cheap check via ls-remote comparison.
869
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
870
+ const branchName = branch.ok ? branch.stdout.trim() : undefined
871
+
872
+ const pull = run(GIT, ['-C', dir, 'pull', '--ff-only', 'origin', branchName], {
873
+ timeout: 120_000,
874
+ })
875
+ const blob = (pull.stdout + '\n' + pull.stderr).trim()
876
+
877
+ if (pull.ok) {
878
+ const upToDate = /already up[- ]to[- ]date/i.test(blob)
879
+ return {
880
+ ok: true,
881
+ upToDate,
882
+ pulled: !upToDate,
883
+ branch: branchName,
884
+ detail: blob,
885
+ }
886
+ }
887
+
888
+ // Fast-forward only failed — usually local commits ahead (need merge) or a file conflict.
889
+ const conflict = /(conflict|CONFLICT|fix conflicts|commit your changes)/i.test(blob)
890
+ return {
891
+ ok: false,
892
+ conflict,
893
+ branch: branchName,
894
+ error: conflict
895
+ ? '拉取存在冲突:本地有未合并改动或与远程冲突,请手动 git pull 处理合并。'
896
+ : (pull.stderr || pull.stdout).trim() || 'git pull 失败',
897
+ detail: blob,
898
+ }
899
+ }
900
+
901
+ /**
902
+ * Merge push: rebase local commits onto the latest remote, then push.
903
+ * Used when BOTH local changes and remote updates exist — combines them into
904
+ * one clean history (no surprise merge commit). Returns structured feedback.
905
+ * @param {string} dir - local folder.
906
+ */
907
+ function mergePushFlow(dir) {
908
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
909
+ if (!existsSync(join(dir, '.git'))) {
910
+ return { ok: false, error: 'not a git repository(尚未 git init)' }
911
+ }
912
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
913
+ const branchName = branch.ok ? branch.stdout.trim() : 'main'
914
+
915
+ const rebase = run(GIT, ['-C', dir, 'pull', '--rebase', 'origin', branchName], {
916
+ timeout: 180_000,
917
+ })
918
+ if (!rebase.ok) {
919
+ const blob = (rebase.stdout + '\n' + rebase.stderr).trim()
920
+ const conflict = /(conflict|CONFLICT|fix conflicts)/i.test(blob)
921
+ return {
922
+ ok: false, conflict, branch: branchName,
923
+ error: conflict
924
+ ? '拉取并推送失败:合并存在冲突,请手动解决后重试。'
925
+ : (rebase.stderr || rebase.stdout).trim() || 'pull --rebase 失败',
926
+ detail: blob,
927
+ }
928
+ }
929
+
930
+ const push = run(GIT, ['-C', dir, 'push', '-u', 'origin', branchName], {
931
+ timeout: 120_000,
932
+ })
933
+ if (!push.ok) {
934
+ return {
935
+ ok: false, branch: branchName, detail: (push.stdout + '\n' + push.stderr).trim(),
936
+ error: (push.stderr || push.stdout).trim() || 'push 失败',
937
+ }
938
+ }
939
+ return { ok: true, branch: branchName, rebased: true, pushed: true }
940
+ }
941
+
942
+ /**
943
+ * Force push: git push --force origin <branch>. Overwrites the remote history
944
+ * with local state. Only shown when the user explicitly wants to discard what
945
+ * the remote has (e.g. the remote updates are not what they need).
946
+ * @param {string} dir - local folder.
947
+ */
948
+ function forcePushFlow(dir) {
949
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
950
+ if (!existsSync(join(dir, '.git'))) {
951
+ return { ok: false, error: 'not a git repository(尚未 git init)' }
952
+ }
953
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
954
+ const branchName = branch.ok ? branch.stdout.trim() : 'main'
955
+ const push = run(GIT, ['-C', dir, 'push', '--force', '-u', 'origin', branchName], {
956
+ timeout: 120_000,
957
+ })
958
+ const blob = (push.stdout + '\n' + push.stderr).trim()
959
+ return {
960
+ ok: push.ok, branch: branchName, forced: push.ok,
961
+ error: push.ok ? undefined : (push.stderr || push.stdout).trim() || 'push --force 失败',
962
+ detail: blob,
963
+ }
964
+ }
965
+
966
+ /**
967
+ * Force pull: git pull --force origin <branch>. Pulls in the remote updates
968
+ * while keeping local changes when they can be merged; local-only commits are
969
+ * rebased/merged in. Shown when the remote updates are what the user needs
970
+ * and the local changes are not what they want to keep.
971
+ * @param {string} dir - local folder.
972
+ */
973
+ function forcePullFlow(dir) {
974
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
975
+ if (!existsSync(join(dir, '.git'))) {
976
+ return { ok: false, error: 'not a git repository(尚未 git init)' }
977
+ }
978
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
979
+ const branchName = branch.ok ? branch.stdout.trim() : 'main'
980
+ const pull = run(GIT, ['-C', dir, 'pull', '--force', 'origin', branchName], {
981
+ timeout: 180_000,
982
+ })
983
+ const blob = (pull.stdout + '\n' + pull.stderr).trim()
984
+ const conflict = /(conflict|CONFLICT|fix conflicts)/i.test(blob)
985
+ return {
986
+ ok: pull.ok, conflict, branch: branchName,
987
+ error: !pull.ok
988
+ ? (conflict
989
+ ? '强制拉取存在冲突,请手动处理。'
990
+ : (pull.stderr || pull.stdout).trim() || 'git pull --force 失败')
991
+ : undefined,
992
+ detail: blob,
993
+ }
994
+ }
995
+
996
+ /**
997
+ * Create a new repo on GitHub or Gitee and push everything.
998
+ *
999
+ * GitHub mode: `gh repo create <name> [--private|--public] --source=. --push`.
1000
+ * Gitee mode: create the repo through the Gitee OpenAPI, then add an SSH
1001
+ * remote (`git@gitee.com:<owner>/<name>.git`, per the user's choice) and push.
1002
+ *
1003
+ * @param {string} dir - local folder.
1004
+ * @param {string} name - repo name.
1005
+ * @param {'private'|'public'} [visibility] - default 'private'.
1006
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
1007
+ */
1008
+ function createRepoFlow(dir, name, visibility, provider = 'github') {
1009
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
1010
+ const repoName = String(name || '').trim()
1011
+ if (!repoName) return { ok: false, error: '缺少仓库名称' }
1012
+ if (!/^[A-Za-z0-9._-]+$/.test(repoName) || repoName === '.') {
1013
+ return { ok: false, error: '仓库名称包含非法字符(仅允许字母、数字、点、下划线、横线)' }
1014
+ }
1015
+ if (!existsSync(join(dir, '.git'))) {
1016
+ const init = run(GIT, ['-C', dir, 'init'])
1017
+ if (!init.ok) return { ok: false, error: 'git init failed' }
1018
+ }
1019
+ run(GIT, ['-C', dir, 'checkout', '-b', 'main'])
1020
+ const ident = run(GIT, ['-C', dir, 'config', 'user.email'])
1021
+ if (!ident.ok) {
1022
+ run(GIT, ['-C', dir, 'config', 'user.name', 'DSH User'])
1023
+ run(GIT, ['-C', dir, 'config', 'user.email', 'dsh@localhost'])
1024
+ }
1025
+ // gh repo create --push requires at least one local commit. On a brand-new
1026
+ // (never-committed) folder, stage everything and create an initial commit so
1027
+ // the push has something to upload — otherwise gh fails with
1028
+ // "`--push` enabled but no commits found". Same requirement applies to a
1029
+ // plain `git push` to a fresh Gitee remote.
1030
+ const head = run(GIT, ['-C', dir, 'rev-parse', 'HEAD'])
1031
+ if (!head.ok) {
1032
+ const stagedCount = run(GIT, ['-C', dir, 'add', '-A'])
1033
+ if (!stagedCount.ok) {
1034
+ return { ok: false, error: `git add 失败:${(stagedCount.stderr || '')}` }
1035
+ }
1036
+ const commit = run(GIT, ['-C', dir, 'commit', '-m', 'init: initial commit from DSH source-code-mgmt'])
1037
+ if (!commit.ok) {
1038
+ // Nothing to commit (fully empty directory / everything already ignored).
1039
+ // That is fine — gh can still create the (empty) remote repo.
1040
+ // Fall through to create.
1041
+ }
1042
+ }
1043
+ const vis = visibility === 'public' ? 'public' : 'private'
1044
+
1045
+ if (provider === 'gitee') {
1046
+ // 1) Create the empty remote repo via the Gitee OpenAPI.
1047
+ const owner = giteeOwner()
1048
+ const token = readGiteeToken()
1049
+ if (!owner || !token) return { ok: false, error: '需要先配置 Gitee 私人令牌' }
1050
+ const api = giteeApi('POST', 'user/repos', { name: repoName, private: vis === 'private' }, token)
1051
+ if (!api.ok || giteeApiFailed(api.data)) {
1052
+ return { ok: false, error: (api.error || (api.data && api.data.message) || 'Gitee 创建仓库失败'), detail: api.data }
1053
+ }
1054
+ // 2) Point origin at the SSH remote and push (SSH per user's choice).
1055
+ const sshUrl = 'git@gitee.com:' + owner + '/' + repoName + '.git'
1056
+ run(GIT, ['-C', dir, 'remote', 'remove', 'origin'])
1057
+ run(GIT, ['-C', dir, 'remote', 'add', 'origin', sshUrl])
1058
+ const push = run(GIT, ['-C', dir, 'push', '-u', 'origin', 'main'], { timeout: 180_000, env: { GIT_SSH: WORKING_SSH } })
1059
+ return {
1060
+ ok: push.ok,
1061
+ repoName,
1062
+ visibility: vis,
1063
+ provider: 'gitee',
1064
+ owner,
1065
+ remote: sshUrl,
1066
+ url: 'https://gitee.com/' + owner + '/' + repoName,
1067
+ pushError: push.ok ? undefined : (push.stderr || push.stdout).trim(),
1068
+ detail: (push.stdout + '\n' + push.stderr).trim(),
1069
+ }
1070
+ }
1071
+
1072
+ const visFlag = vis === 'public' ? '--public' : '--private'
1073
+ const r = run(GH, ['repo', 'create', repoName, visFlag, '--source=' + dir, '--push'], {
1074
+ timeout: 180_000, cwd: dir, env: { GIT_SSH: WORKING_SSH },
1075
+ })
1076
+ const blob = (r.stdout + '\n' + r.stderr)
1077
+ const urlMatch = /https:\/\/github\.com\/[^\s"]+/.exec(blob)
1078
+ return {
1079
+ ok: r.ok,
1080
+ repoName,
1081
+ visibility: vis,
1082
+ provider: 'github',
1083
+ url: urlMatch ? urlMatch[0] : undefined,
1084
+ detail: blob.trim(),
1085
+ }
1086
+ }
1087
+
1088
+ /** Human-readable MB. */
1089
+ function fmtMB(bytes) {
1090
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
1091
+ }
1092
+
1093
+ /**
1094
+ * Force-align the local folder to the remote branch: fetch origin then
1095
+ * `git reset --hard origin/<branch>`. Any local-only commits / working-tree
1096
+ * changes are discarded so local becomes an exact copy of the remote branch.
1097
+ * Only meaningful when the folder is a git repo with a configured origin.
1098
+ * @param {string} dir - local folder.
1099
+ */
1100
+ function alignFlow(dir) {
1101
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
1102
+ if (!existsSync(join(dir, '.git'))) {
1103
+ return { ok: false, error: 'not a git repository(尚未 git init)' }
1104
+ }
1105
+ const branch = run(GIT, ['-C', dir, 'rev-parse', '--abbrev-ref', 'HEAD'])
1106
+ const branchName = branch.ok ? branch.stdout.trim() : undefined
1107
+ if (!branchName) return { ok: false, error: '无法识别当前分支' }
1108
+ const hasRemote = run(GIT, ['-C', dir, 'remote', 'get-url', 'origin']).ok
1109
+ if (!hasRemote) return { ok: false, error: '尚未配置远程仓库 origin,无法对齐。' }
1110
+
1111
+ const fetch = run(GIT, ['-C', dir, 'fetch', 'origin'], { timeout: 120_000 })
1112
+ if (!fetch.ok) {
1113
+ return { ok: false, error: (fetch.stderr || fetch.stdout).trim() || 'git fetch 失败', detail: (fetch.stdout + '\n' + fetch.stderr).trim() }
1114
+ }
1115
+ const reset = run(GIT, ['-C', dir, 'reset', '--hard', 'origin/' + branchName], { timeout: 60_000 })
1116
+ return {
1117
+ ok: reset.ok,
1118
+ branch: branchName,
1119
+ error: reset.ok ? undefined : (reset.stderr || reset.stdout).trim() || 'git reset --hard 失败',
1120
+ detail: (fetch.stdout + '\n' + reset.stdout).trim(),
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Initialize a git repository in the folder (git init) and set a default local
1126
+ * identity if none is configured. Does NOT commit, configure a remote, or
1127
+ * push — the user decides the next step (pull / push) themselves.
1128
+ * @param {string} dir - local folder.
1129
+ */
1130
+ function initGitFlow(dir) {
1131
+ if (!dir || !existsSync(dir)) return { ok: false, error: 'folder does not exist' }
1132
+ if (existsSync(join(dir, '.git'))) {
1133
+ return { ok: true, alreadyRepo: true, dir }
1134
+ }
1135
+ const init = run(GIT, ['-C', dir, 'init'])
1136
+ if (!init.ok) return { ok: false, error: (init.stderr || init.stdout).trim() || 'git init 失败' }
1137
+ const ident = run(GIT, ['-C', dir, 'config', 'user.email'])
1138
+ if (!ident.ok) {
1139
+ run(GIT, ['-C', dir, 'config', 'user.name', 'DSH User'])
1140
+ run(GIT, ['-C', dir, 'config', 'user.email', 'dsh@localhost'])
1141
+ }
1142
+ return { ok: true, alreadyRepo: false, dir, branch: 'main' }
1143
+ }
1144
+
1145
+ // ---------- route handlers ----------
1146
+
1147
+ function json(res, status, payload) {
1148
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
1149
+ res.end(JSON.stringify(payload))
1150
+ }
1151
+
1152
+ function forbidden(res) {
1153
+ json(res, 403, { ok: false, code: 'forbidden' })
1154
+ }
1155
+
1156
+ function methodNotAllowed(res, method) {
1157
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
1158
+ res.end(method + ' not allowed')
1159
+ }
1160
+
1161
+ /** Read `?dir=` from the query string; fall back to `defaultDir` (the active workspace). */
1162
+ function queryDir(req, defaultDir) {
1163
+ try {
1164
+ const url = new URL(req.url ?? '', 'http://localhost')
1165
+ return url.searchParams.get('dir') || defaultDir
1166
+ } catch {
1167
+ return defaultDir
1168
+ }
1169
+ }
1170
+
1171
+ /** Determine the default (current) workspace directory, preferring the first
1172
+ * entry from the durable workspace list (workspace.json). Falls back to the
1173
+ * process cwd when no workspace is known. */
1174
+ function resolveDefaultDir(fallback) {
1175
+ try {
1176
+ const ws = listWorkspaces()
1177
+ if (ws.length > 0) return ws[0]
1178
+ } catch { /* fall through */ }
1179
+ return fallback
1180
+ }
1181
+
1182
+ /** The GitHub login owner (e.g. "Zhucy123"), if gh is signed in. */
1183
+ function ghOwner() {
1184
+ const r = run(GH, ['api', 'user', '--jq', '.login'], { timeout: 20_000 })
1185
+ return r.ok && r.stdout.trim() !== '' ? r.stdout.trim() : undefined
1186
+ }
1187
+
1188
+ // ---------- plugin-owned custom directory list ----------
1189
+ // User-picked directories are persisted by the plugin itself (not written into
1190
+ // DSH's core workspace.json, which carries session linkage and must stay
1191
+ // untouched). listWorkspaces() merges them with the DSH registry.
1192
+
1193
+ /** Persisted file for the plugin's custom (user-picked) directories. */
1194
+ function customDirsFile() {
1195
+ return join(homedir(), '.dsh', 'storages', 'source-code-mgmt-dirs.json')
1196
+ }
1197
+
1198
+ /** Read the plugin's custom directory list (path strings); never throws. */
1199
+ function readCustomDirs() {
1200
+ try {
1201
+ const file = customDirsFile()
1202
+ if (!existsSync(file)) return []
1203
+ const data = JSON.parse(readFileSync(file, 'utf8'))
1204
+ return Array.isArray(data) ? data.filter((p) => typeof p === 'string' && p !== '') : []
1205
+ } catch {
1206
+ return []
1207
+ }
1208
+ }
1209
+
1210
+ /** Append a directory path to the custom list (dedup; keeps existing), never
1211
+ * propagates errors. Returns the updated list. */
1212
+ function saveCustomDir(dir) {
1213
+ const dirs = readCustomDirs()
1214
+ const norm = String(dir || '').trim()
1215
+ if (norm && !dirs.includes(norm)) dirs.push(norm)
1216
+ try {
1217
+ const file = customDirsFile()
1218
+ const parent = dirname(file)
1219
+ mkdirSync(parent, { recursive: true })
1220
+ writeFileSync(file, JSON.stringify(dirs, null, 2), 'utf8')
1221
+ } catch { /* ignore persistence errors */ }
1222
+ return dirs
1223
+ }
1224
+
1225
+ /** Remove a directory path from the custom list (only the dropdown record, the
1226
+ * real folder is untouched). Returns the updated list. */
1227
+ function removeCustomDir(dir) {
1228
+ const norm = String(dir || '').trim()
1229
+ const dirs = readCustomDirs().filter((p) => p !== norm)
1230
+ try {
1231
+ const file = customDirsFile()
1232
+ const parent = dirname(file)
1233
+ mkdirSync(parent, { recursive: true })
1234
+ writeFileSync(file, JSON.stringify(dirs, null, 2), 'utf8')
1235
+ } catch { /* ignore persistence errors */ }
1236
+ return dirs
1237
+ }
1238
+
1239
+ /**
1240
+ * List every workspace directory path the dropdown should offer: DSH's
1241
+ * registered workspaces (from ~/.dsh/storages/workspace.json, with the sessions
1242
+ * fallback) merged with the plugin's custom user-picked directories
1243
+ * (deduplicated, only existing ones).
1244
+ */
1245
+ function listWorkspaces() {
1246
+ const seen = new Set()
1247
+ const out = []
1248
+
1249
+ // Primary: workspace.json — tables.workspaces[].path (no ambiguity).
1250
+ try {
1251
+ const file = join(homedir(), '.dsh', 'storages', 'workspace.json')
1252
+ if (existsSync(file)) {
1253
+ const data = JSON.parse(readFileSync(file, 'utf8'))
1254
+ const tables = data && data.tables && data.tables.workspaces
1255
+ if (tables && typeof tables === 'object') {
1256
+ for (const w of Object.values(tables)) {
1257
+ const p = w && typeof w.path === 'string' ? w.path : null
1258
+ if (p && p !== '' && !seen.has(p)) { seen.add(p); out.push(p) }
1259
+ }
1260
+ }
1261
+ }
1262
+ } catch { /* fall through to sessions fallback */ }
1263
+
1264
+ // Fallback: derive from ~/.dsh/sessions/<--encoded-path--> directory names
1265
+ // (only when the primary file produced nothing).
1266
+ if (out.length === 0) {
1267
+ try {
1268
+ const sessionsRoot = join(homedir(), '.dsh', 'sessions')
1269
+ if (existsSync(sessionsRoot)) {
1270
+ for (const e of readdirSync(sessionsRoot, { withFileTypes: true })) {
1271
+ if (!(e.isDirectory() && e.name.startsWith('--') && e.name.endsWith('--'))) continue
1272
+ const p = decodeSessionDir(e.name)
1273
+ if (p !== null && !seen.has(p) && existsSync(p)) { seen.add(p); out.push(p) }
1274
+ }
1275
+ }
1276
+ } catch { /* fall through */ }
1277
+ }
1278
+
1279
+ // Plugin-owned custom directories (user-picked), merged & deduped.
1280
+ for (const p of readCustomDirs()) {
1281
+ if (existsSync(p) && !seen.has(p)) { seen.add(p); out.push(p) }
1282
+ }
1283
+
1284
+ return out
1285
+ }
1286
+
1287
+ /**
1288
+ * Open a native OS folder-picker dialog on the host and return the chosen path
1289
+ * (or undefined if cancelled). Uses a short STA PowerShell + FolderBrowserDialog
1290
+ * on Windows; other platforms fall back to the no-dialog (undefined) result.
1291
+ * @param {string} [initialDir] - directory the dialog opens at.
1292
+ */
1293
+ function pickDirDialog(initialDir) {
1294
+ if (!IS_WIN) return undefined
1295
+ const initial = String(initialDir || '').replace(/"/g, '""')
1296
+ const script =
1297
+ 'Add-Type -AssemblyName System.Windows.Forms;' +
1298
+ '$dlg = New-Object System.Windows.Forms.FolderBrowserDialog;' +
1299
+ '$dlg.Description = \'选择本地目录\';' +
1300
+ (initial ? `$dlg.SelectedPath = '${initial}';` : '') +
1301
+ 'if ($dlg.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { Write-Output $dlg.SelectedPath }'
1302
+ const r = run('powershell', ['-NoProfile', '-STA', '-Command', script], { timeout: 180_000 })
1303
+ const outLine = (r.stdout || '').trim().split(/\r?\n/)[0] || ''
1304
+ return outLine !== '' && existsSync(outLine) ? outLine : undefined
1305
+ }
1306
+
1307
+ /** Decode a `--C-Users-27775-workspace--` session dir name back to a Windows
1308
+ * path (best-effort; segments are joined by '\'). Ambiguous when a folder
1309
+ * name itself contains '-', which is why workspace.json is preferred. */
1310
+ function decodeSessionDir(name) {
1311
+ if (typeof name !== 'string') return null
1312
+ const inner = name.replace(/^--/, '').replace(/--$/, '')
1313
+ if (inner === '') return null
1314
+ // Split on '-'; first token is the drive letter.
1315
+ const parts = inner.split('-')
1316
+ if (parts.length === 0) return null
1317
+ const drive = parts[0]
1318
+ const rest = parts.slice(1).join('\\')
1319
+ return drive + ':\\' + rest
1320
+ }
1321
+
1322
+ /**
1323
+ * Whether a repo `<owner>/<name>` already exists. GitHub mode uses gh; Gitee
1324
+ * mode uses the Gitee OpenAPI (needs a configured personal token).
1325
+ * @param {string} name - repo name (without owner).
1326
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
1327
+ */
1328
+ function repoExists(name, provider = 'github') {
1329
+ if (provider === 'gitee') {
1330
+ const owner = giteeOwner()
1331
+ if (!owner || !name) return { owner, checked: false, provider: 'gitee' }
1332
+ const api = giteeApi('GET', 'repos/' + owner + '/' + name)
1333
+ if (!api.ok) return { owner, checked: false, provider: 'gitee', error: api.error }
1334
+ return { owner, checked: true, exists: !giteeApiFailed(api.data), provider: 'gitee' }
1335
+ }
1336
+ const owner = ghOwner()
1337
+ if (!owner || !name) return { owner, checked: false, provider: 'github' }
1338
+ const r = run(GH, ['repo', 'view', owner + '/' + name, '--json', 'name'], { timeout: 20_000 })
1339
+ return { owner, checked: true, exists: r.ok, provider: 'github' }
1340
+ }
1341
+
1342
+ /**
1343
+ * Query the current visibility ('private' | 'public') of a repo. Returns
1344
+ * undefined when the account is not available or the repo cannot be viewed, so
1345
+ * callers treat "unknown" as "no visibility info".
1346
+ * @param {string} name - repo name (without owner).
1347
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
1348
+ */
1349
+ function repoVisibility(name, provider = 'github') {
1350
+ if (provider === 'gitee') {
1351
+ const owner = giteeOwner()
1352
+ if (!owner || !name) return undefined
1353
+ const api = giteeApi('GET', 'repos/' + owner + '/' + name)
1354
+ if (!api.ok || !api.data || giteeApiFailed(api.data)) return undefined
1355
+ return api.data.private === true ? 'private' : (api.data.private === false ? 'public' : undefined)
1356
+ }
1357
+ const owner = ghOwner()
1358
+ if (!owner || !name) return undefined
1359
+ const r = run(GH, ['repo', 'view', owner + '/' + name, '--json', 'visibility'], { timeout: 20_000 })
1360
+ if (!r.ok) return undefined
1361
+ try {
1362
+ const parsed = JSON.parse(r.stdout)
1363
+ const v = String(parsed.visibility || '').toLowerCase()
1364
+ return v === 'public' || v === 'private' ? v : undefined
1365
+ } catch {
1366
+ return undefined
1367
+ }
1368
+ }
1369
+
1370
+ /**
1371
+ * Change a repo's visibility. GitHub mode uses gh; Gitee mode uses the Gitee
1372
+ * OpenAPI. Returns the new visibility on success.
1373
+ * @param {string} name - repo name (without owner).
1374
+ * @param {'private'|'public'} target - desired visibility.
1375
+ * @param {string} [provider] - 'github' (default) | 'gitee'.
1376
+ */
1377
+ function setVisibilityFlow(name, target, provider = 'github') {
1378
+ const vis = target === 'public' ? 'public' : 'private'
1379
+ if (provider === 'gitee') {
1380
+ const owner = giteeOwner()
1381
+ if (!owner || !name) return { ok: false, error: '需要先配置 Gitee 私人令牌' }
1382
+ const api = giteeApi('PATCH', 'repos/' + owner + '/' + name, { private: vis === 'private' })
1383
+ if (!api.ok || giteeApiFailed(api.data)) {
1384
+ return { ok: false, error: (api.error || (api.data && api.data.message) || 'Gitee 修改可见性失败') }
1385
+ }
1386
+ return { ok: true, name, visibility: vis, provider: 'gitee' }
1387
+ }
1388
+ const owner = ghOwner()
1389
+ if (!owner || !name) return { ok: false, error: '需要先登录 GitHub CLI(gh)' }
1390
+ const r = run(GH, [
1391
+ 'repo', 'edit', owner + '/' + name,
1392
+ '--visibility', vis,
1393
+ '--accept-visibility-change-consequences',
1394
+ ], { timeout: 30_000 })
1395
+ if (!r.ok) {
1396
+ return {
1397
+ ok: false,
1398
+ error: `修改可见性失败:${(r.stderr || r.stdout || '').trim() || 'gh repo edit 失败'}`,
1399
+ }
1400
+ }
1401
+ return { ok: true, name, visibility: vis, provider: 'github' }
1402
+ }
1403
+
1404
+ /** basename of a directory path (cross-platform). */
1405
+ function baseName(p) {
1406
+ const cleaned = String(p).replace(/[\\/]+$/, '')
1407
+ const parts = cleaned.split(/[\\/]/)
1408
+ return parts[parts.length - 1] || cleaned
1409
+ }
1410
+
1411
+ export function apply(ctx) {
1412
+ const fallbackDir = resolveDefaultDir(process.cwd())
1413
+ const handle = async (req, res, fn) => {
1414
+ if (!isLoopbackRequest(req)) return forbidden(res)
1415
+ const payload = await fn(req)
1416
+ json(res, 200, payload)
1417
+ }
1418
+
1419
+ const routes = {
1420
+ '/env': (req, res) => handle(req, res, async () => ({ ok: true, ...checkEnv() })),
1421
+ '/ssh': (req, res) => handle(req, res, async () => ({ ok: true, ...checkSsh() })),
1422
+ '/gen-key': async (req, res) => {
1423
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1424
+ await handle(req, res, async () => ({ ok: true, ...generateKey() }))
1425
+ },
1426
+ '/write-config': async (req, res) => {
1427
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1428
+ await handle(req, res, async (req) => {
1429
+ const body = JSON.parse((await readBody(req)) || '{}')
1430
+ return { ok: true, ...writeSshConfig(body.provider) }
1431
+ })
1432
+ },
1433
+ '/ssh-test': async (req, res) => {
1434
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1435
+ await handle(req, res, async (req) => {
1436
+ const body = JSON.parse((await readBody(req)) || '{}')
1437
+ return { ok: true, ...sshTest(body.provider) }
1438
+ })
1439
+ },
1440
+ '/default-dir': async (req, res) => {
1441
+ await handle(req, res, async () => ({ ok: true, dir: fallbackDir, name: baseName(fallbackDir) }))
1442
+ },
1443
+ '/repo-exists': async (req, res) => {
1444
+ const body = JSON.parse((await readBody(req)) || '{}')
1445
+ await handle(req, res, async () => {
1446
+ const r = repoExists(body.name, body.provider)
1447
+ return { ok: true, ...r }
1448
+ })
1449
+ },
1450
+ '/workspaces': async (req, res) => {
1451
+ await handle(req, res, async () => ({ ok: true, workspaces: listWorkspaces(), customDirs: readCustomDirs() }))
1452
+ },
1453
+ '/remove-workspace': async (req, res) => {
1454
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1455
+ await handle(req, res, async (req) => {
1456
+ const body = JSON.parse((await readBody(req)) || '{}')
1457
+ const dir = String(body.dir || '').trim()
1458
+ if (!dir) return { ok: false, error: '缺少目录路径' }
1459
+ removeCustomDir(dir)
1460
+ return { ok: true, removed: dir, customDirs: readCustomDirs(), workspaces: listWorkspaces() }
1461
+ })
1462
+ },
1463
+ '/align': async (req, res) => {
1464
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1465
+ await handle(req, res, async (req) => {
1466
+ const body = JSON.parse((await readBody(req)) || '{}')
1467
+ return alignFlow(body.dir || fallbackDir)
1468
+ })
1469
+ },
1470
+ '/init-git': async (req, res) => {
1471
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1472
+ await handle(req, res, async (req) => {
1473
+ const body = JSON.parse((await readBody(req)) || '{}')
1474
+ return initGitFlow(body.dir || fallbackDir)
1475
+ })
1476
+ },
1477
+ '/pick-dir': async (req, res) => {
1478
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1479
+ await handle(req, res, async (req) => {
1480
+ const body = JSON.parse((await readBody(req)) || '{}')
1481
+ const picked = pickDirDialog(body.initial || fallbackDir)
1482
+ if (!picked) return { ok: false, cancelled: true, error: '未选择目录' }
1483
+ return { ok: true, dir: picked, name: baseName(picked) }
1484
+ })
1485
+ },
1486
+ '/add-workspace': async (req, res) => {
1487
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1488
+ await handle(req, res, async (req) => {
1489
+ const body = JSON.parse((await readBody(req)) || '{}')
1490
+ const dir = String(body.dir || '').trim()
1491
+ if (!dir) return { ok: false, error: '缺少目录路径' }
1492
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) {
1493
+ return { ok: false, error: '目录不存在或不是文件夹:' + dir }
1494
+ }
1495
+ saveCustomDir(dir)
1496
+ return { ok: true, dir, name: baseName(dir), workspaces: listWorkspaces(), customDirs: readCustomDirs() }
1497
+ })
1498
+ },
1499
+ '/repo': async (req, res) => {
1500
+ if (req.method !== 'GET') return methodNotAllowed(res, req.method)
1501
+ await handle(req, res, async (req) => {
1502
+ try {
1503
+ const q = new URL(req.url ?? '', 'http://localhost')
1504
+ const provider = q.searchParams.get('provider') || 'github'
1505
+ return repoStatus(queryDir(req, fallbackDir), provider)
1506
+ } catch {
1507
+ return repoStatus(queryDir(req, fallbackDir), 'github')
1508
+ }
1509
+ })
1510
+ },
1511
+ '/push': async (req, res) => {
1512
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1513
+ await handle(req, res, async (req) => {
1514
+ const body = JSON.parse((await readBody(req)) || '{}')
1515
+ return pushFlow(body.dir || fallbackDir)
1516
+ })
1517
+ },
1518
+ '/pull': async (req, res) => {
1519
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1520
+ await handle(req, res, async (req) => {
1521
+ const body = JSON.parse((await readBody(req)) || '{}')
1522
+ return pullFlow(body.dir || fallbackDir)
1523
+ })
1524
+ },
1525
+ '/merge-push': async (req, res) => {
1526
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1527
+ await handle(req, res, async (req) => {
1528
+ const body = JSON.parse((await readBody(req)) || '{}')
1529
+ return mergePushFlow(body.dir || fallbackDir)
1530
+ })
1531
+ },
1532
+ '/force-push': async (req, res) => {
1533
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1534
+ await handle(req, res, async (req) => {
1535
+ const body = JSON.parse((await readBody(req)) || '{}')
1536
+ return forcePushFlow(body.dir || fallbackDir)
1537
+ })
1538
+ },
1539
+ '/force-pull': async (req, res) => {
1540
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1541
+ await handle(req, res, async (req) => {
1542
+ const body = JSON.parse((await readBody(req)) || '{}')
1543
+ return forcePullFlow(body.dir || fallbackDir)
1544
+ })
1545
+ },
1546
+ '/create': async (req, res) => {
1547
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1548
+ await handle(req, res, async (req) => {
1549
+ const body = JSON.parse((await readBody(req)) || '{}')
1550
+ return createRepoFlow(body.dir || fallbackDir, body.name, body.visibility, body.provider || 'github')
1551
+ })
1552
+ },
1553
+ '/set-visibility': async (req, res) => {
1554
+ if (req.method !== 'POST') return methodNotAllowed(res, req.method)
1555
+ await handle(req, res, async (req) => {
1556
+ const body = JSON.parse((await readBody(req)) || '{}')
1557
+ const name = body.name || baseName(body.dir || fallbackDir)
1558
+ return setVisibilityFlow(name, body.visibility, body.provider || 'github')
1559
+ })
1560
+ },
1561
+ '/gitee-token': async (req, res) => {
1562
+ if (req.method === 'GET') {
1563
+ await handle(req, res, async () => ({
1564
+ ok: true,
1565
+ // 只回传「是否已配置」,不回传令牌本身,避免把密钥写进浏览器/日志。
1566
+ configured: readGiteeToken() !== '',
1567
+ owner: giteeOwner(),
1568
+ }))
1569
+ } else if (req.method === 'POST') {
1570
+ await handle(req, res, async (req) => {
1571
+ const body = JSON.parse((await readBody(req)) || '{}')
1572
+ if (body.clear) {
1573
+ saveGiteeToken('')
1574
+ return { ok: true, configured: false, cleared: true }
1575
+ }
1576
+ const token = String(body.token || '').trim()
1577
+ if (!token) return { ok: false, error: '令牌不能为空' }
1578
+ saveGiteeToken(token)
1579
+ const owner = giteeOwner()
1580
+ if (!owner) {
1581
+ // 令牌无效则清掉,避免留有坏令牌。
1582
+ saveGiteeToken('')
1583
+ return { ok: false, error: 'Gitee 令牌无效,请检查(需有 projects 权限)' }
1584
+ }
1585
+ return { ok: true, configured: true, owner }
1586
+ })
1587
+ } else {
1588
+ return methodNotAllowed(res, req.method)
1589
+ }
1590
+ },
1591
+ }
1592
+
1593
+ ctx.effect(() => {
1594
+ const disposers = Object.entries(routes).map(([path, handler]) =>
1595
+ ctx.webServer.register({ kind: 'exact', path: BASE + path, handler })
1596
+ )
1597
+ return () => { for (const d of disposers) d() }
1598
+ }, 'source-code-mgmt: routes')
1599
+ }
1600
+
1601
+ export default { name, inject, apply }