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