@ban-sky/dsh-github 0.0.0-stage → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +246 -2
- package/README.zh.md +210 -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/tools.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional Agent Tool exposing the same guarded operations as the UI.
|
|
3
|
+
*
|
|
4
|
+
* Off by default: `agentTools` in the settings file decides whether the tool
|
|
5
|
+
* is registered at mount, and every action re-reads the credential store and
|
|
6
|
+
* the clone root, so enabling it never widens the path or remote allow-list.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-github/lib/tools
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Tool name registered with `ctx.tools`. */
|
|
12
|
+
export const TOOL_NAME = 'github'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Build the `github` Tool definition.
|
|
16
|
+
*
|
|
17
|
+
* @param {ReturnType<import('./service.js').createService>} service - Plugin service.
|
|
18
|
+
* @returns {object} Tool definition accepted by `ctx.tools.register`.
|
|
19
|
+
*/
|
|
20
|
+
export function buildGithubTool(service) {
|
|
21
|
+
return {
|
|
22
|
+
name: TOOL_NAME,
|
|
23
|
+
description: [
|
|
24
|
+
'Work with the GitHub account signed in through Settings → GitHub.',
|
|
25
|
+
'status reports the sign-in and settings; list_repos lists repositories the token can reach;',
|
|
26
|
+
'create_repo creates a repository for the account; clone clones owner/name into the configured clone root;',
|
|
27
|
+
'pull fast-forwards one local repository; commit stages changes and commits them (optionally pushing);',
|
|
28
|
+
'push publishes the current branch. All local paths must resolve inside the clone root,',
|
|
29
|
+
'only https://github.com remotes are accepted, and the access token never appears in arguments or output.',
|
|
30
|
+
].join(' '),
|
|
31
|
+
parameters: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
properties: {
|
|
35
|
+
action: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
enum: ['status', 'list_repos', 'create_repo', 'clone', 'pull', 'commit', 'push'],
|
|
38
|
+
description: 'The GitHub operation to perform.',
|
|
39
|
+
},
|
|
40
|
+
fullName: {
|
|
41
|
+
type: 'string',
|
|
42
|
+
description: 'clone: repository as "owner/name".',
|
|
43
|
+
},
|
|
44
|
+
name: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'create_repo: repository name (letters, digits, ".", "_", "-").',
|
|
47
|
+
},
|
|
48
|
+
description: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'create_repo: optional description.',
|
|
51
|
+
},
|
|
52
|
+
private: {
|
|
53
|
+
type: 'boolean',
|
|
54
|
+
description: 'create_repo: create a private repository (default true).',
|
|
55
|
+
},
|
|
56
|
+
autoInit: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
description: 'create_repo: initialize with a README and default branch (default true).',
|
|
59
|
+
},
|
|
60
|
+
path: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'pull/commit/push: repository path, absolute or relative to the clone root.',
|
|
63
|
+
},
|
|
64
|
+
message: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'commit: commit message (required).',
|
|
67
|
+
},
|
|
68
|
+
files: {
|
|
69
|
+
type: 'array',
|
|
70
|
+
items: { type: 'string' },
|
|
71
|
+
description: 'commit: repository-relative files to stage; omit to stage every change.',
|
|
72
|
+
},
|
|
73
|
+
push: {
|
|
74
|
+
type: 'boolean',
|
|
75
|
+
description: 'commit: push after committing (default false).',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['action'],
|
|
79
|
+
},
|
|
80
|
+
output: {
|
|
81
|
+
schema: { type: 'object', additionalProperties: true },
|
|
82
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
83
|
+
},
|
|
84
|
+
timeoutMs: 300000,
|
|
85
|
+
/**
|
|
86
|
+
* @param {unknown} args - Tool arguments.
|
|
87
|
+
* @param {any} exec - Execution context (`signal`).
|
|
88
|
+
* @returns {Promise<Record<string, unknown>>}
|
|
89
|
+
*/
|
|
90
|
+
async execute(args, exec) {
|
|
91
|
+
const input = args !== null && typeof args === 'object' ? /** @type {Record<string, unknown>} */ (args) : {}
|
|
92
|
+
const action = typeof input.action === 'string' ? input.action : ''
|
|
93
|
+
try {
|
|
94
|
+
const value = await service.dispatchTool(action, input, exec?.signal)
|
|
95
|
+
return { ok: true, .../** @type {Record<string, unknown>} */ (value) }
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const failure = service.failure(error)
|
|
98
|
+
return { ok: false, code: failure.code, message: failure.message }
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
}
|
package/lib/validate.js
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Input validators shared by the HTTP route and the Agent Tool.
|
|
3
|
+
*
|
|
4
|
+
* Rules encoded here are also security boundaries:
|
|
5
|
+
* - repository and owner names are ASCII-only, so they cannot inject options
|
|
6
|
+
* or escape a path segment;
|
|
7
|
+
* - committed file paths are repository-relative, cannot contain `..`, and
|
|
8
|
+
* cannot start with git pathspec magic (`:`);
|
|
9
|
+
* - local repository paths must resolve inside the configured clone root.
|
|
10
|
+
*
|
|
11
|
+
* @module dsh-github/lib/validate
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path'
|
|
15
|
+
import { GithubError } from './errors.js'
|
|
16
|
+
|
|
17
|
+
/** GitHub owner (user or organization) names. */
|
|
18
|
+
export const OWNER_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/
|
|
19
|
+
|
|
20
|
+
/** GitHub repository names. */
|
|
21
|
+
export const REPO_PATTERN = /^[A-Za-z0-9._-]{1,100}$/
|
|
22
|
+
|
|
23
|
+
/** Longest accepted commit message (characters). */
|
|
24
|
+
const MAX_MESSAGE_LENGTH = 5000
|
|
25
|
+
|
|
26
|
+
/** Longest accepted repository-relative file path (characters). */
|
|
27
|
+
const MAX_FILE_PATH_LENGTH = 1024
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Assert a repository name is acceptable to GitHub.
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} raw - Candidate name.
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
export function assertRepoName(raw) {
|
|
36
|
+
const name = typeof raw === 'string' ? raw.trim() : ''
|
|
37
|
+
if (name === '' || name.length > 100 || !REPO_PATTERN.test(name)) {
|
|
38
|
+
throw new GithubError(
|
|
39
|
+
'invalid-repo-name',
|
|
40
|
+
'Repository names may only contain ASCII letters, digits, ".", "_" and "-" (1-100 characters).',
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
if (name === '.' || name === '..') {
|
|
44
|
+
throw new GithubError('invalid-repo-name', 'Repository name must not be "." or "..".')
|
|
45
|
+
}
|
|
46
|
+
return name
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Assert an owner name is acceptable to GitHub.
|
|
51
|
+
*
|
|
52
|
+
* @param {unknown} raw - Candidate owner.
|
|
53
|
+
* @returns {string}
|
|
54
|
+
*/
|
|
55
|
+
export function assertOwner(raw) {
|
|
56
|
+
const owner = typeof raw === 'string' ? raw.trim() : ''
|
|
57
|
+
if (!OWNER_PATTERN.test(owner)) {
|
|
58
|
+
throw new GithubError('invalid-owner', 'Owner must be a GitHub account or organization name (letters, digits, "-").')
|
|
59
|
+
}
|
|
60
|
+
return owner
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Parse `owner/name` (a trailing `.git` is accepted).
|
|
65
|
+
*
|
|
66
|
+
* @param {unknown} raw - Candidate full name.
|
|
67
|
+
* @returns {{owner: string, name: string}}
|
|
68
|
+
*/
|
|
69
|
+
export function parseFullName(raw) {
|
|
70
|
+
const value = typeof raw === 'string' ? raw.trim() : ''
|
|
71
|
+
const match = /^([^/\s]+)\/([^/\s]+)$/.exec(value)
|
|
72
|
+
if (match === null) {
|
|
73
|
+
throw new GithubError('invalid-repo-name', 'Expected a repository in the form "owner/name".')
|
|
74
|
+
}
|
|
75
|
+
const name = match[2].endsWith('.git') ? match[2].slice(0, -4) : match[2]
|
|
76
|
+
return { owner: assertOwner(match[1]), name: assertRepoName(name) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* True when `target` is strictly inside `root` (both are resolved first).
|
|
81
|
+
*
|
|
82
|
+
* @param {string} root - Directory that must contain the target.
|
|
83
|
+
* @param {string} target - Candidate path.
|
|
84
|
+
* @returns {boolean}
|
|
85
|
+
*/
|
|
86
|
+
export function isInside(root, target) {
|
|
87
|
+
const rel = relative(resolve(root), resolve(target))
|
|
88
|
+
if (rel === '' || isAbsolute(rel)) return false
|
|
89
|
+
const parts = rel.split(sep)
|
|
90
|
+
return parts.length > 0 && parts[0] !== '..' && parts.every((part) => part !== '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Assert `target` is strictly inside `root`.
|
|
95
|
+
*
|
|
96
|
+
* @param {string} root - Directory that must contain the target.
|
|
97
|
+
* @param {string} target - Candidate path.
|
|
98
|
+
* @returns {string} The resolved target.
|
|
99
|
+
*/
|
|
100
|
+
export function assertInside(root, target) {
|
|
101
|
+
const rootAbs = resolve(root)
|
|
102
|
+
const targetAbs = resolve(target)
|
|
103
|
+
if (targetAbs === rootAbs) {
|
|
104
|
+
throw new GithubError('unsafe-path', 'Refusing to operate on the clone root itself.')
|
|
105
|
+
}
|
|
106
|
+
if (!isInside(rootAbs, targetAbs)) {
|
|
107
|
+
throw new GithubError('unsafe-path', 'Refusing to operate on a path outside the configured clone root.')
|
|
108
|
+
}
|
|
109
|
+
return targetAbs
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Validate a commit message.
|
|
114
|
+
*
|
|
115
|
+
* @param {unknown} raw - Candidate message.
|
|
116
|
+
* @returns {string}
|
|
117
|
+
*/
|
|
118
|
+
export function assertCommitMessage(raw) {
|
|
119
|
+
const message = typeof raw === 'string' ? raw.trim() : ''
|
|
120
|
+
if (message === '') throw new GithubError('invalid-message', 'Commit message must not be empty.')
|
|
121
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
122
|
+
throw new GithubError('invalid-message', `Commit message must be at most ${MAX_MESSAGE_LENGTH} characters.`)
|
|
123
|
+
}
|
|
124
|
+
if (message.includes('\0')) throw new GithubError('invalid-message', 'Commit message must not contain NUL.')
|
|
125
|
+
return message
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Validate one repository-relative path passed to `git add`.
|
|
130
|
+
*
|
|
131
|
+
* @param {unknown} raw - Candidate path.
|
|
132
|
+
* @returns {string}
|
|
133
|
+
*/
|
|
134
|
+
export function assertRepoPath(raw) {
|
|
135
|
+
const path = typeof raw === 'string' ? raw.trim() : ''
|
|
136
|
+
if (path === '' || path.length > MAX_FILE_PATH_LENGTH) {
|
|
137
|
+
throw new GithubError('invalid-files', 'Each file path must be 1-1024 characters.')
|
|
138
|
+
}
|
|
139
|
+
if (path.includes('\0')) throw new GithubError('invalid-files', 'File paths must not contain NUL.')
|
|
140
|
+
if (isAbsolute(path) || /^[A-Za-z]:[\\/]/.test(path)) {
|
|
141
|
+
throw new GithubError('invalid-files', 'File paths must be repository-relative.')
|
|
142
|
+
}
|
|
143
|
+
const parts = path.split(/[\\/]/)
|
|
144
|
+
if (parts.some((part) => part === '..')) {
|
|
145
|
+
throw new GithubError('invalid-files', 'File paths must not contain "..".')
|
|
146
|
+
}
|
|
147
|
+
if (path.startsWith(':')) {
|
|
148
|
+
throw new GithubError('invalid-files', 'File paths must not start with git pathspec magic (":").')
|
|
149
|
+
}
|
|
150
|
+
return path
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Validate a list of repository-relative paths.
|
|
155
|
+
*
|
|
156
|
+
* @param {unknown} raw - Candidate list; `undefined` means "all changes".
|
|
157
|
+
* @returns {string[] | undefined}
|
|
158
|
+
*/
|
|
159
|
+
export function assertRepoPaths(raw) {
|
|
160
|
+
if (raw === undefined || raw === null) return undefined
|
|
161
|
+
if (!Array.isArray(raw)) throw new GithubError('invalid-files', 'files must be an array of repository-relative paths.')
|
|
162
|
+
if (raw.length > 200) throw new GithubError('invalid-files', 'At most 200 file paths may be staged at once.')
|
|
163
|
+
return raw.map(assertRepoPath)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Read an optional string field of a request payload.
|
|
168
|
+
*
|
|
169
|
+
* @param {unknown} payload - Parsed request body.
|
|
170
|
+
* @param {string} key - Field name.
|
|
171
|
+
* @param {number} [maxLength] - Maximum accepted length.
|
|
172
|
+
* @returns {string | undefined}
|
|
173
|
+
*/
|
|
174
|
+
export function optionalString(payload, key, maxLength = 500) {
|
|
175
|
+
if (payload === null || typeof payload !== 'object') return undefined
|
|
176
|
+
const value = /** @type {Record<string, unknown>} */ (payload)[key]
|
|
177
|
+
if (value === undefined || value === null) return undefined
|
|
178
|
+
if (typeof value !== 'string') throw new GithubError('bad-request', `${key} must be a string.`)
|
|
179
|
+
if (value.length > maxLength) throw new GithubError('bad-request', `${key} is too long.`)
|
|
180
|
+
return value
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read an optional boolean field of a request payload.
|
|
185
|
+
*
|
|
186
|
+
* @param {unknown} payload - Parsed request body.
|
|
187
|
+
* @param {string} key - Field name.
|
|
188
|
+
* @param {boolean} fallback - Value used when the field is absent.
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
export function optionalBoolean(payload, key, fallback) {
|
|
192
|
+
if (payload === null || typeof payload !== 'object') return fallback
|
|
193
|
+
const value = /** @type {Record<string, unknown>} */ (payload)[key]
|
|
194
|
+
if (value === undefined || value === null) return fallback
|
|
195
|
+
if (typeof value !== 'boolean') throw new GithubError('bad-request', `${key} must be a boolean.`)
|
|
196
|
+
return value
|
|
197
|
+
}
|
package/locale/en.json
ADDED
package/locale/zh.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,127 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ban-sky/dsh-github",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.2",
|
|
4
|
+
"description": "GitHub integration for DSH: official device-flow sign-in, repository creation, cloning, pull, commit and push. Security first.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./index.js",
|
|
11
|
+
"./client": "./client.js",
|
|
12
|
+
"./locale/*.json": "./locale/*.json",
|
|
13
|
+
"./icon": "./icon.svg"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"client.js",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"lib/*.js",
|
|
20
|
+
"locale/*.json",
|
|
21
|
+
"icon.svg",
|
|
22
|
+
"README.md",
|
|
23
|
+
"README.zh.md",
|
|
24
|
+
"LICENSE"
|
|
25
|
+
],
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/ban-sky/dsh-github.git"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/ban-sky/dsh-github#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/ban-sky/dsh-github/issues"
|
|
36
|
+
},
|
|
37
|
+
"author": "ban-sky",
|
|
38
|
+
"dsh": {
|
|
39
|
+
"bundle": {
|
|
40
|
+
"patch": "./cordis.patch.yml"
|
|
41
|
+
},
|
|
42
|
+
"client": {
|
|
43
|
+
"platform": "web",
|
|
44
|
+
"immediately": true,
|
|
45
|
+
"inject": [
|
|
46
|
+
"@deepseek-ai/dsh-client-locale",
|
|
47
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"manifestVersion": 1
|
|
51
|
+
},
|
|
52
|
+
"dshGo": {
|
|
53
|
+
"v": 1,
|
|
54
|
+
"title": {
|
|
55
|
+
"zh": "GitHub",
|
|
56
|
+
"en": "GitHub"
|
|
57
|
+
},
|
|
58
|
+
"icon": "fork",
|
|
59
|
+
"wants": [
|
|
60
|
+
"draft"
|
|
61
|
+
],
|
|
62
|
+
"contributes": {
|
|
63
|
+
"composer.actions": [
|
|
64
|
+
{
|
|
65
|
+
"id": "save-client-id",
|
|
66
|
+
"label": {
|
|
67
|
+
"zh": "存 ID",
|
|
68
|
+
"en": "Save ID"
|
|
69
|
+
},
|
|
70
|
+
"icon": "save"
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
"id": "commit-draft",
|
|
74
|
+
"label": {
|
|
75
|
+
"zh": "提交",
|
|
76
|
+
"en": "Commit"
|
|
77
|
+
},
|
|
78
|
+
"icon": "check"
|
|
79
|
+
}
|
|
80
|
+
],
|
|
81
|
+
"composer.tools": [
|
|
82
|
+
{
|
|
83
|
+
"id": "github-open",
|
|
84
|
+
"label": {
|
|
85
|
+
"zh": "GitHub",
|
|
86
|
+
"en": "GitHub"
|
|
87
|
+
},
|
|
88
|
+
"icon": "fork",
|
|
89
|
+
"panel": "github"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
"id": "clone-repo",
|
|
93
|
+
"label": {
|
|
94
|
+
"zh": "克隆",
|
|
95
|
+
"en": "Clone"
|
|
96
|
+
},
|
|
97
|
+
"icon": "download"
|
|
98
|
+
}
|
|
99
|
+
],
|
|
100
|
+
"panels": [
|
|
101
|
+
{
|
|
102
|
+
"id": "github",
|
|
103
|
+
"title": {
|
|
104
|
+
"zh": "GitHub",
|
|
105
|
+
"en": "GitHub"
|
|
106
|
+
},
|
|
107
|
+
"icon": "fork",
|
|
108
|
+
"placement": [
|
|
109
|
+
"chat.panel",
|
|
110
|
+
"session.tab",
|
|
111
|
+
"features"
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
]
|
|
115
|
+
},
|
|
116
|
+
"minApp": "1.0.13"
|
|
117
|
+
},
|
|
118
|
+
"license": "MIT",
|
|
119
|
+
"keywords": [
|
|
120
|
+
"dsh",
|
|
121
|
+
"dsh-plugin",
|
|
122
|
+
"dsh-go",
|
|
123
|
+
"github",
|
|
124
|
+
"git",
|
|
125
|
+
"oauth"
|
|
126
|
+
]
|
|
127
|
+
}
|