@goodandready/dsh-github-ops 0.2.3 → 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,26 @@
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
+
20
+ ## 0.2.4
21
+
22
+ ### Fixed
23
+ - Fixed GitHub client cache holding onto stale credentials when rotated with a token of the exact same length: cache key now incorporates a non-reversible sha256 token fingerprint (#38).
24
+
5
25
  ## 0.2.3
6
26
 
7
27
  ### Added
package/lib/auth.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from 'node:crypto'
1
2
  // Access resolution across the sources a GitHub user actually has.
2
3
  //
3
4
  // Order (tokenSource: "auto"): the DSH credentials service, then the environment variable of
@@ -69,3 +70,66 @@ export async function resolveAccess({ credentials, tokenEnv, tokenSource = 'auto
69
70
  }
70
71
  return { value: '', source: '', guidance: NO_ACCESS_GUIDANCE }
71
72
  }
73
+ /**
74
+ * Deterministic non-reversible token fingerprint for client cache keys:
75
+ * differentiates tokens of identical length without leaking token content into logs or keys.
76
+ */
77
+ export function tokenFingerprint(value) {
78
+ if (!value || typeof value !== 'string') return ''
79
+ return createHash('sha256').update(value).digest('hex').slice(0, 16)
80
+ }
81
+
82
+ /**
83
+ * Cache key for shared GitHub client instances.
84
+ * Incorporates the token fingerprint (not length) so rotation between equal-length tokens
85
+ * creates a new client and uses the new Bearer immediately.
86
+ */
87
+ export function clientCacheKey(access, cfg = {}) {
88
+ return [
89
+ access?.source || '',
90
+ tokenFingerprint(access?.value),
91
+ cfg?.baseUrl || '',
92
+ cfg?.timeoutMs || '',
93
+ cfg?.cacheTtlMs || '',
94
+ cfg?.maxRetries ?? '',
95
+ ].join('|')
96
+ }
97
+
98
+ /**
99
+ * Manages the shared GitHub client instance:
100
+ * caches the client while the key matches; recreates it when the token or config changes.
101
+ */
102
+ export function createClientHolder({ createClient, signalProvider } = {}) {
103
+ let sharedClient = null
104
+ let sharedKey = ''
105
+
106
+ return {
107
+ get(access, cfg = {}) {
108
+ if (!access || !access.value) {
109
+ throw new Error(`GitHub access is not configured: ${access?.guidance || NO_ACCESS_GUIDANCE}`)
110
+ }
111
+ const key = clientCacheKey(access, cfg)
112
+ if (!sharedClient || sharedKey !== key) {
113
+ if (typeof createClient !== 'function') {
114
+ throw new Error('createClient factory must be a function')
115
+ }
116
+ sharedClient = createClient({
117
+ token: access.value,
118
+ baseUrl: cfg.baseUrl || 'https://api.github.com',
119
+ timeoutMs: Number(cfg.timeoutMs) || 30000,
120
+ cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
121
+ maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
122
+ signalProvider,
123
+ })
124
+ sharedKey = key
125
+ }
126
+ return sharedClient
127
+ },
128
+ currentKey: () => sharedKey,
129
+ currentClient: () => sharedClient,
130
+ reset: () => {
131
+ sharedClient = null
132
+ sharedKey = ''
133
+ },
134
+ }
135
+ }
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
@@ -15,7 +16,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools'
15
16
  import { credentialRef } from '@deepseek-ai/dsh-credentials'
16
17
 
17
18
  import { createGitHubClient, parseRepoSpec, describeError, GitHubError } from './github.js'
18
- import { resolveAccess, NO_ACCESS_GUIDANCE } from './auth.js'
19
+ import { resolveAccess, NO_ACCESS_GUIDANCE, createClientHolder } from './auth.js'
19
20
  import { resolveRepoContext } from './repo-context.js'
20
21
  import { presentationFor } from './present.js'
21
22
  import { createRecords, startReviewJob } from './jobs.js'
@@ -233,39 +234,20 @@ export function apply(ctx, rawConfig) {
233
234
  }
234
235
 
235
236
  // One client per configuration: the read cache lives in it, and the signal for the
236
- // current tool call arrives through the async context.
237
- let sharedClient = null
238
- let sharedKey = ''
237
+ // current tool call arrives through the async context. Token rotation immediately recreates
238
+ // the client even when the new token has the exact same length.
239
+ const clientHolder = createClientHolder({
240
+ createClient: createGitHubClient,
241
+ signalProvider: () => {
242
+ const store = callContext.getStore()
243
+ return store && store.signal ? store.signal : undefined
244
+ },
245
+ })
239
246
 
240
247
  async function client() {
241
248
  const cfg = liveConfig()
242
249
  const access = await resolveToken()
243
- if (!access.value) {
244
- throw new Error(`GitHub access is not configured: ${access.guidance || NO_ACCESS_GUIDANCE}`)
245
- }
246
- const key = [
247
- access.source,
248
- access.value.length,
249
- cfg.baseUrl || '',
250
- cfg.timeoutMs || '',
251
- cfg.cacheTtlMs || '',
252
- cfg.maxRetries ?? '',
253
- ].join('|')
254
- if (!sharedClient || sharedKey !== key) {
255
- sharedClient = createGitHubClient({
256
- token: access.value,
257
- baseUrl: cfg.baseUrl || 'https://api.github.com',
258
- timeoutMs: Number(cfg.timeoutMs) || 30000,
259
- cacheTtlMs: Number.isFinite(Number(cfg.cacheTtlMs)) ? Number(cfg.cacheTtlMs) : 60000,
260
- maxRetries: Number.isFinite(Number(cfg.maxRetries)) ? Number(cfg.maxRetries) : 2,
261
- signalProvider: () => {
262
- const store = callContext.getStore()
263
- return store && store.signal ? store.signal : undefined
264
- },
265
- })
266
- sharedKey = key
267
- }
268
- return sharedClient
250
+ return clientHolder.get(access, cfg)
269
251
  }
270
252
 
271
253
  /**
@@ -322,7 +304,7 @@ export function apply(ctx, rawConfig) {
322
304
  // A write may invalidate what a cached read returned.
323
305
  if (WRITE_PREFIXES.some((prefix) => toolName === prefix || toolName.startsWith(prefix))) {
324
306
  try {
325
- sharedClient?.cacheClear?.()
307
+ clientHolder.currentClient()?.cacheClear?.()
326
308
  } catch {
327
309
  // a cache reset must never fail a successful call
328
310
  }
@@ -408,13 +390,28 @@ export function apply(ctx, rawConfig) {
408
390
  },
409
391
  })
410
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
+
411
408
  registerRoutes(ctx, {
412
409
  resolveToken,
413
410
  client,
414
411
  liveConfig,
415
412
  describeError,
416
413
  makeGitRunner,
417
- cacheStats: () => (sharedClient && typeof sharedClient.cacheStats === 'function' ? sharedClient.cacheStats() : null),
414
+ cacheStats: () => clientHolder.currentClient()?.cacheStats?.() || null,
418
415
  })
419
416
 
420
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.3",
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",