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/README.md +260 -0
- package/lib/client.js +997 -0
- package/lib/index.js +1601 -0
- package/package.json +22 -0
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 }
|