dsh-codespace-workspace 0.0.0-stage → 0.1.0
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 +680 -2
- package/cordis.patch.yml +11 -0
- package/icon.svg +10 -0
- package/lib/client.js +3598 -0
- package/lib/host/codespaces.js +942 -0
- package/lib/host/gh.js +271 -0
- package/lib/host/github.js +619 -0
- package/lib/host/remote-tools.js +565 -0
- package/lib/host/routes.js +555 -0
- package/lib/host/session.js +267 -0
- package/lib/host/transport.js +463 -0
- package/lib/index.js +170 -0
- package/lib/shared.js +181 -0
- package/locale/en.json +122 -0
- package/locale/zh.json +122 -0
- package/package.json +80 -4
package/lib/host/gh.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub CLI (`gh`) detection and invocation for dsh-codespace-workspace.
|
|
3
|
+
*
|
|
4
|
+
* `gh` is the *preferred* data plane, not a required one: it is not installed
|
|
5
|
+
* on every machine (it is absent on the one this plugin was developed on), so
|
|
6
|
+
* every entry point here degrades to a plain, actionable result rather than
|
|
7
|
+
* throwing. The caller decides what to do when `gh` is unavailable.
|
|
8
|
+
*
|
|
9
|
+
* The `run` executor is injected so this module stays testable and so the
|
|
10
|
+
* caller owns timeouts, cancellation, and process policy.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-codespace-workspace/host/gh
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { redact } from '../shared.js'
|
|
17
|
+
|
|
18
|
+
/** Detection must return quickly even when `gh` is present but wedged. */
|
|
19
|
+
const DETECT_TIMEOUT_MS = 5000
|
|
20
|
+
|
|
21
|
+
/** Default ceiling for one remote command. */
|
|
22
|
+
const EXEC_TIMEOUT_MS = 60000
|
|
23
|
+
|
|
24
|
+
/** Captured output is bounded; a runaway command must not exhaust memory. */
|
|
25
|
+
const MAX_OUTPUT_BYTES = 4 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Spawn one program and capture its output.
|
|
29
|
+
*
|
|
30
|
+
* This is the default `run` implementation. It never rejects on a non-zero
|
|
31
|
+
* exit status — a non-zero exit is a result, not an exception — and it
|
|
32
|
+
* rejects only when the process could not be started at all, which is the
|
|
33
|
+
* case {@link ghInfo} needs to distinguish "absent" from "failed".
|
|
34
|
+
*
|
|
35
|
+
* @param {string} command - executable name or path.
|
|
36
|
+
* @param {string[]} args - argument vector.
|
|
37
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal, cwd?: string}} [options] - execution options.
|
|
38
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} the captured result.
|
|
39
|
+
*/
|
|
40
|
+
export function defaultRun(command, args, options = {}) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
let child
|
|
43
|
+
try {
|
|
44
|
+
child = spawn(command, args, {
|
|
45
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
46
|
+
windowsHide: true,
|
|
47
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
48
|
+
})
|
|
49
|
+
} catch (error) {
|
|
50
|
+
reject(error)
|
|
51
|
+
return
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let stdout = ''
|
|
55
|
+
let stderr = ''
|
|
56
|
+
let settled = false
|
|
57
|
+
let timer = null
|
|
58
|
+
|
|
59
|
+
const finish = (fn, value) => {
|
|
60
|
+
if (settled) return
|
|
61
|
+
settled = true
|
|
62
|
+
if (timer !== null) clearTimeout(timer)
|
|
63
|
+
if (options.signal !== undefined) options.signal.removeEventListener('abort', onAbort)
|
|
64
|
+
fn(value)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const kill = () => {
|
|
68
|
+
try {
|
|
69
|
+
child.kill()
|
|
70
|
+
} catch {
|
|
71
|
+
/* already gone */
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function onAbort() {
|
|
76
|
+
kill()
|
|
77
|
+
finish(reject, new Error('gh invocation aborted'))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : EXEC_TIMEOUT_MS
|
|
81
|
+
if (timeoutMs > 0) {
|
|
82
|
+
timer = setTimeout(() => {
|
|
83
|
+
kill()
|
|
84
|
+
finish(resolve, { stdout, stderr: `${stderr}\n[timed out after ${timeoutMs}ms]`, exitCode: 124 })
|
|
85
|
+
}, timeoutMs)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (options.signal !== undefined) {
|
|
89
|
+
if (options.signal.aborted) {
|
|
90
|
+
onAbort()
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
options.signal.addEventListener('abort', onAbort, { once: true })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
child.stdout.on('data', (chunk) => {
|
|
97
|
+
if (stdout.length < MAX_OUTPUT_BYTES) stdout += chunk.toString('utf8')
|
|
98
|
+
})
|
|
99
|
+
child.stderr.on('data', (chunk) => {
|
|
100
|
+
if (stderr.length < MAX_OUTPUT_BYTES) stderr += chunk.toString('utf8')
|
|
101
|
+
})
|
|
102
|
+
child.on('error', (error) => finish(reject, error))
|
|
103
|
+
child.on('close', (code) => finish(resolve, { stdout, stderr, exitCode: code ?? 0 }))
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Detect `gh` and, when present, its version, auth state, and login.
|
|
109
|
+
*
|
|
110
|
+
* @param {Function} [run] - injected executor.
|
|
111
|
+
* @returns {Promise<{installed: boolean, version: string, authenticated: boolean, login: string, error?: string}>} the detection result.
|
|
112
|
+
*/
|
|
113
|
+
export async function ghInfo(run = defaultRun) {
|
|
114
|
+
const absent = { installed: false, version: '', authenticated: false, login: '' }
|
|
115
|
+
|
|
116
|
+
let version
|
|
117
|
+
try {
|
|
118
|
+
version = await run('gh', ['--version'], { timeoutMs: DETECT_TIMEOUT_MS })
|
|
119
|
+
} catch {
|
|
120
|
+
// ENOENT (not installed) and any other spawn failure land here. Both mean
|
|
121
|
+
// "no usable gh"; the difference is not actionable for the user, so it is
|
|
122
|
+
// not reported as an error.
|
|
123
|
+
return absent
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!version || version.exitCode !== 0) return absent
|
|
127
|
+
|
|
128
|
+
// `gh version 2.40.0 (2024-01-01)` — the first token after the word "version".
|
|
129
|
+
const first = String(version.stdout ?? '').split('\n')[0] ?? ''
|
|
130
|
+
const match = /version\s+(\S+)/i.exec(first)
|
|
131
|
+
const reported = match?.[1] ?? first.trim()
|
|
132
|
+
|
|
133
|
+
const info = { installed: true, version: reported, authenticated: false, login: '' }
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const status = await run('gh', ['auth', 'status'], { timeoutMs: DETECT_TIMEOUT_MS })
|
|
137
|
+
// `gh auth status` writes its report to stderr and exits non-zero when not
|
|
138
|
+
// logged in; both streams are scanned so the message shape does not matter.
|
|
139
|
+
const text = `${status?.stdout ?? ''}\n${status?.stderr ?? ''}`
|
|
140
|
+
info.authenticated = status?.exitCode === 0
|
|
141
|
+
const login = /Logged in to \S+ account ([^\s(]+)/i.exec(text)
|
|
142
|
+
if (login?.[1] !== undefined) info.login = login[1]
|
|
143
|
+
} catch {
|
|
144
|
+
// Auth state is informational: an absent answer leaves both fields false.
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return info
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether `gh` can be used as a data plane.
|
|
152
|
+
*
|
|
153
|
+
* @param {{installed?: boolean, authenticated?: boolean}} info - a {@link ghInfo} result.
|
|
154
|
+
* @returns {boolean} true when `gh` is installed and logged in.
|
|
155
|
+
*/
|
|
156
|
+
export function ghAvailable(info) {
|
|
157
|
+
return info !== undefined && info !== null && info.installed === true && info.authenticated === true
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Quote one string as a single POSIX shell word.
|
|
162
|
+
*
|
|
163
|
+
* This is the only place a value becomes part of a command line. Wrapping in
|
|
164
|
+
* single quotes makes every byte literal — spaces, `$`, backticks, newlines,
|
|
165
|
+
* glob characters — and an embedded single quote is closed, escaped, and
|
|
166
|
+
* reopened (`'\''`), which is the one sequence that cannot be expressed
|
|
167
|
+
* inside single quotes directly.
|
|
168
|
+
*
|
|
169
|
+
* @param {unknown} value - the value to quote.
|
|
170
|
+
* @returns {string} a single-quoted shell word.
|
|
171
|
+
*/
|
|
172
|
+
export function shq(value) {
|
|
173
|
+
return `'${String(value).split("'").join("'\\''")}'`
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run one command inside a Codespace through `gh codespace ssh`.
|
|
178
|
+
*
|
|
179
|
+
* The remote command is single-quoted by {@link shq} and passed as one argv
|
|
180
|
+
* element after `sh -lc`, so it survives `gh`'s argv forwarding and the remote
|
|
181
|
+
* shell receives it verbatim instead of re-splitting it on spaces.
|
|
182
|
+
*
|
|
183
|
+
* VERIFY: `gh` is not installed on this machine, so the exact argv forwarding
|
|
184
|
+
* of `gh codespace ssh -c <name> -- sh -lc '<cmd>'` could not be exercised.
|
|
185
|
+
* Tolerant branch: a forwarding difference shows up as a non-zero exit with
|
|
186
|
+
* `gh`'s own stderr text, which the caller surfaces verbatim; it cannot
|
|
187
|
+
* silently run a *different* command, because the quoting is applied here
|
|
188
|
+
* rather than left to the remote shell.
|
|
189
|
+
*
|
|
190
|
+
* @param {Function} run - injected executor.
|
|
191
|
+
* @param {string} codespaceName - target Codespace.
|
|
192
|
+
* @param {string} command - the complete POSIX shell command line, unquoted.
|
|
193
|
+
* @param {{timeoutMs?: number, signal?: AbortSignal}} [options] - execution options.
|
|
194
|
+
* @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} the result, with `exitCode: -1` when `gh` could not start.
|
|
195
|
+
*/
|
|
196
|
+
export async function ghExec(run, codespaceName, command, options = {}) {
|
|
197
|
+
const args = ['codespace', 'ssh', '-c', String(codespaceName), '--', 'sh', '-lc', shq(command)]
|
|
198
|
+
try {
|
|
199
|
+
const result = await run('gh', args, {
|
|
200
|
+
timeoutMs: Number.isFinite(options.timeoutMs) ? options.timeoutMs : EXEC_TIMEOUT_MS,
|
|
201
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
202
|
+
})
|
|
203
|
+
return {
|
|
204
|
+
stdout: String(result?.stdout ?? ''),
|
|
205
|
+
stderr: redact(String(result?.stderr ?? '')),
|
|
206
|
+
exitCode: Number.isFinite(result?.exitCode) ? result.exitCode : 0,
|
|
207
|
+
}
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
210
|
+
return { stdout: '', stderr: redact(`gh codespace ssh failed: ${reason}`), exitCode: -1 }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a failed `gh` invocation means the Codespace is not running.
|
|
216
|
+
*
|
|
217
|
+
* The UI offers to start it instead of showing a bare failure.
|
|
218
|
+
*
|
|
219
|
+
* @param {{stderr?: string, exitCode?: number}} result - a {@link ghExec} result.
|
|
220
|
+
* @returns {boolean} true when the message names a non-running Codespace.
|
|
221
|
+
*/
|
|
222
|
+
export function isNotRunning(result) {
|
|
223
|
+
const text = String(result?.stderr ?? '')
|
|
224
|
+
return /not\s+running|is\s+shutdown|is\s+stopped|start\s+the\s+codespace/i.test(text)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Create a Codespace through the CLI.
|
|
229
|
+
*
|
|
230
|
+
* VERIFY: the flag set below is taken from the `gh codespace create` manual
|
|
231
|
+
* (`-R/--repo`, `-b/--branch`, `-m/--machine`, `-d/--display-name`,
|
|
232
|
+
* `--idle-timeout` as a *duration* such as `30m`, `--default-permissions`).
|
|
233
|
+
* It could not be exercised here because `gh` is not installed on this
|
|
234
|
+
* machine. Tolerant branch: the REST path is the one this plugin actually
|
|
235
|
+
* uses for creation, so a wrong flag degrades to "the CLI path failed" and
|
|
236
|
+
* never to a wrong Codespace.
|
|
237
|
+
*
|
|
238
|
+
* @param {Function} run - injected executor.
|
|
239
|
+
* @param {{repo: string, branch?: string, machine?: string, displayName?: string, idleTimeoutMinutes?: number}} options - creation inputs.
|
|
240
|
+
* @returns {Promise<{name: string, stdout: string, stderr: string, exitCode: number}>} the new Codespace name when it succeeded.
|
|
241
|
+
*/
|
|
242
|
+
export async function ghCreate(run, options) {
|
|
243
|
+
const args = ['codespace', 'create', '-R', String(options.repo)]
|
|
244
|
+
if (typeof options.branch === 'string' && options.branch.trim().length > 0) {
|
|
245
|
+
args.push('-b', options.branch.trim())
|
|
246
|
+
}
|
|
247
|
+
if (typeof options.machine === 'string' && options.machine.trim().length > 0) {
|
|
248
|
+
args.push('-m', options.machine.trim())
|
|
249
|
+
}
|
|
250
|
+
if (typeof options.displayName === 'string' && options.displayName.trim().length > 0) {
|
|
251
|
+
args.push('-d', options.displayName.trim().slice(0, 48))
|
|
252
|
+
}
|
|
253
|
+
if (Number.isFinite(options.idleTimeoutMinutes) && options.idleTimeoutMinutes > 0) {
|
|
254
|
+
args.push('--idle-timeout', `${Math.round(options.idleTimeoutMinutes)}m`)
|
|
255
|
+
}
|
|
256
|
+
args.push('--default-permissions')
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// No `--json` on this subcommand: it prints the new Codespace name alone.
|
|
260
|
+
const result = await run('gh', args, { timeoutMs: 300000 })
|
|
261
|
+
return {
|
|
262
|
+
name: String(result?.stdout ?? '').trim(),
|
|
263
|
+
stdout: String(result?.stdout ?? ''),
|
|
264
|
+
stderr: redact(String(result?.stderr ?? '')),
|
|
265
|
+
exitCode: Number.isFinite(result?.exitCode) ? result.exitCode : 0,
|
|
266
|
+
}
|
|
267
|
+
} catch (error) {
|
|
268
|
+
const reason = error instanceof Error ? error.message : String(error)
|
|
269
|
+
return { name: '', stdout: '', stderr: redact(`gh codespace create failed: ${reason}`), exitCode: -1 }
|
|
270
|
+
}
|
|
271
|
+
}
|