@ban-sky/dsh-github 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +249 -2
- package/README.zh.md +213 -0
- package/client.js +1689 -0
- package/cordis.patch.yml +8 -0
- package/icon.svg +10 -0
- package/index.js +78 -0
- package/lib/config.js +180 -0
- package/lib/dsh-go.js +674 -0
- package/lib/errors.js +67 -0
- package/lib/git.js +677 -0
- package/lib/github-api.js +390 -0
- package/lib/http.js +113 -0
- package/lib/service.js +974 -0
- package/lib/tools.js +102 -0
- package/lib/validate.js +197 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +125 -4
package/lib/dsh-go.js
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH GO surface of `dsh-github`.
|
|
3
|
+
*
|
|
4
|
+
* The Cordis plugin ships a Web UI, which the phone app cannot draw. This module
|
|
5
|
+
* registers the same capability on the `dshGo` service the DSH GO bridge
|
|
6
|
+
* provides: one native panel (account state, device-code sign-in, the session
|
|
7
|
+
* folder's repository actions) plus three composer entries that take their text
|
|
8
|
+
* from the message box, because native form inputs are not available everywhere.
|
|
9
|
+
*
|
|
10
|
+
* It reuses the very service instance created by `index.js`, so there is one
|
|
11
|
+
* sign-in state machine, one credential record and one settings file.
|
|
12
|
+
*
|
|
13
|
+
* Nothing secret lives here: the access token stays in the DSH credential store.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-github/lib/dsh-go
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { asGithubError, redact } from './errors.js'
|
|
19
|
+
|
|
20
|
+
/** Public GitHub device-code page; the only link this module ever opens. */
|
|
21
|
+
const DEVICE_URL = 'https://github.com/login/device'
|
|
22
|
+
|
|
23
|
+
/** Public Client IDs are short and safe to echo back. */
|
|
24
|
+
const CLIENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
25
|
+
|
|
26
|
+
/** `owner/name`, as the clone action wants it. */
|
|
27
|
+
const FULL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/
|
|
28
|
+
|
|
29
|
+
/** Deferred results must come back inside one minute. */
|
|
30
|
+
const SLOW_TIMEOUT_MS = 60_000
|
|
31
|
+
|
|
32
|
+
/** How long a sign-in attempt keeps the panel refreshing itself. */
|
|
33
|
+
const WATCH_MS = 15 * 60 * 1000
|
|
34
|
+
|
|
35
|
+
/** Wording for the failure codes the service raises. */
|
|
36
|
+
const FAILURES = {
|
|
37
|
+
'not-signed-in': ['先登录 GitHub', 'Sign in to GitHub first'],
|
|
38
|
+
'missing-client-id': ['先填 OAuth App 的 Client ID', 'Set the Client ID first'],
|
|
39
|
+
'client-id-invalid': ['Client ID 格式不对', 'That Client ID looks wrong'],
|
|
40
|
+
'device-flow-disabled': ['OAuth App 没开 Device flow', 'Enable Device Flow on the OAuth App'],
|
|
41
|
+
'login-denied': ['你在 GitHub 上拒绝了授权', 'The authorization was denied'],
|
|
42
|
+
'login-expired': ['设备码过期了,重新登录', 'The code expired; sign in again'],
|
|
43
|
+
'login-failed': ['登录没成功,再试一次', 'Sign-in failed; try again'],
|
|
44
|
+
'not-a-repo': ['当前目录不是 git 仓库', 'This folder is not a git repository'],
|
|
45
|
+
'no-remote': ['这个仓库没有 origin', 'This repository has no origin'],
|
|
46
|
+
'no-upstream': ['当前分支还没有上游', 'The branch has no upstream'],
|
|
47
|
+
'not-fast-forward': ['远端有新提交,先拉取', 'Pull first: the remote moved'],
|
|
48
|
+
'push-rejected': ['推送被 GitHub 拒绝了', 'GitHub rejected the push'],
|
|
49
|
+
'nothing-to-commit': ['没有要提交的改动', 'Nothing to commit'],
|
|
50
|
+
'dirty-worktree': ['工作区还有改动,先提交', 'Commit the working tree first'],
|
|
51
|
+
'target-exists': ['目标目录已经有了', 'That folder already exists'],
|
|
52
|
+
'invalid-repo-name': ['仓库名不合法', 'That repository name is invalid'],
|
|
53
|
+
'invalid-owner': ['账号名不合法', 'That owner name is invalid'],
|
|
54
|
+
'invalid-path': ['路径不合法', 'That path is invalid'],
|
|
55
|
+
'unsafe-path': ['路径不安全,拒绝了', 'That path is unsafe'],
|
|
56
|
+
'invalid-message': ['提交信息不合法', 'That commit message is invalid'],
|
|
57
|
+
'invalid-files': ['指定的文件不合法', 'Those files are invalid'],
|
|
58
|
+
'session-not-found': ['先打开一个对话', 'Open a session first'],
|
|
59
|
+
'detached-head': ['现在不在任何分支上', 'HEAD is detached'],
|
|
60
|
+
'git-missing': ['这台机器上没有 git', 'git is missing on this host'],
|
|
61
|
+
'git-failed': ['git 执行失败', 'git failed'],
|
|
62
|
+
'git-timeout': ['git 超时了', 'git timed out'],
|
|
63
|
+
'network-error': ['连不上 GitHub,等下再试', 'Cannot reach GitHub; try later'],
|
|
64
|
+
'rate-limited': ['GitHub 限流了,等下再试', 'GitHub is rate limiting; try later'],
|
|
65
|
+
'forbidden': ['这个令牌没有权限', 'The token lacks permission'],
|
|
66
|
+
'token-invalid': ['登录已失效,再登录一次', 'Sign in again'],
|
|
67
|
+
'not-found': ['GitHub 上找不到它', 'Not found on GitHub'],
|
|
68
|
+
'clone-root-missing': ['克隆根目录不可用', 'The clone root is not usable'],
|
|
69
|
+
'invalid-clone-root': ['克隆根目录不合法', 'The clone root is invalid'],
|
|
70
|
+
'cancelled': ['已取消', 'Cancelled'],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Pick one of two strings.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} zh - Chinese wording.
|
|
77
|
+
* @param {string} en - English wording.
|
|
78
|
+
* @param {boolean} zhMode - Chinese when true.
|
|
79
|
+
* @returns {string}
|
|
80
|
+
*/
|
|
81
|
+
function s(zh, en, zhMode) {
|
|
82
|
+
return zhMode ? zh : en
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Trim, collapse whitespace and cut a line for a toast or a view field.
|
|
87
|
+
*
|
|
88
|
+
* @param {unknown} value - Raw text.
|
|
89
|
+
* @param {number} max - Maximum characters.
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function clip(value, max) {
|
|
93
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
94
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A short, human sentence for a thrown failure.
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} error - Thrown value.
|
|
101
|
+
* @param {boolean} zhMode - Chinese when true.
|
|
102
|
+
* @returns {string}
|
|
103
|
+
*/
|
|
104
|
+
function describe(error, zhMode) {
|
|
105
|
+
const failure = asGithubError(error, 'internal')
|
|
106
|
+
const mapped = FAILURES[failure.code]
|
|
107
|
+
if (mapped !== undefined) return s(mapped[0], mapped[1], zhMode)
|
|
108
|
+
return clip(redact(failure.message), 60) || s('操作没成功', 'That did not work', zhMode)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Show only the ends of a public Client ID.
|
|
113
|
+
*
|
|
114
|
+
* @param {string} clientId - Stored Client ID.
|
|
115
|
+
* @returns {string}
|
|
116
|
+
*/
|
|
117
|
+
function masked(clientId) {
|
|
118
|
+
if (clientId === '') return ''
|
|
119
|
+
return clientId.length <= 10 ? clientId : `${clientId.slice(0, 6)}…${clientId.slice(-3)}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Find one form field in whatever shape the client sends on submit.
|
|
124
|
+
*
|
|
125
|
+
* @param {unknown} value - `event.value` of a panel action.
|
|
126
|
+
* @param {string} field - Bound data field name.
|
|
127
|
+
* @returns {string | undefined}
|
|
128
|
+
*/
|
|
129
|
+
function fieldValue(value, field) {
|
|
130
|
+
const seen = new Set()
|
|
131
|
+
/**
|
|
132
|
+
* @param {unknown} node - Current node.
|
|
133
|
+
* @param {number} depth - Recursion depth.
|
|
134
|
+
* @returns {string | undefined}
|
|
135
|
+
*/
|
|
136
|
+
const walk = (node, depth) => {
|
|
137
|
+
if (node === null || typeof node !== 'object' || depth > 6 || seen.has(node)) return undefined
|
|
138
|
+
seen.add(node)
|
|
139
|
+
if (Array.isArray(node)) {
|
|
140
|
+
for (const item of node) {
|
|
141
|
+
const hit = walk(item, depth + 1)
|
|
142
|
+
if (hit !== undefined) return hit
|
|
143
|
+
}
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
for (const [key, raw] of Object.entries(node)) {
|
|
147
|
+
const leaf = key.slice(key.lastIndexOf('.') + 1)
|
|
148
|
+
if (leaf === field && (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean')) return String(raw)
|
|
149
|
+
}
|
|
150
|
+
for (const raw of Object.values(node)) {
|
|
151
|
+
const hit = walk(raw, depth + 1)
|
|
152
|
+
if (hit !== undefined) return hit
|
|
153
|
+
}
|
|
154
|
+
return undefined
|
|
155
|
+
}
|
|
156
|
+
return walk(value, 0)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* One line that sums up a local checkout.
|
|
161
|
+
*
|
|
162
|
+
* @param {any} repo - Status object from the service, or null.
|
|
163
|
+
* @param {boolean} zhMode - Chinese when true.
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
function repoLine(repo, zhMode) {
|
|
167
|
+
if (repo === null || repo === undefined) return s('不是 git 仓库', 'not a git repository', zhMode)
|
|
168
|
+
const parts = [typeof repo.branch === 'string' && repo.branch !== '' ? repo.branch : s('游离 HEAD', 'detached HEAD', zhMode)]
|
|
169
|
+
if (typeof repo.ahead === 'number' && repo.ahead > 0) parts.push(s(`领先 ${repo.ahead}`, `${repo.ahead} ahead`, zhMode))
|
|
170
|
+
if (typeof repo.behind === 'number' && repo.behind > 0) parts.push(s(`落后 ${repo.behind}`, `${repo.behind} behind`, zhMode))
|
|
171
|
+
if (typeof repo.changeCount === 'number' && repo.changeCount > 0) parts.push(s(`${repo.changeCount} 处改动`, `${repo.changeCount} changed`, zhMode))
|
|
172
|
+
else parts.push(s('没有改动', 'clean', zhMode))
|
|
173
|
+
const remote = repo.remote
|
|
174
|
+
if (remote !== null && typeof remote === 'object' && typeof remote.owner === 'string' && remote.owner !== '') {
|
|
175
|
+
parts.push(`${remote.owner}/${remote.name}`)
|
|
176
|
+
}
|
|
177
|
+
return parts.join(' · ')
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Register the DSH GO panel and composer entries.
|
|
182
|
+
*
|
|
183
|
+
* @param {any} ctx - Plugin context that has `dshGo`.
|
|
184
|
+
* @param {any} service - Service created by `index.js`.
|
|
185
|
+
*/
|
|
186
|
+
export function registerDshGo(ctx, service) {
|
|
187
|
+
let watchUntil = 0
|
|
188
|
+
let watchStarted = 0
|
|
189
|
+
let loopToken = 0
|
|
190
|
+
|
|
191
|
+
/** Start or extend the live watch while a sign-in is in flight. */
|
|
192
|
+
function watch() {
|
|
193
|
+
if (watchUntil <= Date.now()) watchStarted = Date.now()
|
|
194
|
+
watchUntil = Date.now() + WATCH_MS
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* @param {any} scope - Panel scope.
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
function sessionIdOf(scope) {
|
|
202
|
+
return scope !== null && typeof scope === 'object' && typeof scope.sessionId === 'string' ? scope.sessionId : ''
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read everything the panel shows.
|
|
207
|
+
*
|
|
208
|
+
* @param {any} scope - Panel scope.
|
|
209
|
+
* @param {string} locale - Client locale.
|
|
210
|
+
* @returns {Promise<Record<string, any>>}
|
|
211
|
+
*/
|
|
212
|
+
async function collect(scope, locale) {
|
|
213
|
+
const zh = !String(locale ?? '').toLowerCase().startsWith('en')
|
|
214
|
+
/** @type {Record<string, any>} */
|
|
215
|
+
const data = {
|
|
216
|
+
hint: s('正在读取…', 'Reading…', zh),
|
|
217
|
+
hintTone: 'info',
|
|
218
|
+
account: '—',
|
|
219
|
+
clientIdMasked: '—',
|
|
220
|
+
repo: '—',
|
|
221
|
+
tools: '—',
|
|
222
|
+
code: '—',
|
|
223
|
+
signedIn: false,
|
|
224
|
+
loginPending: false,
|
|
225
|
+
clientIdDraft: '',
|
|
226
|
+
commitDraft: '',
|
|
227
|
+
cloneDraft: '',
|
|
228
|
+
publishDraft: '',
|
|
229
|
+
}
|
|
230
|
+
/** @type {any} */
|
|
231
|
+
let snapshot = null
|
|
232
|
+
try {
|
|
233
|
+
snapshot = await service.dispatch('state')
|
|
234
|
+
} catch (error) {
|
|
235
|
+
data.hint = describe(error, zh)
|
|
236
|
+
data.hintTone = 'bad'
|
|
237
|
+
return data
|
|
238
|
+
}
|
|
239
|
+
const settings = snapshot?.settings ?? {}
|
|
240
|
+
const auth = snapshot?.auth ?? {}
|
|
241
|
+
const login = snapshot?.login ?? {}
|
|
242
|
+
const clientId = typeof settings.clientId === 'string' ? settings.clientId : ''
|
|
243
|
+
const status = typeof login.status === 'string' ? login.status : 'idle'
|
|
244
|
+
const pending = status === 'starting' || status === 'pending'
|
|
245
|
+
const code = typeof login.userCode === 'string' ? login.userCode : ''
|
|
246
|
+
data.signedIn = auth.signedIn === true
|
|
247
|
+
data.loginPending = pending
|
|
248
|
+
data.clientIdMasked = clientId === '' ? s('未配置', 'not set', zh) : masked(clientId)
|
|
249
|
+
data.tools = snapshot?.toolsRegistered === true ? s('已开启', 'on', zh) : s('未开启', 'off', zh)
|
|
250
|
+
data.account = data.signedIn
|
|
251
|
+
? `@${typeof auth.login === 'string' && auth.login !== '' ? auth.login : 'github'}`
|
|
252
|
+
: pending
|
|
253
|
+
? s('等待授权…', 'waiting…', zh)
|
|
254
|
+
: s('未登录', 'not signed in', zh)
|
|
255
|
+
data.code = pending && code !== '' ? code : '—'
|
|
256
|
+
|
|
257
|
+
if (status === 'failed' && typeof login.message === 'string' && login.message !== '') {
|
|
258
|
+
data.hint = clip(redact(login.message), 120)
|
|
259
|
+
data.hintTone = 'bad'
|
|
260
|
+
} else if (status === 'expired') {
|
|
261
|
+
data.hint = s('设备码过期了,重新点登录', 'That code expired; start again', zh)
|
|
262
|
+
data.hintTone = 'warn'
|
|
263
|
+
} else if (clientId === '' && !data.signedIn) {
|
|
264
|
+
data.hint = s('还没有 Client ID:把 OAuth App 的 ID 粘到输入框,点「存 ID」', 'No Client ID yet: paste it in the box, tap Save ID', zh)
|
|
265
|
+
data.hintTone = 'warn'
|
|
266
|
+
} else if (pending) {
|
|
267
|
+
data.hint = code !== ''
|
|
268
|
+
? s(`设备码 ${code}:打开授权页输入,授权后这里会自动刷新`, `Code ${code}: enter it on GitHub; this page refreshes itself`, zh)
|
|
269
|
+
: s('正在向 GitHub 要设备码…', 'Asking GitHub for a device code…', zh)
|
|
270
|
+
} else if (!data.signedIn) {
|
|
271
|
+
data.hint = s('点「登录」用设备码登录,不用密码', 'Tap Sign in for the device-code flow; no password', zh)
|
|
272
|
+
} else if (Array.isArray(auth.missingScopes) && auth.missingScopes.length > 0) {
|
|
273
|
+
data.hint = s(
|
|
274
|
+
'已登录,但授权缺少 workflow 权限:推送 .github/workflows 会被拒,重新登录一次即可补上',
|
|
275
|
+
'Signed in, but the token lacks the workflow scope: pushes that touch .github/workflows are refused. Sign in again to add it',
|
|
276
|
+
zh,
|
|
277
|
+
)
|
|
278
|
+
data.hintTone = 'warn'
|
|
279
|
+
} else {
|
|
280
|
+
data.hint = s('已登录,可以对当前对话目录拉取、提交、推送', 'Signed in; pull, commit and push the session folder', zh)
|
|
281
|
+
data.hintTone = 'good'
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const sessionId = sessionIdOf(scope)
|
|
285
|
+
if (sessionId === '') {
|
|
286
|
+
data.repo = s('没有打开的对话', 'no session open', zh)
|
|
287
|
+
} else {
|
|
288
|
+
try {
|
|
289
|
+
const local = await service.dispatch('session_status', { sessionId })
|
|
290
|
+
data.repo = local?.repoRoot === null || local?.repoRoot === undefined
|
|
291
|
+
? s('不是 git 仓库', 'not a git repository', zh)
|
|
292
|
+
: repoLine(local?.repo, zh)
|
|
293
|
+
} catch {
|
|
294
|
+
data.repo = s('读不到目录状态', 'cannot read the folder', zh)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return data
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Wrap one repository action in the deferred-result effect.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} label - Pending label.
|
|
304
|
+
* @param {() => Promise<any>} work - Work returning effects.
|
|
305
|
+
* @returns {any}
|
|
306
|
+
*/
|
|
307
|
+
function slow(label, work) {
|
|
308
|
+
return { pending: { label: clip(label, 16), timeoutMs: SLOW_TIMEOUT_MS }, result: work }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* A session-scoped repository action.
|
|
313
|
+
*
|
|
314
|
+
* @param {any} scope - Panel or capsule scope.
|
|
315
|
+
* @param {{locale?: string}} event - Panel event (only `locale` is read).
|
|
316
|
+
* @param {string} zhLabel - Pending label (zh).
|
|
317
|
+
* @param {string} enLabel - Pending label (en).
|
|
318
|
+
* @param {(sessionId: string, signal: AbortSignal) => Promise<any>} call - Service call.
|
|
319
|
+
* @param {string} zhDone - Success toast (zh).
|
|
320
|
+
* @param {string} enDone - Success toast (en).
|
|
321
|
+
* @returns {any}
|
|
322
|
+
*/
|
|
323
|
+
function sessionAction(scope, event, zhLabel, enLabel, call, zhDone, enDone) {
|
|
324
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
325
|
+
const sessionId = sessionIdOf(scope)
|
|
326
|
+
if (sessionId === '') return { toast: s('先在对话里打开会话', 'Open a session first', zh) }
|
|
327
|
+
return slow(s(zhLabel, enLabel, zh), async (signal) => {
|
|
328
|
+
try {
|
|
329
|
+
await call(sessionId, signal)
|
|
330
|
+
return { toast: s(zhDone, enDone, zh), patch: await collect(scope, event.locale) }
|
|
331
|
+
} catch (error) {
|
|
332
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Store the public Client ID that switches device sign-in on.
|
|
339
|
+
*
|
|
340
|
+
* @param {unknown} raw - Client ID text.
|
|
341
|
+
* @param {boolean} zh - Chinese wording.
|
|
342
|
+
* @returns {Promise<{ok: boolean, toast: string}>}
|
|
343
|
+
*/
|
|
344
|
+
async function saveClientId(raw, zh) {
|
|
345
|
+
const value = String(raw ?? '').trim()
|
|
346
|
+
if (!CLIENT_ID_PATTERN.test(value)) return { ok: false, toast: s('Client ID 看着不对', 'That Client ID looks wrong', zh) }
|
|
347
|
+
try {
|
|
348
|
+
const result = await service.dispatch('settings_save', { clientId: value })
|
|
349
|
+
if (result?.settings?.clientId !== value) return { ok: false, toast: s('Client ID 没存进去,检查一下', 'That Client ID was not saved', zh) }
|
|
350
|
+
return { ok: true, toast: s('已保存 Client ID', 'Client ID saved', zh) }
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return { ok: false, toast: clip(describe(error, zh), 40) }
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Commit and push the session folder with one message.
|
|
358
|
+
*
|
|
359
|
+
* @param {any} scope - Panel or capsule scope.
|
|
360
|
+
* @param {string} locale - Client locale.
|
|
361
|
+
* @param {unknown} message - Commit message.
|
|
362
|
+
* @returns {any}
|
|
363
|
+
*/
|
|
364
|
+
function commitWith(scope, locale, message) {
|
|
365
|
+
const zh = !String(locale ?? '').toLowerCase().startsWith('en')
|
|
366
|
+
const text = String(message ?? '').trim()
|
|
367
|
+
if (text === '') return { toast: s('先写提交信息', 'Type a commit message', zh) }
|
|
368
|
+
return sessionAction(
|
|
369
|
+
scope,
|
|
370
|
+
{ locale },
|
|
371
|
+
'正在提交',
|
|
372
|
+
'Committing',
|
|
373
|
+
(sessionId, signal) => service.dispatch('session_commit', { sessionId, message: text.slice(0, 2000), push: true }, signal),
|
|
374
|
+
'已提交并推送',
|
|
375
|
+
'Committed and pushed',
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Clone one repository into the session folder.
|
|
381
|
+
*
|
|
382
|
+
* @param {any} scope - Panel or capsule scope.
|
|
383
|
+
* @param {string} locale - Client locale.
|
|
384
|
+
* @param {unknown} fullName - `owner/name`.
|
|
385
|
+
* @returns {any}
|
|
386
|
+
*/
|
|
387
|
+
function cloneWith(scope, locale, fullName) {
|
|
388
|
+
const zh = !String(locale ?? '').toLowerCase().startsWith('en')
|
|
389
|
+
const name = String(fullName ?? '').trim()
|
|
390
|
+
if (!FULL_NAME_PATTERN.test(name)) return { toast: s('仓库写 owner/name', 'Use owner/name', zh) }
|
|
391
|
+
return sessionAction(
|
|
392
|
+
scope,
|
|
393
|
+
{ locale },
|
|
394
|
+
'正在克隆',
|
|
395
|
+
'Cloning',
|
|
396
|
+
(sessionId, signal) => service.dispatch('session_clone', { sessionId, fullName: name }, signal),
|
|
397
|
+
'已克隆到对话目录',
|
|
398
|
+
'Cloned into the session folder',
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
ctx.dshGo.panel('github', {
|
|
403
|
+
view: (_scope, caps) => {
|
|
404
|
+
const list = Array.isArray(caps?.components) ? caps.components : null
|
|
405
|
+
const forms = list !== null && list.includes('form') && list.includes('textField')
|
|
406
|
+
/** @type {any[]} */
|
|
407
|
+
const children = [
|
|
408
|
+
{ type: 'callout', text: { $: 'data.hint' }, tone: { $: 'data.hintTone' } },
|
|
409
|
+
{
|
|
410
|
+
type: 'keyValue',
|
|
411
|
+
items: [
|
|
412
|
+
{ label: '账号', value: { $: 'data.account' } },
|
|
413
|
+
{ label: 'Client ID', value: { $: 'data.clientIdMasked' } },
|
|
414
|
+
{ label: '对话目录', value: { $: 'data.repo' } },
|
|
415
|
+
{ label: 'Agent 工具', value: { $: 'data.tools' } },
|
|
416
|
+
],
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
type: 'actions',
|
|
420
|
+
items: [
|
|
421
|
+
{ label: '登录', action: 'login' },
|
|
422
|
+
{ label: '取消登录', action: 'cancel-login' },
|
|
423
|
+
{ label: '退出登录', action: 'logout' },
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
{ type: 'text', text: { $: 'data.code' }, style: { size: 'title', weight: 'bold' } },
|
|
427
|
+
{
|
|
428
|
+
type: 'actions',
|
|
429
|
+
items: [
|
|
430
|
+
{ label: '复制设备码', action: 'copy-code' },
|
|
431
|
+
{ label: '打开授权页', action: 'open-device' },
|
|
432
|
+
],
|
|
433
|
+
},
|
|
434
|
+
{ type: 'divider' },
|
|
435
|
+
{
|
|
436
|
+
type: 'text',
|
|
437
|
+
text: '下面的操作都在当前对话目录里进行。提交信息写进输入框,再点输入框上方的「提交」。',
|
|
438
|
+
tone: 'muted',
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
type: 'actions',
|
|
442
|
+
items: [
|
|
443
|
+
{ label: '刷新', action: 'refresh' },
|
|
444
|
+
{ label: '更新', action: 'fetch' },
|
|
445
|
+
{ label: '拉取', action: 'pull' },
|
|
446
|
+
{ label: '推送', action: 'push' },
|
|
447
|
+
],
|
|
448
|
+
},
|
|
449
|
+
]
|
|
450
|
+
if (forms) {
|
|
451
|
+
children.push({
|
|
452
|
+
type: 'form',
|
|
453
|
+
label: '提交并推送',
|
|
454
|
+
submit: 'commit',
|
|
455
|
+
submitLabel: '提交并推送',
|
|
456
|
+
children: [{ type: 'textField', label: '提交信息', bind: 'data.commitDraft', placeholder: '这次改了什么', maxLength: 200 }],
|
|
457
|
+
})
|
|
458
|
+
}
|
|
459
|
+
children.push({
|
|
460
|
+
type: 'text',
|
|
461
|
+
text: '克隆仓库:把 owner/name 写进输入框,点加号面板里的「克隆」;要把当前目录建成新仓库,用网页版的设置页。',
|
|
462
|
+
tone: 'muted',
|
|
463
|
+
})
|
|
464
|
+
if (forms) {
|
|
465
|
+
children.push({
|
|
466
|
+
type: 'collapsible',
|
|
467
|
+
label: '克隆 / 新建仓库',
|
|
468
|
+
children: [
|
|
469
|
+
{
|
|
470
|
+
type: 'form',
|
|
471
|
+
label: '克隆仓库到对话目录',
|
|
472
|
+
submit: 'clone',
|
|
473
|
+
submitLabel: '克隆',
|
|
474
|
+
children: [{ type: 'textField', label: '仓库', bind: 'data.cloneDraft', placeholder: 'owner/name', maxLength: 200 }],
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
type: 'form',
|
|
478
|
+
label: '把对话目录新建成私有仓库并推送',
|
|
479
|
+
submit: 'publish',
|
|
480
|
+
submitLabel: '新建并推送',
|
|
481
|
+
children: [{ type: 'textField', label: '仓库名', bind: 'data.publishDraft', placeholder: 'my-project', maxLength: 100 }],
|
|
482
|
+
},
|
|
483
|
+
],
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
children.push({
|
|
487
|
+
type: 'text',
|
|
488
|
+
text: '建 OAuth App:GitHub → Settings → Developer settings → OAuth Apps → New OAuth App;Homepage URL 和 Authorization callback URL 都填 https://github.com/,建好后再进应用页勾上 Enable Device Flow。',
|
|
489
|
+
tone: 'muted',
|
|
490
|
+
})
|
|
491
|
+
children.push({
|
|
492
|
+
type: 'text',
|
|
493
|
+
text: '填 Client ID:把 Iv1.… / Ov23… 整段粘到输入框,点输入框上方的「存 ID」。',
|
|
494
|
+
})
|
|
495
|
+
if (forms) {
|
|
496
|
+
children.push({
|
|
497
|
+
type: 'form',
|
|
498
|
+
label: 'Client ID',
|
|
499
|
+
submit: 'save-client',
|
|
500
|
+
submitLabel: '保存',
|
|
501
|
+
children: [{ type: 'textField', label: 'Client ID', bind: 'data.clientIdDraft', placeholder: 'Iv1.… 或 Ov23…', maxLength: 64 }],
|
|
502
|
+
})
|
|
503
|
+
}
|
|
504
|
+
children.push({ type: 'actions', items: [{ label: '开启 Agent 工具', action: 'enable-tools' }] })
|
|
505
|
+
return { v: 2, type: 'stack', title: 'GitHub', children }
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
data: (scope, emit, caps) => {
|
|
509
|
+
const token = (loopToken += 1)
|
|
510
|
+
let stopped = false
|
|
511
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
512
|
+
let timer
|
|
513
|
+
const tick = async () => {
|
|
514
|
+
if (stopped || token !== loopToken) return
|
|
515
|
+
/** @type {Record<string, any>} */
|
|
516
|
+
let data
|
|
517
|
+
try {
|
|
518
|
+
data = await collect(scope, caps?.locale)
|
|
519
|
+
} catch {
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
if (stopped || token !== loopToken) return
|
|
523
|
+
emit(data)
|
|
524
|
+
const watching = data.loginPending === true || (watchUntil > Date.now() && data.signedIn !== true)
|
|
525
|
+
if (!watching) return
|
|
526
|
+
if (data.loginPending === true) {
|
|
527
|
+
if (watchUntil <= Date.now()) watchStarted = Date.now()
|
|
528
|
+
watchUntil = Math.max(watchUntil, Date.now() + WATCH_MS)
|
|
529
|
+
}
|
|
530
|
+
const age = Date.now() - (watchStarted === 0 ? Date.now() : watchStarted)
|
|
531
|
+
timer = setTimeout(tick, age < 60_000 ? 3000 : 10_000)
|
|
532
|
+
timer.unref?.()
|
|
533
|
+
}
|
|
534
|
+
void tick()
|
|
535
|
+
return () => {
|
|
536
|
+
stopped = true
|
|
537
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
|
|
541
|
+
actions: {
|
|
542
|
+
refresh: async (scope, event) => {
|
|
543
|
+
try {
|
|
544
|
+
return { patch: await collect(scope, event?.locale) }
|
|
545
|
+
} catch (error) {
|
|
546
|
+
return { toast: clip(describe(error, true), 40) }
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
|
|
550
|
+
login: async (scope, event) => {
|
|
551
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
552
|
+
try {
|
|
553
|
+
const before = await service.dispatch('state')
|
|
554
|
+
if (before?.auth?.signedIn === true) {
|
|
555
|
+
return { toast: s('已经登录了', 'Already signed in', zh), patch: await collect(scope, event?.locale) }
|
|
556
|
+
}
|
|
557
|
+
if (!before?.settings?.clientId) return { toast: s('先填 Client ID 再登录', 'Set the Client ID first', zh) }
|
|
558
|
+
watch()
|
|
559
|
+
await service.dispatch('login_start')
|
|
560
|
+
const deadline = Date.now() + 2000
|
|
561
|
+
let state = null
|
|
562
|
+
while (Date.now() < deadline) {
|
|
563
|
+
await new Promise((resolve) => setTimeout(resolve, 300))
|
|
564
|
+
state = await service.dispatch('login_status')
|
|
565
|
+
const status = state?.login?.status
|
|
566
|
+
if (status === 'pending' && typeof state?.login?.userCode === 'string' && state.login.userCode !== '') break
|
|
567
|
+
if (status === 'failed' || status === 'cancelled' || status === 'authorized') break
|
|
568
|
+
}
|
|
569
|
+
const patch = await collect(scope, event?.locale)
|
|
570
|
+
const code = typeof state?.login?.userCode === 'string' ? state.login.userCode : ''
|
|
571
|
+
const status = state?.login?.status
|
|
572
|
+
if (status === 'failed') {
|
|
573
|
+
return { toast: clip(redact(state?.login?.message ?? ''), 40) || s('登录没成功', 'Sign-in failed', zh), patch }
|
|
574
|
+
}
|
|
575
|
+
if (code !== '') return { toast: s(`设备码 ${code}`, `Code ${code}`, zh), patch, refresh: true }
|
|
576
|
+
return { toast: s('正在等 GitHub 回话…', 'Waiting for GitHub…', zh), patch, refresh: true }
|
|
577
|
+
} catch (error) {
|
|
578
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
|
|
582
|
+
'cancel-login': async (scope, event) => {
|
|
583
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
584
|
+
try {
|
|
585
|
+
await service.dispatch('login_cancel')
|
|
586
|
+
watchUntil = 0
|
|
587
|
+
return { toast: s('已取消登录', 'Sign-in cancelled', zh), patch: await collect(scope, event?.locale) }
|
|
588
|
+
} catch (error) {
|
|
589
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
590
|
+
}
|
|
591
|
+
},
|
|
592
|
+
|
|
593
|
+
logout: async (scope, event) => {
|
|
594
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
595
|
+
try {
|
|
596
|
+
await service.dispatch('logout')
|
|
597
|
+
watchUntil = 0
|
|
598
|
+
return { toast: s('已退出 GitHub', 'Signed out of GitHub', zh), patch: await collect(scope, event?.locale) }
|
|
599
|
+
} catch (error) {
|
|
600
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
601
|
+
}
|
|
602
|
+
},
|
|
603
|
+
|
|
604
|
+
'copy-code': async (scope, event) => {
|
|
605
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
606
|
+
try {
|
|
607
|
+
const state = await service.dispatch('state')
|
|
608
|
+
const code = state?.login?.userCode
|
|
609
|
+
if (typeof code !== 'string' || code === '') return { toast: s('还没有设备码,先点登录', 'No code yet; sign in first', zh) }
|
|
610
|
+
return { copy: code, toast: s('已复制设备码', 'Code copied', zh) }
|
|
611
|
+
} catch (error) {
|
|
612
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
613
|
+
}
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
'open-device': () => ({ link: DEVICE_URL }),
|
|
617
|
+
|
|
618
|
+
fetch: (scope, event) =>
|
|
619
|
+
sessionAction(scope, event, '正在更新', 'Fetching', (sessionId, signal) => service.dispatch('session_fetch', { sessionId }, signal), '已更新远端信息', 'Fetched'),
|
|
620
|
+
|
|
621
|
+
pull: (scope, event) =>
|
|
622
|
+
sessionAction(scope, event, '正在拉取', 'Pulling', (sessionId, signal) => service.dispatch('session_pull', { sessionId }, signal), '已快进拉取', 'Pulled'),
|
|
623
|
+
|
|
624
|
+
push: (scope, event) =>
|
|
625
|
+
sessionAction(scope, event, '正在推送', 'Pushing', (sessionId, signal) => service.dispatch('session_push', { sessionId }, signal), '已推送', 'Pushed'),
|
|
626
|
+
|
|
627
|
+
commit: (scope, event) => commitWith(scope, event?.locale, fieldValue(event?.value, 'commitDraft')),
|
|
628
|
+
|
|
629
|
+
clone: (scope, event) => cloneWith(scope, event?.locale, fieldValue(event?.value, 'cloneDraft')),
|
|
630
|
+
|
|
631
|
+
publish: (scope, event) => {
|
|
632
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
633
|
+
const repoName = (fieldValue(event?.value, 'publishDraft') ?? '').trim()
|
|
634
|
+
if (!/^[A-Za-z0-9._-]+$/.test(repoName)) return { toast: s('仓库名只能用字母数字和 . - _', 'Letters, digits, . - _ only', zh) }
|
|
635
|
+
return sessionAction(
|
|
636
|
+
scope,
|
|
637
|
+
event,
|
|
638
|
+
'正在新建',
|
|
639
|
+
'Creating',
|
|
640
|
+
(sessionId, signal) => service.dispatch('session_publish', { sessionId, name: repoName, private: true }, signal),
|
|
641
|
+
'已新建私有仓库并推送',
|
|
642
|
+
'Private repository created',
|
|
643
|
+
)
|
|
644
|
+
},
|
|
645
|
+
|
|
646
|
+
'save-client': async (scope, event) => {
|
|
647
|
+
const result = await saveClientId(fieldValue(event?.value, 'clientIdDraft'), !String(event?.locale ?? '').toLowerCase().startsWith('en'))
|
|
648
|
+
if (!result.ok) return { toast: result.toast }
|
|
649
|
+
return { toast: result.toast, patch: await collect(scope, event?.locale) }
|
|
650
|
+
},
|
|
651
|
+
|
|
652
|
+
'enable-tools': async (scope, event) => {
|
|
653
|
+
const zh = !String(event?.locale ?? '').toLowerCase().startsWith('en')
|
|
654
|
+
try {
|
|
655
|
+
await service.dispatch('settings_save', { agentTools: true })
|
|
656
|
+
return { toast: s('已开启,重启引擎后生效', 'On; restart the engine', zh), patch: await collect(scope, event?.locale) }
|
|
657
|
+
} catch (error) {
|
|
658
|
+
return { toast: clip(describe(error, zh), 40) }
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
// The composer is the keyboard the phone app is good at: paste a Client ID or a
|
|
665
|
+
// commit message there and tap the entry, instead of a native form input.
|
|
666
|
+
ctx.dshGo.on('save-client-id', async (scope, event) => {
|
|
667
|
+
const result = await saveClientId(scope?.draft, !String(event?.locale ?? '').toLowerCase().startsWith('en'))
|
|
668
|
+
return { toast: result.toast }
|
|
669
|
+
})
|
|
670
|
+
|
|
671
|
+
ctx.dshGo.on('commit-draft', (scope, event) => commitWith(scope, event?.locale, scope?.draft))
|
|
672
|
+
|
|
673
|
+
ctx.dshGo.on('clone-repo', (scope, event) => cloneWith(scope, event?.locale, scope?.draft))
|
|
674
|
+
}
|