@goodandready/dsh-github-ops 0.2.12 → 0.3.1

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
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1
4
+
5
+ ### Fixed
6
+ - **Cordis Context CWD Guard & SCM Route Exception Shield** (#70, #76): Added `safeCwd(ctx)` helper across routes and tools to prevent `Error: cannot get property "cwd" without inject` when accessing undeclared context properties in Cordis 8+ / DSH 0.2 runtimes. Wrapped SCM HTTP route handler in top-level error handling.
7
+
8
+ ## 0.3.0
9
+
10
+ ### Features
11
+ - **Token Capability Diagnostics & Scopes Audit** (#72): Introduced `gh_token_audit` tool (`auditTokenScopes`) providing automated token permission checks (classic vs fine-grained) and early capability warnings for missing repository, workflow, packages, or org administration scopes. Added `scopeWarnings` in `/status` payload.
12
+ - **Selective CI Failure Log Inspector** (#67): Added `gh_run_failure_inspector` tool with regex-based failure step isolation, automatic ANSI sequence stripping, secret token masking, and highlighted failure summaries without needing to download full workflow artifact archives.
13
+ - **Interactive Stash Management & Diff Preview** (#70): Added stash diff preview endpoint (`GET /scm?stash=stash@{N}`) and full lifecycle actions (`stashShow`, `stashApply`, `stashPop`, `stashDrop` with confirmation safety). Updated Source Control UI with quick diff viewer and stash operation buttons.
14
+
3
15
  ## 0.2.12
4
16
 
5
17
  ### Security
package/lib/client.js CHANGED
@@ -101,6 +101,7 @@ window.__ModuleLoader__.load({
101
101
  scmDelete: 'Delete',
102
102
  scmSwitch: 'Switch',
103
103
  scmStashSave: 'Save stash',
104
+ scmStashView: 'Diff',
104
105
  scmStashApply: 'Apply',
105
106
  scmStashPop: 'Pop',
106
107
  scmStashDrop: 'Drop',
@@ -203,6 +204,7 @@ window.__ModuleLoader__.load({
203
204
  scmDelete: '删除',
204
205
  scmSwitch: '切换',
205
206
  scmStashSave: '保存 stash',
207
+ scmStashView: '差异',
206
208
  scmStashApply: '应用',
207
209
  scmStashPop: '弹出',
208
210
  scmStashDrop: '删除',
@@ -328,7 +330,17 @@ window.__ModuleLoader__.load({
328
330
  const openDiff = (file, staged) => {
329
331
  setSelected({ path: file.path, staged })
330
332
  setDiff({ phase: 'loading', text: '' })
331
- const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}`
333
+ const query = `?file=${encodeURIComponent(file.path)}${staged ? '&staged=1' : ''}${cwdQuery ? `&${cwdQuery}` : ''}`
334
+ fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
335
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
336
+ .then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
337
+ .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
338
+ }
339
+
340
+ const openStashDiff = (stashRef) => {
341
+ setSelected({ path: stashRef, isStash: true })
342
+ setDiff({ phase: 'loading', text: '' })
343
+ const query = `?stash=${encodeURIComponent(stashRef)}${cwdQuery ? `&${cwdQuery}` : ''}`
332
344
  fetchWithTimeout(SCM_PATH + query, { headers: { accept: 'application/json' } })
333
345
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
334
346
  .then((payload) => setDiff({ phase: 'ready', text: payload.diff || '' }))
@@ -460,6 +472,7 @@ window.__ModuleLoader__.load({
460
472
  onChange: (event) => setStashMessage(event.target.value),
461
473
  }),
462
474
  button(t('scmStashSave'), () => runAction('stashPush', { message: stashMessage }), { disabled: Boolean(busy) }),
475
+ (payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashView')} ${entry.ref}`, () => openStashDiff(entry.ref))),
463
476
  (payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashApply')} ${entry.ref}`, () => runAction('stashApply', { ref: entry.ref }))),
464
477
  (payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashPop')} ${entry.ref}`, () => runAction('stashPop', { ref: entry.ref }))),
465
478
  (payload.stashes || []).slice(0, 4).map((entry) => button(
@@ -1128,7 +1141,7 @@ window.__ModuleLoader__.load({
1128
1141
  exports.SCM_ACTIONS = [
1129
1142
  'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
1130
1143
  'branchCreate', 'branchCheckout', 'branchDelete',
1131
- 'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'continue', 'abort',
1144
+ 'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow', 'continue', 'abort',
1132
1145
  ]
1133
1146
  exports.PullRequestBar = PullRequestBar
1134
1147
  exports.GitHubPanel = GitHubPanel
package/lib/index.js CHANGED
@@ -63,6 +63,14 @@ function makeGitRunner() {
63
63
  }
64
64
  }
65
65
 
66
+ export function safeCwd(ctx) {
67
+ try {
68
+ return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
69
+ } catch {
70
+ return process.cwd()
71
+ }
72
+ }
73
+
66
74
  export const name = 'dsh-github-ops'
67
75
  export const inject = ['tools', 'credentials', 'settings']
68
76
 
@@ -279,7 +287,7 @@ export function apply(ctx, rawConfig) {
279
287
  const fallback = cfg.defaultRepository || ''
280
288
  let remoteUrl = ''
281
289
  if (!(explicit && explicit.includes('/')) && !fallback) {
282
- const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
290
+ const cwd = safeCwd(ctx)
283
291
  const git = makeGitRunner()
284
292
  const origin = await git(['remote', 'get-url', 'origin'], { cwd })
285
293
  if (origin.code === 0) remoteUrl = origin.stdout.trim()
@@ -375,7 +383,7 @@ export function apply(ctx, rawConfig) {
375
383
  },
376
384
  }
377
385
 
378
- const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd() }
386
+ const deps = { tool, asRepo, client, liveConfig, reviewRules, auth: authSeam, defaultCwd: () => safeCwd(ctx) }
379
387
  registerCoreTools(deps)
380
388
  registerCollabTools(deps)
381
389
  registerOpsTools(deps)
@@ -384,7 +392,7 @@ export function apply(ctx, rawConfig) {
384
392
  registerMirrorTools({
385
393
  tool,
386
394
  git: makeGitRunner(),
387
- defaultCwd: () => (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd(),
395
+ defaultCwd: () => safeCwd(ctx),
388
396
  })
389
397
 
390
398
  // Background reviews: the host registry when the composition has one, our own records
@@ -454,7 +462,7 @@ export function apply(ctx, rawConfig) {
454
462
  try {
455
463
  const git = makeGitRunner()
456
464
  const readGitState = async () => {
457
- const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
465
+ const cwd = safeCwd(ctx)
458
466
  const branch = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
459
467
  const remote = await git(['remote', 'get-url', 'origin'], { cwd })
460
468
  const resolved = remote.code === 0
package/lib/routes.js CHANGED
@@ -9,7 +9,7 @@ import { existsSync, statSync } from 'node:fs'
9
9
  import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, isTrustedWriteRequest, sendJson } from './status.js'
12
- import { buildAction, validateCwd } from './scm-write.js'
12
+ import { buildAction, validateCwd, validateStashRef } from './scm-write.js'
13
13
  import { parseStatus, parseBranches, parseStashes, parseWorktrees, parseCount, scmPayload, mergeStateFrom } from './scm.js'
14
14
  import { listIssues } from './tools/issues.js'
15
15
  import { listRuns } from './tools/actions.js'
@@ -31,6 +31,14 @@ 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
+ function safeCwd(ctx) {
35
+ try {
36
+ return (typeof ctx?.cwd === 'string' && ctx.cwd) || process.cwd()
37
+ } catch {
38
+ return process.cwd()
39
+ }
40
+ }
41
+
34
42
  export function registerRoutes(ctx, deps = {}) {
35
43
  const {
36
44
  resolveToken = async () => ({ value: '', source: '', guidance: '' }),
@@ -93,6 +101,7 @@ export function registerRoutes(ctx, deps = {}) {
93
101
  kind: 'exact',
94
102
  path: SCM_PATH,
95
103
  handler: async (req, res) => {
104
+ try {
96
105
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
97
106
  if (req.method === 'POST') {
98
107
  if (!isTrustedWriteRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
@@ -107,7 +116,7 @@ export function registerRoutes(ctx, deps = {}) {
107
116
  // The tab may look at another directory than the session workspace; the value is
108
117
  // validated, must exist and must be a directory.
109
118
  const requested = validateCwd(url.searchParams.get('cwd') || '')
110
- let cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
119
+ let cwd = safeCwd(ctx)
111
120
  if (requested) {
112
121
  if (!existsSync(requested) || !statSync(requested).isDirectory()) {
113
122
  return sendJson(res, 400, { error: `not a directory: ${requested}` })
@@ -168,6 +177,19 @@ export function registerRoutes(ctx, deps = {}) {
168
177
  return sendJson(res, 200, { file, staged, diff: String(text || '').slice(0, 200000) })
169
178
  }
170
179
 
180
+ const stash = url.searchParams.get('stash') || ''
181
+ if (stash) {
182
+ let stashRef
183
+ try {
184
+ stashRef = validateStashRef(stash)
185
+ } catch (err) {
186
+ return sendJson(res, 400, { error: String((err && err.message) || err) })
187
+ }
188
+ const diff = await git(['stash', 'show', '-p', stashRef], { cwd })
189
+ const text = diff.code === 0 ? diff.stdout : diff.stderr
190
+ return sendJson(res, 200, { stash: stashRef, diff: String(text || '').slice(0, 200000) })
191
+ }
192
+
171
193
  const branchAnswer = await git(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd })
172
194
  if (branchAnswer.code !== 0) {
173
195
  return sendJson(res, 200, scmPayload({ cwd, error: 'not a git repository (or no commits yet)' }))
@@ -211,6 +233,9 @@ export function registerRoutes(ctx, deps = {}) {
211
233
  worktrees: parseWorktrees(worktreesAnswer.code === 0 ? worktreesAnswer.stdout : ''),
212
234
  merge,
213
235
  }))
236
+ } catch (err) {
237
+ return sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
238
+ }
214
239
  },
215
240
  })
216
241
  if (typeof wctx.effect === 'function') wctx.effect(register, 'dsh-github-ops: scm route')
package/lib/scm-write.js CHANGED
@@ -36,9 +36,10 @@ export function validateMessage(message) {
36
36
  return trimmed
37
37
  }
38
38
 
39
- /** Stash references are `stash@{N}`; nothing else is accepted. */
39
+ /** Stash references are `stash@{N}` or integer indices; nothing else is accepted. */
40
40
  export function validateStashRef(ref) {
41
- const value = String(ref == null ? '' : ref).trim()
41
+ let value = String(ref == null ? '' : ref).trim()
42
+ if (/^\d{1,3}$/.test(value)) value = `stash@{${value}}`
42
43
  if (!/^stash@\{\d{1,3}\}$/.test(value)) throw new Error(`refusing an unsafe stash reference: "${value.slice(0, 40)}"`)
43
44
  return value
44
45
  }
@@ -92,11 +93,17 @@ export function buildAction(action, args = {}) {
92
93
  return result(name, steps, destructive, 'save the working copy to a stash')
93
94
  }
94
95
  case 'stashApply':
96
+ case 'stash_apply':
95
97
  return result(name, [['stash', 'apply', validateStashRef(args.ref)]], destructive, `apply ${args.ref}`)
96
98
  case 'stashPop':
99
+ case 'stash_pop':
97
100
  return result(name, [['stash', 'pop', validateStashRef(args.ref)]], destructive, `apply and drop ${args.ref}`)
98
101
  case 'stashDrop':
102
+ case 'stash_drop':
99
103
  return result(name, [['stash', 'drop', validateStashRef(args.ref)]], destructive, `drop ${args.ref}`)
104
+ case 'stashShow':
105
+ case 'stash_show':
106
+ return result(name, [['stash', 'show', '-p', validateStashRef(args.ref)]], destructive, `show diff of ${args.ref}`)
100
107
  case 'continue': {
101
108
  const verb = String(args.verb || '').trim()
102
109
  if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to continue an unknown operation: "${verb.slice(0, 20)}"`)
@@ -136,7 +143,7 @@ export function buildAction(action, args = {}) {
136
143
  }
137
144
  }
138
145
 
139
- const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'abort', 'worktreeRemove'])
146
+ const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'stash_drop', 'abort', 'worktreeRemove'])
140
147
 
141
148
  function result(action, steps, destructive, summary) {
142
149
  return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
@@ -146,7 +153,7 @@ function result(action, steps, destructive, summary) {
146
153
  export const ACTIONS = Object.freeze([
147
154
  'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
148
155
  'branchCreate', 'branchCheckout', 'branchDelete',
149
- 'stashPush', 'stashApply', 'stashPop', 'stashDrop',
156
+ 'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'stashShow',
150
157
  'worktreeAdd', 'worktreeRemove',
151
158
  'continue', 'abort',
152
159
  ])
package/lib/status.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { auditTokenScopes } from './token-audit.js'
1
2
  // What the settings card is allowed to know.
2
3
  //
3
4
  // The status of the access — where it comes from, which account, what the rate limit looks
@@ -126,12 +127,14 @@ function header(req, name) {
126
127
  */
127
128
  export function statusPayload({ access, identity, rateLimit, scopes, cache, error } = {}) {
128
129
  const configured = Boolean(access && access.value)
130
+ const audit = auditTokenScopes(scopes)
129
131
  return {
130
132
  configured,
131
133
  source: configured ? access.source || '' : '',
132
134
  login: (identity && identity.login) || '',
133
135
  name: (identity && identity.name) || '',
134
136
  scopes: scopes || '',
137
+ scopeWarnings: audit.warnings,
135
138
  rateLimit: rateLimit
136
139
  ? { limit: rateLimit.limit, remaining: rateLimit.remaining, resetAt: rateLimit.resetAt }
137
140
  : null,
@@ -0,0 +1,84 @@
1
+ // Token scopes audit and capability diagnostics.
2
+ // Compares GitHub x-oauth-scopes against features of dsh-github-ops.
3
+
4
+ export const CAPABILITY_REQUIREMENTS = [
5
+ {
6
+ capability: 'Repositories & Code (Private)',
7
+ requiredAny: ['repo'],
8
+ impact: 'Access to private repositories, code pushes, branches, releases, issues and PRs',
9
+ },
10
+ {
11
+ capability: 'Repositories & Code (Public)',
12
+ requiredAny: ['repo', 'public_repo'],
13
+ impact: 'Access to public repository code, issues, releases and PRs',
14
+ },
15
+ {
16
+ capability: 'Workflows & CI Runs',
17
+ requiredAny: ['workflow'],
18
+ impact: 'Triggering workflow runs, cancelling runs and updating .github/workflows',
19
+ },
20
+ {
21
+ capability: 'GitHub Packages',
22
+ requiredAny: ['write:packages', 'read:packages'],
23
+ impact: 'Publishing and downloading package bundles from ghcr.io / npm.pkg.github.com',
24
+ },
25
+ {
26
+ capability: 'Organization & Teams',
27
+ requiredAny: ['read:org', 'admin:org'],
28
+ impact: 'Listing organization repositories, teams and membership',
29
+ },
30
+ ]
31
+
32
+ export function auditTokenScopes(rawScopes) {
33
+ const scopesStr = typeof rawScopes === 'string' ? rawScopes.trim() : ''
34
+ if (!scopesStr) {
35
+ return {
36
+ scopes: [],
37
+ raw: '',
38
+ isFineGrained: true,
39
+ capabilities: [],
40
+ missing: [],
41
+ warnings: ['Token does not report OAuth scopes (typical for fine-grained Personal Access Tokens or GitHub Apps). Permissions are scoped per-repository.'],
42
+ hasFullRepoAccess: false,
43
+ hasWorkflowAccess: false,
44
+ }
45
+ }
46
+
47
+ const scopeList = scopesStr.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
48
+ const scopeSet = new Set(scopeList)
49
+
50
+ const capabilities = []
51
+ const missing = []
52
+ const warnings = []
53
+
54
+ for (const item of CAPABILITY_REQUIREMENTS) {
55
+ const granted = item.requiredAny.some((s) => scopeSet.has(s))
56
+ capabilities.push({
57
+ capability: item.capability,
58
+ granted,
59
+ requiredAny: item.requiredAny,
60
+ })
61
+ if (!granted) {
62
+ missing.push({
63
+ capability: item.capability,
64
+ requiredAny: item.requiredAny,
65
+ impact: item.impact,
66
+ })
67
+ warnings.push(`Missing scope [${item.requiredAny.join(' or ')}] for ${item.capability}: ${item.impact}`)
68
+ }
69
+ }
70
+
71
+ const hasFullRepoAccess = scopeSet.has('repo')
72
+ const hasWorkflowAccess = scopeSet.has('workflow')
73
+
74
+ return {
75
+ scopes: scopeList,
76
+ raw: scopesStr,
77
+ isFineGrained: false,
78
+ capabilities,
79
+ missing,
80
+ warnings,
81
+ hasFullRepoAccess,
82
+ hasWorkflowAccess,
83
+ }
84
+ }
@@ -175,11 +175,13 @@ export async function getJobLogSummary(client, { owner, repo, runId, jobId, maxL
175
175
  }
176
176
 
177
177
  const lines = cleanLogContent(logText, maxLines)
178
+ const errorHighlights = lines.filter((l) => /\b(error|failed|failure|fatal)\b|##\[error\]|npm err!/i.test(l)).slice(-10)
178
179
  return {
179
180
  jobId: targetJobId,
180
181
  jobName: targetJobName,
181
182
  failedStep,
182
183
  lineCount: lines.length,
183
184
  lines,
185
+ errorHighlights,
184
186
  }
185
187
  }
@@ -102,6 +102,19 @@ export function registerOpsTools({ tool, asRepo, client, liveConfig }) {
102
102
  async (args) => withRepo(args, async ({ owner, repo }) => getRunLogsUrl(await client(), { owner, repo, runId: args.runId })),
103
103
  )
104
104
 
105
+ tool(
106
+ 'gh_run_failure_inspector',
107
+ 'Inspect failed jobs and steps in a GitHub Actions workflow run, downloading only the relevant job log text, stripping ANSI escapes, redacting tokens, and returning the diagnostic error lines without downloading full zip archives. Read-only.',
108
+ repoParams({
109
+ runId: { type: 'number', description: 'Workflow run id (e.g. from gh_run_list).' },
110
+ jobId: { type: 'number', description: 'Specific job id to inspect (optional, defaults to first failed job).' },
111
+ maxLines: { type: 'number', description: 'Maximum log lines to return (default 100, max 250).' },
112
+ }),
113
+ async (args) => withRepo(args, async ({ owner, repo }) => getJobLogSummary(await client(), {
114
+ owner, repo, runId: args.runId, jobId: args.jobId, maxLines: args.maxLines,
115
+ })),
116
+ )
117
+
105
118
  tool(
106
119
  'gh_run_dispatch',
107
120
  'Trigger a workflow_dispatch event for a GitHub Actions workflow with inputs. Requires confirm: true.',
@@ -1,3 +1,4 @@
1
+ import { auditTokenScopes } from '../token-audit.js'
1
2
  // Content and account tools: writing files without a checkout, and signing in without a
2
3
  // terminal.
3
4
  //
@@ -226,6 +227,28 @@ export function registerWriteTools({ tool, asRepo, client, liveConfig, auth }) {
226
227
  },
227
228
  )
228
229
 
230
+ tool(
231
+ 'gh_token_audit',
232
+ 'Audit OAuth token scopes and report granted vs missing capabilities (repo, workflow, packages, org) with diagnostic warnings. Read-only.',
233
+ {},
234
+ async () => {
235
+ const access = await auth.resolve()
236
+ if (!access.value) return { configured: false, guidance: access.guidance }
237
+ const github = await client()
238
+ const { data, rateLimit } = await github.get('/user')
239
+ const rawScopes = github.lastScopes ? github.lastScopes() : ''
240
+ const audit = auditTokenScopes(rawScopes)
241
+ return {
242
+ configured: true,
243
+ source: access.source,
244
+ login: data && data.login,
245
+ name: data && data.name,
246
+ rateLimit: rateLimit || undefined,
247
+ ...audit,
248
+ }
249
+ },
250
+ )
251
+
229
252
  tool(
230
253
  'gh_auth_logout',
231
254
  'Forget the sign-in stored by this plugin (gh_auth_login). Other sources (a DSH credential, the environment, the gh CLI) are untouched. Requires confirm: true.',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.12",
3
+ "version": "0.3.1",
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",