@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 +20 -0
- package/lib/auth.js +64 -0
- package/lib/client.js +4 -4
- package/lib/index.js +29 -32
- 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,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
|
|
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
|
|
@@ -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
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: () => (
|
|
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:
|
|
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",
|