@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 +15 -0
- package/lib/client.js +4 -4
- package/lib/index.js +18 -2
- package/lib/present.js +0 -2
- package/lib/routes.js +14 -5
- package/lib/scm-write.js +0 -0
- package/lib/status.js +26 -3
- package/lib/updater.js +276 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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: () => (
|
|
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:
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|