@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 +36 -0
- package/lib/client.js +227 -28
- package/lib/index.js +5 -2
- package/lib/lossless.js +90 -0
- package/lib/routes.js +90 -3
- package/lib/scm-write.js +144 -0
- package/lib/tools/write-files.js +6 -6
- package/package.json +4 -3
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
|
|
233
|
-
|
|
234
|
-
className: 'gho-scm-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
React.createElement('
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' }, `↑${
|
|
453
|
+
? React.createElement('span', { className: 'gho-scm-counts' }, `↑${ahead} ↓${behind}`)
|
|
277
454
|
: React.createElement('span', { className: 'gho-scm-counts' }, t('scmNoUpstream')),
|
|
278
|
-
|
|
455
|
+
button(busy ? t('scmWorking') : t('scmRefresh'), load, { disabled: Boolean(busy) }),
|
|
279
456
|
),
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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.
|
|
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', '
|
|
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
|
-
|
|
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
|
})
|
package/lib/lossless.js
ADDED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/scm-write.js
ADDED
|
@@ -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
|
+
}
|
package/lib/tools/write-files.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
type: entry.type,
|
|
26
|
-
size
|
|
27
|
-
|
|
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.
|
|
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
|
|
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": {
|