@goodandready/dsh-github-ops 0.2.4 → 0.2.5

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 CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  Notable changes to `@goodandready/dsh-github-ops`.
4
4
 
5
+ ## 0.2.5
6
+
7
+ ### Fixed
8
+ - Fixed injected dependencies in `registerRoutes` throwing errors when accessing status and SCM endpoints (#53).
9
+ - Fixed `url` ReferenceError before initialization in SCM route query parameter parsing (#54).
10
+ - Hardened SCM route with fail-closed origin check and restricted allowed repository paths (#55).
11
+ - Fixed UI surface registrations (sidebar, PR bar, Source Control tab) getting cut off by misplaced apply closing bracket in client loader (#56).
12
+ - Fixed cache invalidation on mutating write tools and restored `cacheStats` via `clientHolder` (#57).
13
+ - Removed unused dead export `identityMeta` from `lib/present.js` (#58).
14
+ - Eliminated hardcoded color fallbacks in client styling (#61).
15
+ - Ignored internal planning and agent artifacts in git index (#60).
16
+
17
+ ### Added
18
+ - One-click plugin updater in settings via standard `registerPluginUpdater` endpoint (#59).
19
+
5
20
  ## 0.2.4
6
21
 
7
22
  ### Fixed
package/lib/client.js CHANGED
@@ -740,7 +740,7 @@ window.__ModuleLoader__.load({
740
740
  '.gho-status-key{min-width:88px;color:var(--dsw-alias-label-tertiary)}',
741
741
  '.gho-status-value{color:var(--dsw-alias-label-primary);font-variant-numeric:tabular-nums}',
742
742
  '.gho-status-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary)}',
743
- '.gho-status-bad{color:var(--dsw-alias-label-error,#c0392b)}',
743
+ '.gho-status-bad{color:var(--dsw-alias-label-error)}',
744
744
  '.gho-link{appearance:none;border:0;background:0 0;font:inherit;font-size:12px;color:var(--dsw-alias-label-secondary);text-decoration:underline;cursor:pointer;padding:0;margin-left:auto}',
745
745
  '.gho-scm{display:flex;flex-direction:column;gap:12px;padding:4px 2px;font-size:13px;color:var(--dsw-alias-label-primary)}',
746
746
  '.gho-scm-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}',
@@ -776,10 +776,10 @@ window.__ModuleLoader__.load({
776
776
  '.gho-scm-bar input[type="text"]{flex:1;min-width:160px;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);padding:0 8px;font:inherit}',
777
777
  '.gho-scm-btn{appearance:none;font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--dsw-alias-border-l2);background:0 0;color:var(--dsw-alias-label-primary);border-radius:8px;padding:3px 8px}',
778
778
  '.gho-scm-btn[disabled]{opacity:.5;cursor:default}',
779
- '.gho-scm-btn-warn{border-color:var(--dsw-alias-label-error,#c0392b);color:var(--dsw-alias-label-error,#c0392b)}',
779
+ '.gho-scm-btn-warn{border-color:var(--dsw-alias-label-error);color:var(--dsw-alias-label-error)}',
780
780
  '.gho-scm-actions{display:flex;gap:4px;margin-left:auto}',
781
781
  '.gho-scm-check{display:flex;align-items:center;gap:4px;color:var(--dsw-alias-label-secondary)}',
782
- '.gho-scm-error{color:var(--dsw-alias-label-error,#c0392b)}',
782
+ '.gho-scm-error{color:var(--dsw-alias-label-error)}',
783
783
  '.gho-input-area{height:auto;min-height:64px;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical}',
784
784
  '.gho-input{height:34px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:8px;padding:0 12px;font-size:13px}',
785
785
  '.gho-foot{border-top:1px solid var(--dsw-alias-border-l2);display:flex;justify-content:flex-end;align-items:center;gap:8px;padding:12px 0 4px}',
@@ -1024,7 +1024,6 @@ window.__ModuleLoader__.load({
1024
1024
  /* a core without this seat: the remaining seats still carry the card */
1025
1025
  }
1026
1026
  }
1027
- }
1028
1027
 
1029
1028
  // The panel goes into the sidebar: better-sidebar first (its contract is explicit), and
1030
1029
  // the built-in right sidebar through its tab registry. Both are feature-detected, so a
@@ -1089,6 +1088,7 @@ window.__ModuleLoader__.load({
1089
1088
  } catch {
1090
1089
  /* a core without the session view ring: the rest of the plugin still works */
1091
1090
  }
1091
+ }
1092
1092
 
1093
1093
  exports.inject = ['slots', 'locale', 'configForms']
1094
1094
  exports.apply = apply
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { registerPluginUpdater } from './updater.js'
1
2
  // dsh-github-ops — host half.
2
3
  //
3
4
  // Gives the agent the GitHub operations the release pipeline needs and the plugin
@@ -303,7 +304,7 @@ export function apply(ctx, rawConfig) {
303
304
  // A write may invalidate what a cached read returned.
304
305
  if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
305
306
  try {
306
- sharedClient?.cacheClear?.()
307
+ clientHolder.currentClient()?.cacheClear?.()
307
308
  } catch {
308
309
  // a cache reset must never fail a successful call
309
310
  }
@@ -389,13 +390,28 @@ export function apply(ctx, rawConfig) {
389
390
  },
390
391
  })
391
392
 
393
+
394
+ ctx.inject(['webServer'], (wctx) => {
395
+ try {
396
+ const mount = () => registerPluginUpdater(wctx, {
397
+ endpoint: '/dsh-github-ops/update',
398
+ packageName: '@goodandready/dsh-github-ops',
399
+ manifestUrl: new URL('../package.json', import.meta.url),
400
+ })
401
+ if (typeof wctx.effect === 'function') wctx.effect(mount, 'dsh-github-ops: updater')
402
+ else mount()
403
+ } catch (err) {
404
+ ctx.logger?.warn?.('[dsh-github-ops] updater route not registered:', err && err.message)
405
+ }
406
+ })
407
+
392
408
  registerRoutes(ctx, {
393
409
  resolveToken,
394
410
  client,
395
411
  liveConfig,
396
412
  describeError,
397
413
  makeGitRunner,
398
- cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
414
+ cacheStats: () => clientHolder.currentClient()?.cacheStats?.() || null,
399
415
  })
400
416
 
401
417
  // One short paragraph in the system prompt: 50+ tools otherwise leave the model guessing.
package/lib/present.js CHANGED
@@ -29,8 +29,6 @@ function repoOf(args) {
29
29
  return args && args.repository ? String(args.repository) : 'the default repository'
30
30
  }
31
31
 
32
- export const identityMeta = (args) => ({ title: String((args && args.repository) || '') })
33
-
34
32
  const PRESENTATIONS = {
35
33
  gh_repo: {
36
34
  call: (args) => pending(`Read repository ${repoOf(args)}`, pick(args, ['repository'])),
package/lib/routes.js CHANGED
@@ -31,7 +31,15 @@ export const PANEL_PATH = '/dsh-github-ops/panel'
31
31
  * @param deps - resolveToken, client, liveConfig, describeError, makeGitRunner and cacheStats,
32
32
  * injected so this module stays free of the host's own composition.
33
33
  */
34
- export function registerRoutes(ctx, deps) {
34
+ export function registerRoutes(ctx, deps = {}) {
35
+ const {
36
+ resolveToken = async () => ({ value: '', source: '', guidance: '' }),
37
+ client = async () => null,
38
+ liveConfig = () => ({}),
39
+ describeError = (e) => String((e && e.message) || e),
40
+ makeGitRunner = () => () => ({ code: 1, stdout: '', stderr: 'git not available' }),
41
+ cacheStats = () => null,
42
+ } = deps
35
43
  // The card reads the access status from here instead of from a tool result: the model has
36
44
  // no business holding the access value, and the payload describes this machine's account,
37
45
  // so only a local or same-origin caller is answered.
@@ -64,7 +72,7 @@ export function registerRoutes(ctx, deps) {
64
72
  identity,
65
73
  rateLimit,
66
74
  scopes,
67
- cache: deps.cacheStats ? deps.cacheStats() : null,
75
+ cache: typeof cacheStats === 'function' ? cacheStats() : null,
68
76
  interceptLinks: liveConfig().interceptLinks === true,
69
77
  error,
70
78
  }))
@@ -88,6 +96,10 @@ export function registerRoutes(ctx, deps) {
88
96
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
89
97
  if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
90
98
  const git = makeGitRunner()
99
+ const url = new URL(req.url || '/', 'http://localhost')
100
+ const file = url.searchParams.get('file') || ''
101
+ const staged = url.searchParams.get('staged') === '1'
102
+
91
103
  // The tab may look at another directory than the session workspace; the value is
92
104
  // validated, must exist and must be a directory.
93
105
  const requested = validateCwd(url.searchParams.get('cwd') || '')
@@ -98,9 +110,6 @@ export function registerRoutes(ctx, deps) {
98
110
  }
99
111
  cwd = requested
100
112
  }
101
- const url = new URL(req.url || '/', 'http://localhost')
102
- const file = url.searchParams.get('file') || ''
103
- const staged = url.searchParams.get('staged') === '1'
104
113
 
105
114
  if (req.method === 'POST') {
106
115
  // A write always comes from a click in the panel. The body names one action; the
package/lib/scm-write.js CHANGED
Binary file
package/lib/status.js CHANGED
@@ -13,11 +13,34 @@ export function isTrustedRequest(req) {
13
13
  const address = (req.socket && (req.socket.remoteAddress || req.socket.address?.().address)) || ''
14
14
  const loopback = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
15
15
  if (loopback) return true
16
- const host = header(req, 'host')
17
- const origin = header(req, 'origin')
16
+
17
+ // For non-loopback callers: fail-closed source verification
18
+ const secFetchSite = header(req, 'sec-fetch-site')
19
+ if (secFetchSite === 'cross-site') return false
20
+
21
+ const host = header(req, 'x-forwarded-host') || header(req, 'host')
22
+ const origin = header(req, 'origin') || header(req, 'referer')
18
23
  if (!host || !origin) return false
24
+
19
25
  try {
20
- return new URL(origin).host === host
26
+ const originUrl = new URL(origin)
27
+ if (originUrl.host.toLowerCase() !== host.toLowerCase()) return false
28
+
29
+
30
+
31
+ // DNS rebinding guard: host must be a loopback, local or private LAN address
32
+ const hostname = host.split(':')[0].toLowerCase()
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')
42
+
43
+ return isPrivate
21
44
  } catch {
22
45
  return false
23
46
  }
package/lib/updater.js ADDED
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Host-side one-click updater for @goodandready/dsh-github-ops.
3
+ * Conforms to the DSH authoring reference implementation.
4
+ */
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { homedir } from 'node:os'
10
+ import { basename, dirname, isAbsolute, resolve } from 'node:path'
11
+ import { fileURLToPath } from 'node:url'
12
+
13
+ const UPDATE_HEADER = 'x-dsh-plugin-update'
14
+ const UPDATE_TIMEOUT_MS = 10 * 60_000
15
+ const VERSION_CACHE_MS = 5 * 60_000
16
+ let latestCache = undefined
17
+
18
+ function header(request, name) {
19
+ const value = request?.headers?.[name]
20
+ return Array.isArray(value) ? value[0] : value
21
+ }
22
+
23
+ function isLoopback(value) {
24
+ const address = value?.toLowerCase().replace(/^\[|\]$/g, '')
25
+ return address === 'localhost' || address === 'localhost.' || address === '::1'
26
+ || address?.startsWith('127.') === true
27
+ || address?.startsWith('::ffff:127.') === true
28
+ }
29
+
30
+ export function isTrustedUpdateRequest(request) {
31
+ if (header(request, UPDATE_HEADER) !== '1') return false
32
+ if (!isLoopback(request?.socket?.remoteAddress)) return false
33
+ const site = header(request, 'sec-fetch-site')
34
+ if (site !== undefined && site !== 'same-origin') return false
35
+ const origin = header(request, 'origin')
36
+ const host = header(request, 'host')
37
+ if (origin === undefined || host === undefined) return false
38
+ try {
39
+ const url = new URL(origin)
40
+ return (url.protocol === 'http:' || url.protocol === 'https:')
41
+ && isLoopback(url.hostname) && url.host === host
42
+ } catch {
43
+ return false
44
+ }
45
+ }
46
+
47
+ function validProfileName(value) {
48
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
49
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value)
50
+ }
51
+
52
+ function profileNameFromArgv(argv) {
53
+ if (!Array.isArray(argv)) return undefined
54
+ for (let index = 2; index < argv.length; index += 1) {
55
+ if (argv[index] === '--profile') return argv[index + 1]
56
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length)
57
+ }
58
+ return argv[2] === 'web' ? 'web' : undefined
59
+ }
60
+
61
+ function findDshCliEntry() {
62
+ const value = process.argv[1]
63
+ if (value === undefined || value === '') return undefined
64
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value)
65
+ if (!existsSync(entry)) return undefined
66
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
67
+ const manifestPath = resolve(directory, 'package.json')
68
+ if (existsSync(manifestPath)) {
69
+ try {
70
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
71
+ const bin = typeof manifest.bin === 'string'
72
+ ? manifest.bin
73
+ : typeof manifest.bin === 'object' && manifest.bin !== null
74
+ ? manifest.bin.dsh
75
+ : undefined
76
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
77
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry
78
+ } catch {
79
+ /* continue */
80
+ }
81
+ }
82
+ const parent = dirname(directory)
83
+ if (parent === directory) return undefined
84
+ }
85
+ }
86
+
87
+ function detectRuntime() {
88
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
89
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'))
90
+ const selected = profileNameFromArgv(process.argv)
91
+ const profileName = validProfileName(selected)
92
+ ? selected
93
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web'
94
+ const cliEntry = findDshCliEntry()
95
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry }
96
+ }
97
+
98
+ function parseSemver(value) {
99
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || ''))
100
+ if (match === null) return undefined
101
+ return {
102
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
103
+ prerelease: match[4]?.split('.') ?? [],
104
+ }
105
+ }
106
+
107
+ function comparePrerelease(candidateParts, currentParts) {
108
+ // A normal version with no pre-release tag is newer than a pre-release version
109
+ if (candidateParts.length === 0 && currentParts.length > 0) return 1
110
+ if (candidateParts.length > 0 && currentParts.length === 0) return -1
111
+ if (candidateParts.length === 0 && currentParts.length === 0) return 0
112
+
113
+ const len = Math.max(candidateParts.length, currentParts.length)
114
+ for (let i = 0; i < len; i += 1) {
115
+ const a = candidateParts[i]
116
+ const b = currentParts[i]
117
+ if (a === undefined) return -1
118
+ if (b === undefined) return 1
119
+ if (a === b) continue
120
+
121
+ const aNum = /^\d+$/.test(a) ? Number(a) : undefined
122
+ const bNum = /^\d+$/.test(b) ? Number(b) : undefined
123
+
124
+ if (aNum !== undefined && bNum !== undefined) {
125
+ return aNum > bNum ? 1 : -1
126
+ }
127
+ if (aNum !== undefined && bNum === undefined) {
128
+ return -1
129
+ }
130
+ if (aNum === undefined && bNum !== undefined) {
131
+ return 1
132
+ }
133
+ return a.localeCompare(b) > 0 ? 1 : -1
134
+ }
135
+ return 0
136
+ }
137
+
138
+ export function isNewerVersion(currentValue, candidateValue) {
139
+ const current = parseSemver(currentValue)
140
+ const candidate = parseSemver(candidateValue)
141
+ if (current === undefined || candidate === undefined) return false
142
+ for (let index = 0; index < 3; index += 1) {
143
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index]
144
+ }
145
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0
146
+ }
147
+
148
+ async function fetchLatestVersion(packageName, registry) {
149
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
150
+ return latestCache.version
151
+ }
152
+ try {
153
+ const reg = registry.replace(/\/+$/, '')
154
+ const response = await fetch(`${reg}/${encodeURIComponent(packageName)}/latest`, {
155
+ signal: AbortSignal.timeout(6000),
156
+ })
157
+ if (!response.ok) return undefined
158
+ const value = await response.json()
159
+ if (typeof value?.version !== 'string' || !value.version) return undefined
160
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS }
161
+ return value.version
162
+ } catch {
163
+ return undefined
164
+ }
165
+ }
166
+
167
+ async function readCurrentVersion(manifestUrl) {
168
+ const value = JSON.parse(await readFile(manifestUrl, 'utf8'))
169
+ if (typeof value?.version !== 'string' || !value.version) throw new Error('Cannot read current plugin version.')
170
+ return value.version
171
+ }
172
+
173
+ export async function checkUpdateStatus(options, target = detectRuntime()) {
174
+ const current = await readCurrentVersion(options.manifestUrl)
175
+ const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
176
+ return {
177
+ packageName: options.packageName,
178
+ currentVersion: current,
179
+ ...(latest === undefined ? {} : { latestVersion: latest }),
180
+ latestCheckFailed: latest === undefined,
181
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
182
+ profileName: target.profileName,
183
+ canAutoUpdate: target.cliEntry !== undefined,
184
+ }
185
+ }
186
+
187
+ async function installExact(target, packageSpec, options) {
188
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.')
189
+ await new Promise((resolvePromise, reject) => {
190
+ const child = spawn(process.execPath, [
191
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
192
+ '--config.minimumReleaseAge=0', packageSpec,
193
+ `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
194
+ ], {
195
+ cwd: target.profileDir,
196
+ windowsHide: true,
197
+ stdio: ['ignore', 'pipe', 'pipe'],
198
+ env: { ...process.env, NO_COLOR: '1' },
199
+ })
200
+ let detail = ''
201
+ child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
202
+ child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
203
+ const timer = setTimeout(() => {
204
+ child.kill()
205
+ reject(new Error('Update timed out.'))
206
+ }, UPDATE_TIMEOUT_MS)
207
+ child.once('error', (error) => { clearTimeout(timer); reject(error) })
208
+ child.once('exit', (code) => {
209
+ clearTimeout(timer)
210
+ if (code === 0) resolvePromise()
211
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`))
212
+ })
213
+ })
214
+ }
215
+
216
+ function writeResponseJson(response, statusCode, value) {
217
+ response.writeHead(statusCode, {
218
+ 'content-type': 'application/json; charset=utf-8',
219
+ 'cache-control': 'no-store',
220
+ })
221
+ response.end(JSON.stringify(value))
222
+ }
223
+
224
+ export function registerPluginUpdater(ctx, options) {
225
+ if (!ctx?.webServer?.register) return () => {}
226
+ let installing = false
227
+ return ctx.webServer.register({
228
+ kind: 'exact',
229
+ path: options.endpoint,
230
+ handler: async (request, response) => {
231
+ try {
232
+ const target = detectRuntime()
233
+ if (request.method === 'GET' || request.method === 'HEAD') {
234
+ const payload = await checkUpdateStatus(options, target)
235
+ writeResponseJson(response, 200, payload)
236
+ return
237
+ }
238
+ if (request.method !== 'POST') {
239
+ response.writeHead(405, { allow: 'GET, HEAD, POST' })
240
+ response.end()
241
+ return
242
+ }
243
+ if (!isTrustedUpdateRequest(request)) {
244
+ writeResponseJson(response, 403, { ok: false, error: 'Rejected non-local or cross-origin update request.' })
245
+ return
246
+ }
247
+ if (installing) {
248
+ writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
249
+ return
250
+ }
251
+ installing = true
252
+ try {
253
+ const before = await checkUpdateStatus(options, target)
254
+ if (before.latestVersion === undefined) {
255
+ writeResponseJson(response, 503, { ok: false, error: 'The latest version is temporarily unavailable.' })
256
+ return
257
+ }
258
+ if (!before.updateAvailable) {
259
+ writeResponseJson(response, 200, before)
260
+ return
261
+ }
262
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options)
263
+ writeResponseJson(response, 200, {
264
+ ...before,
265
+ updatedVersion: before.latestVersion,
266
+ restartRequired: true,
267
+ })
268
+ } finally {
269
+ installing = false
270
+ }
271
+ } catch (error) {
272
+ writeResponseJson(response, 500, { ok: false, error: String(error?.message || error) })
273
+ }
274
+ },
275
+ })
276
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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",