@goodandready/dsh-github-ops 0.2.0 → 0.2.2

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,42 @@
2
2
 
3
3
  Notable changes to `@goodandready/dsh-github-ops`.
4
4
 
5
+ ## 0.2.2
6
+
7
+ ### Fixed
8
+ - Settings no longer wait on the removed settingsScope service. The client uses configForms (#39).
9
+
10
+ ## 0.2.1
11
+
12
+ Two things: the Source Control tab learned to write, and a class of defect that silently killed
13
+ tool results is closed for good.
14
+
15
+ ### The tab can write now
16
+
17
+ - a commit bar: message, `amend`, Commit, Commit & push, and a button that pushes when the branch
18
+ is ahead and syncs (rebase pull with autostash, then push) when it is behind;
19
+ - per-file stage and unstage, and discard behind a two-second-click confirmation;
20
+ - branches: create from the current one, switch, delete and push; stashes: save with a message,
21
+ apply, pop and drop — the destructive ones behind the same confirmation;
22
+ - an unfinished merge, rebase, cherry-pick or revert shows its own bar with continue and abort;
23
+ - every write is a named action: the host builds validated argv (`lib/scm-write.js`) and never a
24
+ shell string, refuses the destructive ones without an explicit confirmation, and reports which
25
+ step failed and what git said.
26
+
27
+ ### Every tool result survives JSON without loss
28
+
29
+ - a live call to `gh_repo_tree` failed with "value is not lossless JSON" because a directory entry
30
+ in a git tree has no `size`, and the result carried `size: undefined`; the harness refuses the
31
+ whole call for one value it cannot serialise;
32
+ - `lib/lossless.js` now decides what a result may contain (dropping `undefined`, replacing `NaN`
33
+ and the infinities with `null`, converting `Date`, `Map`, `Set`, typed arrays, `Buffer` and
34
+ `BigInt`, naming a cycle instead of following it, dropping functions and symbols), and the tool
35
+ wrapper applies it to every result, success and failure alike, so no single tool has to remember
36
+ the rule;
37
+ - `gh_repo_tree` no longer invents the field at all: a directory carries no `size` key;
38
+ - the contract is written into `docs/design/DESIGN.md`, section 5.
39
+
40
+
5
41
  ## 0.2.0
6
42
 
7
43
  The development wave: reliability, reports, writing without a checkout, and the user surfaces.
package/lib/client.js CHANGED
@@ -80,6 +80,32 @@ window.__ModuleLoader__.load({
80
80
  scmStashes: 'Stashes',
81
81
  scmInProgress: 'In progress',
82
82
  scmNoUpstream: 'no upstream',
83
+ scmCommit: 'Commit',
84
+ scmCommitPush: 'Commit & push',
85
+ scmSync: 'Sync',
86
+ scmAmend: 'amend',
87
+ scmMessage: 'Commit message',
88
+ scmStage: 'Stage',
89
+ scmUnstage: 'Unstage',
90
+ scmDiscard: 'Discard',
91
+ scmConfirm: 'Confirm?',
92
+ scmNewBranch: 'New branch',
93
+ scmCreate: 'Create',
94
+ scmDelete: 'Delete',
95
+ scmSwitch: 'Switch',
96
+ scmStashSave: 'Save stash',
97
+ scmStashApply: 'Apply',
98
+ scmStashPop: 'Pop',
99
+ scmStashDrop: 'Drop',
100
+ scmContinue: 'Continue',
101
+ scmAbort: 'Abort',
102
+ scmWorking: 'Working…',
103
+ scmActionFailed: 'git refused',
104
+ scmNoChanges: 'Nothing staged or changed',
105
+ scmBehindHint: 'Behind the upstream: sync pulls with rebase, then pushes',
106
+ scmPushBranch: 'Push branch',
107
+ scmRepoPath: 'Repository path (optional)',
108
+ scmRepoPathHint: 'Absolute path; empty means the session workspace',
83
109
  prBarBranch: 'to',
84
110
  prBarCommits: 'commit(s) ahead',
85
111
  panelTitle: 'GitHub',
@@ -155,6 +181,32 @@ window.__ModuleLoader__.load({
155
181
  scmStashes: '暂存',
156
182
  scmInProgress: '进行中',
157
183
  scmNoUpstream: '无上游',
184
+ scmCommit: '提交',
185
+ scmCommitPush: '提交并推送',
186
+ scmSync: '同步',
187
+ scmAmend: '修补',
188
+ scmMessage: '提交信息',
189
+ scmStage: '暂存',
190
+ scmUnstage: '取消暂存',
191
+ scmDiscard: '放弃更改',
192
+ scmConfirm: '确认?',
193
+ scmNewBranch: '新分支',
194
+ scmCreate: '创建',
195
+ scmDelete: '删除',
196
+ scmSwitch: '切换',
197
+ scmStashSave: '保存 stash',
198
+ scmStashApply: '应用',
199
+ scmStashPop: '弹出',
200
+ scmStashDrop: '删除',
201
+ scmContinue: '继续',
202
+ scmAbort: '中止',
203
+ scmWorking: '执行中…',
204
+ scmActionFailed: 'git 拒绝',
205
+ scmNoChanges: '没有已暂存或已修改的内容',
206
+ scmBehindHint: '落后于上游:同步会先 rebase 拉取,再推送',
207
+ scmPushBranch: '推送分支',
208
+ scmRepoPath: '仓库路径(可选)',
209
+ scmRepoPathHint: '绝对路径;留空则使用会话工作区',
158
210
  prBarBranch: '→',
159
211
  prBarCommits: '个提交领先',
160
212
  panelTitle: 'GitHub',
@@ -204,6 +256,24 @@ window.__ModuleLoader__.load({
204
256
  const [state, setState] = React.useState({ phase: 'loading' })
205
257
  const [selected, setSelected] = React.useState(null)
206
258
  const [diff, setDiff] = React.useState({ phase: 'idle', text: '' })
259
+ const [busy, setBusy] = React.useState('')
260
+ const [actionError, setActionError] = React.useState('')
261
+ const [message, setMessage] = React.useState('')
262
+ const [amend, setAmend] = React.useState(false)
263
+ const [confirming, setConfirming] = React.useState('')
264
+ const [branchName, setBranchName] = React.useState('')
265
+ const [stashMessage, setStashMessage] = React.useState('')
266
+ const [repoPath, setRepoPath] = React.useState(() => {
267
+ try { return localStorage.getItem('gho-scm-cwd') || '' } catch { return '' }
268
+ })
269
+
270
+ // The tab reads the session workspace by default; an explicit path lets it look at any
271
+ // repository the local user names.
272
+ const cwdQuery = repoPath ? `cwd=${encodeURIComponent(repoPath)}` : ''
273
+ const rememberPath = (value) => {
274
+ setRepoPath(value)
275
+ try { value ? localStorage.setItem('gho-scm-cwd', value) : localStorage.removeItem('gho-scm-cwd') } catch { /* private mode */ }
276
+ }
207
277
 
208
278
  const load = React.useCallback(() => {
209
279
  setState({ phase: 'loading' })
@@ -211,14 +281,41 @@ window.__ModuleLoader__.load({
211
281
  setState({ phase: 'error', message: 'fetch is unavailable in this client' })
212
282
  return
213
283
  }
214
- fetch(SCM_PATH, { headers: { accept: 'application/json' } })
284
+ fetch(cwdQuery ? `${SCM_PATH}?${cwdQuery}` : SCM_PATH, { headers: { accept: 'application/json' } })
215
285
  .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
216
286
  .then((payload) => setState({ phase: 'ready', payload }))
217
287
  .catch((err) => setState({ phase: 'error', message: String((err && err.message) || err) }))
218
- }, [])
288
+ }, [repoPath])
219
289
 
220
290
  React.useEffect(() => { load() }, [load])
221
291
 
292
+ // Every write is one named action; the host validates it and runs argv, never a shell.
293
+ const runAction = (action, args) => {
294
+ if (typeof fetch !== 'function') return
295
+ setBusy(action)
296
+ setActionError('')
297
+ fetch(SCM_PATH, {
298
+ method: 'POST',
299
+ headers: { 'content-type': 'application/json', accept: 'application/json' },
300
+ body: JSON.stringify(repoPath ? { action, args: args || {}, cwd: repoPath } : { action, args: args || {} }),
301
+ })
302
+ .then((response) => (response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`))))
303
+ .then((payload) => {
304
+ setBusy('')
305
+ setConfirming('')
306
+ if (!payload || payload.ok !== true) setActionError((payload && payload.error) || t('scmActionFailed'))
307
+ load()
308
+ })
309
+ .catch((err) => { setBusy(''); setActionError(String((err && err.message) || err)) })
310
+ }
311
+
312
+ // Destructive actions ask twice: the first click arms the button, the second runs it.
313
+ const armed = (key, action, args) => {
314
+ if (confirming !== key) { setConfirming(key); return }
315
+ setConfirming('')
316
+ runAction(action, args)
317
+ }
318
+
222
319
  const openDiff = (file, staged) => {
223
320
  setSelected({ path: file.path, staged })
224
321
  setDiff({ phase: 'loading', text: '' })
@@ -229,18 +326,34 @@ window.__ModuleLoader__.load({
229
326
  .catch((err) => setDiff({ phase: 'error', text: String((err && err.message) || err) }))
230
327
  }
231
328
 
232
- const fileRow = (file, staged) => React.createElement('div', {
233
- key: `${staged ? 's' : 'w'}-${file.path}`,
234
- className: 'gho-scm-file',
235
- role: 'button',
236
- tabIndex: 0,
237
- 'aria-selected': selected && selected.path === file.path && selected.staged === staged,
238
- onClick: () => openDiff(file, staged),
239
- onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') openDiff(file, staged) },
240
- },
241
- React.createElement('span', { className: 'gho-scm-badge' }, file.status),
242
- React.createElement('span', { className: 'gho-scm-path' }, file.renamedFrom ? `${file.renamedFrom} → ${file.path}` : file.path),
243
- )
329
+ const button = (label, onClick, options) => React.createElement('button', {
330
+ type: 'button',
331
+ className: `gho-scm-btn${options && options.warn ? ' gho-scm-btn-warn' : ''}`,
332
+ disabled: Boolean(busy) || Boolean(options && options.disabled),
333
+ onClick,
334
+ }, label)
335
+
336
+ const fileRow = (file, staged) => {
337
+ const discardKey = `discard:${file.path}`
338
+ return React.createElement('div', {
339
+ key: `${staged ? 's' : 'w'}-${file.path}`,
340
+ className: 'gho-scm-file',
341
+ role: 'button',
342
+ tabIndex: 0,
343
+ 'aria-selected': selected && selected.path === file.path && selected.staged === staged,
344
+ onClick: () => openDiff(file, staged),
345
+ onKeyDown: (event) => { if (event.key === 'Enter' || event.key === ' ') openDiff(file, staged) },
346
+ },
347
+ React.createElement('span', { className: 'gho-scm-badge' }, file.status),
348
+ React.createElement('span', { className: 'gho-scm-path' }, file.renamedFrom ? `${file.renamedFrom} → ${file.path}` : file.path),
349
+ React.createElement('span', { className: 'gho-scm-actions' },
350
+ staged
351
+ ? button(t('scmUnstage'), () => runAction('unstage', { path: file.path }))
352
+ : button(t('scmStage'), () => runAction('stage', { path: file.path })),
353
+ button(confirming === discardKey ? t('scmConfirm') : t('scmDiscard'), () => armed(discardKey, 'discard', { path: file.path, confirm: true }), { warn: true }),
354
+ ),
355
+ )
356
+ }
244
357
 
245
358
  const group = (title, files, staged) => (files && files.length
246
359
  ? React.createElement('div', { className: 'gho-scm-group', key: title },
@@ -254,7 +367,7 @@ window.__ModuleLoader__.load({
254
367
  return React.createElement('div', { className: 'gho-scm' },
255
368
  React.createElement('div', { className: 'gho-scm-group-head' }, t('scmError')),
256
369
  React.createElement('div', { className: 'gho-scm-meta' }, state.message),
257
- React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
370
+ button(t('scmRefresh'), load),
258
371
  )
259
372
  }
260
373
  const payload = state.payload || {}
@@ -262,25 +375,99 @@ window.__ModuleLoader__.load({
262
375
  return React.createElement('div', { className: 'gho-scm' },
263
376
  React.createElement('div', { className: 'gho-scm-group-head' }, t('scmNotRepo')),
264
377
  payload.error ? React.createElement('div', { className: 'gho-scm-meta' }, payload.error) : null,
265
- React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
378
+ button(t('scmRefresh'), load),
266
379
  )
267
380
  }
268
- const files = payload.files || { staged: [], changes: [], untracked: [], unmerged: [] }
381
+ const files = payload.files || { staged: [], changes: [], untracked: [], unmerged: [], total: 0 }
269
382
  const clean = !files.total
383
+ const canCommit = files.staged.length > 0 && message.trim().length > 0 && !busy
384
+ const ahead = payload.ahead || 0
385
+ const behind = payload.behind || 0
386
+
387
+ const commitBar = React.createElement('div', { className: 'gho-scm-bar' },
388
+ React.createElement('input', {
389
+ type: 'text',
390
+ value: message,
391
+ placeholder: t('scmMessage'),
392
+ onChange: (event) => setMessage(event.target.value),
393
+ onKeyDown: (event) => { if (event.key === 'Enter' && canCommit) runAction('commit', { message, amend }) },
394
+ }),
395
+ React.createElement('label', { className: 'gho-scm-check' },
396
+ React.createElement('input', { type: 'checkbox', checked: amend, onChange: (event) => setAmend(event.target.checked) }),
397
+ t('scmAmend'),
398
+ ),
399
+ button(t('scmCommit'), () => runAction('commit', { message, amend }), { disabled: !canCommit }),
400
+ button(t('scmCommitPush'), () => runAction('commit', { message, amend, push: true }), { disabled: !canCommit }),
401
+ button(behind > 0 ? t('scmSync') : t('scmPushBranch'), () => runAction(behind > 0 ? 'sync' : 'push', {}), { disabled: Boolean(busy) || (ahead === 0 && behind === 0) }),
402
+ )
403
+
404
+ const mergeBar = payload.mergeState && payload.mergeState.inProgress
405
+ ? React.createElement('div', { className: 'gho-scm-bar' },
406
+ React.createElement('span', null, `${t('scmInProgress')}: ${payload.mergeState.kind}`),
407
+ button(t('scmContinue'), () => runAction('continue', { verb: payload.mergeState.kind })),
408
+ button(confirming === 'abort' ? t('scmConfirm') : t('scmAbort'), () => armed('abort', 'abort', { verb: payload.mergeState.kind, confirm: true }), { warn: true }),
409
+ )
410
+ : null
411
+
412
+ const branchBar = React.createElement('div', { className: 'gho-scm-bar' },
413
+ React.createElement('input', {
414
+ type: 'text',
415
+ value: branchName,
416
+ placeholder: t('scmNewBranch'),
417
+ onChange: (event) => setBranchName(event.target.value),
418
+ }),
419
+ button(t('scmCreate'), () => runAction('branchCreate', { branch: branchName }), { disabled: !branchName.trim() || Boolean(busy) }),
420
+ (payload.branches || []).filter((b) => !b.current).slice(0, 6).map((b) => button(
421
+ confirming === `branch:${b.name}` ? `${b.name}: ${t('scmConfirm')}` : `${t('scmSwitch')} ${b.name}`,
422
+ () => armed(`branch:${b.name}`, 'branchCheckout', { branch: b.name }),
423
+ )),
424
+ (payload.branches || []).filter((b) => !b.current).slice(0, 6).map((b) => button(
425
+ confirming === `del:${b.name}` ? `${t('scmDelete')}?` : `${t('scmDelete')} ${b.name}`,
426
+ () => armed(`del:${b.name}`, 'branchDelete', { branch: b.name, confirm: true }),
427
+ { warn: true },
428
+ )),
429
+ )
430
+
431
+ const stashBar = React.createElement('div', { className: 'gho-scm-bar' },
432
+ React.createElement('input', {
433
+ type: 'text',
434
+ value: stashMessage,
435
+ placeholder: t('scmStashSave'),
436
+ onChange: (event) => setStashMessage(event.target.value),
437
+ }),
438
+ button(t('scmStashSave'), () => runAction('stashPush', { message: stashMessage }), { disabled: Boolean(busy) }),
439
+ (payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashApply')} ${entry.ref}`, () => runAction('stashApply', { ref: entry.ref }))),
440
+ (payload.stashes || []).slice(0, 4).map((entry) => button(`${t('scmStashPop')} ${entry.ref}`, () => runAction('stashPop', { ref: entry.ref }))),
441
+ (payload.stashes || []).slice(0, 4).map((entry) => button(
442
+ confirming === `stash:${entry.ref}` ? `${entry.ref}: ${t('scmConfirm')}` : `${t('scmStashDrop')} ${entry.ref}`,
443
+ () => armed(`stash:${entry.ref}`, 'stashDrop', { ref: entry.ref, confirm: true }),
444
+ { warn: true },
445
+ )),
446
+ )
270
447
 
271
448
  return React.createElement('div', { className: 'gho-scm' },
272
449
  React.createElement('div', { className: 'gho-scm-head' },
273
450
  React.createElement('span', { className: 'gho-scm-repo' }, payload.cwd || ''),
274
451
  React.createElement('span', { className: 'gho-scm-branch' }, payload.branch),
275
452
  payload.upstream
276
- ? React.createElement('span', { className: 'gho-scm-counts' }, `↑${payload.ahead ?? 0} ↓${payload.behind ?? 0}`)
453
+ ? React.createElement('span', { className: 'gho-scm-counts' }, `↑${ahead} ↓${behind}`)
277
454
  : React.createElement('span', { className: 'gho-scm-counts' }, t('scmNoUpstream')),
278
- React.createElement('button', { type: 'button', className: 'gho-link', onClick: load }, t('scmRefresh')),
455
+ button(busy ? t('scmWorking') : t('scmRefresh'), load, { disabled: Boolean(busy) }),
279
456
  ),
280
- payload.mergeState && payload.mergeState.inProgress
281
- ? React.createElement('div', { className: 'gho-scm-meta' },
282
- React.createElement('span', { className: 'gho-scm-chip' }, `${t('scmInProgress')}: ${payload.mergeState.kind}`))
283
- : null,
457
+ React.createElement('div', { className: 'gho-scm-bar' },
458
+ React.createElement('input', {
459
+ type: 'text',
460
+ value: repoPath,
461
+ placeholder: t('scmRepoPath'),
462
+ title: t('scmRepoPathHint'),
463
+ onChange: (event) => rememberPath(event.target.value),
464
+ onKeyDown: (event) => { if (event.key === 'Enter') load() },
465
+ }),
466
+ ),
467
+ actionError ? React.createElement('div', { className: 'gho-scm-meta gho-scm-error' }, `${t('scmActionFailed')}: ${actionError}`) : null,
468
+ behind > 0 ? React.createElement('div', { className: 'gho-scm-meta' }, t('scmBehindHint')) : null,
469
+ mergeBar,
470
+ commitBar,
284
471
  clean ? React.createElement('div', { className: 'gho-scm-meta' }, t('scmNothing')) : null,
285
472
  group(t('scmConflicts'), files.unmerged, false),
286
473
  group(t('scmStaged'), files.staged, true),
@@ -290,15 +477,14 @@ window.__ModuleLoader__.load({
290
477
  ? React.createElement('pre', { className: 'gho-scm-diff' },
291
478
  diff.phase === 'loading' ? t('scmLoading') : (diff.text || t('scmDiffEmpty')))
292
479
  : null,
480
+ branchBar,
481
+ stashBar,
293
482
  React.createElement('div', { className: 'gho-scm-meta' },
294
- React.createElement('span', null, `${t('scmBranches')}: ${(payload.branches || []).map((b) => (b.current ? `*${b.name}` : b.name)).join(', ') || '—'}`),
295
483
  React.createElement('span', null, `${t('scmTags')}: ${(payload.tags || []).join(', ') || '—'}`),
296
- React.createElement('span', null, `${t('scmStashes')}: ${(payload.stashes || []).length}`),
297
484
  ),
298
485
  )
299
486
  }
300
487
 
301
-
302
488
  /**
303
489
  * The pull request bar above the composer.
304
490
  *
@@ -586,6 +772,14 @@ window.__ModuleLoader__.load({
586
772
  '.gho-panel-muted{color:var(--dsw-alias-label-tertiary)}',
587
773
  '.gho-panel-file{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
588
774
  '.gho-panel-pre{margin:0;max-height:340px;overflow:auto;background:var(--dsw-alias-bg-layer-3);border-radius:8px;padding:8px;white-space:pre;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
775
+ '.gho-scm-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:8px 10px}',
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
+ '.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
+ '.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)}',
780
+ '.gho-scm-actions{display:flex;gap:4px;margin-left:auto}',
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)}',
589
783
  '.gho-input-area{height:auto;min-height:64px;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical}',
590
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}',
591
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}',
@@ -641,7 +835,7 @@ window.__ModuleLoader__.load({
641
835
 
642
836
  let scope = null
643
837
  try {
644
- scope = ctx && ctx.settingsScope && ctx.settingsScope.bind({ namespace: NS })
838
+ scope = ctx && ctx.configForms && ctx.configForms.get(NS)
645
839
  } catch {
646
840
  scope = null
647
841
  }
@@ -896,10 +1090,15 @@ window.__ModuleLoader__.load({
896
1090
  /* a core without the session view ring: the rest of the plugin still works */
897
1091
  }
898
1092
 
899
- exports.inject = ['slots', 'locale', 'settingsScope']
1093
+ exports.inject = ['slots', 'locale', 'configForms']
900
1094
  exports.apply = apply
901
1095
  exports.GitHubOpsCard = GitHubOpsCard
902
1096
  exports.SourceControlView = SourceControlView
1097
+ exports.SCM_ACTIONS = [
1098
+ 'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
1099
+ 'branchCreate', 'branchCheckout', 'branchDelete',
1100
+ 'stashPush', 'stashApply', 'stashPop', 'stashDrop', 'continue', 'abort',
1101
+ ]
903
1102
  exports.PullRequestBar = PullRequestBar
904
1103
  exports.GitHubPanel = GitHubPanel
905
1104
  return module.exports
package/lib/index.js CHANGED
@@ -31,6 +31,7 @@ import { accessFilePath, readAccessFile } from './auth-device.js'
31
31
  import { DEFAULT_CLIENT_ID, DEFAULT_SCOPE } from './auth-device.js'
32
32
  import { GUIDANCE_SECTION, GUIDANCE_ORDER, guidanceText } from './guidance.js'
33
33
  import { registerRoutes, STATUS_PATH, SCM_PATH, PANEL_PATH } from './routes.js'
34
+ import { lossless } from './lossless.js'
34
35
  import { AsyncLocalStorage } from 'node:async_hooks'
35
36
  import { execFile } from 'node:child_process'
36
37
  import { promisify } from 'node:util'
@@ -326,7 +327,9 @@ export function apply(ctx, rawConfig) {
326
327
  // a cache reset must never fail a successful call
327
328
  }
328
329
  }
329
- return { ok: true, data: value }
330
+ // One place decides what a result may contain: the harness refuses a value that
331
+ // cannot be serialised losslessly, and it refuses the whole call for one undefined.
332
+ return { ok: true, data: lossless(value) }
330
333
  } catch (err) {
331
334
  const out = { ok: false, error: describeError(err) }
332
335
  if (err instanceof GitHubError) {
@@ -334,7 +337,7 @@ export function apply(ctx, rawConfig) {
334
337
  if (err.code) out.code = err.code
335
338
  if (err.rateLimit) out.rateLimit = err.rateLimit
336
339
  }
337
- return out
340
+ return lossless(out)
338
341
  }
339
342
  },
340
343
  })
@@ -0,0 +1,90 @@
1
+ // Making every tool result survive JSON without losing anything.
2
+ //
3
+ // The harness refuses a tool result that cannot be serialised losslessly, and it refuses the
4
+ // WHOLE call: one `undefined` deep inside a payload drops the answer the caller waited for. That
5
+ // is how a directory entry without a `size` field took down `gh_repo_tree`, and how the same
6
+ // class of defect took down two tools of another plugin.
7
+ //
8
+ // This module is the single place that decides what a result may contain. It is applied to every
9
+ // tool result in one wrapper, so no individual tool has to remember the rule.
10
+
11
+ const MAX_DEPTH = 40
12
+
13
+ /**
14
+ * Return a value that serialises identically in every JSON implementation:
15
+ * - `undefined` is dropped from objects and becomes `null` inside arrays (JSON has no holes);
16
+ * - `NaN` and the infinities become `null` (they have no JSON form);
17
+ * - a `Date` becomes its ISO string; a `Map` an object; a `Set` and a typed array an array; a
18
+ * `Buffer` a base64 string;
19
+ * - functions and symbols are dropped — they cannot be sent anywhere;
20
+ * - a `BigInt` becomes a string (JSON has no integer that large);
21
+ * - a cycle becomes the string "[circular]" instead of endless recursion.
22
+ */
23
+ export function lossless(value, depth = 0, seen = new WeakSet()) {
24
+ if (value === null) return null
25
+ const type = typeof value
26
+ if (type === 'string' || type === 'boolean') return value
27
+ if (type === 'number') return Number.isFinite(value) ? value : null
28
+ if (type === 'bigint') return value.toString()
29
+ if (type === 'undefined' || type === 'function' || type === 'symbol') return undefined
30
+
31
+ if (depth > MAX_DEPTH) return '[too deep]'
32
+ if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
33
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) return value.toString('base64')
34
+
35
+ if (typeof value === 'object') {
36
+ if (seen.has(value)) return '[circular]'
37
+ seen.add(value)
38
+
39
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
40
+ const out = Array.from(value, (item) => {
41
+ const converted = lossless(item, depth + 1, seen)
42
+ return converted === undefined ? null : converted
43
+ })
44
+ seen.delete(value)
45
+ return out
46
+ }
47
+ if (value instanceof Map) {
48
+ const out = {}
49
+ for (const [key, item] of value.entries()) {
50
+ const converted = lossless(item, depth + 1, seen)
51
+ if (converted !== undefined) out[String(key)] = converted
52
+ }
53
+ seen.delete(value)
54
+ return out
55
+ }
56
+ if (value instanceof Set) {
57
+ const out = []
58
+ for (const item of value.values()) {
59
+ const converted = lossless(item, depth + 1, seen)
60
+ out.push(converted === undefined ? null : converted)
61
+ }
62
+ seen.delete(value)
63
+ return out
64
+ }
65
+
66
+ const out = {}
67
+ for (const key of Object.keys(value)) {
68
+ const converted = lossless(value[key], depth + 1, seen)
69
+ if (converted !== undefined) out[key] = converted
70
+ }
71
+ seen.delete(value)
72
+ return out
73
+ }
74
+
75
+ return undefined
76
+ }
77
+
78
+ /**
79
+ * Whether a value would pass the harness's check. Kept as a separate, tiny predicate so tests can
80
+ * assert the rule instead of comparing strings.
81
+ */
82
+ export function isLossless(value) {
83
+ try {
84
+ const once = JSON.stringify(value)
85
+ if (once === undefined) return false
86
+ return JSON.stringify(lossless(JSON.parse(once))) === once
87
+ } catch {
88
+ return false
89
+ }
90
+ }
package/lib/routes.js CHANGED
@@ -5,10 +5,11 @@
5
5
  // stays a composition: the card, the Source Control tab and the sidebar panel each need one
