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.
@@ -0,0 +1,619 @@
1
+ /**
2
+ * GitHub REST control plane for dsh-codespace-workspace.
3
+ *
4
+ * Every function here is pure transport plus normalization: it knows the
5
+ * endpoint shapes and the error envelope, and it never leaks the token into a
6
+ * thrown message, a log line, or a returned value.
7
+ *
8
+ * The endpoints and payload field names below were verified against the
9
+ * Codespaces REST reference; the ones this build could not exercise live carry
10
+ * a `VERIFY:` comment naming the tolerant branch.
11
+ *
12
+ * @module dsh-codespace-workspace/host/github
13
+ */
14
+
15
+ import { redact } from '../shared.js'
16
+ import { defaultRun } from './gh.js'
17
+ import { request as httpsRequest } from 'node:https'
18
+ // Namespace import, NOT `import { getCACertificates }`: that export only exists
19
+ // on newer Node, and a named import of a missing builtin export fails at module
20
+ // LINK time -- which would take the whole plugin down instead of falling back.
21
+ import * as nodeTls from 'node:tls'
22
+
23
+ /** REST root. */
24
+ const API_BASE = 'https://api.github.com'
25
+
26
+ /** Pinned stable API version (the docs' newest is newer; this one matches the shapes we read). */
27
+ const API_VERSION = '2022-11-28'
28
+
29
+ /** GitHub asks every caller to identify itself. */
30
+ const USER_AGENT = 'dsh-codespace-workspace'
31
+
32
+ /** Per-request ceiling. Keeps every RPC inside the "never block the UI" budget. */
33
+ const REQUEST_TIMEOUT_MS = 20000
34
+
35
+ /** A rate-limit retry never waits longer than this, so an RPC still returns. */
36
+ const MAX_RETRY_WAIT_MS = 5000
37
+
38
+ /** One page of repos is enough for a picker and keeps the call bounded. */
39
+ const REPO_PAGE_SIZE = 100
40
+
41
+ /**
42
+ * Sentinel key marking a failed attempt inside {@link api}.
43
+ *
44
+ * A Symbol, not a string: a successful response body is arbitrary JSON and
45
+ * must never be mistaken for a failure envelope because it happened to carry
46
+ * a `__failure` property.
47
+ */
48
+ const FAILURE = Symbol('github-failure')
49
+
50
+ /**
51
+ * A control-plane failure carrying a stable `code` and the HTTP `status`.
52
+ *
53
+ * The message is always passed through `redact()`: a GitHub error body is
54
+ * echoed into it, and an error body must never be trusted to be credential-free.
55
+ */
56
+ export class GithubError extends Error {
57
+ /**
58
+ * @param {string} message - human-readable, already free of secrets.
59
+ * @param {string} code - stable machine code (`E_AUTH`, `E_RATELIMIT`, …).
60
+ * @param {number} [status] - HTTP status when the failure came from a response.
61
+ */
62
+ constructor(message, code, status) {
63
+ super(redact(message))
64
+ this.name = 'GithubError'
65
+ this.code = code
66
+ if (status !== undefined) this.status = status
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Split `owner/name` into its parts.
72
+ *
73
+ * @param {string} repo - `owner/name`.
74
+ * @returns {{ owner: string, name: string }} the parts.
75
+ * @throws {GithubError} `E_INVALID` when the argument is not `owner/name`.
76
+ */
77
+ export function splitRepo(repo) {
78
+ const text = typeof repo === 'string' ? repo.trim() : ''
79
+ const at = text.indexOf('/')
80
+ if (at <= 0 || at === text.length - 1) {
81
+ throw new GithubError(`invalid repository "${text}": expected "owner/name"`, 'E_INVALID')
82
+ }
83
+ return { owner: text.slice(0, at), name: text.slice(at + 1) }
84
+ }
85
+
86
+ /** Whether a token-ish string is present and non-blank. */
87
+ function present(value) {
88
+ return typeof value === 'string' && value.trim().length > 0
89
+ }
90
+
91
+ /**
92
+ * TLS failures that mean "this process does not trust the chain", not "the
93
+ * network is down".
94
+ *
95
+ * Measured on a machine behind a TLS-intercepting proxy: `fetch` rejects with
96
+ * `TypeError: fetch failed` whose `cause.code` is one of these, while the very
97
+ * same request succeeds through the OS trust store. See {@link httpsOnce}.
98
+ */
99
+ const TRUST_FAILURE_CODES = new Set([
100
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
101
+ 'UNABLE_TO_GET_ISSUER_CERT',
102
+ 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
103
+ 'SELF_SIGNED_CERT_IN_CHAIN',
104
+ 'DEPTH_ZERO_SELF_SIGNED_CERT',
105
+ 'CERT_SIGNATURE_FAILURE',
106
+ ])
107
+
108
+ /** The deepest error code a transport failure carries, for the message. */
109
+ function transportDetail(error) {
110
+ const cause = error?.cause
111
+ if (typeof cause?.code === 'string' && cause.code.length > 0) return cause.code
112
+ if (typeof cause?.message === 'string' && cause.message.length > 0) return cause.message
113
+ if (error instanceof Error && error.message.length > 0) return error.message
114
+ return 'unknown'
115
+ }
116
+
117
+ /** Whether a transport failure is the TLS-trust case {@link httpsOnce} handles. */
118
+ function isTrustFailure(error) {
119
+ return TRUST_FAILURE_CODES.has(String(error?.cause?.code ?? ''))
120
+ }
121
+
122
+ /** Memoized OS trust store, or an empty list when this Node cannot read it. */
123
+ let cachedSystemCa
124
+ function systemCaList() {
125
+ if (cachedSystemCa !== undefined) return cachedSystemCa
126
+ try {
127
+ // `tls.getCACertificates` is Node 24+; on anything older the property read
128
+ // is undefined and the caller keeps Node's bundled list (i.e. exactly the
129
+ // behaviour it had before this fallback existed).
130
+ cachedSystemCa = typeof nodeTls.getCACertificates === 'function' ? (nodeTls.getCACertificates('system') ?? []) : []
131
+ } catch {
132
+ // An unreadable system store is not fatal: the request then fails exactly
133
+ // as it would have without this fallback.
134
+ cachedSystemCa = []
135
+ }
136
+ return cachedSystemCa
137
+ }
138
+
139
+ /**
140
+ * One request through Node's own HTTPS client, trusting the OS certificate
141
+ * store in addition to the bundled one.
142
+ *
143
+ * This exists for one measured failure mode. `fetch` (undici) validates against
144
+ * Node's *bundled* CA list only. Behind a TLS-intercepting proxy — a corporate
145
+ * middlebox, or this very development machine — the intercepting root lives in
146
+ * the Windows/macOS trust store and nowhere else, so **every** `fetch` to the
147
+ * API dies with `UNABLE_TO_VERIFY_LEAF_SIGNATURE` while `gh` (Go, which reads
148
+ * the OS store) works fine. The plugin's whole control plane would be dead with
149
+ * no way for the user to tell why.
150
+ *
151
+ * `tls.getCACertificates('system')` reads exactly that store. It is preferred
152
+ * over `tls.setDefaultCACertificates()`, which would silently widen trust for
153
+ * every other plugin in the host process; this keeps the change inside this
154
+ * transport. The result is wrapped in a real `Response` so every downstream
155
+ * reader (`response.status`, `headers.get`, `text()`) is unchanged.
156
+ *
157
+ * @param {string} url - absolute request URL.
158
+ * @param {{method: string, headers: object, body?: string}} init - request shape.
159
+ * @param {AbortSignal} signal - the same fused signal `fetch` was given.
160
+ * @returns {Promise<Response>} the response.
161
+ */
162
+ function httpsOnce(url, init, signal) {
163
+ const ca = systemCaList()
164
+ return new Promise((resolve, reject) => {
165
+ const request = httpsRequest(url, {
166
+ method: init.method,
167
+ headers: init.headers,
168
+ signal,
169
+ ...(ca.length === 0 ? {} : { ca }),
170
+ }, (response) => {
171
+ let text = ''
172
+ response.setEncoding('utf8')
173
+ response.on('data', (chunk) => { text += chunk })
174
+ response.on('end', () => {
175
+ // 204/304 are null-body statuses and must not carry a body.
176
+ const nullBody = response.statusCode === 204 || response.statusCode === 304
177
+ resolve(new Response(nullBody ? null : text, {
178
+ status: response.statusCode ?? 500,
179
+ headers: response.headers,
180
+ }))
181
+ })
182
+ })
183
+ request.on('error', reject)
184
+ if (init.body !== undefined) request.write(init.body)
185
+ request.end()
186
+ })
187
+ }
188
+
189
+ /**
190
+ * Map one HTTP failure onto a stable code plus a plain-language message.
191
+ *
192
+ * The order matters: a rate-limited 403 is a different remedy from a
193
+ * permission-denied 403, so the rate-limit headers are consulted first.
194
+ *
195
+ * @param {number} status - HTTP status.
196
+ * @param {Headers} headers - response headers.
197
+ * @param {unknown} body - parsed JSON body, when there was one.
198
+ * @returns {{ code: string, message: string, retryAfterMs: number }} the classification.
199
+ */
200
+ function classify(status, headers, body) {
201
+ const detail = body && typeof body === 'object' && typeof body.message === 'string' ? body.message : ''
202
+ const suffix = detail.length > 0 ? `: ${detail}` : ''
203
+
204
+ const remaining = headers.get('x-ratelimit-remaining')
205
+ const reset = Number(headers.get('x-ratelimit-reset'))
206
+ const retryAfter = Number(headers.get('retry-after'))
207
+ const limited = status === 429 || (status === 403 && remaining === '0')
208
+ if (limited) {
209
+ let waitMs = 0
210
+ if (Number.isFinite(retryAfter) && retryAfter > 0) waitMs = retryAfter * 1000
211
+ else if (Number.isFinite(reset) && reset > 0) waitMs = reset * 1000 - Date.now()
212
+ waitMs = Math.max(0, Math.min(MAX_RETRY_WAIT_MS, waitMs))
213
+ return { code: 'E_RATELIMIT', message: `GitHub API rate limit reached${suffix}`, retryAfterMs: waitMs }
214
+ }
215
+
216
+ if (status === 401) return { code: 'E_AUTH', message: `GitHub token is invalid or expired${suffix}`, retryAfterMs: 0 }
217
+ if (status === 403) return { code: 'E_FORBIDDEN', message: `GitHub refused the request (missing scope or permission)${suffix}`, retryAfterMs: 0 }
218
+ if (status === 404) return { code: 'E_NOT_FOUND', message: `GitHub resource not found${suffix}`, retryAfterMs: 0 }
219
+ if (status === 409) return { code: 'E_CONFLICT', message: `GitHub reported a conflicting state; the resource is mid-transition, retry shortly${suffix}`, retryAfterMs: 0 }
220
+ if (status === 402) return { code: 'E_PAYMENT', message: `GitHub requires payment or available Codespaces quota for this action${suffix}`, retryAfterMs: 0 }
221
+ if (status === 422) return { code: 'E_INVALID', message: `GitHub rejected the request as invalid${suffix}`, retryAfterMs: 0 }
222
+ if (status === 503) return { code: 'E_UNAVAILABLE', message: `GitHub Codespaces is temporarily unavailable${suffix}`, retryAfterMs: 0 }
223
+ return { code: 'E_HTTP', message: `GitHub API request failed with HTTP ${status}${suffix}`, retryAfterMs: 0 }
224
+ }
225
+
226
+ /** Sleep for a bounded interval. */
227
+ function sleep(ms) {
228
+ return new Promise((resolve) => setTimeout(resolve, ms))
229
+ }
230
+
231
+ /**
232
+ * One REST call, with a single bounded retry for a rate limit or a 5xx.
233
+ *
234
+ * @param {string|null} token - bearer token, or null for an anonymous call.
235
+ * @param {string} method - HTTP method.
236
+ * @param {string} path - `/repos/...` or an absolute URL.
237
+ * @param {unknown} [body] - JSON request body; omitted entirely when undefined.
238
+ * @param {{signal?: AbortSignal, timeoutMs?: number, retry?: boolean}} [options] - call options.
239
+ * `timeoutMs` overrides the per-request ceiling, and `retry: false` suppresses
240
+ * the bounded retry — both exist for the shutdown path, which runs under a
241
+ * hard 5s budget and cannot afford a retry.
242
+ * @returns {Promise<any>} parsed JSON, or `{}` for an empty/204 response.
243
+ * @throws {GithubError} always with `.code` and, for HTTP failures, `.status`.
244
+ */
245
+ export async function api(token, method, path, body, options = {}) {
246
+ const url = path.startsWith('http') ? path : `${API_BASE}${path}`
247
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : REQUEST_TIMEOUT_MS
248
+ const allowRetry = options.retry !== false
249
+
250
+ /** One attempt. */
251
+ const attempt = async () => {
252
+ const headers = {
253
+ accept: 'application/vnd.github+json',
254
+ 'x-github-api-version': API_VERSION,
255
+ 'user-agent': USER_AGENT,
256
+ }
257
+ if (present(token)) headers.authorization = `Bearer ${token}`
258
+ // The caller's signal (when given) and the per-request timeout are fused:
259
+ // aborting either one cancels the fetch.
260
+ const signal = options.signal === undefined
261
+ ? AbortSignal.timeout(timeoutMs)
262
+ : AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)])
263
+ const init = { method, headers, signal }
264
+ if (body !== undefined) {
265
+ headers['content-type'] = 'application/json'
266
+ init.body = JSON.stringify(body)
267
+ }
268
+
269
+ let response
270
+ try {
271
+ response = await fetch(url, init)
272
+ } catch (error) {
273
+ // A TLS-trust failure is the one transport error worth retrying a
274
+ // different way: the request never reached the network, so it cannot
275
+ // double-apply a side effect. Everything else is reported as-is.
276
+ if (!isTrustFailure(error)) {
277
+ // A transport failure is reported without the URL's query string, which
278
+ // can carry a user-supplied ref, and never with the request headers.
279
+ const code = error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')
280
+ ? 'E_TIMEOUT'
281
+ : 'E_NETWORK'
282
+ throw new GithubError(`cannot reach the GitHub API (${transportDetail(error)})`, code)
283
+ }
284
+ try {
285
+ response = await httpsOnce(url, init, signal)
286
+ } catch (second) {
287
+ const code = second instanceof Error && (second.name === 'AbortError' || second.name === 'TimeoutError')
288
+ ? 'E_TIMEOUT'
289
+ : 'E_NETWORK'
290
+ throw new GithubError(`cannot reach the GitHub API (${transportDetail(second)})`, code)
291
+ }
292
+ }
293
+
294
+ const status = response.status
295
+ let parsed = null
296
+ let text = ''
297
+ try {
298
+ text = await response.text()
299
+ if (text.length > 0) parsed = JSON.parse(text)
300
+ } catch {
301
+ parsed = null
302
+ }
303
+
304
+ if (status >= 200 && status < 300) {
305
+ return parsed === null || typeof parsed !== 'object' ? {} : parsed
306
+ }
307
+
308
+ const failure = classify(status, response.headers, parsed)
309
+ return { [FAILURE]: failure, status }
310
+ }
311
+
312
+ const first = await attempt()
313
+ if (first[FAILURE] === undefined) return first
314
+
315
+ const { code, retryAfterMs } = first[FAILURE]
316
+ const retryable = allowRetry && (code === 'E_RATELIMIT' || first.status >= 500)
317
+ if (!retryable) throw new GithubError(first[FAILURE].message, code, first.status)
318
+
319
+ // A single bounded retry: a reset far in the future must not hold an RPC open.
320
+ if (retryAfterMs > 0) await sleep(retryAfterMs)
321
+ const second = await attempt()
322
+ if (second[FAILURE] === undefined) return second
323
+ throw new GithubError(second[FAILURE].message, second[FAILURE].code, second.status)
324
+ }
325
+
326
+ /**
327
+ * Resolve a token. Order: settings → `GITHUB_TOKEN` → `gh auth token` → null.
328
+ *
329
+ * The `gh` branch is only attempted when `gh` is actually installed, so this
330
+ * resolves to `none` quickly on a machine without the CLI.
331
+ *
332
+ * @param {object} ctx - plugin context, used only for logging.
333
+ * @param {object} settings - the resolved plugin settings.
334
+ * @param {Function} [run] - injected executor, for tests.
335
+ * @returns {Promise<{ token: string|null, source: 'settings'|'env'|'gh'|'none' }>} the token and where it came from.
336
+ */
337
+ export async function resolveToken(ctx, settings, run = defaultRun) {
338
+ const configured = settings && typeof settings === 'object' ? settings.githubToken : undefined
339
+ if (present(configured)) return { token: configured.trim(), source: 'settings' }
340
+
341
+ if (present(process.env.GITHUB_TOKEN)) return { token: process.env.GITHUB_TOKEN.trim(), source: 'env' }
342
+
343
+ try {
344
+ const result = await run('gh', ['auth', 'token'], { timeoutMs: 5000 })
345
+ if (result && result.exitCode === 0 && present(result.stdout)) {
346
+ return { token: result.stdout.trim(), source: 'gh' }
347
+ }
348
+ } catch {
349
+ // `gh` absent, or it refused: fall through to "no token" rather than failing.
350
+ // The caller turns `none` into an actionable message, which is a better
351
+ // surface than an exception raised from a detection path.
352
+ }
353
+ return { token: null, source: 'none' }
354
+ }
355
+
356
+ /**
357
+ * The authenticated user.
358
+ *
359
+ * @param {string} token - bearer token.
360
+ * @returns {Promise<{ login: string, name: string }>} the viewer.
361
+ */
362
+ export async function getViewer(token) {
363
+ const body = await api(token, 'GET', '/user')
364
+ return {
365
+ login: typeof body.login === 'string' ? body.login : '',
366
+ name: typeof body.name === 'string' ? body.name : '',
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Repositories the token can reach, most recently updated first.
372
+ *
373
+ * `isEmpty` here is a *hint* derived from `size === 0`. The API does not make
374
+ * `size` an authoritative emptiness signal, so the create path confirms with
375
+ * {@link repoIsEmpty} before it seeds anything.
376
+ *
377
+ * @param {string} token - bearer token.
378
+ * @returns {Promise<Array<{name: string, nameWithOwner: string, owner: string, isPrivate: boolean, isEmpty: boolean, defaultBranch: string}>>} the repos.
379
+ */
380
+ export async function listRepos(token) {
381
+ const query = `?per_page=${REPO_PAGE_SIZE}&sort=updated&affiliation=owner,collaborator,organization_member`
382
+ const body = await api(token, 'GET', `/user/repos${query}`)
383
+ const rows = Array.isArray(body) ? body : []
384
+ return rows.map((row) => ({
385
+ name: typeof row.name === 'string' ? row.name : '',
386
+ nameWithOwner: typeof row.full_name === 'string' ? row.full_name : '',
387
+ owner: row.owner && typeof row.owner.login === 'string' ? row.owner.login : '',
388
+ isPrivate: row.private === true,
389
+ isEmpty: row.size === 0,
390
+ defaultBranch: typeof row.default_branch === 'string' ? row.default_branch : '',
391
+ }))
392
+ }
393
+
394
+ /**
395
+ * Whether a repository has no commits.
396
+ *
397
+ * `size` is unreliable, so this asks for one commit: an empty repository
398
+ * answers 404 or 409 ("Git Repository is empty") rather than an array.
399
+ *
400
+ * @param {string} token - bearer token.
401
+ * @param {string} repo - `owner/name`.
402
+ * @returns {Promise<boolean>} true when the repository has no commits.
403
+ */
404
+ export async function repoIsEmpty(token, repo) {
405
+ const { owner, name } = splitRepo(repo)
406
+ try {
407
+ const body = await api(token, 'GET', `/repos/${owner}/${name}/commits?per_page=1`)
408
+ return Array.isArray(body) && body.length === 0
409
+ } catch (error) {
410
+ if (error instanceof GithubError && (error.status === 404 || error.status === 409)) return true
411
+ throw error
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Normalize one Codespace payload onto the wire shape the client expects.
417
+ *
418
+ * @param {any} row - a raw Codespace object.
419
+ * @returns {object} the normalized Codespace (without `managed`, which the manager adds).
420
+ */
421
+ export function normalizeCodespace(row) {
422
+ const source = row && typeof row === 'object' ? row : {}
423
+ const machine = source.machine && typeof source.machine === 'object' ? source.machine : {}
424
+ const gitStatus = source.git_status && typeof source.git_status === 'object' ? source.git_status : {}
425
+ return {
426
+ name: typeof source.name === 'string' ? source.name : '',
427
+ displayName: typeof source.display_name === 'string' ? source.display_name : '',
428
+ repository: source.repository && typeof source.repository.full_name === 'string' ? source.repository.full_name : '',
429
+ branch: typeof gitStatus.ref === 'string' ? gitStatus.ref : '',
430
+ state: typeof source.state === 'string' ? source.state : '',
431
+ machine: {
432
+ name: typeof machine.name === 'string' ? machine.name : '',
433
+ displayName: typeof machine.display_name === 'string' ? machine.display_name : '',
434
+ cpus: Number.isFinite(machine.cpus) ? machine.cpus : 0,
435
+ memoryGb: Number.isFinite(machine.memory_in_bytes) ? Math.round(machine.memory_in_bytes / 1024 ** 3) : 0,
436
+ },
437
+ idleTimeoutMinutes: Number.isFinite(source.idle_timeout_minutes) ? source.idle_timeout_minutes : 0,
438
+ lastUsedAt: typeof source.last_used_at === 'string' ? source.last_used_at : '',
439
+ createdAt: typeof source.created_at === 'string' ? source.created_at : '',
440
+ webUrl: typeof source.web_url === 'string' ? source.web_url : '',
441
+ // `prebuild` is a boolean on the Codespace itself; absent means unknown.
442
+ prebuild: typeof source.prebuild === 'boolean' ? source.prebuild : null,
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Codespaces for the viewer, or for one repository.
448
+ *
449
+ * @param {string} token - bearer token.
450
+ * @param {string} [repo] - `owner/name`; omitted lists the viewer's own.
451
+ * @returns {Promise<object[]>} normalized Codespaces.
452
+ */
453
+ export async function listCodespaces(token, repo) {
454
+ let path = '/user/codespaces?per_page=100'
455
+ if (present(repo)) {
456
+ const { owner, name } = splitRepo(repo)
457
+ path = `/repos/${owner}/${name}/codespaces?per_page=100`
458
+ }
459
+ const body = await api(token, 'GET', path)
460
+ const rows = Array.isArray(body.codespaces) ? body.codespaces : []
461
+ return rows.map(normalizeCodespace)
462
+ }
463
+
464
+ /**
465
+ * Machine types available to one repository.
466
+ *
467
+ * Sizes come back in bytes and are converted to whole GB. Sorted smallest
468
+ * first so the wizard's default is the cheapest machine that works.
469
+ *
470
+ * @param {string} token - bearer token.
471
+ * @param {string} repo - `owner/name`.
472
+ * @param {string} [branch] - ref the machines are resolved against.
473
+ * @returns {Promise<Array<{name: string, displayName: string, cpus: number, memoryGb: number, storageGb: number, gpu: boolean, prebuildAvailability: string|null}>>} the machines.
474
+ */
475
+ export async function listMachines(token, repo, branch) {
476
+ const { owner, name } = splitRepo(repo)
477
+ const ref = present(branch) ? `?ref=${encodeURIComponent(branch.trim())}` : ''
478
+ const body = await api(token, 'GET', `/repos/${owner}/${name}/codespaces/machines${ref}`)
479
+ const rows = Array.isArray(body.machines) ? body.machines : []
480
+ return rows
481
+ .map((row) => {
482
+ const source = row && typeof row === 'object' ? row : {}
483
+ return {
484
+ name: typeof source.name === 'string' ? source.name : '',
485
+ displayName: typeof source.display_name === 'string' ? source.display_name : '',
486
+ cpus: Number.isFinite(source.cpus) ? source.cpus : 0,
487
+ memoryGb: Number.isFinite(source.memory_in_bytes) ? Math.round(source.memory_in_bytes / 1024 ** 3) : 0,
488
+ storageGb: Number.isFinite(source.storage_in_bytes) ? Math.round(source.storage_in_bytes / 1024 ** 3) : 0,
489
+ // VERIFY: the Codespaces machine object is documented without a `gpu`
490
+ // field. Tolerant branch: read it when present, otherwise report false.
491
+ gpu: source.gpu === true,
492
+ prebuildAvailability: typeof source.prebuild_availability === 'string' ? source.prebuild_availability : null,
493
+ }
494
+ })
495
+ .sort((a, b) => a.cpus - b.cpus || a.memoryGb - b.memoryGb || a.storageGb - b.storageGb)
496
+ }
497
+
498
+ /**
499
+ * Whether a repository has a ready prebuild for a branch.
500
+ *
501
+ * The authoritative source is `prebuild_availability` on the machine list:
502
+ * `GET /repos/{o}/{r}/codespaces/new` carries no prebuild field, so it is not
503
+ * used here. `in_progress` is deliberately not `ready` — it must still show
504
+ * the slow-creation notice.
505
+ *
506
+ * @param {string} token - bearer token.
507
+ * @param {string} repo - `owner/name`.
508
+ * @param {string} [branch] - ref to check; omitted uses the repository default.
509
+ * @returns {Promise<boolean|null>} true/false, or null when it cannot be determined.
510
+ */
511
+ export async function getPrebuild(token, repo, branch) {
512
+ try {
513
+ const machines = await listMachines(token, repo, branch)
514
+ if (machines.length === 0) return null
515
+ return machines.some((machine) => machine.prebuildAvailability === 'ready')
516
+ } catch {
517
+ // "Unknown" must degrade to "no prebuild": that shows the slow-creation
518
+ // notice, which is the safe branch. It must never surface as an error.
519
+ return null
520
+ }
521
+ }
522
+
523
+ /**
524
+ * Create a Codespace.
525
+ *
526
+ * @param {string} token - bearer token.
527
+ * @param {{repo: string, branch?: string, machine?: string, displayName?: string, idleTimeoutMinutes?: number, location?: string}} options - creation inputs.
528
+ * @returns {Promise<{codespace: object, pending: boolean}>} the Codespace; `pending` is true for a 202.
529
+ */
530
+ export async function createCodespace(token, options) {
531
+ const { owner, name } = splitRepo(options.repo)
532
+ const body = {}
533
+ // The branch field is `ref`, not `branch`.
534
+ if (present(options.branch)) body.ref = options.branch.trim()
535
+ if (present(options.machine)) body.machine = options.machine.trim()
536
+ if (present(options.displayName)) body.display_name = options.displayName.trim().slice(0, 48)
537
+ if (Number.isFinite(options.idleTimeoutMinutes) && options.idleTimeoutMinutes > 0) {
538
+ body.idle_timeout_minutes = Math.round(options.idleTimeoutMinutes)
539
+ }
540
+ if (present(options.location)) body.geo = options.location.trim()
541
+
542
+ const raw = await api(token, 'POST', `/repos/${owner}/${name}/codespaces`, body)
543
+ // VERIFY: the reference documents 201 for a completed create and 202 for a
544
+ // create that failed but is being retried in the background. `api()`
545
+ // collapses both onto the parsed body, so the 202 case cannot be told apart
546
+ // from the 201 case here; it is surfaced as `pending: false` and the caller
547
+ // learns the truth from the Codespace's own `state` on the next poll.
548
+ return { codespace: normalizeCodespace(raw), pending: false }
549
+ }
550
+
551
+ /**
552
+ * Start a Codespace.
553
+ *
554
+ * @param {string} token - bearer token.
555
+ * @param {string} name - Codespace name.
556
+ * @returns {Promise<object>} the Codespace.
557
+ */
558
+ export async function startCodespace(token, name) {
559
+ const raw = await api(token, 'POST', `/user/codespaces/${encodeURIComponent(name)}/start`)
560
+ return normalizeCodespace(raw)
561
+ }
562
+
563
+ /**
564
+ * Stop a Codespace.
565
+ *
566
+ * @param {string} token - bearer token.
567
+ * @param {string} name - Codespace name.
568
+ * @param {{signal?: AbortSignal, timeoutMs?: number, retry?: boolean}} [options] - call options.
569
+ * @returns {Promise<object>} the Codespace.
570
+ */
571
+ export async function stopCodespace(token, name, options = {}) {
572
+ const raw = await api(token, 'POST', `/user/codespaces/${encodeURIComponent(name)}/stop`, undefined, options)
573
+ return normalizeCodespace(raw)
574
+ }
575
+
576
+ /**
577
+ * Delete a Codespace.
578
+ *
579
+ * @param {string} token - bearer token.
580
+ * @param {string} name - Codespace name.
581
+ * @returns {Promise<void>} resolves once GitHub has accepted the deletion.
582
+ */
583
+ export async function deleteCodespace(token, name) {
584
+ // 202 with an empty body: the deletion is queued, not finished.
585
+ await api(token, 'DELETE', `/user/codespaces/${encodeURIComponent(name)}`)
586
+ }
587
+
588
+ /**
589
+ * Commit a README into a repository that has no commits.
590
+ *
591
+ * Creating a Codespace from a repository with no commits fails, so the create
592
+ * path seeds one first. The initial commit must not name a `branch`: the
593
+ * Contents API rejects a branch that does not exist yet, and it creates the
594
+ * repository's default branch from this call.
595
+ *
596
+ * @param {string} token - bearer token.
597
+ * @param {{repo: string, branch?: string, content: string}} options - the repo and README text.
598
+ * @returns {Promise<{commit: string, path: string}>} the created commit sha and path.
599
+ */
600
+ export async function seedEmptyRepo(token, options) {
601
+ const { owner, name } = splitRepo(options.repo)
602
+ const content = typeof options.content === 'string' && options.content.length > 0
603
+ ? options.content
604
+ : '# Codespace workspace\n\nInitialized by dsh-codespace-workspace.\n'
605
+ const body = {
606
+ message: 'Initialize repository for GitHub Codespaces',
607
+ content: Buffer.from(content, 'utf8').toString('base64'),
608
+ }
609
+ // `options.branch` is accepted for interface compatibility and deliberately
610
+ // not sent: this is the repository's initial commit, and naming a branch
611
+ // that does not exist yet is rejected by the Contents API.
612
+ const raw = await api(token, 'PUT', `/repos/${owner}/${name}/contents/README.md`, body)
613
+ return {
614
+ commit: raw && raw.commit && typeof raw.commit.sha === 'string' ? raw.commit.sha : '',
615
+ path: raw && typeof raw.content === 'object' && raw.content !== null && typeof raw.content.path === 'string'
616
+ ? raw.content.path
617
+ : 'README.md',
618
+ }
619
+ }