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