6
6
  // endpoint, and none of them needs a tool.
7
7
 
8
- import { existsSync } from 'node:fs'
8
+ import { existsSync, statSync } from 'node:fs'
9
9
  import { join as joinPath } from 'node:path'
10
10
 
11
11
  import { statusPayload, isTrustedRequest, sendJson } from './status.js'
12
+ import { buildAction, validateCwd } from './scm-write.js'
12
13
  import { parseStatus, parseBranches, parseStashes, parseCount, scmPayload, mergeStateFrom } from './scm.js'
13
14
  import { listIssues } from './tools/issues.js'
14
15
  import { listRuns } from './tools/actions.js'
@@ -84,14 +85,68 @@ export function registerRoutes(ctx, deps) {
84
85
  kind: 'exact',
85
86
  path: SCM_PATH,
86
87
  handler: async (req, res) => {
87
- if (req.method !== 'GET') return sendJson(res, 405, { error: 'method not allowed' })
88
+ if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { error: 'method not allowed' })
88
89
  if (!isTrustedRequest(req)) return sendJson(res, 403, { error: 'forbidden' })
89
90
  const git = makeGitRunner()
90
- const cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
91
+ // The tab may look at another directory than the session workspace; the value is
92
+ // validated, must exist and must be a directory.
93
+ const requested = validateCwd(url.searchParams.get('cwd') || '')
94
+ let cwd = (typeof ctx.cwd === 'string' && ctx.cwd) || process.cwd()
95
+ if (requested) {
96
+ if (!existsSync(requested) || !statSync(requested).isDirectory()) {
97
+ return sendJson(res, 400, { error: `not a directory: ${requested}` })
98
+ }
99
+ cwd = requested
100
+ }
91
101
  const url = new URL(req.url || '/', 'http://localhost')
92
102
  const file = url.searchParams.get('file') || ''
93
103
  const staged = url.searchParams.get('staged') === '1'
94
104
 
105
+ if (req.method === 'POST') {
106
+ // A write always comes from a click in the panel. The body names one action; the
107
+ // argv it becomes is built and validated in lib/scm-write.js, and every step is
108
+ // run on its own — never through a shell.
109
+ let body = {}
110
+ try {
111
+ body = await readJsonBody(req)
112
+ } catch (err) {
113
+ return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
114
+ }
115
+ if (body.cwd) {
116
+ try {
117
+ const wanted = validateCwd(body.cwd)
118
+ if (wanted) {
119
+ if (!existsSync(wanted) || !statSync(wanted).isDirectory()) {
120
+ return sendJson(res, 400, { ok: false, error: `not a directory: ${wanted}` })
121
+ }
122
+ cwd = wanted
123
+ }
124
+ } catch (err) {
125
+ return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
126
+ }
127
+ }
128
+ let built
129
+ try {
130
+ built = buildAction(body.action, body.args || body)
131
+ } catch (err) {
132
+ return sendJson(res, 400, { ok: false, error: String((err && err.message) || err) })
133
+ }
134
+ const steps = []
135
+ for (const argv of built.steps) {
136
+ const answer = await git(argv, { cwd, timeoutMs: 120000 })
137
+ steps.push({
138
+ argv,
139
+ code: answer.code,
140
+ stdout: String(answer.stdout || '').slice(0, 4000),
141
+ stderr: String(answer.stderr || '').slice(0, 4000),
142
+ })
143
+ if (answer.code !== 0) {
144
+ return sendJson(res, 200, { ok: false, action: built.action, summary: built.summary, steps, error: 'git refused the step' })
145
+ }
146
+ }
147
+ return sendJson(res, 200, { ok: true, action: built.action, summary: built.summary, steps })
148
+ }
149
+
95
150
  if (file) {
96
151
  // a single diff, capped: the tab shows a file at a time
97
152
  const args = staged ? ['diff', '--cached', '--', file] : ['diff', '--', file]
@@ -220,3 +275,35 @@ export function registerRoutes(ctx, deps) {
220
275
  })
221
276
 
222
277
  }
