@goodandready/dsh-github-ops 0.2.10 → 0.2.11
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/CHANGELOG.md +8 -0
- package/lib/github.js +14 -0
- package/lib/index.js +1 -1
- package/lib/routes.js +13 -3
- package/lib/status.js +76 -22
- package/lib/tools/mirror.js +32 -1
- package/lib/tools/register-core.js +2 -1
- package/lib/tools/releases.js +43 -5
- package/lib/tools/repos.js +3 -2
- package/lib/tools/write-files.js +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.11
|
|
4
|
+
|
|
5
|
+
### Security
|
|
6
|
+
- **Strict CSRF & Origin Verification on SCM Writes** (#98, #81, #99): Added `isTrustedWriteRequest` guarding `POST /scm`. Discarded client-supplied `X-Forwarded-Host` in favor of validated `Host` header. Enforced strict fail-closed policy on cross-site requests (`Sec-Fetch-Site: cross-site`) across loopback and local networks.
|
|
7
|
+
- **Path Segment Encoding & Traversal Defense** (#87): Added `encodeRepoPath` to prevent directory traversal (`..`) and URI-encode path segments in GitHub Contents API endpoints (`getFile`, `deleteFile`, and blob viewer route).
|
|
8
|
+
- **Mirror Publication Isolation & Option Injection Defense** (#83, #91): Added strict validation on mirror destination URLs and branch names (`validateRemote`, `validateBranchName`) to reject leading hyphen flags and CLI option injection into git arguments. Replaced predictable temp index paths with `crypto.randomUUID()`-backed paths in `tmpdir()`.
|
|
9
|
+
- **Release Asset Upload Workspace Containment** (#82): Restricted release asset file uploads to files within the workspace root or allowlisted local files, rejecting system directories (`/etc`, `/proc`, `/sys`, etc.), traversal paths, and control characters.
|
|
10
|
+
|
|
3
11
|
## 0.2.10
|
|
4
12
|
|
|
5
13
|
### Fixed
|
package/lib/github.js
CHANGED
|
@@ -424,3 +424,17 @@ export function describeError(err) {
|
|
|
424
424
|
}
|
|
425
425
|
return String(err.message || err)
|
|
426
426
|
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Safely encode a repository file path segment for the GitHub contents API.
|
|
430
|
+
* Rejects path traversal (..) and encodes each path component.
|
|
431
|
+
*/
|
|
432
|
+
export function encodeRepoPath(rawPath) {
|
|
433
|
+
const cleaned = String(rawPath == null ? '' : rawPath).trim().replace(/^\/+/, '')
|
|
434
|
+
if (!cleaned) throw new Error('path is required')
|
|
435
|
+
const segments = cleaned.split('/')
|
|
436
|
+
if (segments.some((s) => s === '..' || s === '.')) {
|
|
437
|
+
throw new Error('path must not contain traversal segments')
|
|
438
|
+
}
|
|
439
|
+
return segments.map(encodeURIComponent).join('/')
|
|
440
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -375,7 +375,7 @@ export function apply(ctx, rawConfig) {
|
|
|
375
375
|
},
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
-
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam }
|
|
378
|
+
const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd() }
|
|
379
379
|
registerCoreTools(deps)
|
|
380
380
|
registerCollabTools(deps)
|
|
381
381
|
registerOpsTools(deps)
|
package/lib/routes.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { existsSync, statSync } from 'node:fs'
|
|
9
9
|
import { join as joinPath } from 'node:path'
|
|
10
10
|
|
|
11
|
-
import { statusPayload, isTrustedRequest, sendJson } from './status.js'
|
|
11
|
+
import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
|
|
12
12
|
import { buildAction, validateCwd } from './scm-write.js'
|
|
13
13
|
import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
|
|
14
14
|
import { listIssues } from './tools/issues.js'
|
|
@@ -94,7 +94,11 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
94
94
|
path: SCM_PATH,
|
|
95
95
|
handler: async (req, res) => {
|
|
96
96
|
if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
|
|
97
|
-
if (
|
|
97
|
+
if (req.method === 'POST') {
|
|
98
|
+
if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
99
|
+
} else {
|
|
100
|
+
if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
|
|
101
|
+
}
|
|
98
102
|
const git = makeGitRunner()
|
|
99
103
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
100
104
|
const file = url.searchParams.get('file') || ''
|
|
@@ -254,7 +258,13 @@ export function registerRoutes(ctx, deps = {}) {
|
|
|
254
258
|
return sendJson(res, 200, { configured: true, ...view })
|
|
255
259
|
}
|
|
256
260
|
if (what === 'blob') {
|
|
257
|
-
|
|
261
|
+
let encodedPath = ''
|
|
262
|
+
try {
|
|
263
|
+
encodedPath = encodeRepoPath(path)
|
|
264
|
+
} catch (err) {
|
|
265
|
+
return sendJson(res, 400, { configured: true, error: (err && err.message) || String(err) })
|
|
266
|
+
}
|
|
267
|
+
const { data } = await github.get(`/repos/${repoOwner}/${repoName}/contents/${encodedPath}`, { query: ref ? { ref } : undefined })
|
|
258
268
|
if (Array.isArray(data)) return sendJson(res, 200, { configured: true, entries: data.map((e) => ({ name: e.name, type: e.type, size: e.size, path: e.path })) })
|
|
259
269
|
const encoded = String((data && data.content) || '').replace(/\n/g, '')
|
|
260
270
|
const text = encoded ? Buffer.from(encoded, 'base64').toString('utf8') : ''
|
package/lib/status.js
CHANGED
|
@@ -7,43 +7,97 @@
|
|
|
7
7
|
// The payload never contains the access value. The route answers only a local or same-origin
|
|
8
8
|
// caller, because the payload describes the machine's account.
|
|
9
9
|
|
|
10
|
+
/** Whether a request may read the status payload. Fail-closed. */
|
|
11
|
+
function isTrustedHost(host) {
|
|
12
|
+
if (!host || typeof host !== 'string') return false
|
|
13
|
+
let hostname = host.trim()
|
|
14
|
+
if (hostname.startsWith('[')) {
|
|
15
|
+
const end = hostname.indexOf(']')
|
|
16
|
+
if (end > 0) hostname = hostname.slice(1, end)
|
|
17
|
+
} else {
|
|
18
|
+
hostname = hostname.split(':')[0]
|
|
19
|
+
}
|
|
20
|
+
hostname = hostname.toLowerCase()
|
|
21
|
+
if (!hostname) return false
|
|
22
|
+
if (hostname === 'localhost' || hostname.endsWith('.local') || hostname.endsWith('.lan')) return true
|
|
23
|
+
const clean = hostname.replace(/^::ffff:/, '')
|
|
24
|
+
if (clean === '127.0.0.1' || clean === '::1' || clean.startsWith('127.')) return true
|
|
25
|
+
if (clean.startsWith('10.') || clean.startsWith('192.168.')) return true
|
|
26
|
+
const match172 = clean.match(/^172\.(\d+)\./)
|
|
27
|
+
if (match172) {
|
|
28
|
+
const octet = parseInt(match172[1], 10)
|
|
29
|
+
if (octet >= 16 && octet <= 31) return true
|
|
30
|
+
}
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
|
|
10
34
|
/** Whether a request may read the status payload. Fail-closed. */
|
|
11
35
|
export function isTrustedRequest(req) {
|
|
12
36
|
if (!req) return false
|
|
13
|
-
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
14
|
-
const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
15
|
-
if (loopback) return true
|
|
16
37
|
|
|
17
|
-
//
|
|
38
|
+
// Fail-closed against cross-site fetches regardless of source IP
|
|
18
39
|
const secFetchSite = header(req, 'sec-fetch-site')
|
|
19
40
|
if (secFetchSite === 'cross-site') return false
|
|
20
41
|
|
|
21
|
-
const
|
|
42
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
43
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
44
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
45
|
+
|
|
46
|
+
const host = header(req, 'host')
|
|
22
47
|
const origin = header(req, 'origin') || header(req, 'referer')
|
|
23
|
-
if (!host || !origin) return false
|
|
24
48
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
49
|
+
// If host is provided, it MUST be a trusted local/private host (DNS rebinding guard)
|
|
50
|
+
if (host && !isTrustedHost(host)) return false
|
|
51
|
+
|
|
52
|
+
// If browser headers (origin/referer/sec-fetch-site) are absent and caller is loopback, allow local caller
|
|
53
|
+
if (!origin && !secFetchSite && loopback) return true
|
|
28
54
|
|
|
55
|
+
// If origin/referer is present, verify it matches host or is a trusted local host
|
|
56
|
+
if (origin) {
|
|
57
|
+
try {
|
|
58
|
+
const originUrl = new URL(origin)
|
|
59
|
+
if (!isTrustedHost(originUrl.hostname)) return false
|
|
60
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
29
66
|
|
|
67
|
+
// Non-loopback caller without origin/referer is refused
|
|
68
|
+
if (!loopback) return false
|
|
30
69
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const isPrivate =
|
|
34
|
-
hostname === 'localhost' ||
|
|
35
|
-
hostname === '127.0.0.1' ||
|
|
36
|
-
hostname === '::1' ||
|
|
37
|
-
/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
38
|
-
/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
39
|
-
/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname) ||
|
|
40
|
-
hostname.endsWith('.local') ||
|
|
41
|
-
hostname.endsWith('.lan')
|
|
70
|
+
return true
|
|
71
|
+
}
|
|
42
72
|
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
/** Whether a write/mutating request (e.g. POST /scm) is safe. Strict write guard against CSRF. */
|
|
74
|
+
export function isTrustedWriteRequest(req) {
|
|
75
|
+
if (!req) return false
|
|
76
|
+
if (!isTrustedRequest(req)) return false
|
|
77
|
+
|
|
78
|
+
const secFetchSite = header(req, 'sec-fetch-site')
|
|
79
|
+
if (secFetchSite && secFetchSite !== 'same-origin' && secFetchSite !== 'none') {
|
|
45
80
|
return false
|
|
46
81
|
}
|
|
82
|
+
|
|
83
|
+
const host = header(req, 'host')
|
|
84
|
+
const origin = header(req, 'origin') || header(req, 'referer')
|
|
85
|
+
if (origin) {
|
|
86
|
+
try {
|
|
87
|
+
const originUrl = new URL(origin)
|
|
88
|
+
if (host && originUrl.host.toLowerCase() !== host.toLowerCase()) return false
|
|
89
|
+
} catch {
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
} else {
|
|
93
|
+
// Write request without origin: only permit local loopback
|
|
94
|
+
const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
|
|
95
|
+
const cleanIp = address.replace(/^::ffff:/, '')
|
|
96
|
+
const loopback = cleanIp === '127.0.0.1' || cleanIp === '::1' || cleanIp.startsWith('127.')
|
|
97
|
+
if (!loopback) return false
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return true
|
|
47
101
|
}
|
|
48
102
|
|
|
49
103
|
function header(req, name) {
|
package/lib/tools/mirror.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
1
4
|
// Sanitized mirror publication.
|
|
2
5
|
//
|
|
3
6
|
// A GitHub mirror is a product channel, not a copy of the history: only the npm
|
|
@@ -76,7 +79,30 @@ async function readManifest(git, { cwd, ref }) {
|
|
|
76
79
|
* Read-only plan: which files would be published, how many would be left behind, and
|
|
77
80
|
* whether the publication must be refused (forbidden path present, required file missing).
|
|
78
81
|
*/
|
|
82
|
+
|
|
83
|
+
const REMOTE_NAME_RE = /^(?!-)[a-zA-Z0-9._/-]+$/
|
|
84
|
+
const BRANCH_NAME_RE = /^(?!-)[a-zA-Z0-9._/-]+$/
|
|
85
|
+
|
|
86
|
+
export function validateRemote(remote) {
|
|
87
|
+
const val = String(remote || '').trim()
|
|
88
|
+
if (!val || val.startsWith('-') || !REMOTE_NAME_RE.test(val)) {
|
|
89
|
+
throw new Error(`invalid mirror remote name: "${remote}"`)
|
|
90
|
+
}
|
|
91
|
+
return val
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function validateBranchName(branch) {
|
|
95
|
+
const val = String(branch || '').trim()
|
|
96
|
+
if (!val || val.startsWith('-') || !BRANCH_NAME_RE.test(val)) {
|
|
97
|
+
throw new Error(`invalid branch name: "${branch}"`)
|
|
98
|
+
}
|
|
99
|
+
return val
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
export async function planMirror({ git, cwd, ref = 'origin/main', allow, forbidden, required = DEFAULT_REQUIRED }) {
|
|
103
|
+
if (String(ref || '').trim().startsWith('-')) {
|
|
104
|
+
throw new Error(`invalid source ref: "${ref}"`)
|
|
105
|
+
}
|
|
80
106
|
const manifest = await readManifest(git, { cwd, ref })
|
|
81
107
|
const allowlist = allow || allowlistFromManifest(manifest)
|
|
82
108
|
const ls = await git(['ls-tree', '-r', '--name-only', ref], { cwd })
|
|
@@ -115,6 +141,11 @@ export async function publishMirror({
|
|
|
115
141
|
git, cwd, ref = 'origin/main', mirror = 'github', branch = 'main',
|
|
116
142
|
allow, forbidden, required = DEFAULT_REQUIRED, dryRun = false, message,
|
|
117
143
|
}) {
|
|
144
|
+
validateRemote(mirror)
|
|
145
|
+
validateBranchName(branch)
|
|
146
|
+
if (String(ref || '').trim().startsWith('-')) {
|
|
147
|
+
throw new Error(`invalid source ref: "${ref}"`)
|
|
148
|
+
}
|
|
118
149
|
const plan = await planMirror({ git, cwd, ref, allow, forbidden, required })
|
|
119
150
|
if (plan.refused) {
|
|
120
151
|
const err = new Error(plan.refused)
|
|
@@ -131,7 +162,7 @@ export async function publishMirror({
|
|
|
131
162
|
|
|
132
163
|
if (dryRun) return { ...plan, mirror: mirrorRef, parent, commit: null, pushed: false }
|
|
133
164
|
|
|
134
|
-
const indexFile =
|
|
165
|
+
const indexFile = path.join(tmpdir(), `dsh-mirror-${process.pid}-${randomUUID()}.index`)
|
|
135
166
|
const env = { GIT_INDEX_FILE: indexFile }
|
|
136
167
|
try {
|
|
137
168
|
await git(['read-tree', '--empty'], { cwd, env })
|
|
@@ -14,7 +14,7 @@ const REPOSITORY = {
|
|
|
14
14
|
repository: { type: 'string', description: 'Repository as "owner/repo". Omit to use the default from settings.' },
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
17
|
+
export function registerCoreTools({ tool, asRepo, client, liveConfig, defaultCwd }) {
|
|
18
18
|
const repoParams = (extra = {}) => ({ ...REPOSITORY, ...extra })
|
|
19
19
|
|
|
20
20
|
// ------------------------------------------------------------------ repository
|
|
@@ -223,6 +223,7 @@ export function registerCoreTools({ tool, asRepo, client, liveConfig }) {
|
|
|
223
223
|
name: args.name,
|
|
224
224
|
label: args.label,
|
|
225
225
|
contentType: args.contentType,
|
|
226
|
+
cwd: typeof defaultCwd === 'function' ? defaultCwd() : undefined,
|
|
226
227
|
})
|
|
227
228
|
},
|
|
228
229
|
)
|
package/lib/tools/releases.js
CHANGED
|
@@ -139,23 +139,61 @@ export async function listReleaseAssets(client, { owner, repo, tag, releaseId, l
|
|
|
139
139
|
return { assets, count: assets.length, releaseId: id, rateLimit }
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
-
|
|
142
|
+
const DISALLOWED_ROOTS = ['/etc', '/proc', '/sys', '/dev', '/root', '/bin', '/sbin', '/usr', '/boot', '/var/run']
|
|
143
|
+
|
|
144
|
+
function checkDisallowedRoots(resolvedPath, sep) {
|
|
145
|
+
for (const root of DISALLOWED_ROOTS) {
|
|
146
|
+
if (resolvedPath === root || resolvedPath.startsWith(root + sep) || resolvedPath.startsWith(root + '/')) {
|
|
147
|
+
throw new Error(`access to system directory "${root}" is not allowed`)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function uploadReleaseAsset(client, { owner, repo, tag, releaseId, filePath, name, label, contentType, cwd }) {
|
|
143
153
|
ensureRepo({ owner, repo })
|
|
144
|
-
if (!filePath) throw new Error('filePath is required')
|
|
145
|
-
|
|
154
|
+
if (!filePath || typeof filePath !== 'string') throw new Error('filePath is required')
|
|
155
|
+
if (/[\x00-\x1f]/.test(filePath)) throw new Error('filePath may not contain control characters')
|
|
146
156
|
|
|
147
157
|
const fs = await import('node:fs/promises')
|
|
148
158
|
const path = await import('node:path')
|
|
149
159
|
|
|
160
|
+
const baseDir = cwd ? path.resolve(cwd) : null
|
|
161
|
+
const resolvedPath = baseDir ? path.resolve(baseDir, filePath) : path.resolve(filePath)
|
|
162
|
+
|
|
163
|
+
if (baseDir) {
|
|
164
|
+
if (resolvedPath !== baseDir && !resolvedPath.startsWith(baseDir + path.sep)) {
|
|
165
|
+
throw new Error(`filePath must reside within the workspace directory: ${filePath}`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
checkDisallowedRoots(resolvedPath, path.sep)
|
|
170
|
+
|
|
171
|
+
let realTarget = resolvedPath
|
|
172
|
+
try {
|
|
173
|
+
realTarget = await fs.realpath(resolvedPath)
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new Error(`cannot access file "${filePath}": ${err && err.message}`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
checkDisallowedRoots(realTarget, path.sep)
|
|
179
|
+
|
|
180
|
+
if (baseDir) {
|
|
181
|
+
const realBase = await fs.realpath(baseDir).catch(() => baseDir)
|
|
182
|
+
if (realTarget !== realBase && !realTarget.startsWith(realBase + path.sep)) {
|
|
183
|
+
throw new Error(`filePath must reside within the workspace directory: ${filePath}`)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
150
187
|
let stats
|
|
151
188
|
try {
|
|
152
|
-
stats = await fs.stat(
|
|
189
|
+
stats = await fs.stat(realTarget)
|
|
153
190
|
} catch (err) {
|
|
154
191
|
throw new Error(`cannot access file "${filePath}": ${err && err.message}`)
|
|
155
192
|
}
|
|
156
193
|
if (!stats.isFile()) throw new Error(`"${filePath}" is not a file`)
|
|
157
194
|
|
|
158
|
-
const
|
|
195
|
+
const id = await resolveReleaseId(client, { owner, repo, tag, releaseId })
|
|
196
|
+
const fileBuffer = await fs.readFile(realTarget)
|
|
159
197
|
const assetName = name || path.basename(filePath)
|
|
160
198
|
const mimeType = contentType || 'application/octet-stream'
|
|
161
199
|
|
package/lib/tools/repos.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeRepoPath } from '../github.js'
|
|
1
2
|
// Repository and file operations: metadata, file content at a ref, repository
|
|
2
3
|
// search (its own quota), plus repository creation and edits.
|
|
3
4
|
|
|
@@ -36,8 +37,8 @@ export async function getRepo(client, { owner, repo }) {
|
|
|
36
37
|
|
|
37
38
|
export async function getFile(client, { owner, repo, path, ref }) {
|
|
38
39
|
ensureRepo({ owner, repo })
|
|
39
|
-
|
|
40
|
-
const { data } = await client.get(`/repos/${owner}/${repo}/contents/${
|
|
40
|
+
const encodedPath = encodeRepoPath(path)
|
|
41
|
+
const { data } = await client.get(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
41
42
|
query: ref ? { ref } : undefined,
|
|
42
43
|
})
|
|
43
44
|
if (Array.isArray(data)) {
|
package/lib/tools/write-files.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { encodeRepoPath } from '../github.js'
|
|
1
2
|
// Writing files without a local checkout.
|
|
2
3
|
//
|
|
3
4
|
// A commit is built the way git builds it: blobs, a tree on top of the branch's tree, a
|
|
@@ -185,14 +186,14 @@ export async function uploadProject(client, {
|
|
|
185
186
|
/** Delete one file, resolving its blob sha first (the contents API requires it). */
|
|
186
187
|
export async function deleteFile(client, { owner, repo, path, message, branch } = {}) {
|
|
187
188
|
ensureRepo({ owner, repo })
|
|
188
|
-
if (!path) throw new Error('path is required')
|
|
189
189
|
if (!message) throw new Error('message is required')
|
|
190
|
-
const
|
|
190
|
+
const encodedPath = encodeRepoPath(path)
|
|
191
|
+
const { data: current } = await client.get(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
191
192
|
query: branch ? { ref: branch } : undefined,
|
|
192
193
|
})
|
|
193
194
|
const sha = current && current.sha
|
|
194
195
|
if (!sha) throw new Error(`cannot resolve ${path}`)
|
|
195
|
-
const { data } = await client.del(`/repos/${owner}/${repo}/contents/${
|
|
196
|
+
const { data } = await client.del(`/repos/${owner}/${repo}/contents/${encodedPath}`, {
|
|
196
197
|
body: { message, sha, ...(branch ? { branch } : {}) },
|
|
197
198
|
})
|
|
198
199
|
return { deleted: true, path, commit: data && data.commit ? data.commit.sha : null }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-github-ops",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.11",
|
|
4
4
|
"description": "GitHub operations for DeepSeek Harness: releases, tags, a guarded generic API pass-through, workflow runs, secrets and sanitized mirror publication.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|