dsh-codespace-workspace 0.0.0-stage → 0.1.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/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
+ }