@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/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
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "GitHub",
4
+ "description": "Sign in with GitHub's official device flow, then create repositories, clone, pull, commit and push from Settings. Tokens stay in the local credential store."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "GitHub",
4
+ "description": "GitHub 官方设备码登录,并在设置页创建仓库、克隆、拉取、提交与推送;令牌只保存在本机凭据库。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,127 @@
1
1
  {
2
2
  "name": "@ban-sky/dsh-github",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }