dsh-ide-git 0.22.0 → 0.24.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.
@@ -0,0 +1,423 @@
1
+ /**
2
+
3
+ * dsh-ide-git host: the argv-only git process and the parsers shared by every
4
+
5
+ * method. Callers never pass a shell string; all input is validated in ./core.js.
6
+
7
+ */
8
+
9
+ import { spawn } from 'node:child_process'
10
+
11
+ import { realpathSync } from 'node:fs'
12
+
13
+ import path from 'node:path'
14
+
15
+ import { PanelError, messageOf, badRequest, requireAbsolute } from './core.js'
16
+
17
+
18
+
19
+ const MAX_OUTPUT_BYTES = 8 * 1024 * 1024
20
+
21
+ /* Ring buffer of the most recent git runs — the panel's "Git output" view
22
+ (VS Code's scm showOutput equivalent). Bounded on both axes: entries and
23
+ per-entry text, so a hostile `log -p` cannot grow it without limit. */
24
+ const GIT_LOG_MAX_ENTRIES = 200
25
+ const GIT_LOG_TEXT_MAX = 4000
26
+ const gitLog = []
27
+ function pushGitLog(entry) {
28
+ gitLog.push(entry)
29
+ if (gitLog.length > GIT_LOG_MAX_ENTRIES) gitLog.splice(0, gitLog.length - GIT_LOG_MAX_ENTRIES)
30
+ }
31
+ function clipGitLogText(text) {
32
+ if (typeof text !== 'string' || text.length <= GIT_LOG_TEXT_MAX) return text
33
+ return text.slice(0, GIT_LOG_TEXT_MAX / 2) + '\n…[' + String(text.length - GIT_LOG_TEXT_MAX) + ' more chars]…\n' + text.slice(-GIT_LOG_TEXT_MAX / 2)
34
+ }
35
+ const MAX_DIFF_CHARS = 400 * 1024
36
+ const DEFAULT_TIMEOUT_MS = 30_000
37
+ const NETWORK_TIMEOUT_MS = 180_000
38
+ const RECORD_SEP = '\u001e'
39
+ const FIELD_SEP = '\u001f'
40
+
41
+ /* ============================== argv git ============================== */
42
+
43
+ function gitBinary() {
44
+ const override = process.env.DSH_IDE_GIT_BIN
45
+ return override !== undefined && override.trim() !== '' ? override.trim() : 'git'
46
+ }
47
+
48
+ /**
49
+ * Run git with an argv array (no shell). Resolves for ANY exit code so callers
50
+ * can decide whether a non-zero status is data (e.g. `git diff --quiet`) or an
51
+ * error; `git()` below is the throwing form.
52
+ */
53
+ function runGit(cwd, args, options = {}) {
54
+ const timeoutMs = options.timeoutMs === undefined ? DEFAULT_TIMEOUT_MS : options.timeoutMs
55
+ const startedAt = Date.now()
56
+ return new Promise((resolve, reject) => {
57
+ let child
58
+ try {
59
+ child = spawn(gitBinary(), args, { cwd, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_TERMINAL_PROMPT: '0' } })
60
+ } catch (error) {
61
+ reject(new PanelError('git-missing', 'cannot start git: ' + messageOf(error), 500))
62
+ return
63
+ }
64
+ const out = []
65
+ const err = []
66
+ let outBytes = 0
67
+ let errBytes = 0
68
+ let settled = false
69
+ const timer = setTimeout(() => {
70
+ if (settled) return
71
+ settled = true
72
+ child.kill('SIGKILL')
73
+ pushGitLog({ at: new Date(startedAt).toISOString(), argv: args.join(' '), code: 'timeout', ms: timeoutMs, out: '', err: 'timed out after ' + String(timeoutMs) + 'ms' })
74
+ reject(new PanelError('git-timeout', 'git ' + args[0] + ' timed out after ' + timeoutMs + 'ms', 504))
75
+ }, timeoutMs)
76
+ child.stdout.on('data', (chunk) => {
77
+ outBytes += chunk.length
78
+ if (outBytes <= MAX_OUTPUT_BYTES) out.push(chunk)
79
+ })
80
+ child.stderr.on('data', (chunk) => {
81
+ errBytes += chunk.length
82
+ if (errBytes <= MAX_OUTPUT_BYTES) err.push(chunk)
83
+ })
84
+ child.on('error', (error) => {
85
+ if (settled) return
86
+ settled = true
87
+ clearTimeout(timer)
88
+ reject(new PanelError('git-missing', 'cannot run git: ' + messageOf(error), 500))
89
+ })
90
+ child.on('close', (code, signal) => {
91
+ if (settled) return
92
+ settled = true
93
+ clearTimeout(timer)
94
+ pushGitLog({
95
+ at: new Date(startedAt).toISOString(),
96
+ argv: args.join(' '),
97
+ code: code === null ? -1 : code,
98
+ ms: Date.now() - startedAt,
99
+ out: clipGitLogText(Buffer.concat(out).toString('utf8')),
100
+ err: clipGitLogText(Buffer.concat(err).toString('utf8')),
101
+ })
102
+ resolve({
103
+ code: code === null ? -1 : code,
104
+ signal: signal === null ? undefined : signal,
105
+ stdout: Buffer.concat(out).toString('utf8'),
106
+ stderr: Buffer.concat(err).toString('utf8'),
107
+ truncated: outBytes > MAX_OUTPUT_BYTES,
108
+ })
109
+ })
110
+ })
111
+ }
112
+
113
+ async function git(cwd, args, options = {}) {
114
+ const result = await runGit(cwd, args, options)
115
+ if (result.code !== 0) {
116
+ const detail = result.stderr.trim() === '' ? result.stdout.trim() : result.stderr.trim()
117
+ throw new PanelError('git-failed', 'git ' + args.join(' ') + ' failed: ' + (detail === '' ? 'exit ' + result.code : detail), 409)
118
+ }
119
+ return result.stdout
120
+ }
121
+
122
+ /* Turn the failures users actually hit into named codes (v0.24.0), so the
123
+ panel can answer with an actionable sentence instead of the raw git wall.
124
+ Everything else keeps the honest git-failed message. */
125
+ function gitFailure(command, error) {
126
+ const message = messageOf(error)
127
+ if (command === 'push' && /\[rejected\]|non-fast-forward|fetch first|Updates were rejected because/i.test(message)) {
128
+ return new PanelError('push-rejected', 'push rejected: the remote branch has commits that are not local; pull (merge) first. ' + message, 409)
129
+ }
130
+ if (command === 'pull') {
131
+ if (/CONFLICT|Automatic merge failed|Fix conflicts/i.test(message)) {
132
+ return new PanelError('pull-conflict', 'pull stopped on merge conflicts; resolve them and commit before continuing. ' + message, 409)
133
+ }
134
+ if (/Not possible to fast-forward|Diverging branches/i.test(message)) {
135
+ return new PanelError('pull-diverged', 'local and remote branches have diverged; pull with merge or rebase instead of fast-forward. ' + message, 409)
136
+ }
137
+ if (/Your local changes|would be overwritten by merge|Please commit your changes or stash them/i.test(message)) {
138
+ return new PanelError('pull-dirty', 'pull would overwrite local changes; commit or stash them first. ' + message, 409)
139
+ }
140
+ }
141
+ return error
142
+ }
143
+
144
+ /* ============================== git reads ============================== */
145
+
146
+ async function repoRootOf(cwd, payload) {
147
+ /* Normalize the separator: `rev-parse --show-toplevel` spells drive paths
148
+ with forward slashes on Windows while path.join produces backslashes, so
149
+ the untracked-diff fence (root + path.sep prefix check) rejected every
150
+ in-repo path there. macOS/Linux are byte-identical no-ops. */
151
+ if (payload !== undefined && payload.repoRoot !== undefined) return path.normalize(requireAbsolute(payload.repoRoot, 'repoRoot'))
152
+ const result = await runGit(cwd, ['rev-parse', '--show-toplevel'])
153
+ if (result.code !== 0) {
154
+ // A workspace that is not a repository is a normal state, not a failure:
155
+ // the client answers it with a repo picker instead of an error banner.
156
+ throw new PanelError('not-a-repo', 'not a git repository: ' + cwd, 409)
157
+ }
158
+ const top = result.stdout.trim()
159
+ return path.normalize(top === '' ? cwd : top)
160
+ }
161
+
162
+ async function currentBranchOf(root) {
163
+ const name = (await git(root, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim()
164
+ return name
165
+ }
166
+
167
+ async function upstreamOf(root, branch) {
168
+ const result = await runGit(root, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', branch + '@{u}'])
169
+ if (result.code !== 0) return null
170
+ const name = result.stdout.trim()
171
+ return name === '' ? null : name
172
+ }
173
+
174
+ async function aheadBehindOf(root, upstream, branch) {
175
+ if (upstream === null) return { ahead: 0, behind: 0 }
176
+ const result = await runGit(root, ['rev-list', '--left-right', '--count', upstream + '...' + branch])
177
+ if (result.code !== 0) return { ahead: 0, behind: 0 }
178
+ const parts = result.stdout.trim().split(/\s+/)
179
+ const behind = Number.parseInt(parts[0], 10)
180
+ const ahead = Number.parseInt(parts[1], 10)
181
+ return { ahead: Number.isFinite(ahead) ? ahead : 0, behind: Number.isFinite(behind) ? behind : 0 }
182
+ }
183
+
184
+ /** Parse `git status --porcelain=v1 -z` (NUL separated, rename pairs follow). */
185
+ function parsePorcelain(stdout) {
186
+ const records = stdout.split('\u0000')
187
+ const entries = []
188
+ for (let index = 0; index < records.length; index += 1) {
189
+ const record = records[index]
190
+ if (record.length < 4) continue
191
+ const x = record[0]
192
+ const y = record[1]
193
+ const filePath = record.slice(3)
194
+ let origPath
195
+ if (x === 'R' || x === 'C' || y === 'R' || y === 'C') {
196
+ const next = records[index + 1]
197
+ if (next !== undefined && next !== '') {
198
+ origPath = next
199
+ index += 1
200
+ }
201
+ }
202
+ entries.push({ x, y, path: filePath, origPath })
203
+ }
204
+ return entries
205
+ }
206
+
207
+ function classify(entries) {
208
+ const staged = []
209
+ const unstaged = []
210
+ const untracked = []
211
+ const conflicted = []
212
+ for (const entry of entries) {
213
+ const pair = entry.x + entry.y
214
+ if (pair === '??') { untracked.push(entry); continue }
215
+ if (pair === '!!') continue
216
+ if (pair === 'DD' || pair === 'AU' || pair === 'UD' || pair === 'UA' || pair === 'DU' || pair === 'AA' || pair === 'UU') {
217
+ conflicted.push(entry)
218
+ continue
219
+ }
220
+ if (entry.x !== ' ' && entry.x !== '?') staged.push(entry)
221
+ if (entry.y !== ' ' && entry.y !== '?') unstaged.push(entry)
222
+ }
223
+ return { staged, unstaged, untracked, conflicted }
224
+ }
225
+
226
+ /** An ignored tree can be enormous, and this list is informational only. */
227
+ const MAX_IGNORED_ENTRIES = 500
228
+
229
+ /**
230
+ * Ignored paths, asked for on their own. `git status -uall --ignored` expands
231
+ * every file inside an ignored directory — node_modules alone would flood the
232
+ * panel — while `ls-files --directory` reports the directory as a single entry.
233
+ */
234
+ async function ignoredEntriesOf(root) {
235
+ const result = await runGit(root, ['ls-files', '--others', '--ignored', '--exclude-standard', '--directory', '-z'])
236
+ if (result.code !== 0) return { entries: [], truncated: false }
237
+ const paths = result.stdout.split('\u0000').filter((entry) => entry !== '')
238
+ const kept = paths.slice(0, MAX_IGNORED_ENTRIES)
239
+ return {
240
+ entries: kept.map((filePath) => ({ index: '!', worktree: '!', path: filePath })),
241
+ truncated: paths.length > kept.length,
242
+ }
243
+ }
244
+
245
+ /** `git diff --numstat -z` -> Map(path -> { additions, deletions, binary }). */
246
+ async function numstatOf(root, args) {
247
+ const result = await runGit(root, ['diff', '--numstat', '-z', ...args])
248
+ const map = new Map()
249
+ if (result.code !== 0) return map
250
+ const records = result.stdout.split('\u0000')
251
+ for (let index = 0; index < records.length; index += 1) {
252
+ const record = records[index]
253
+ if (record === '') continue
254
+ const firstTab = record.indexOf('\t')
255
+ const secondTab = record.indexOf('\t', firstTab + 1)
256
+ if (firstTab < 0 || secondTab < 0) continue
257
+ const addRaw = record.slice(0, firstTab)
258
+ const delRaw = record.slice(firstTab + 1, secondTab)
259
+ let filePath = record.slice(secondTab + 1)
260
+ if (filePath === '') {
261
+ filePath = records[index + 1] === undefined ? '' : records[index + 1]
262
+ index += 1
263
+ const renamed = records[index + 1]
264
+ if (renamed !== undefined && renamed !== '') index += 1
265
+ }
266
+ const binary = addRaw === '-' || delRaw === '-'
267
+ map.set(filePath, {
268
+ additions: binary ? 0 : Number.parseInt(addRaw, 10) || 0,
269
+ deletions: binary ? 0 : Number.parseInt(delRaw, 10) || 0,
270
+ binary,
271
+ })
272
+ }
273
+ return map
274
+ }
275
+
276
+ function decorate(entries, numstat) {
277
+ return entries.map((entry) => {
278
+ const stat = numstat.get(entry.path)
279
+ return {
280
+ path: entry.path,
281
+ origPath: entry.origPath,
282
+ index: entry.x,
283
+ worktree: entry.y,
284
+ additions: stat === undefined ? 0 : stat.additions,
285
+ deletions: stat === undefined ? 0 : stat.deletions,
286
+ binary: stat === undefined ? false : stat.binary,
287
+ }
288
+ })
289
+ }
290
+
291
+ /** Parse the FIELD_SEP/RECORD_SEP pretty format used by `log`/`show`. */
292
+ function parseLog(stdout) {
293
+ const commits = []
294
+ for (const record of stdout.split(RECORD_SEP)) {
295
+ const trimmed = record.replace(/^\n+/, '')
296
+ if (trimmed.trim() === '') continue
297
+ const parts = trimmed.split(FIELD_SEP)
298
+ if (parts.length < 8) continue
299
+ commits.push({
300
+ hash: parts[0],
301
+ shortHash: parts[1],
302
+ parents: parts[2].trim() === '' ? [] : parts[2].trim().split(' '),
303
+ author: parts[3],
304
+ email: parts[4],
305
+ date: parts[5],
306
+ subject: parts[6],
307
+ refs: parts[7].trim() === '' ? [] : parts[7].split(',').map((ref) => ref.trim()).filter((ref) => ref !== ''),
308
+ })
309
+ }
310
+ return commits
311
+ }
312
+
313
+ const LOG_FORMAT = '%H' + FIELD_SEP + '%h' + FIELD_SEP + '%P' + FIELD_SEP + '%an' + FIELD_SEP + '%ae' + FIELD_SEP + '%aI' + FIELD_SEP + '%s' + FIELD_SEP + '%D' + RECORD_SEP
314
+
315
+ async function stashCountOf(root) {
316
+ const result = await runGit(root, ['rev-list', '--walk-reflogs', '--count', 'refs/stash'])
317
+ if (result.code !== 0) return 0
318
+ const count = Number.parseInt(result.stdout.trim(), 10)
319
+ return Number.isFinite(count) ? count : 0
320
+ }
321
+
322
+ /** Same directory, tolerating a trailing slash and macOS /var vs /private/var. */
323
+ function sameDirectory(left, right) {
324
+ const one = path.resolve(String(left))
325
+ const two = path.resolve(String(right))
326
+ if (one === two) return true
327
+ try { return realpathSync(one) === realpathSync(two) } catch { return false }
328
+ }
329
+
330
+ /* `--porcelain -z` is the only parseable form: a path may contain spaces and
331
+ newlines, and git realpaths the main worktree (/var -> /private/var on macOS),
332
+ so every record is NUL-terminated and an empty record closes the current
333
+ block. The flags describe the ADMIN view: `locked` and `prunable` say
334
+ whether removal is blocked or the directory is already gone. */
335
+ async function worktreesOf(root) {
336
+ const result = await runGit(root, ['worktree', 'list', '--porcelain', '-z'])
337
+ if (result.code !== 0) return []
338
+ const trees = []
339
+ let current = null
340
+ const push = () => { if (current !== null) trees.push(current); current = null }
341
+ for (const record of result.stdout.split('\u0000')) {
342
+ if (record === '') { push(); continue }
343
+ if (record.startsWith('worktree ')) {
344
+ push()
345
+ current = {
346
+ path: record.slice('worktree '.length),
347
+ branch: null,
348
+ head: null,
349
+ detached: false,
350
+ bare: false,
351
+ locked: false,
352
+ lockReason: null,
353
+ prunable: false,
354
+ pruneReason: null,
355
+ }
356
+ continue
357
+ }
358
+ if (current === null) continue
359
+ if (record.startsWith('HEAD ')) current.head = record.slice('HEAD '.length)
360
+ else if (record.startsWith('branch ')) current.branch = record.slice('branch '.length).replace('refs/heads/', '')
361
+ else if (record === 'detached') current.detached = true
362
+ else if (record === 'bare') current.bare = true
363
+ else if (record.startsWith('locked')) {
364
+ current.locked = true
365
+ const reason = record.slice('locked'.length).trim()
366
+ current.lockReason = reason === '' ? null : reason
367
+ } else if (record.startsWith('prunable')) {
368
+ current.prunable = true
369
+ const reason = record.slice('prunable'.length).trim()
370
+ current.pruneReason = reason === '' ? null : reason
371
+ }
372
+ }
373
+ push()
374
+ return trees.map((tree, index) => Object.assign(tree, {
375
+ primary: index === 0,
376
+ current: sameDirectory(tree.path, root),
377
+ }))
378
+ }
379
+
380
+ /* The repository's own name, cut from its origin URL — a cloned folder that
381
+ was renamed on disk still reports the name the remote gave it (the leaf of
382
+ the URL, minus a trailing .git). scp-style (user@host:path), URL-style and
383
+ plain local paths all cut on the last slash or colon. */
384
+ function repoNameFromUrl(url) {
385
+ if (typeof url !== 'string' || url.trim() === '') return ''
386
+ const tail = url.trim().replace(/\/+$/, '').replace(/\.git$/i, '')
387
+ const segments = tail.split(/[\\/:]/).filter((segment) => segment !== '')
388
+ return segments.length > 0 ? segments[segments.length - 1] : ''
389
+ }
390
+ /* origin first, then the first configured remote, then '' — the client
391
+ falls back to the folder basename when this is empty. Both reads are
392
+ local config reads, never network. */
393
+ async function originNameOf(root) {
394
+ try {
395
+ const origin = await git(root, ['remote', 'get-url', 'origin'])
396
+ const name = repoNameFromUrl(origin)
397
+ if (name !== '') return name
398
+ } catch (error) { void error }
399
+ try {
400
+ const remotesOut = await runGit(root, ['remote'])
401
+ if (remotesOut.code !== 0) return ''
402
+ const first = remotesOut.stdout.split('\n').map((line) => line.trim()).filter((line) => line !== '')[0]
403
+ if (first === undefined) return ''
404
+ const urlOut = await runGit(root, ['remote', 'get-url', first])
405
+ return urlOut.code === 0 ? repoNameFromUrl(urlOut.stdout.trim()) : ''
406
+ } catch (error) { void error; return '' }
407
+ }
408
+
409
+ export {
410
+
411
+ MAX_DIFF_CHARS, NETWORK_TIMEOUT_MS, FIELD_SEP, RECORD_SEP, gitLog, GIT_LOG_MAX_ENTRIES,
412
+
413
+ gitBinary, runGit, git, gitFailure,
414
+
415
+ repoRootOf, currentBranchOf, upstreamOf, aheadBehindOf,
416
+
417
+ parsePorcelain, classify, ignoredEntriesOf, numstatOf, decorate,
418
+
419
+ parseLog, LOG_FORMAT, stashCountOf, sameDirectory, worktreesOf,
420
+
421
+ repoNameFromUrl, originNameOf,
422
+
423
+ }
@@ -0,0 +1,237 @@
1
+ /** dsh-ide-git host: POST /dsh-ide-git/api/<method> and its trust fence. */
2
+
3
+ import { PanelError, badRequest, messageOf, WRITE_METHODS, withRepoLock, lockKeyOf } from './core.js'
4
+
5
+ import { summary, stats, branches, log, me, mergePoint, commitDetail, diff, compare, gitLogs } from './read.js'
6
+
7
+ import {
8
+
9
+ stage, unstage, discard, commit, checkout, branchCreate, worktreeAdd, worktreeRemove,
10
+
11
+ worktreePrune, branchUpstream, branchRename, branchDelete, merge, rebase, rebaseAbort,
12
+
13
+ remoteAdd, remoteRemove, branchDeleteRemote, tagDeleteRemote, pushTags, stashClear,
14
+
15
+ cherryPick, revert, reset, fetch, pull, push, stashList, stashPush, stashApply,
16
+
17
+ stashDrop, tagCreate, tagDelete, clone,
18
+
19
+ } from './write.js'
20
+
21
+ import { undoList, undoApply } from './undo.js'
22
+
23
+ import { repos, version } from './repos.js'
24
+
25
+ import { commitMessage, commitModels, commitEfforts } from './commit-message.js'
26
+
27
+
28
+
29
+ const ROUTE_PREFIX = '/dsh-ide-git/api'
30
+
31
+
32
+
33
+ const METHODS = {
34
+ version,
35
+ 'commit-message': commitMessage,
36
+ 'commit-models': commitModels,
37
+ 'commit-efforts': commitEfforts,
38
+ repos,
39
+ summary,
40
+ stats,
41
+ branches,
42
+ log,
43
+ me,
44
+ 'merge-point': mergePoint,
45
+ commitDetail,
46
+ diff,
47
+ compare,
48
+ stage,
49
+ unstage,
50
+ discard,
51
+ commit,
52
+ checkout,
53
+ branchCreate,
54
+ branchRename,
55
+ 'branch-upstream': branchUpstream,
56
+ branchDelete,
57
+ worktreeAdd,
58
+ worktreeRemove,
59
+ worktreePrune,
60
+ merge,
61
+ rebase,
62
+ cherryPick,
63
+ revert,
64
+ reset,
65
+ fetch,
66
+ pull,
67
+ push,
68
+ stashList,
69
+ stashPush,
70
+ stashApply,
71
+ stashDrop,
72
+ tagCreate,
73
+ tagDelete,
74
+ tagDeleteRemote,
75
+ pushTags,
76
+ clone,
77
+ gitLogs,
78
+ remoteAdd,
79
+ remoteRemove,
80
+ branchDeleteRemote,
81
+ rebaseAbort,
82
+ stashClear,
83
+ undoList,
84
+ undoApply,
85
+ }
86
+
87
+ /* ============================== http plumbing ============================== */
88
+
89
+ function headerOf(req, name) {
90
+ const value = req.headers[name]
91
+ if (Array.isArray(value)) return value[0]
92
+ return value
93
+ }
94
+
95
+ /* Normalize an authority ("host[:port]" or an absolute URL) to
96
+ "hostname:port", filling the scheme default port when it is implicit.
97
+ Returns '' for anything unparseable (including the "null" opaque origin). */
98
+ function authorityKey(value, protocol) {
99
+ const text = String(value === undefined || value === null ? '' : value).trim()
100
+ if (text === '') return ''
101
+ let hostname = text
102
+ let port = ''
103
+ let scheme = protocol
104
+ if (text.includes('://') === true) {
105
+ let parsed = null
106
+ try { parsed = new URL(text) } catch (error) { parsed = null }
107
+ if (parsed === null) return ''
108
+ hostname = parsed.hostname
109
+ port = parsed.port
110
+ scheme = parsed.protocol
111
+ } else {
112
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
113
+ if (bracketed !== null) {
114
+ hostname = bracketed[1]
115
+ port = bracketed[2] === undefined ? '' : bracketed[2]
116
+ } else {
117
+ const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
118
+ if (plain === null) return ''
119
+ hostname = plain[1]
120
+ port = plain[2] === undefined ? '' : plain[2]
121
+ }
122
+ }
123
+ if (hostname === '') return ''
124
+ if (port === '') port = scheme === 'https:' ? '443' : '80'
125
+ return hostname.toLowerCase() + ':' + port
126
+ }
127
+
128
+ /* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
129
+ served over plain HTTP from a non-loopback address is not a secure context,
130
+ and the fetch-metadata headers are omitted there (issue #20: the git panel
131
+ answered 403 on a LAN address). Origin and Referer are forbidden headers a
132
+ page cannot forge — a cross-site request always carries the caller's own
133
+ origin — so comparing either against the Host header restores the
134
+ same-origin fence without trusting anything the request body claims. */
135
+ function sameOriginOf(req, host) {
136
+ const hostKey = authorityKey(host, 'http:')
137
+ if (hostKey === '') return false
138
+ /* A Host header without a port says nothing about the scheme, so either
139
+ scheme default may be the one the browser used in Origin. */
140
+ const candidates = new Set([hostKey])
141
+ if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
142
+ const matches = (value) => {
143
+ const key = authorityKey(value, 'http:')
144
+ return key !== '' && candidates.has(key)
145
+ }
146
+ if (matches(headerOf(req, 'origin')) === true) return true
147
+ return matches(headerOf(req, 'referer'))
148
+ }
149
+
150
+ /** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
151
+ function isTrusted(req) {
152
+ const host = headerOf(req, 'host')
153
+ if (host === undefined || host === '') return false
154
+ const hostname = host.replace(/^\[/, '').replace(/\]:\d+$/, '').replace(/:\d+$/, '')
155
+ if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
156
+ if (/^127\./.test(hostname)) return true
157
+ const site = headerOf(req, 'sec-fetch-site')
158
+ if (site === 'same-origin' || site === 'same-site') return true
159
+ return sameOriginOf(req, host)
160
+ }
161
+
162
+ function sendJson(res, status, body) {
163
+ const text = JSON.stringify(body)
164
+ res.writeHead(status, {
165
+ 'content-type': 'application/json; charset=utf-8',
166
+ 'cache-control': 'no-store',
167
+ 'content-length': Buffer.byteLength(text),
168
+ })
169
+ res.end(text)
170
+ }
171
+
172
+ function readJsonBody(req, limit = 1 << 20) {
173
+ return new Promise((resolve, reject) => {
174
+ const chunks = []
175
+ let size = 0
176
+ req.on('data', (chunk) => {
177
+ size += chunk.length
178
+ if (size > limit) {
179
+ reject(new PanelError('too-large', 'request body is too large', 413))
180
+ req.destroy()
181
+ return
182
+ }
183
+ chunks.push(chunk)
184
+ })
185
+ req.on('end', () => {
186
+ const raw = Buffer.concat(chunks).toString('utf8')
187
+ if (raw.trim() === '') { resolve({}); return }
188
+ try {
189
+ const parsed = JSON.parse(raw)
190
+ resolve(parsed !== null && typeof parsed === 'object' ? parsed : {})
191
+ } catch {
192
+ reject(badRequest('request body must be JSON'))
193
+ }
194
+ })
195
+ req.on('error', (error) => { reject(new PanelError('transport', messageOf(error), 500)) })
196
+ })
197
+ }
198
+
199
+ async function handle(req, res) {
200
+ if (!isTrusted(req)) {
201
+ sendJson(res, 403, {
202
+ ok: false,
203
+ error: {
204
+ code: 'forbidden',
205
+ message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
206
+ },
207
+ })
208
+ return
209
+ }
210
+ if (req.method !== 'POST') {
211
+ sendJson(res, 405, { ok: false, error: { code: 'method-error', message: 'POST required' } })
212
+ return
213
+ }
214
+ const pathname = new URL(req.url === undefined ? '/' : req.url, 'http://dsh.internal').pathname
215
+ const method = pathname.startsWith(ROUTE_PREFIX + '/') ? pathname.slice(ROUTE_PREFIX.length + 1) : ''
216
+ const handler = Object.prototype.hasOwnProperty.call(METHODS, method) ? METHODS[method] : undefined
217
+ if (handler === undefined || method.includes('/')) {
218
+ sendJson(res, 404, { ok: false, error: { code: 'not-found', message: 'unknown method "' + method + '"' } })
219
+ return
220
+ }
221
+ try {
222
+ const payload = await readJsonBody(req)
223
+ const data = WRITE_METHODS.has(method)
224
+ ? await withRepoLock(lockKeyOf(payload), () => handler(payload))
225
+ : await handler(payload)
226
+ sendJson(res, 200, { ok: true, data: data })
227
+ } catch (error) {
228
+ if (error instanceof PanelError) {
229
+ sendJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
230
+ return
231
+ }
232
+ const message = messageOf(error)
233
+ sendJson(res, 500, { ok: false, error: { code: 'internal', message } })
234
+ }
235
+ }
236
+
237
+ export { ROUTE_PREFIX, handle }