278
+
279
+ /**
280
+ * Read a small JSON body, bounded, so a POST that names an action cannot exhaust memory.
281
+ * The panel sends a few hundred bytes; anything larger is refused rather than buffered.
282
+ */
283
+ function readJsonBody(req, limit = 64 * 1024) {
284
+ return new Promise((resolve, reject) => {
285
+ let size = 0
286
+ const chunks = []
287
+ req.on('data', (chunk) => {
288
+ size += chunk.length
289
+ if (size > limit) {
290
+ reject(new Error('the request body is too large'))
291
+ req.destroy()
292
+ return
293
+ }
294
+ chunks.push(chunk)
295
+ })
296
+ req.on('end', () => {
297
+ const text = Buffer.concat(chunks).toString('utf8').trim()
298
+ if (!text) return resolve({})
299
+ try {
300
+ const parsed = JSON.parse(text)
301
+ if (!parsed || typeof parsed !== 'object') return reject(new Error('the request body must be a JSON object'))
302
+ resolve(parsed)
303
+ } catch {
304
+ reject(new Error('the request body is not valid JSON'))
305
+ }
306
+ })
307
+ req.on('error', (err) => reject(err))
308
+ })
309
+ }
@@ -0,0 +1,144 @@
1
+ // Writing to a working copy, from the Source Control tab.
2
+ //
3
+ // Every action is a list of argv arrays — never a shell string. The route runs them one by one
4
+ // and stops at the first failure, so a caller can see exactly which step refused. Validation
5
+ // lives here, next to the commands it protects, and the destructive actions require an explicit
6
+ // confirmation from the human who clicked.
7
+ //
8
+ // Nothing in this module touches the filesystem or spawns a process: it only decides what would
9
+ // be run, which makes the decisions testable without a repository.
10
+
11
+ /** A branch name git will accept: no spaces, no leading dash, no shell metacharacters. */
12
+ const BRANCH_RE = /^(?!-)(?!.*\.\.)[A-Za-z0-9._/-]{1,120}$/
13
+
14
+ /** A repository-relative path: no absolute paths, no traversal, no control characters. */
15
+ const PATH_RE = /^(?!-)(?!\/)(?!.*\.\.)[^\u0000-\u001f]{1,500}$/
16
+
17
+ export function validateBranch(name) {
18
+ const value = String(name == null ? '' : name).trim()
19
+ if (!BRANCH_RE.test(value)) throw new Error(`refusing an unsafe branch name: "${String(name).slice(0, 60)}"`)
20
+ return value
21
+ }
22
+
23
+ export function validatePath(path) {
24
+ const value = String(path == null ? '' : path).trim()
25
+ if (!PATH_RE.test(value)) throw new Error(`refusing an unsafe path: "${String(path).slice(0, 60)}"`)
26
+ return value
27
+ }
28
+
29
+ export function validateMessage(message) {
30
+ const value = String(message == null ? '' : message)
31
+ if (value.includes('\u0000')) throw new Error('refusing a message with a NUL byte')
32
+ const trimmed = value.trim()
33
+ if (!trimmed) throw new Error('a commit or stash needs a message')
34
+ if (trimmed.length > 5000) throw new Error('refusing a message longer than 5000 characters')
35
+ return trimmed
36
+ }
37
+
38
+ /** Stash references are `stash@{N}`; nothing else is accepted. */
39
+ export function validateStashRef(ref) {
40
+ const value = String(ref == null ? '' : ref).trim()
41
+ if (!/^stash@\{\d{1,3}\}$/.test(value)) throw new Error(`refusing an unsafe stash reference: "${value.slice(0, 40)}"`)
42
+ return value
43
+ }
44
+
45
+ const CONTINUE_VERBS = ['merge', 'rebase', 'cherry-pick', 'revert']
46
+
47
+ /**
48
+ * What a named action would run.
49
+ *
50
+ * @returns {{steps: string[][], mutating: true, destructive: boolean, needsConfirm: boolean, summary: string}}
51
+ */
52
+ export function buildAction(action, args = {}) {
53
+ const name = String(action || '').trim()
54
+ const destructive = DESTRUCTIVE.has(name)
55
+ const needsConfirm = destructive
56
+ if (needsConfirm && args.confirm !== true) {
57
+ throw new Error(`refusing "${name}" without confirm: true — it cannot be undone`)
58
+ }
59
+
60
+ switch (name) {
61
+ case 'stage': {
62
+ const path = args.path ? validatePath(args.path) : ''
63
+ return result(name, [path ? ['add', '--', path] : ['add', '-A']], destructive, `stage ${path || 'everything'}`)
64
+ }
65
+ case 'unstage': {
66
+ const path = args.path ? validatePath(args.path) : ''
67
+ return result(name, [path ? ['reset', 'HEAD', '--', path] : ['reset', 'HEAD']], destructive, `unstage ${path || 'everything'}`)
68
+ }
69
+ case 'discard': {
70
+ const path = validatePath(args.path)
71
+ return result(name, [['checkout', '--', path]], destructive, `discard changes in ${path}`)
72
+ }
73
+ case 'commit': {
74
+ const message = validateMessage(args.message)
75
+ const steps = [args.amend === true ? ['commit', '--amend', '-m', message] : ['commit', '-m', message]]
76
+ if (args.push === true) steps.push(['push'])
77
+ return result(name, steps, destructive, args.amend ? 'amend the last commit' : 'create a commit')
78
+ }
79
+ case 'push':
80
+ return result(name, [args.setUpstream ? ['push', '-u', 'origin', validateBranch(args.branch)] : ['push']], destructive, 'push')
81
+ case 'sync':
82
+ return result(name, [['pull', '--rebase', '--autostash'], ['push']], destructive, 'pull with rebase, then push')
83
+ case 'branchCreate':
84
+ return result(name, [['checkout', '-b', validateBranch(args.branch)]], destructive, `create and switch to ${args.branch}`)
85
+ case 'branchCheckout':
86
+ return result(name, [['checkout', validateBranch(args.branch)]], destructive, `switch to ${args.branch}`)
87
+ case 'branchDelete':
88
+ return result(name, [['branch', '-D', validateBranch(args.branch)]], destructive, `delete ${args.branch}`)
89
+ case 'stashPush': {
90
+ const steps = args.message ? [['stash', 'push', '-m', validateMessage(args.message)]] : [['stash', 'push']]
91
+ return result(name, steps, destructive, 'save the working copy to a stash')
92
+ }
93
+ case 'stashApply':
94
+ return result(name, [['stash', 'apply', validateStashRef(args.ref)]], destructive, `apply ${args.ref}`)
95
+ case 'stashPop':
96
+ return result(name, [['stash', 'pop', validateStashRef(args.ref)]], destructive, `apply and drop ${args.ref}`)
97
+ case 'stashDrop':
98
+ return result(name, [['stash', 'drop', validateStashRef(args.ref)]], destructive, `drop ${args.ref}`)
99
+ case 'continue': {
100
+ const verb = String(args.verb || '').trim()
101
+ if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to continue an unknown operation: "${verb.slice(0, 20)}"`)
102
+ return result(name, [[verb, '--continue']], destructive, `continue the ${verb}`)
103
+ }
104
+ case 'abort': {
105
+ const verb = String(args.verb || '').trim()
106
+ if (!CONTINUE_VERBS.includes(verb)) throw new Error(`refusing to abort an unknown operation: "${verb.slice(0, 20)}"`)
107
+ return result(name, [[verb, '--abort']], destructive, `abort the ${verb}`)
108
+ }
109
+ default:
110
+ throw new Error(`unknown action: "${name.slice(0, 40)}"`)
111
+ }
112
+ }
113
+
114
+ const DESTRUCTIVE = new Set(['discard', 'branchDelete', 'stashDrop', 'abort'])
115
+
116
+ function result(action, steps, destructive, summary) {
117
+ return { action, steps, mutating: true, destructive, needsConfirm: destructive, summary }
118
+ }
119
+
120
+ /** Every action the tab may ask for, for the client and for tests. */
121
+ export const ACTIONS = Object.freeze([
122
+ 'stage', 'unstage', 'discard', 'commit', 'push', 'sync',
123
+ 'branchCreate', 'branchCheckout', 'branchDelete',
124
+ 'stashPush', 'stashApply', 'stashPop', 'stashDrop',
125
+ 'continue', 'abort',
126
+ ])
127
+
128
+ /** The verbs the panel offers for an unfinished operation. */
129
+ export const OPERATION_VERBS = Object.freeze(CONTINUE_VERBS.slice())
130
+
131
+ /**
132
+ * An optional directory the tab should look at instead of the session workspace. The value is
133
+ * only ever used locally (the route answers a local caller and nothing else executes it), but it
134
+ * is still checked: absolute, without traversal or control bytes.
135
+ */
136
+ export function validateCwd(path) {
137
+ const value = String(path == null ? '' : path).trim()
138
+ if (!value) return ''
139
+ if (!value.startsWith('/')) throw new Error('a repository path must be absolute')
140
+ if (value.includes('..')) throw new Error('a repository path may not contain ".."')
141
+ if (/[\u0000-\u001f]/.test(value)) throw new Error('a repository path may not contain control characters')
142
+ if (value.length > 500) throw new Error('a repository path is too long')
143
+ return value
144
+ }
@@ -20,12 +20,12 @@ export async function repoTree(client, { owner, repo, ref, path, recursive = tru
20
20
  const { data } = await client.get(`/repos/${owner}/${repo}/git/trees/${encodeURIComponent(target)}${suffix}`, {
21
21
  query: recursive ? { recursive: '1' } : undefined,
22
22
  })
23
- const entries = (data && Array.isArray(data.tree) ? data.tree : []).slice(0, capped).map((entry) => ({
24
- path: entry.path,
25
- type: entry.type,
26
- size: entry.size,
27
- sha: entry.sha,
28
- }))
23
+ const entries = (data && Array.isArray(data.tree) ? data.tree : []).slice(0, capped).map((entry) => {
24
+ // a directory has no size in the API: say nothing rather than claiming undefined
25
+ const row = { path: entry.path, type: entry.type, sha: entry.sha }
26
+ if (typeof entry.size === 'number') row.size = entry.size
27
+ return row
28
+ })
29
29
  return {
30
30
  ref: target,
31
31
  path: path || '',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-github-ops",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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",
@@ -27,12 +27,13 @@
27
27
  "platform": "web",
28
28
  "inject": [
29
29
  "@deepseek-ai/dsh-client-locale",
30
- "@deepseek-ai/dsh-client-ui-settings"
30
+ "@deepseek-ai/dsh-client-ui-settings",
31
+ "@deepseek-ai/dsh-client-ui-slots"
31
32
  ]
32
33
  }
33
34
  },
34
35
  "scripts": {
35
- "pretest": "for f in lib/index.js lib/github.js lib/tools/*.js; do node --check \"$f\" || exit 1; done",
36
+ "pretest": "for f in lib/*.js lib/tools/*.js; do node --check \"$f\" || exit 1; done",
36
37
  "test": "node --test test/*.test.mjs"
37
38
  },
38
39
  "peerDependencies": {