@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/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
+ }