@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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-github bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Mounts the Host half of the plugin. The Client half is discovered from the
|
|
4
|
+
# package's `dsh.client` manifest and rendered by the Web UI as one entry in
|
|
5
|
+
# Settings → GitHub.
|
|
6
|
+
- insert:
|
|
7
|
+
- id: github
|
|
8
|
+
name: '@ban-sky/dsh-github'
|
package/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="GitHub">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#24292F"/>
|
|
3
|
+
<g fill="none" stroke="#FFFFFF" stroke-width="3.2" stroke-linecap="round">
|
|
4
|
+
<circle cx="21" cy="18" r="4.6" />
|
|
5
|
+
<circle cx="21" cy="46" r="4.6" />
|
|
6
|
+
<circle cx="43" cy="26" r="4.6" />
|
|
7
|
+
<path d="M21 22.6v18.8" />
|
|
8
|
+
<path d="M21 30c0-6 22-6 22-4" />
|
|
9
|
+
</g>
|
|
10
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host half of `dsh-github`.
|
|
3
|
+
*
|
|
4
|
+
* Registers, on the authenticated Host context:
|
|
5
|
+
* 1. one DSH authorization flow for the credential key `dsh-github/token`
|
|
6
|
+
* (GitHub OAuth Device Flow — the official, browser-based sign-in);
|
|
7
|
+
* 2. the authenticated JSON route `/api/github` used by the Settings page;
|
|
8
|
+
* 3. optionally, an Agent Tool (`github`) when `agentTools` is enabled in the
|
|
9
|
+
* settings file at mount time.
|
|
10
|
+
*
|
|
11
|
+
* The access token is only ever read from / written to the DSH credential
|
|
12
|
+
* store. It is never part of the settings file, a git remote URL, a command
|
|
13
|
+
* argument, or a response body.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-github
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readConfigSync } from './lib/config.js'
|
|
19
|
+
import { createHandler, ROUTE_PATH } from './lib/http.js'
|
|
20
|
+
import { createService } from './lib/service.js'
|
|
21
|
+
import { buildGithubTool } from './lib/tools.js'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Register the Host side of the plugin.
|
|
25
|
+
*
|
|
26
|
+
* @param {any} ctx - Host context.
|
|
27
|
+
*/
|
|
28
|
+
export function apply(ctx) {
|
|
29
|
+
ctx.inject(['connection', 'credentials', 'authorization', 'subprocess'], (child) => {
|
|
30
|
+
const service = createService(child)
|
|
31
|
+
|
|
32
|
+
child.effect(
|
|
33
|
+
() => child.authorization.registerFlow(service.authorizationFlow()),
|
|
34
|
+
'dsh-github: authorization flow',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
child.effect(
|
|
38
|
+
() =>
|
|
39
|
+
child.connection.fetch.register({
|
|
40
|
+
path: ROUTE_PATH,
|
|
41
|
+
methods: ['POST'],
|
|
42
|
+
requestBody: 'buffered',
|
|
43
|
+
fetch: createHandler(service),
|
|
44
|
+
}),
|
|
45
|
+
'dsh-github: fetch route',
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// DSH GO (the phone app) cannot draw the Web UI, so it gets a native panel
|
|
49
|
+
// and three composer entries backed by the same service instance. The web
|
|
50
|
+
// artifact ships without this module, so it is loaded on demand.
|
|
51
|
+
child.inject(['dshGo'], (ui) => {
|
|
52
|
+
const report = (error) => {
|
|
53
|
+
if (typeof ui.logger?.warn === 'function') ui.logger.warn(error)
|
|
54
|
+
else console.warn('[dsh-github] the DSH GO surface is unavailable:', error?.message ?? error)
|
|
55
|
+
}
|
|
56
|
+
import('./lib/dsh-go.js').then(
|
|
57
|
+
({ registerDshGo }) => {
|
|
58
|
+
try {
|
|
59
|
+
registerDshGo(ui, service)
|
|
60
|
+
} catch (error) {
|
|
61
|
+
report(error)
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
report,
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Tool registration is a mount-time decision: `ctx.tools.register` is not
|
|
69
|
+
// hot-reloadable, so changing the setting takes effect on the next plugin
|
|
70
|
+
// load. The UI says so.
|
|
71
|
+
if (readConfigSync().agentTools) {
|
|
72
|
+
service.markToolsRegistered(true)
|
|
73
|
+
child.inject(['tools'], (toolCtx) => {
|
|
74
|
+
toolCtx.effect(() => toolCtx.tools.register(buildGithubTool(service)), 'dsh-github: agent tool')
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-secret plugin settings.
|
|
3
|
+
*
|
|
4
|
+
* The access token never lives here: it is a credential record
|
|
5
|
+
* (`dsh-github/token`) in the DSH credential store. This file only holds the
|
|
6
|
+
* OAuth App Client ID (public by design), the clone root, and whether Agent
|
|
7
|
+
* tools are exposed.
|
|
8
|
+
*
|
|
9
|
+
* File: `$DSH_HOME/dsh-github.json`, written atomically with mode 0600.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-github/lib/config
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
|
18
|
+
import { GithubError } from './errors.js'
|
|
19
|
+
|
|
20
|
+
/** File name inside DSH_HOME. */
|
|
21
|
+
export const CONFIG_FILE_NAME = 'dsh-github.json'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GitHub OAuth App Client IDs are short alphanumeric tokens (for example
|
|
25
|
+
* `Ov23liAbCdEfGhIjKlMn`). This is deliberately narrow: the value is echoed
|
|
26
|
+
* back into the UI and used in a device-code request.
|
|
27
|
+
*/
|
|
28
|
+
export const CLIENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} GithubConfig
|
|
32
|
+
* @property {string} clientId - OAuth App Client ID, '' when not configured.
|
|
33
|
+
* @property {string} cloneRoot - Absolute directory that contains every clone.
|
|
34
|
+
* @property {boolean} agentTools - Whether Agent tools are registered at mount.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* DSH home directory (`DSH_HOME`, else `~/.dsh`).
|
|
39
|
+
*
|
|
40
|
+
* @returns {string}
|
|
41
|
+
*/
|
|
42
|
+
export function dshHome() {
|
|
43
|
+
const fromEnv = process.env.DSH_HOME
|
|
44
|
+
if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return resolve(fromEnv)
|
|
45
|
+
return join(homedir(), '.dsh')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Absolute path of the settings file.
|
|
50
|
+
*
|
|
51
|
+
* @returns {string}
|
|
52
|
+
*/
|
|
53
|
+
export function configFile() {
|
|
54
|
+
return join(dshHome(), CONFIG_FILE_NAME)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Expand a leading `~` or `~/`.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} value - Raw path from the UI.
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
export function expandHome(value) {
|
|
64
|
+
if (value === '~') return homedir()
|
|
65
|
+
if (value.startsWith('~/')) return join(homedir(), value.slice(2))
|
|
66
|
+
return value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Default settings.
|
|
71
|
+
*
|
|
72
|
+
* @returns {GithubConfig}
|
|
73
|
+
*/
|
|
74
|
+
export function defaultConfig() {
|
|
75
|
+
return { clientId: '', cloneRoot: join(homedir(), 'github'), agentTools: false }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Coerce an unknown JSON object into a valid config, dropping invalid fields.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} raw - Parsed file contents.
|
|
82
|
+
* @returns {GithubConfig}
|
|
83
|
+
*/
|
|
84
|
+
export function normalizeConfig(raw) {
|
|
85
|
+
const config = defaultConfig()
|
|
86
|
+
if (raw === null || typeof raw !== 'object') return config
|
|
87
|
+
const record = /** @type {Record<string, unknown>} */ (raw)
|
|
88
|
+
if (typeof record.clientId === 'string') {
|
|
89
|
+
const clientId = record.clientId.trim()
|
|
90
|
+
if (clientId === '' || CLIENT_ID_PATTERN.test(clientId)) config.clientId = clientId
|
|
91
|
+
}
|
|
92
|
+
if (typeof record.cloneRoot === 'string' && record.cloneRoot.trim() !== '') {
|
|
93
|
+
const expanded = expandHome(record.cloneRoot.trim())
|
|
94
|
+
if (isAbsolute(expanded) && !expanded.includes('\0')) config.cloneRoot = resolve(expanded)
|
|
95
|
+
}
|
|
96
|
+
if (typeof record.agentTools === 'boolean') config.agentTools = record.agentTools
|
|
97
|
+
return config
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validate a partial settings patch, returning only the accepted fields.
|
|
102
|
+
*
|
|
103
|
+
* @param {Record<string, unknown>} patch - Fields from the UI.
|
|
104
|
+
* @returns {Partial<GithubConfig>}
|
|
105
|
+
*/
|
|
106
|
+
export function validatePatch(patch) {
|
|
107
|
+
/** @type {Partial<GithubConfig>} */
|
|
108
|
+
const next = {}
|
|
109
|
+
if ('clientId' in patch) {
|
|
110
|
+
const clientId = typeof patch.clientId === 'string' ? patch.clientId.trim() : ''
|
|
111
|
+
if (clientId !== '' && !CLIENT_ID_PATTERN.test(clientId)) {
|
|
112
|
+
throw new GithubError(
|
|
113
|
+
'invalid-client-id',
|
|
114
|
+
'clientId must be 1-64 letters, digits, ".", "-" or "_" (it is the public OAuth App Client ID).',
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
next.clientId = clientId
|
|
118
|
+
}
|
|
119
|
+
if ('cloneRoot' in patch) {
|
|
120
|
+
const raw = typeof patch.cloneRoot === 'string' ? patch.cloneRoot.trim() : ''
|
|
121
|
+
if (raw === '' || raw.includes('\0')) {
|
|
122
|
+
throw new GithubError('invalid-clone-root', 'cloneRoot must be a non-empty absolute directory path.')
|
|
123
|
+
}
|
|
124
|
+
const expanded = expandHome(raw)
|
|
125
|
+
if (!isAbsolute(expanded)) {
|
|
126
|
+
throw new GithubError('invalid-clone-root', 'cloneRoot must be an absolute path (leading "~" is allowed).')
|
|
127
|
+
}
|
|
128
|
+
next.cloneRoot = resolve(expanded)
|
|
129
|
+
}
|
|
130
|
+
if ('agentTools' in patch) next.agentTools = patch.agentTools === true
|
|
131
|
+
return next
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Read settings, falling back to defaults when the file is absent or corrupt.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Promise<GithubConfig>}
|
|
138
|
+
*/
|
|
139
|
+
export async function readConfig() {
|
|
140
|
+
try {
|
|
141
|
+
const text = await readFile(configFile(), 'utf8')
|
|
142
|
+
return normalizeConfig(JSON.parse(text))
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const code = /** @type {{code?: string}} */ (error)?.code
|
|
145
|
+
if (code === 'ENOENT' || error instanceof SyntaxError) return defaultConfig()
|
|
146
|
+
throw error
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Synchronous read used only to decide, at mount time, whether to register
|
|
152
|
+
* Agent tools.
|
|
153
|
+
*
|
|
154
|
+
* @returns {GithubConfig}
|
|
155
|
+
*/
|
|
156
|
+
export function readConfigSync() {
|
|
157
|
+
try {
|
|
158
|
+
return normalizeConfig(JSON.parse(readFileSync(configFile(), 'utf8')))
|
|
159
|
+
} catch {
|
|
160
|
+
return defaultConfig()
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Merge a validated patch into the settings file and persist it atomically.
|
|
166
|
+
*
|
|
167
|
+
* @param {Record<string, unknown>} patch - Fields to change.
|
|
168
|
+
* @returns {Promise<GithubConfig>}
|
|
169
|
+
*/
|
|
170
|
+
export async function updateConfig(patch) {
|
|
171
|
+
const validated = validatePatch(patch)
|
|
172
|
+
const current = await readConfig()
|
|
173
|
+
const next = normalizeConfig({ ...current, ...validated })
|
|
174
|
+
const file = configFile()
|
|
175
|
+
await mkdir(dirname(file), { recursive: true })
|
|
176
|
+
const temp = `${file}.${process.pid}.${Date.now()}.tmp`
|
|
177
|
+
await writeFile(temp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 })
|
|
178
|
+
await rename(temp, file)
|
|
179
|
+
return next
|
|
180
|
+
}
|