@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/service.js
ADDED
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Business layer of the GitHub plugin.
|
|
3
|
+
*
|
|
4
|
+
* One instance is created per plugin mount. It owns:
|
|
5
|
+
* - the sign-in state machine driven by the DSH authorization service;
|
|
6
|
+
* - credential reads/writes (the only place the token is read);
|
|
7
|
+
* - the repository and local-git operations;
|
|
8
|
+
* - failure redaction.
|
|
9
|
+
*
|
|
10
|
+
* The token is read from the credential store on every operation and is never
|
|
11
|
+
* cached in the settings file, the UI payload, or a Tool result.
|
|
12
|
+
*
|
|
13
|
+
* @module dsh-github/lib/service
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { lstat, readdir, realpath } from 'node:fs/promises'
|
|
17
|
+
import { join, resolve } from 'node:path'
|
|
18
|
+
import { configFile, readConfig, updateConfig } from './config.js'
|
|
19
|
+
import { GithubError, asGithubError, redact } from './errors.js'
|
|
20
|
+
import {
|
|
21
|
+
createRepository,
|
|
22
|
+
getAuthenticatedUser,
|
|
23
|
+
listRepositories,
|
|
24
|
+
missingScopes,
|
|
25
|
+
pollAccessToken,
|
|
26
|
+
requestDeviceCode,
|
|
27
|
+
} from './github-api.js'
|
|
28
|
+
import {
|
|
29
|
+
cloneIntoDirectory,
|
|
30
|
+
cloneRepository,
|
|
31
|
+
commitAll,
|
|
32
|
+
commitChanges,
|
|
33
|
+
ensureOrigin,
|
|
34
|
+
fetchRepository,
|
|
35
|
+
initRepository,
|
|
36
|
+
pullRepository,
|
|
37
|
+
pushRepository,
|
|
38
|
+
readStatus,
|
|
39
|
+
repositoryTopLevel,
|
|
40
|
+
} from './git.js'
|
|
41
|
+
import {
|
|
42
|
+
assertCommitMessage,
|
|
43
|
+
assertRepoName,
|
|
44
|
+
assertRepoPaths,
|
|
45
|
+
isInside,
|
|
46
|
+
optionalBoolean,
|
|
47
|
+
optionalString,
|
|
48
|
+
parseFullName,
|
|
49
|
+
} from './validate.js'
|
|
50
|
+
|
|
51
|
+
/** Credential record that holds the OAuth access token. */
|
|
52
|
+
export const TOKEN_KEY = 'dsh-github/token'
|
|
53
|
+
|
|
54
|
+
/** Maximum number of local repositories reported by `local_list`. */
|
|
55
|
+
const MAX_LOCAL_REPOS = 40
|
|
56
|
+
|
|
57
|
+
/** Repository status reads run in small parallel batches. */
|
|
58
|
+
const STATUS_BATCH = 4
|
|
59
|
+
|
|
60
|
+
/** How long a verified GitHub identity is trusted before re-checking (ms). */
|
|
61
|
+
const USER_CACHE_MS = 60000
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @typedef {object} LoginState
|
|
65
|
+
* @property {'idle' | 'starting' | 'pending' | 'authorized' | 'cancelled' | 'failed' | 'expired'} status
|
|
66
|
+
* @property {string} [userCode] - Device code to type on GitHub.
|
|
67
|
+
* @property {string} [verificationUri] - GitHub verification page.
|
|
68
|
+
* @property {string} [expiresAt] - ISO timestamp when the code expires.
|
|
69
|
+
* @property {string} [message] - Human-readable progress line.
|
|
70
|
+
* @property {string} [code] - Failure code.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Create the plugin service.
|
|
75
|
+
*
|
|
76
|
+
* @param {any} ctx - Host context carrying `credentials`, `authorization` and `subprocess`.
|
|
77
|
+
* @returns {object} Service API used by the HTTP route, the Tool and `index.js`.
|
|
78
|
+
*/
|
|
79
|
+
export function createService(ctx) {
|
|
80
|
+
/** @type {LoginState} */
|
|
81
|
+
let login = { status: 'idle' }
|
|
82
|
+
/** @type {string} */
|
|
83
|
+
let lastToken = ''
|
|
84
|
+
/** @type {{token: string, at: number, user: any} | null} */
|
|
85
|
+
let userCache = null
|
|
86
|
+
/** @type {boolean} */
|
|
87
|
+
let toolsRegistered = false
|
|
88
|
+
|
|
89
|
+
/** Serialize mutating operations (clone/commit/pull/push/create). */
|
|
90
|
+
let tail = Promise.resolve()
|
|
91
|
+
/**
|
|
92
|
+
* @template T
|
|
93
|
+
* @param {() => Promise<T>} work - Critical section.
|
|
94
|
+
* @returns {Promise<T>}
|
|
95
|
+
*/
|
|
96
|
+
function exclusive(work) {
|
|
97
|
+
const run = tail.then(work, work)
|
|
98
|
+
tail = run.then(
|
|
99
|
+
() => undefined,
|
|
100
|
+
() => undefined,
|
|
101
|
+
)
|
|
102
|
+
return run
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scrub every known secret from a message.
|
|
107
|
+
*
|
|
108
|
+
* @param {string} value - Text.
|
|
109
|
+
* @returns {string}
|
|
110
|
+
*/
|
|
111
|
+
function scrub(value) {
|
|
112
|
+
return redact(value, lastToken === '' ? [] : [lastToken])
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Read the stored token record, if any.
|
|
117
|
+
*
|
|
118
|
+
* @returns {Promise<{token: string, payload: Record<string, any>} | null>}
|
|
119
|
+
*/
|
|
120
|
+
async function readToken() {
|
|
121
|
+
let record = null
|
|
122
|
+
try {
|
|
123
|
+
record = await ctx.credentials.readRecord(TOKEN_KEY)
|
|
124
|
+
} catch {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
if (record === null || typeof record !== 'object' || record.kind !== 'grant') return null
|
|
128
|
+
const payload = record.payload
|
|
129
|
+
if (payload === null || typeof payload !== 'object') return null
|
|
130
|
+
if (typeof payload.accessToken !== 'string' || payload.accessToken.length < 8) return null
|
|
131
|
+
lastToken = payload.accessToken
|
|
132
|
+
return { token: payload.accessToken, payload }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Require a signed-in token.
|
|
137
|
+
*
|
|
138
|
+
* @returns {Promise<{token: string, payload: Record<string, any>}>}
|
|
139
|
+
*/
|
|
140
|
+
async function requireToken() {
|
|
141
|
+
const record = await readToken()
|
|
142
|
+
if (record === null) throw new GithubError('not-signed-in', 'Sign in with GitHub first.')
|
|
143
|
+
return record
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Public login state (normalizes an expired pending code).
|
|
148
|
+
*
|
|
149
|
+
* @returns {LoginState}
|
|
150
|
+
*/
|
|
151
|
+
function publicLogin() {
|
|
152
|
+
if (login.status === 'pending' && typeof login.expiresAt === 'string') {
|
|
153
|
+
const expires = Date.parse(login.expiresAt)
|
|
154
|
+
if (Number.isFinite(expires) && expires < Date.now()) return { ...login, status: 'expired' }
|
|
155
|
+
}
|
|
156
|
+
return { ...login }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Full UI state: settings, signed-in identity and the login machine.
|
|
161
|
+
*
|
|
162
|
+
* @returns {Promise<object>}
|
|
163
|
+
*/
|
|
164
|
+
async function state() {
|
|
165
|
+
const config = await readConfig()
|
|
166
|
+
const record = await readToken()
|
|
167
|
+
/** @type {Record<string, unknown>} */
|
|
168
|
+
const auth = { signedIn: false }
|
|
169
|
+
if (record !== null) {
|
|
170
|
+
auth.signedIn = true
|
|
171
|
+
auth.login = typeof record.payload.login === 'string' ? record.payload.login : ''
|
|
172
|
+
auth.name = typeof record.payload.name === 'string' ? record.payload.name : ''
|
|
173
|
+
auth.avatarUrl = typeof record.payload.avatarUrl === 'string' ? record.payload.avatarUrl : ''
|
|
174
|
+
auth.scopes = typeof record.payload.scope === 'string' ? record.payload.scope : ''
|
|
175
|
+
auth.missingScopes = missingScopes(auth.scopes)
|
|
176
|
+
auth.obtainedAt = typeof record.payload.obtainedAt === 'string' ? record.payload.obtainedAt : ''
|
|
177
|
+
auth.expired = false
|
|
178
|
+
try {
|
|
179
|
+
const cached = userCache !== null && userCache.token === record.token && Date.now() - userCache.at < USER_CACHE_MS
|
|
180
|
+
const user = cached ? userCache.user : await getAuthenticatedUser(record.token, AbortSignal.timeout(6000))
|
|
181
|
+
if (!cached) userCache = { token: record.token, at: Date.now(), user }
|
|
182
|
+
auth.login = user.login !== '' ? user.login : auth.login
|
|
183
|
+
auth.name = user.name !== '' ? user.name : auth.name
|
|
184
|
+
auth.avatarUrl = user.avatarUrl !== '' ? user.avatarUrl : auth.avatarUrl
|
|
185
|
+
auth.url = user.htmlUrl
|
|
186
|
+
} catch (error) {
|
|
187
|
+
const failure = asGithubError(error)
|
|
188
|
+
if (failure.code === 'token-invalid' || failure.code === 'forbidden') {
|
|
189
|
+
auth.expired = true
|
|
190
|
+
userCache = null
|
|
191
|
+
} else {
|
|
192
|
+
auth.unverified = true
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
settings: { ...config, configFile: configFile() },
|
|
198
|
+
auth,
|
|
199
|
+
login: publicLogin(),
|
|
200
|
+
toolsRegistered,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Start the device-flow sign-in.
|
|
206
|
+
*
|
|
207
|
+
* The HTTP request's abort signal is deliberately not forwarded: the flow
|
|
208
|
+
* must outlive the response so the client can keep polling `login_status`.
|
|
209
|
+
*
|
|
210
|
+
* @returns {Promise<LoginState>}
|
|
211
|
+
*/
|
|
212
|
+
async function startLogin() {
|
|
213
|
+
const config = await readConfig()
|
|
214
|
+
if (config.clientId === '') {
|
|
215
|
+
throw new GithubError('missing-client-id', 'Configure the OAuth App Client ID before signing in.')
|
|
216
|
+
}
|
|
217
|
+
if (login.status === 'starting' || login.status === 'pending') return publicLogin()
|
|
218
|
+
login = { status: 'starting' }
|
|
219
|
+
|
|
220
|
+
/** @type {{notify: (notice: any) => void, prompt: () => Promise<string>}} */
|
|
221
|
+
const interaction = {
|
|
222
|
+
notify(notice) {
|
|
223
|
+
if (login.status !== 'starting' && login.status !== 'pending') return
|
|
224
|
+
/** @type {LoginState} */
|
|
225
|
+
const next = { ...login, status: 'pending' }
|
|
226
|
+
if (notice !== null && typeof notice === 'object') {
|
|
227
|
+
if (typeof notice.message === 'string') next.message = notice.message
|
|
228
|
+
if (typeof notice.code === 'string') next.userCode = notice.code
|
|
229
|
+
if (typeof notice.url === 'string') next.verificationUri = notice.url
|
|
230
|
+
}
|
|
231
|
+
login = next
|
|
232
|
+
},
|
|
233
|
+
async prompt() {
|
|
234
|
+
throw new GithubError('prompt-unsupported', 'The GitHub device flow does not ask questions.')
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let started
|
|
239
|
+
try {
|
|
240
|
+
started = ctx.authorization.begin({ key: TOKEN_KEY, method: 'device', interaction })
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const failure = asGithubError(error)
|
|
243
|
+
login = { status: 'failed', code: failure.code, message: scrub(failure.message) }
|
|
244
|
+
return publicLogin()
|
|
245
|
+
}
|
|
246
|
+
void started.then(
|
|
247
|
+
(outcome) => {
|
|
248
|
+
if (outcome?.status === 'cancelled') login = { status: 'cancelled' }
|
|
249
|
+
else login = { status: 'authorized' }
|
|
250
|
+
},
|
|
251
|
+
(error) => {
|
|
252
|
+
const failure = asGithubError(error)
|
|
253
|
+
login =
|
|
254
|
+
failure.code === 'cancelled'
|
|
255
|
+
? { status: 'cancelled' }
|
|
256
|
+
: { status: 'failed', code: failure.code, message: scrub(failure.message) }
|
|
257
|
+
},
|
|
258
|
+
)
|
|
259
|
+
return publicLogin()
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Cancel an in-flight sign-in.
|
|
264
|
+
*
|
|
265
|
+
* @returns {Promise<LoginState>}
|
|
266
|
+
*/
|
|
267
|
+
async function cancelLogin() {
|
|
268
|
+
try {
|
|
269
|
+
await ctx.authorization.cancel(TOKEN_KEY)
|
|
270
|
+
} catch {
|
|
271
|
+
// Nothing in flight is not an error for the UI.
|
|
272
|
+
}
|
|
273
|
+
if (login.status === 'starting' || login.status === 'pending') login = { status: 'cancelled' }
|
|
274
|
+
return publicLogin()
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Forget the stored token.
|
|
279
|
+
*
|
|
280
|
+
* @returns {Promise<object>} Fresh state.
|
|
281
|
+
*/
|
|
282
|
+
async function logout() {
|
|
283
|
+
try {
|
|
284
|
+
await ctx.authorization.cancel(TOKEN_KEY)
|
|
285
|
+
} catch {
|
|
286
|
+
// ignore
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
await ctx.credentials.deleteRecord(TOKEN_KEY)
|
|
290
|
+
} catch (error) {
|
|
291
|
+
throw new GithubError('logout-failed', scrub(`Could not delete the stored token: ${asGithubError(error).message}`))
|
|
292
|
+
}
|
|
293
|
+
lastToken = ''
|
|
294
|
+
userCache = null
|
|
295
|
+
login = { status: 'idle' }
|
|
296
|
+
return state()
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Persist non-secret settings.
|
|
301
|
+
*
|
|
302
|
+
* @param {Record<string, unknown>} payload - Partial settings.
|
|
303
|
+
* @returns {Promise<object>}
|
|
304
|
+
*/
|
|
305
|
+
async function saveSettings(payload) {
|
|
306
|
+
const before = await readConfig()
|
|
307
|
+
/** @type {Record<string, unknown>} */
|
|
308
|
+
const patch = {}
|
|
309
|
+
if (payload !== null && typeof payload === 'object') {
|
|
310
|
+
if ('clientId' in payload) patch.clientId = optionalString(payload, 'clientId', 128) ?? ''
|
|
311
|
+
if ('cloneRoot' in payload) patch.cloneRoot = optionalString(payload, 'cloneRoot', 4096) ?? ''
|
|
312
|
+
if ('agentTools' in payload) patch.agentTools = optionalBoolean(payload, 'agentTools', false)
|
|
313
|
+
}
|
|
314
|
+
const next = await updateConfig(patch)
|
|
315
|
+
return {
|
|
316
|
+
settings: { ...next, configFile: configFile() },
|
|
317
|
+
toolsRestartRequired: next.agentTools !== before.agentTools,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* List repositories visible to the token.
|
|
323
|
+
*
|
|
324
|
+
* @returns {Promise<{repos: any[], truncated: boolean}>}
|
|
325
|
+
*/
|
|
326
|
+
async function listRepos() {
|
|
327
|
+
const record = await requireToken()
|
|
328
|
+
const repos = await listRepositories(record.token)
|
|
329
|
+
return { repos, truncated: repos.length >= 100 }
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a repository for the signed-in account.
|
|
334
|
+
*
|
|
335
|
+
* @param {Record<string, unknown>} payload - `{name, description, private, autoInit}`.
|
|
336
|
+
* @returns {Promise<{repo: any}>}
|
|
337
|
+
*/
|
|
338
|
+
function createRepo(payload) {
|
|
339
|
+
return exclusive(async () => {
|
|
340
|
+
const record = await requireToken()
|
|
341
|
+
const name = assertRepoName(payload?.name)
|
|
342
|
+
const description = optionalString(payload, 'description', 350) ?? ''
|
|
343
|
+
const isPrivate = optionalBoolean(payload, 'private', true)
|
|
344
|
+
const autoInit = optionalBoolean(payload, 'autoInit', true)
|
|
345
|
+
const repo = await createRepository(record.token, { name, description, private: isPrivate, autoInit })
|
|
346
|
+
return { repo }
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Clone one repository into the clone root.
|
|
352
|
+
*
|
|
353
|
+
* @param {string} fullName - `owner/name`.
|
|
354
|
+
* @param {AbortSignal | undefined} signal - Cancellation.
|
|
355
|
+
* @returns {Promise<{path: string, repo: object}>}
|
|
356
|
+
*/
|
|
357
|
+
function cloneRepo(fullName, signal) {
|
|
358
|
+
return exclusive(async () => {
|
|
359
|
+
const record = await requireToken()
|
|
360
|
+
const config = await readConfig()
|
|
361
|
+
const target = await cloneRepository(ctx, {
|
|
362
|
+
token: record.token,
|
|
363
|
+
fullName: typeof fullName === 'string' ? fullName : '',
|
|
364
|
+
root: config.cloneRoot,
|
|
365
|
+
signal,
|
|
366
|
+
})
|
|
367
|
+
const repo = await readStatus(ctx, { path: target, signal })
|
|
368
|
+
return { path: target, repo }
|
|
369
|
+
})
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve a user-supplied path to a git repository inside the clone root.
|
|
374
|
+
*
|
|
375
|
+
* @param {unknown} raw - Absolute path or path relative to the clone root.
|
|
376
|
+
* @returns {Promise<{path: string, root: string}>}
|
|
377
|
+
*/
|
|
378
|
+
async function resolveLocalRepo(raw) {
|
|
379
|
+
const input = typeof raw === 'string' ? raw.trim() : ''
|
|
380
|
+
if (input === '' || input.includes('\0') || input.length > 4096) {
|
|
381
|
+
throw new GithubError('invalid-path', 'A repository path is required.')
|
|
382
|
+
}
|
|
383
|
+
const config = await readConfig()
|
|
384
|
+
const rootInfo = await lstat(config.cloneRoot).catch(() => null)
|
|
385
|
+
if (rootInfo === null || !rootInfo.isDirectory()) {
|
|
386
|
+
throw new GithubError('clone-root-missing', 'The configured clone root does not exist.')
|
|
387
|
+
}
|
|
388
|
+
const realRoot = await realpath(config.cloneRoot)
|
|
389
|
+
const candidate = resolve(realRoot, input)
|
|
390
|
+
const real = await realpath(candidate).catch(() => null)
|
|
391
|
+
if (real === null) throw new GithubError('not-found', 'That directory does not exist.')
|
|
392
|
+
if (!isInside(realRoot, real)) {
|
|
393
|
+
throw new GithubError('unsafe-path', 'Refusing to operate outside the configured clone root.')
|
|
394
|
+
}
|
|
395
|
+
const gitDir = await lstat(join(real, '.git')).catch(() => null)
|
|
396
|
+
if (gitDir === null || !gitDir.isDirectory()) {
|
|
397
|
+
throw new GithubError('not-a-repo', 'That directory is not a git repository.')
|
|
398
|
+
}
|
|
399
|
+
return { path: real, root: realRoot }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Scan the clone root for local repositories and read their status.
|
|
404
|
+
*
|
|
405
|
+
* @returns {Promise<{root: string, exists: boolean, repos: any[], truncated: boolean}>}
|
|
406
|
+
*/
|
|
407
|
+
async function listLocal() {
|
|
408
|
+
const config = await readConfig()
|
|
409
|
+
const rootInfo = await lstat(config.cloneRoot).catch(() => null)
|
|
410
|
+
if (rootInfo === null || !rootInfo.isDirectory()) {
|
|
411
|
+
return { root: config.cloneRoot, exists: false, repos: [], truncated: false }
|
|
412
|
+
}
|
|
413
|
+
const realRoot = await realpath(config.cloneRoot)
|
|
414
|
+
/** @type {string[]} */
|
|
415
|
+
const found = []
|
|
416
|
+
outer: for (const ownerEntry of await readdir(realRoot, { withFileTypes: true }).catch(() => [])) {
|
|
417
|
+
if (!ownerEntry.isDirectory() || ownerEntry.isSymbolicLink() || ownerEntry.name.startsWith('.')) continue
|
|
418
|
+
const ownerPath = join(realRoot, ownerEntry.name)
|
|
419
|
+
for (const repoEntry of await readdir(ownerPath, { withFileTypes: true }).catch(() => [])) {
|
|
420
|
+
if (!repoEntry.isDirectory() || repoEntry.isSymbolicLink() || repoEntry.name.startsWith('.')) continue
|
|
421
|
+
const repoPath = join(ownerPath, repoEntry.name)
|
|
422
|
+
const gitDir = await lstat(join(repoPath, '.git')).catch(() => null)
|
|
423
|
+
if (gitDir === null || !gitDir.isDirectory()) continue
|
|
424
|
+
found.push(repoPath)
|
|
425
|
+
if (found.length >= MAX_LOCAL_REPOS) break outer
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
/** @type {any[]} */
|
|
429
|
+
const repos = []
|
|
430
|
+
for (let index = 0; index < found.length; index += STATUS_BATCH) {
|
|
431
|
+
const batch = found.slice(index, index + STATUS_BATCH)
|
|
432
|
+
const statuses = await Promise.all(
|
|
433
|
+
batch.map(async (repoPath) => {
|
|
434
|
+
try {
|
|
435
|
+
return await readStatus(ctx, { path: repoPath })
|
|
436
|
+
} catch (error) {
|
|
437
|
+
return { path: repoPath, branch: '', clean: true, changeCount: 0, changes: [], lastCommit: null, remote: null, error: asGithubError(error).code }
|
|
438
|
+
}
|
|
439
|
+
}),
|
|
440
|
+
)
|
|
441
|
+
repos.push(...statuses)
|
|
442
|
+
}
|
|
443
|
+
return { root: realRoot, exists: true, repos, truncated: found.length >= MAX_LOCAL_REPOS }
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* Read one local repository's status.
|
|
448
|
+
*
|
|
449
|
+
* @param {unknown} raw - Repository path.
|
|
450
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
451
|
+
* @returns {Promise<object>}
|
|
452
|
+
*/
|
|
453
|
+
async function localStatus(raw, signal) {
|
|
454
|
+
const { path } = await resolveLocalRepo(raw)
|
|
455
|
+
return { repo: await readStatus(ctx, { path, signal }) }
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Fast-forward pull one local repository.
|
|
460
|
+
*
|
|
461
|
+
* @param {unknown} raw - Repository path.
|
|
462
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
463
|
+
* @returns {Promise<object>}
|
|
464
|
+
*/
|
|
465
|
+
function pullRepo(raw, signal) {
|
|
466
|
+
return exclusive(async () => {
|
|
467
|
+
const record = await requireToken()
|
|
468
|
+
const { path } = await resolveLocalRepo(raw)
|
|
469
|
+
const repo = await pullRepository(ctx, { path, token: record.token, signal })
|
|
470
|
+
return { repo }
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Commit staged changes (all changes unless `files` is given) and optionally
|
|
476
|
+
* push.
|
|
477
|
+
*
|
|
478
|
+
* @param {Record<string, unknown>} payload - `{path, message, files, push}`.
|
|
479
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
480
|
+
* @returns {Promise<object>}
|
|
481
|
+
*/
|
|
482
|
+
function commitRepo(payload, signal) {
|
|
483
|
+
return exclusive(async () => {
|
|
484
|
+
const { path } = await resolveLocalRepo(payload?.path)
|
|
485
|
+
const message = assertCommitMessage(payload?.message)
|
|
486
|
+
const files = assertRepoPaths(payload?.files)
|
|
487
|
+
const shouldPush = optionalBoolean(payload, 'push', false)
|
|
488
|
+
const commit = await commitChanges(ctx, { path, message, files, signal })
|
|
489
|
+
/** @type {{branch: string, upstream: string} | null} */
|
|
490
|
+
let pushed = null
|
|
491
|
+
if (shouldPush) {
|
|
492
|
+
const record = await requireToken()
|
|
493
|
+
pushed = await pushRepository(ctx, { path, token: record.token, signal })
|
|
494
|
+
}
|
|
495
|
+
const repo = await readStatus(ctx, { path, signal })
|
|
496
|
+
return { commit, pushed, repo }
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Push one local repository.
|
|
502
|
+
*
|
|
503
|
+
* @param {unknown} raw - Repository path.
|
|
504
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
505
|
+
* @returns {Promise<object>}
|
|
506
|
+
*/
|
|
507
|
+
function pushRepo(raw, signal) {
|
|
508
|
+
return exclusive(async () => {
|
|
509
|
+
const record = await requireToken()
|
|
510
|
+
const { path } = await resolveLocalRepo(raw)
|
|
511
|
+
const pushed = await pushRepository(ctx, { path, token: record.token, signal })
|
|
512
|
+
const repo = await readStatus(ctx, { path, signal })
|
|
513
|
+
return { pushed, repo }
|
|
514
|
+
})
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Resolve the committed working directory of one live session.
|
|
519
|
+
*
|
|
520
|
+
* The Client only names a session; the Host resolves the directory itself
|
|
521
|
+
* through `agents` + `workingDirectory`, so a request can never point the
|
|
522
|
+
* plugin at an arbitrary path.
|
|
523
|
+
*
|
|
524
|
+
* @param {unknown} sessionId - Session identifier from the Client.
|
|
525
|
+
* @returns {Promise<string>} Real path of the session directory.
|
|
526
|
+
*/
|
|
527
|
+
async function sessionDirectory(sessionId) {
|
|
528
|
+
if (typeof sessionId !== 'string' || sessionId === '' || sessionId.length > 200) {
|
|
529
|
+
throw new GithubError('session-not-found', 'Open a session and try again.')
|
|
530
|
+
}
|
|
531
|
+
const agents = ctx.get('agents')
|
|
532
|
+
const agent = agents !== undefined && typeof agents.get === 'function' ? agents.get(sessionId) : undefined
|
|
533
|
+
if (agent === undefined || agent === null) {
|
|
534
|
+
throw new GithubError('session-not-found', 'That session is not active. Open it and try again.')
|
|
535
|
+
}
|
|
536
|
+
let dir = ''
|
|
537
|
+
const workingDirectory = ctx.get('workingDirectory')
|
|
538
|
+
if (workingDirectory !== undefined && typeof workingDirectory.get === 'function') {
|
|
539
|
+
try {
|
|
540
|
+
dir = workingDirectory.get(agent.session)
|
|
541
|
+
} catch {
|
|
542
|
+
dir = ''
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
if (typeof dir !== 'string' || dir === '') dir = agent.session?.header?.cwd ?? ''
|
|
546
|
+
if (typeof dir !== 'string' || dir === '') {
|
|
547
|
+
throw new GithubError('session-not-found', 'That session has no working directory.')
|
|
548
|
+
}
|
|
549
|
+
const real = await realpath(dir).catch(() => null)
|
|
550
|
+
if (real === null) throw new GithubError('not-found', 'The session directory no longer exists.')
|
|
551
|
+
const info = await lstat(real).catch(() => null)
|
|
552
|
+
if (info === null || !info.isDirectory()) {
|
|
553
|
+
throw new GithubError('invalid-path', 'The session directory is not a directory.')
|
|
554
|
+
}
|
|
555
|
+
return real
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Find the single git repository directly inside a directory.
|
|
560
|
+
*
|
|
561
|
+
* Cloning into a session folder produces `<session>/<repo>`; treating that
|
|
562
|
+
* repository as the session's repository keeps the dock usable afterwards.
|
|
563
|
+
* More than one immediate repository is ambiguous, so none is chosen.
|
|
564
|
+
*
|
|
565
|
+
* @param {string} base - Session directory.
|
|
566
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
567
|
+
* @returns {Promise<string | null>}
|
|
568
|
+
*/
|
|
569
|
+
async function findChildRepository(base, signal) {
|
|
570
|
+
const directories = []
|
|
571
|
+
for (const entry of await readdir(base, { withFileTypes: true }).catch(() => [])) {
|
|
572
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name.startsWith('.')) continue
|
|
573
|
+
directories.push(join(base, entry.name))
|
|
574
|
+
}
|
|
575
|
+
// Only a folder that holds nothing but the checkout is treated as that
|
|
576
|
+
// checkout: a shared workspace with several projects must not be guessed.
|
|
577
|
+
if (directories.length !== 1) return null
|
|
578
|
+
const child = directories[0]
|
|
579
|
+
const gitDir = await lstat(join(child, '.git')).catch(() => null)
|
|
580
|
+
if (gitDir === null) return null
|
|
581
|
+
if ((await repositoryTopLevel(ctx, child, signal)) !== child) return null
|
|
582
|
+
return child
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Resolve the repository the session actions should operate on.
|
|
587
|
+
*
|
|
588
|
+
* When the session directory is itself a repository it wins; otherwise a
|
|
589
|
+
* single repository one level below is used (the result of a clone).
|
|
590
|
+
*
|
|
591
|
+
* @param {unknown} sessionId - Session identifier.
|
|
592
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
593
|
+
* @returns {Promise<{base: string, path: string, repoRoot: string | null}>}
|
|
594
|
+
*/
|
|
595
|
+
async function sessionRepository(sessionId, signal) {
|
|
596
|
+
const base = await sessionDirectory(sessionId)
|
|
597
|
+
const top = await repositoryTopLevel(ctx, base, signal)
|
|
598
|
+
if (top !== null) return { base, path: base, repoRoot: top }
|
|
599
|
+
const child = await findChildRepository(base, signal)
|
|
600
|
+
if (child === null) return { base, path: base, repoRoot: null }
|
|
601
|
+
return { base, path: child, repoRoot: child }
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Read the git state of the session directory.
|
|
606
|
+
*
|
|
607
|
+
* @param {unknown} sessionId - Session identifier.
|
|
608
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
609
|
+
* @returns {Promise<{base: string, path: string, repoRoot: string | null, repo: object | null}>}
|
|
610
|
+
*/
|
|
611
|
+
async function sessionStatus(sessionId, signal) {
|
|
612
|
+
const { base, path, repoRoot } = await sessionRepository(sessionId, signal)
|
|
613
|
+
if (repoRoot === null) return { base, path, repoRoot: null, repo: null }
|
|
614
|
+
return { base, path, repoRoot, repo: await readStatus(ctx, { path, signal }) }
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Clone one repository into the session directory (flat layout).
|
|
619
|
+
*
|
|
620
|
+
* @param {Record<string, unknown>} payload - `{sessionId, fullName}`.
|
|
621
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
622
|
+
* @returns {Promise<{path: string, repo: object}>}
|
|
623
|
+
*/
|
|
624
|
+
function sessionClone(payload, signal) {
|
|
625
|
+
return exclusive(async () => {
|
|
626
|
+
const record = await requireToken()
|
|
627
|
+
const baseDir = await sessionDirectory(payload?.sessionId)
|
|
628
|
+
const fullName = parseFullName(payload?.fullName)
|
|
629
|
+
const target = await cloneIntoDirectory(ctx, {
|
|
630
|
+
token: record.token,
|
|
631
|
+
fullName: `${fullName.owner}/${fullName.name}`,
|
|
632
|
+
baseDir,
|
|
633
|
+
signal,
|
|
634
|
+
})
|
|
635
|
+
const repo = await readStatus(ctx, { path: target, signal })
|
|
636
|
+
return { path: target, repo }
|
|
637
|
+
})
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Fast-forward pull the repository that contains the session directory.
|
|
642
|
+
*
|
|
643
|
+
* @param {Record<string, unknown>} payload - `{sessionId}`.
|
|
644
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
645
|
+
* @returns {Promise<{path: string, repo: object}>}
|
|
646
|
+
*/
|
|
647
|
+
function sessionPull(payload, signal) {
|
|
648
|
+
return exclusive(async () => {
|
|
649
|
+
const record = await requireToken()
|
|
650
|
+
const { base, path, repoRoot } = await sessionRepository(payload?.sessionId, signal)
|
|
651
|
+
if (repoRoot === null) {
|
|
652
|
+
throw new GithubError('not-a-repo', 'This session directory is not a git repository.')
|
|
653
|
+
}
|
|
654
|
+
const repo = await pullRepository(ctx, { path, token: record.token, signal })
|
|
655
|
+
return { base, path, repo }
|
|
656
|
+
})
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Fetch `origin` for the session repository and report the refreshed status.
|
|
661
|
+
*
|
|
662
|
+
* @param {Record<string, unknown>} payload - `{sessionId}`.
|
|
663
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
664
|
+
* @returns {Promise<{path: string, repo: object}>}
|
|
665
|
+
*/
|
|
666
|
+
function sessionFetch(payload, signal) {
|
|
667
|
+
return exclusive(async () => {
|
|
668
|
+
const record = await requireToken()
|
|
669
|
+
const { base, path, repoRoot } = await sessionRepository(payload?.sessionId, signal)
|
|
670
|
+
if (repoRoot === null) {
|
|
671
|
+
throw new GithubError('not-a-repo', 'This session directory is not a git repository.')
|
|
672
|
+
}
|
|
673
|
+
const repo = await fetchRepository(ctx, { path, token: record.token, signal })
|
|
674
|
+
return { base, path, repo }
|
|
675
|
+
})
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Commit the session repository and optionally push it.
|
|
680
|
+
*
|
|
681
|
+
* @param {Record<string, unknown>} payload - `{sessionId, message, files, push}`.
|
|
682
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
683
|
+
* @returns {Promise<object>}
|
|
684
|
+
*/
|
|
685
|
+
function sessionCommit(payload, signal) {
|
|
686
|
+
return exclusive(async () => {
|
|
687
|
+
const { base, path, repoRoot } = await sessionRepository(payload?.sessionId, signal)
|
|
688
|
+
if (repoRoot === null) {
|
|
689
|
+
throw new GithubError('not-a-repo', 'This session directory is not a git repository.')
|
|
690
|
+
}
|
|
691
|
+
const message = assertCommitMessage(payload?.message)
|
|
692
|
+
const files = assertRepoPaths(payload?.files)
|
|
693
|
+
const shouldPush = optionalBoolean(payload, 'push', false)
|
|
694
|
+
const commit = await commitChanges(ctx, { path, message, files, signal })
|
|
695
|
+
/** @type {{branch: string, upstream: string} | null} */
|
|
696
|
+
let pushed = null
|
|
697
|
+
if (shouldPush) {
|
|
698
|
+
const record = await requireToken()
|
|
699
|
+
pushed = await pushRepository(ctx, { path, token: record.token, signal })
|
|
700
|
+
}
|
|
701
|
+
const repo = await readStatus(ctx, { path, signal })
|
|
702
|
+
return { base, path, commit, pushed, repo }
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Create a GitHub repository for the session directory and push it.
|
|
708
|
+
*
|
|
709
|
+
* The directory is initialised only when it is not a repository yet; when it
|
|
710
|
+
* already sits inside a different repository the request is refused so the
|
|
711
|
+
* plugin never publishes a parent project by accident.
|
|
712
|
+
*
|
|
713
|
+
* @param {Record<string, unknown>} payload - `{sessionId, name, description, private, message}`.
|
|
714
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
715
|
+
* @returns {Promise<object>}
|
|
716
|
+
*/
|
|
717
|
+
function sessionPublish(payload, signal) {
|
|
718
|
+
return exclusive(async () => {
|
|
719
|
+
const record = await requireToken()
|
|
720
|
+
const path = await sessionDirectory(payload?.sessionId)
|
|
721
|
+
const name = assertRepoName(payload?.name)
|
|
722
|
+
const description = optionalString(payload, 'description', 350) ?? ''
|
|
723
|
+
const isPrivate = optionalBoolean(payload, 'private', true)
|
|
724
|
+
const message = optionalString(payload, 'message', 5000) || 'Initial commit'
|
|
725
|
+
assertCommitMessage(message)
|
|
726
|
+
const top = await repositoryTopLevel(ctx, path, signal)
|
|
727
|
+
if (top !== null && top !== path) {
|
|
728
|
+
throw new GithubError(
|
|
729
|
+
'repo-elsewhere',
|
|
730
|
+
'The session directory is inside another git repository. Open that repository instead.',
|
|
731
|
+
)
|
|
732
|
+
}
|
|
733
|
+
if (top === null && (await findChildRepository(path, signal)) !== null) {
|
|
734
|
+
throw new GithubError(
|
|
735
|
+
'repo-nested',
|
|
736
|
+
'The session folder already contains a git repository; use that one instead.',
|
|
737
|
+
)
|
|
738
|
+
}
|
|
739
|
+
const remote = await createRepository(record.token, {
|
|
740
|
+
name,
|
|
741
|
+
description,
|
|
742
|
+
private: isPrivate,
|
|
743
|
+
autoInit: false,
|
|
744
|
+
})
|
|
745
|
+
if (top === null) await initRepository(ctx, path, signal)
|
|
746
|
+
await ensureOrigin(ctx, { path, url: `https://github.com/${remote.fullName}.git`, signal })
|
|
747
|
+
const commit = await commitAll(ctx, { path, message, signal })
|
|
748
|
+
const pushed = await pushRepository(ctx, { path, token: record.token, signal })
|
|
749
|
+
const repo = await readStatus(ctx, { path, signal })
|
|
750
|
+
return { remote, path, commit, pushed, repo }
|
|
751
|
+
})
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/**
|
|
755
|
+
* Push the session repository's current branch to `origin`.
|
|
756
|
+
*
|
|
757
|
+
* @param {Record<string, unknown>} payload - `{sessionId}`.
|
|
758
|
+
* @param {AbortSignal} [signal] - Cancellation.
|
|
759
|
+
* @returns {Promise<{path: string, pushed: {branch: string, upstream: string}, repo: object}>}
|
|
760
|
+
*/
|
|
761
|
+
function sessionPush(payload, signal) {
|
|
762
|
+
return exclusive(async () => {
|
|
763
|
+
const record = await requireToken()
|
|
764
|
+
const { base, path, repoRoot } = await sessionRepository(payload?.sessionId, signal)
|
|
765
|
+
if (repoRoot === null) {
|
|
766
|
+
throw new GithubError('not-a-repo', 'This session directory is not a git repository.')
|
|
767
|
+
}
|
|
768
|
+
const pushed = await pushRepository(ctx, { path, token: record.token, signal })
|
|
769
|
+
const repo = await readStatus(ctx, { path, signal })
|
|
770
|
+
return { base, path, pushed, repo }
|
|
771
|
+
})
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* The DSH authorization flow for `dsh-github/token`.
|
|
776
|
+
*
|
|
777
|
+
* @returns {object}
|
|
778
|
+
*/
|
|
779
|
+
function authorizationFlow() {
|
|
780
|
+
return {
|
|
781
|
+
key: TOKEN_KEY,
|
|
782
|
+
label: 'GitHub',
|
|
783
|
+
methods: [{ id: 'device', label: 'GitHub device code' }],
|
|
784
|
+
/**
|
|
785
|
+
* @param {any} session - Authorization session supplied by the service.
|
|
786
|
+
* @returns {Promise<void>}
|
|
787
|
+
*/
|
|
788
|
+
async run(session) {
|
|
789
|
+
const config = await readConfig()
|
|
790
|
+
if (config.clientId === '') {
|
|
791
|
+
throw new GithubError('missing-client-id', 'Configure the OAuth App Client ID before signing in.')
|
|
792
|
+
}
|
|
793
|
+
const device = await requestDeviceCode(config.clientId, session.signal)
|
|
794
|
+
login = {
|
|
795
|
+
status: 'pending',
|
|
796
|
+
userCode: device.userCode,
|
|
797
|
+
verificationUri: device.verificationUri,
|
|
798
|
+
expiresAt: new Date(Date.now() + device.expiresIn * 1000).toISOString(),
|
|
799
|
+
message: `Enter the code ${device.userCode} at GitHub to authorize this device.`,
|
|
800
|
+
}
|
|
801
|
+
session.notify({
|
|
802
|
+
message: `Enter the code ${device.userCode} at GitHub to authorize this device.`,
|
|
803
|
+
url: device.verificationUri,
|
|
804
|
+
code: device.userCode,
|
|
805
|
+
})
|
|
806
|
+
const granted = await pollAccessToken(config.clientId, device, session.signal)
|
|
807
|
+
const user = await getAuthenticatedUser(granted.accessToken, session.signal)
|
|
808
|
+
lastToken = granted.accessToken
|
|
809
|
+
await session.commit({
|
|
810
|
+
kind: 'grant',
|
|
811
|
+
payload: {
|
|
812
|
+
accessToken: granted.accessToken,
|
|
813
|
+
tokenType: granted.tokenType,
|
|
814
|
+
scope: granted.scope,
|
|
815
|
+
login: user.login,
|
|
816
|
+
name: user.name,
|
|
817
|
+
avatarUrl: user.avatarUrl,
|
|
818
|
+
obtainedAt: new Date().toISOString(),
|
|
819
|
+
},
|
|
820
|
+
})
|
|
821
|
+
userCache = null
|
|
822
|
+
login = { status: 'authorized' }
|
|
823
|
+
},
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Redacted failure payload for a Client response.
|
|
829
|
+
*
|
|
830
|
+
* @param {unknown} error - Thrown value.
|
|
831
|
+
* @returns {{code: string, message: string, details: Record<string, unknown>}}
|
|
832
|
+
*/
|
|
833
|
+
function failure(error) {
|
|
834
|
+
const normalized = asGithubError(error)
|
|
835
|
+
/** @type {Record<string, unknown>} */
|
|
836
|
+
const details = {}
|
|
837
|
+
if (normalized.details !== null && typeof normalized.details === 'object') {
|
|
838
|
+
for (const [key, value] of Object.entries(normalized.details)) {
|
|
839
|
+
details[key] = typeof value === 'string' ? scrub(value) : value
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return { code: normalized.code, message: scrub(normalized.message), details }
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Dispatch one HTTP action.
|
|
847
|
+
*
|
|
848
|
+
* @param {string} action - Action name.
|
|
849
|
+
* @param {any} payload - Parsed request body.
|
|
850
|
+
* @param {AbortSignal} [signal] - Request cancellation.
|
|
851
|
+
* @returns {Promise<unknown>}
|
|
852
|
+
*/
|
|
853
|
+
async function dispatch(action, payload, signal) {
|
|
854
|
+
switch (action) {
|
|
855
|
+
case 'state':
|
|
856
|
+
return state()
|
|
857
|
+
case 'settings_save':
|
|
858
|
+
return saveSettings(payload ?? {})
|
|
859
|
+
case 'login_start':
|
|
860
|
+
await startLogin()
|
|
861
|
+
return state()
|
|
862
|
+
case 'login_status':
|
|
863
|
+
return state()
|
|
864
|
+
case 'login_cancel': {
|
|
865
|
+
await cancelLogin()
|
|
866
|
+
return state()
|
|
867
|
+
}
|
|
868
|
+
case 'logout':
|
|
869
|
+
return logout()
|
|
870
|
+
case 'list_repos':
|
|
871
|
+
return listRepos()
|
|
872
|
+
case 'repo_create':
|
|
873
|
+
return createRepo(payload ?? {})
|
|
874
|
+
case 'repo_clone':
|
|
875
|
+
return cloneRepo(payload?.fullName, signal)
|
|
876
|
+
case 'local_list':
|
|
877
|
+
return listLocal()
|
|
878
|
+
case 'local_status':
|
|
879
|
+
return localStatus(payload?.path, signal)
|
|
880
|
+
case 'repo_pull':
|
|
881
|
+
return pullRepo(payload?.path, signal)
|
|
882
|
+
case 'repo_commit':
|
|
883
|
+
return commitRepo(payload ?? {}, signal)
|
|
884
|
+
case 'repo_push':
|
|
885
|
+
return pushRepo(payload?.path, signal)
|
|
886
|
+
case 'session_status':
|
|
887
|
+
return sessionStatus(payload?.sessionId, signal)
|
|
888
|
+
case 'session_clone':
|
|
889
|
+
return sessionClone(payload ?? {}, signal)
|
|
890
|
+
case 'session_pull':
|
|
891
|
+
return sessionPull(payload ?? {}, signal)
|
|
892
|
+
case 'session_fetch':
|
|
893
|
+
return sessionFetch(payload ?? {}, signal)
|
|
894
|
+
case 'session_commit':
|
|
895
|
+
return sessionCommit(payload ?? {}, signal)
|
|
896
|
+
case 'session_publish':
|
|
897
|
+
return sessionPublish(payload ?? {}, signal)
|
|
898
|
+
case 'session_push':
|
|
899
|
+
return sessionPush(payload ?? {}, signal)
|
|
900
|
+
default:
|
|
901
|
+
throw new GithubError('unknown-action', `Unknown action "${action}".`)
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Dispatch one Agent Tool action (same guards as the HTTP path).
|
|
907
|
+
*
|
|
908
|
+
* @param {string} action - Tool action.
|
|
909
|
+
* @param {Record<string, unknown>} args - Tool arguments.
|
|
910
|
+
* @param {AbortSignal} [signal] - Tool cancellation.
|
|
911
|
+
* @returns {Promise<unknown>}
|
|
912
|
+
*/
|
|
913
|
+
async function dispatchTool(action, args, signal) {
|
|
914
|
+
const input = args !== null && typeof args === 'object' ? args : {}
|
|
915
|
+
switch (action) {
|
|
916
|
+
case 'status':
|
|
917
|
+
return state()
|
|
918
|
+
case 'list_repos':
|
|
919
|
+
return listRepos()
|
|
920
|
+
case 'create_repo':
|
|
921
|
+
return createRepo(input)
|
|
922
|
+
case 'clone':
|
|
923
|
+
return cloneRepo(input.fullName, signal)
|
|
924
|
+
case 'pull':
|
|
925
|
+
return pullRepo(input.path, signal)
|
|
926
|
+
case 'commit':
|
|
927
|
+
return commitRepo(input, signal)
|
|
928
|
+
case 'push':
|
|
929
|
+
return pushRepo(input.path, signal)
|
|
930
|
+
default:
|
|
931
|
+
throw new GithubError('unknown-action', `Unknown action "${action}".`)
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
/**
|
|
936
|
+
* Record that Agent tools were registered at mount.
|
|
937
|
+
*
|
|
938
|
+
* @param {boolean} value - Whether tools are live.
|
|
939
|
+
*/
|
|
940
|
+
function markToolsRegistered(value) {
|
|
941
|
+
toolsRegistered = value === true
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
return {
|
|
945
|
+
TOKEN_KEY,
|
|
946
|
+
state,
|
|
947
|
+
startLogin,
|
|
948
|
+
cancelLogin,
|
|
949
|
+
logout,
|
|
950
|
+
saveSettings,
|
|
951
|
+
listRepos,
|
|
952
|
+
createRepo,
|
|
953
|
+
cloneRepo,
|
|
954
|
+
listLocal,
|
|
955
|
+
localStatus,
|
|
956
|
+
pullRepo,
|
|
957
|
+
commitRepo,
|
|
958
|
+
pushRepo,
|
|
959
|
+
sessionStatus,
|
|
960
|
+
sessionClone,
|
|
961
|
+
sessionPull,
|
|
962
|
+
sessionFetch,
|
|
963
|
+
sessionCommit,
|
|
964
|
+
sessionPublish,
|
|
965
|
+
sessionPush,
|
|
966
|
+
authorizationFlow,
|
|
967
|
+
failure,
|
|
968
|
+
dispatch,
|
|
969
|
+
dispatchTool,
|
|
970
|
+
markToolsRegistered,
|
|
971
|
+
readToken,
|
|
972
|
+
resolveLocalRepo,
|
|
973
|
+
}
|
|
974
|
+
}
|