@aryacode/dsh-aryacode-polish 0.4.4 → 0.6.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.
@@ -16,7 +16,19 @@
16
16
  {
17
17
  "key": "openWorkspaceFolder",
18
18
  "label": "Open workspace folder",
19
- "description": "Add an \u201cOpen workspace folder\u201d row to a Session\u2019s menu that opens its workspace in the file browser.",
19
+ "description": "Add an “Open workspace folder” row to a Session’s menu that opens its workspace in the file browser.",
20
+ "defaultOn": true
21
+ },
22
+ {
23
+ "key": "githubWorkspace",
24
+ "label": "Add a workspace from GitHub",
25
+ "description": "Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.",
26
+ "defaultOn": true
27
+ },
28
+ {
29
+ "key": "workspaceSources",
30
+ "label": "Add a workspace from this PC or GitHub",
31
+ "description": "Put an On this PC / GitHub repository source menu behind the Workspaces + button.",
20
32
  "defaultOn": true
21
33
  }
22
34
  ]
@@ -0,0 +1,81 @@
1
+ # GitHub workspaces
2
+
3
+ **Add workspace from GitHub** turns a repository you can already reach with the
4
+ GitHub CLI into a workspace, without a second sign-in and without storing a
5
+ token.
6
+
7
+ ## What it does
8
+
9
+ 1. **Reads the CLI session.** The host half runs `gh auth token`. That is the
10
+ whole authentication story: no OAuth app, no device code, no token in a
11
+ settings file, no token in the browser. When `gh` is missing or signed out,
12
+ the dialog says so and names the command that fixes it (`gh auth login`).
13
+ 2. **Lists what that session can clone.** `GET /user/repos` with
14
+ `affiliation=owner,collaborator,organization_member`, paged through the
15
+ `link` header, most recently updated first.
16
+ 3. **Clones into your own folder.** `<home>\AryaCode\<owner>\<repo>`, created if
17
+ missing. A directory that is already the same repository is reused instead of
18
+ cloned again; a directory that is a *different* repository is an error, never
19
+ a silent overwrite.
20
+ 4. **Creates the workspace.** The clone path is handed to the workspace
21
+ controller (`workspaces.create({ path })`), which is the same call the "+" menu
22
+ makes, so the new workspace behaves like any other.
23
+
24
+ ## Where the token goes, and where it does not
25
+
26
+ The token is read on the Host and passed to `git` as an `http.extraHeader` value
27
+ in git's own environment (`GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/
28
+ `GIT_CONFIG_VALUE_0`), never in the remote URL and never on the command line — so
29
+ it cannot leak through `ps`, a stored remote, or a shell history. GitHub's git
30
+ server rejects a bearer token for clone, which is why the header carries
31
+ `x-access-token` basic auth. Every line of git output is scrubbed of the token
32
+ before it can reach the browser, and the route answers `cache-control: no-store`.
33
+
34
+ ## How the entry point works
35
+
36
+ The "+" menu's **Add workspace…** entry only appears when something occupies
37
+ `sidebar.workspaces.directoryFlow`, and that slot is `kind: 'single'` — whoever
38
+ takes it stands in for the folder picker. This bundle takes it and offers the
39
+ two sources the owner asked for:
40
+
41
+ - **On this PC** — a folder browser driven by this bundle's own route
42
+ (`api/polish.folders`, actions `roots` and `ls`): roots, a `..` row for going
43
+ back, directories only, hidden and system entries filtered out, and **Use this
44
+ folder** hands the current path to the flow's `onPicked`.
45
+ - **GitHub repository…** — the dialog described above. It opens on behalf of the
46
+ flow, so a finished clone hands its path back through the flow's `onPicked`,
47
+ and the Shell creates the workspace exactly as it does for a picked folder.
48
+
49
+ ### Why the browser does not borrow the Host's chooser
50
+
51
+ A composed directory picker serves exactly one capability: `browse` answers
52
+ `list` and `createDirectory`, `native` answers `pick`. The web profile composes
53
+ the picker whose chooser cannot open there, so `directoryPicker.pick()` waits on
54
+ a dialog that never appears — the first build of this flow did exactly that and
55
+ the row spun forever. Owning the listing keeps one behaviour in the app and in
56
+ the browser, needs no capability guess, and does not break when a profile mounts
57
+ a different picker.
58
+
59
+ The tray of the foot keeps the sign-in state (`GitHub · <login>`, or
60
+ `GitHub · signed out`) next to the balance, so the account is visible without
61
+ opening a dialog; clicking it opens the repository dialog directly.
62
+
63
+ ## Not used from the AryaAI-era plugin
64
+
65
+ `@deepseek-ai/dsh-github` was read as a reference and deliberately not reused:
66
+ it is a typert remote service with generated protocol packages, and it
67
+ authenticates through an OAuth device flow that needs a registered OAuth app.
68
+ Its ideas that were kept: reject anything that is not one path segment, reuse a
69
+ checkout whose origin already matches, present the token as basic auth in git's
70
+ environment, page the repository list, and mark a repository that is already
71
+ cloned.
72
+
73
+ ## Tests
74
+
75
+ `test/github.spec.mjs` covers the path rules (`.`, `..`, separators, empty),
76
+ the destination layout, origin comparison across https/ssh forms, token
77
+ scrubbing, the CLI token's three failure modes, signed-out and signed-in status,
78
+ the `cloned` marker against a real temporary tree, the spawn shape of a clone
79
+ (command, `--`, URL, destination, and the header in the environment rather than
80
+ in the arguments), the reuse path, the refusal to overwrite another repository,
81
+ and a failing clone whose message is scrubbed.
@@ -55,6 +55,12 @@ If the harness ever declares a workspace-scoped menu slot, this feature should m
55
55
  there: the Host call and the path resolution stay exactly as they are, and only the
56
56
  registration line changes.
57
57
 
58
+ **Owner's decision (2026-10-27):** asked again, after the Session row menu shipped,
59
+ whether to add that slot to the harness and release a harness patch, the owner
60
+ declined — "then forget it". The workspace row stays untouched and the feature stays
61
+ on the Session row menu. The question has been asked and answered; do not re-open it
62
+ without a new instruction.
63
+
58
64
  ## Evidence for the next reader
59
65
 
60
66
  - Slot contract: `packages/client/ui-workspace/src/client/contract/slots.ts:152-205`.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The folder list behind "On this PC".
3
+ *
4
+ * Why this exists at all: the add-a-workspace flow is driven by the
5
+ * `sidebar.workspaces.directoryFlow` slot, whose single occupant is a directory
6
+ * picker. A picker serves exactly one capability — `browse` (`list`,
7
+ * `createDirectory`) or `native` (`pick`) — and the browser build composes the
8
+ * one without a chooser to show, so `pick` there waits for a dialog that never
9
+ * opens. Owning the listing instead of borrowing it keeps the row behaving the
10
+ * same in the app and in the browser, and keeps the plugin free of a dependency
11
+ * on which picker a profile happens to mount.
12
+ *
13
+ * The route is the same documented shape the GitHub half uses: a registered
14
+ * `connection.fetch` path, answering JSON, with the browser addressing the
15
+ * document-relative form.
16
+ */
17
+ import { promises as fs } from 'node:fs'
18
+ import { homedir } from 'node:os'
19
+ import { dirname, join, resolve } from 'node:path'
20
+ import { json } from './github.js'
21
+
22
+ /** Absolute path the Host registers; the browser uses {@link FOLDERS_ROUTE}. */
23
+ export const FOLDERS_PATH = '/api/polish.folders'
24
+
25
+ /** Document-relative form the bundle calls with `fetch`. */
26
+ export const FOLDERS_ROUTE = FOLDERS_PATH.slice(1)
27
+
28
+ /**
29
+ * Places a folder browser can start from.
30
+ *
31
+ * @returns labelled roots, the profile's home first.
32
+ */
33
+ export function folderRoots(home = homedir()) {
34
+ const roots = [{ label: 'Home', path: home }]
35
+ const drive = process.env.SystemDrive ?? 'C:'
36
+ if (drive !== '') roots.push({ label: `${drive}\\`, path: `${drive}\\` })
37
+ return roots
38
+ }
39
+
40
+ /**
41
+ * List the subdirectories of one directory.
42
+ *
43
+ * @param input - a directory path; the client passes what the previous listing
44
+ * returned, and never a path it composed itself.
45
+ * @returns the resolved path, its parent (null at a drive root) and the child
46
+ * folders sorted by name, hidden entries excluded.
47
+ */
48
+ export async function listFolders(input) {
49
+ if (typeof input !== 'string' || input === '') {
50
+ throw new Error('a folder path is required')
51
+ }
52
+ const path = resolve(input)
53
+ const entries = []
54
+ for (const entry of await fs.readdir(path, { withFileTypes: true })) {
55
+ if (entry.name.startsWith('.') || entry.name.startsWith('$')) continue
56
+ if (entry.isDirectory() === false) continue
57
+ entries.push({ name: entry.name, path: join(path, entry.name) })
58
+ }
59
+ entries.sort((left, right) => left.name.localeCompare(right.name, undefined, { sensitivity: 'base' }))
60
+ const parent = dirname(path)
61
+ return { path, parent: parent === path ? null : parent, entries }
62
+ }
63
+
64
+ /**
65
+ * Answer one request from the browser.
66
+ *
67
+ * @param body - `{ action: 'roots' }` or `{ action: 'ls', path }`.
68
+ * @returns a Response with a JSON body.
69
+ */
70
+ export async function handleFolders(body) {
71
+ const action = body?.action
72
+ if (action === 'roots') return json({ roots: folderRoots() })
73
+ if (action === 'ls') {
74
+ try {
75
+ return json(await listFolders(body.path))
76
+ } catch (error) {
77
+ return json({ error: error instanceof Error ? error.message : 'cannot list that folder' }, 400)
78
+ }
79
+ }
80
+ return json({ error: `unknown action ${String(action)}` }, 400)
81
+ }
82
+
83
+ /** The host half mounted by the `workspaceSources` feature. */
84
+ export const foldersFeature = {
85
+ name: 'polish-folders',
86
+ inject: ['connection'],
87
+ apply(ctx) {
88
+ ctx.effect(() => ctx.connection.fetch.register({
89
+ path: FOLDERS_PATH,
90
+ methods: ['POST'],
91
+ requestBody: 'buffered',
92
+ fetch: async request => {
93
+ if (request.method !== 'POST') return json({ error: 'POST required' }, 405)
94
+ let body
95
+ try {
96
+ body = await request.json()
97
+ } catch {
98
+ return json({ error: 'a JSON body is required' }, 400)
99
+ }
100
+ return handleFolders(body)
101
+ },
102
+ }))
103
+ },
104
+ }
105
+
106
+ /**
107
+ * Mount the folder route for one configuration.
108
+ *
109
+ * @param ctx - the row's context.
110
+ * @param config - the row's resolved configuration accessor.
111
+ * @returns the mounted plugin fiber.
112
+ */
113
+ export function mountFolders(ctx, config) {
114
+ return ctx.plugin({ ...foldersFeature, config })
115
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * GitHub workspaces: sign in through the GitHub CLI's existing session, list
3
+ * repositories, and clone one under the clone root so it can be adopted as a
4
+ * workspace.
5
+ *
6
+ * The browser half never sees the token. It posts to one route and gets back
7
+ * JSON; every GitHub call and every `git` invocation happens here.
8
+ */
9
+
10
+ import { spawn } from 'node:child_process'
11
+ import { mkdir, realpath, stat } from 'node:fs/promises'
12
+ import { homedir } from 'node:os'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ /** Absolute path the Host registers; the browser addresses `api/polish.github`. */
16
+ export const GITHUB_ROUTE = '/api/polish.github'
17
+
18
+ /** Root a clone lands in: `<home>/AryaCode/<owner>/<repo>`. */
19
+ export function cloneRoot(home = homedir()) {
20
+ return join(home, 'AryaCode')
21
+ }
22
+
23
+ const SEGMENT = /^[A-Za-z0-9_.-]+$/
24
+
25
+ /**
26
+ * Reject a repository part that could escape the clone root.
27
+ * @param value - owner or repository name from GitHub.
28
+ * @returns the same value when it is one plain path segment.
29
+ */
30
+ export function assertSegment(value) {
31
+ if (typeof value !== 'string' || !SEGMENT.test(value) || value === '.' || value === '..') {
32
+ throw new Error('repository name must be a single path segment')
33
+ }
34
+ return value
35
+ }
36
+
37
+ /**
38
+ * Directory one repository clones into.
39
+ * @param owner - repository owner.
40
+ * @param name - repository name.
41
+ * @param home - home directory to root the default clone tree in.
42
+ * @returns absolute destination path.
43
+ */
44
+ export function cloneDestination(owner, name, home = homedir()) {
45
+ return join(cloneRoot(home), assertSegment(owner), assertSegment(name))
46
+ }
47
+
48
+ /**
49
+ * Whether a git origin URL already points at this repository.
50
+ * @param origin - `git remote get-url origin` output.
51
+ * @param owner - repository owner.
52
+ * @param name - repository name.
53
+ * @returns true for either the https or the ssh form of the same repo.
54
+ */
55
+ export function sameOrigin(origin, owner, name) {
56
+ const normalized = String(origin).trim().replace(/\.git$/i, '').replace(/\/+$/, '').toLowerCase()
57
+ return normalized === `https://github.com/${owner}/${name}`.toLowerCase()
58
+ || normalized === `git@github.com:${owner}/${name}`.toLowerCase()
59
+ }
60
+
61
+ /**
62
+ * Run a program and collect its output. Never throws on a non-zero exit.
63
+ * @param command - executable name.
64
+ * @param args - arguments.
65
+ * @param options - extra environment and working directory.
66
+ * @returns exit code plus captured stdout and stderr.
67
+ */
68
+ export function runCommand(command, args, options = {}) {
69
+ return new Promise((resolve, reject) => {
70
+ const child = spawn(command, args, {
71
+ cwd: options.cwd,
72
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never', ...options.env },
73
+ windowsHide: true,
74
+ })
75
+ let stdout = ''
76
+ let stderr = ''
77
+ child.stdout.setEncoding('utf8')
78
+ child.stderr.setEncoding('utf8')
79
+ child.stdout.on('data', chunk => { stdout += chunk })
80
+ child.stderr.on('data', chunk => { stderr += chunk })
81
+ child.on('error', reject)
82
+ child.on('close', code => { resolve({ code: code ?? 1, stdout, stderr }) })
83
+ })
84
+ }
85
+
86
+ /**
87
+ * Remove the token from text that may reach the browser.
88
+ * @param text - command output.
89
+ * @param token - access token.
90
+ * @returns text with every occurrence replaced.
91
+ */
92
+ export function scrubToken(text, token) {
93
+ if (token === undefined || token === '') return text
94
+ return String(text).split(token).join('[redacted]')
95
+ }
96
+
97
+ /**
98
+ * Ask the GitHub CLI for the token it already holds.
99
+ *
100
+ * Nothing is stored by this plugin: when `gh` is signed out, the feature says
101
+ * so instead of collecting a credential of its own.
102
+ * @param run - command runner, injected by tests.
103
+ * @returns the token, or undefined when `gh` is missing or signed out.
104
+ */
105
+ export async function ghToken(run = runCommand) {
106
+ let answer
107
+ try {
108
+ answer = await run('gh', ['auth', 'token'])
109
+ } catch {
110
+ return undefined
111
+ }
112
+ if (answer.code !== 0) return undefined
113
+ const token = answer.stdout.trim()
114
+ return token === '' ? undefined : token
115
+ }
116
+
117
+ /**
118
+ * Read the login that owns a token.
119
+ * @param token - GitHub access token.
120
+ * @param fetchImpl - HTTP implementation.
121
+ * @returns the login name.
122
+ */
123
+ export async function fetchLogin(token, fetchImpl = fetch) {
124
+ const response = await fetchImpl('https://api.github.com/user', { headers: githubHeaders(token) })
125
+ if (!response.ok) throw new Error(`GitHub sign-in check failed (${String(response.status)})`)
126
+ const body = await response.json()
127
+ const login = body?.login
128
+ if (typeof login !== 'string' || login === '') throw new Error('GitHub did not return a login')
129
+ return login
130
+ }
131
+
132
+ /**
133
+ * List repositories the token can clone, most recently updated first.
134
+ * @param token - GitHub access token.
135
+ * @param fetchImpl - HTTP implementation.
136
+ * @param pages - most pages of 100 to read.
137
+ * @returns repository rows in list order.
138
+ */
139
+ export async function fetchRepos(token, fetchImpl = fetch, pages = 3) {
140
+ const repos = []
141
+ let url = 'https://api.github.com/user/repos?per_page=100&sort=updated&affiliation=owner,collaborator,organization_member'
142
+ for (let page = 0; page < pages && url !== undefined; page += 1) {
143
+ const response = await fetchImpl(url, { headers: githubHeaders(token) })
144
+ if (!response.ok) throw new Error(`GitHub repository list failed (${String(response.status)})`)
145
+ const body = await response.json()
146
+ if (!Array.isArray(body)) throw new Error('GitHub repository list was not an array')
147
+ for (const item of body) {
148
+ const row = readRepo(item)
149
+ if (row !== undefined) repos.push(row)
150
+ }
151
+ url = nextLink(response.headers.get('link'))
152
+ }
153
+ return repos
154
+ }
155
+
156
+ function githubHeaders(token) {
157
+ return {
158
+ accept: 'application/vnd.github+json',
159
+ authorization: `Bearer ${token}`,
160
+ 'user-agent': 'aryacode-polish',
161
+ }
162
+ }
163
+
164
+ function readRepo(value) {
165
+ if (value === null || typeof value !== 'object') return undefined
166
+ const name = value.name
167
+ const owner = value.owner !== null && typeof value.owner === 'object' ? value.owner.login : undefined
168
+ if (typeof name !== 'string' || typeof owner !== 'string') return undefined
169
+ return {
170
+ owner,
171
+ name,
172
+ fullName: typeof value.full_name === 'string' ? value.full_name : `${owner}/${name}`,
173
+ isPrivate: value.private === true,
174
+ isFork: value.fork === true,
175
+ description: typeof value.description === 'string' ? value.description : '',
176
+ updatedAt: typeof value.updated_at === 'string' ? value.updated_at : '',
177
+ defaultBranch: typeof value.default_branch === 'string' ? value.default_branch : '',
178
+ }
179
+ }
180
+
181
+ function nextLink(header) {
182
+ if (typeof header !== 'string' || header === '') return undefined
183
+ for (const part of header.split(',')) {
184
+ const match = /<([^>]+)>;\s*rel="next"/.exec(part)
185
+ if (match?.[1] !== undefined) return match[1]
186
+ }
187
+ return undefined
188
+ }
189
+
190
+ async function directoryExists(path) {
191
+ try {
192
+ return (await stat(path)).isDirectory()
193
+ } catch {
194
+ return false
195
+ }
196
+ }
197
+
198
+ async function existingRealpath(path) {
199
+ try {
200
+ return await realpath(path)
201
+ } catch {
202
+ return undefined
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Clone a repository, or reuse a directory whose origin is already that repo.
208
+ *
209
+ * The token travels in git's environment as an http header, never in the
210
+ * remote URL and never on the command line, so it cannot leak through `ps` or
211
+ * a stored remote.
212
+ * @param owner - repository owner.
213
+ * @param name - repository name.
214
+ * @param token - GitHub access token.
215
+ * @param options - command runner, home directory and optional progress sink.
216
+ * @returns destination path and whether an existing checkout was kept.
217
+ */
218
+ export async function cloneRepo(owner, name, token, options = {}) {
219
+ const run = options.run ?? runCommand
220
+ const path = cloneDestination(owner, name, options.home)
221
+ const progress = options.progress ?? (() => {})
222
+ if (await directoryExists(path)) {
223
+ const origin = await run('git', ['remote', 'get-url', 'origin'], { cwd: path })
224
+ if (origin.code === 0 && sameOrigin(origin.stdout, owner, name)) {
225
+ progress(`Reusing the existing checkout at ${path}`)
226
+ return { path, reused: true }
227
+ }
228
+ throw new Error(`${path} already exists and is not ${owner}/${name}`)
229
+ }
230
+ await mkdir(dirname(path), { recursive: true })
231
+ progress(`Cloning ${owner}/${name} into ${path}`)
232
+ // GitHub's git server rejects a bearer token, so it is presented as basic auth
233
+ // through an extra http header in git's own environment.
234
+ const header = `Authorization: Basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`
235
+ const cloned = await run(
236
+ 'git',
237
+ ['-c', 'credential.helper=', 'clone', '--progress', '--', `https://github.com/${owner}/${name}.git`, path],
238
+ {
239
+ env: {
240
+ GIT_CONFIG_COUNT: '1',
241
+ GIT_CONFIG_KEY_0: 'http.extraHeader',
242
+ GIT_CONFIG_VALUE_0: header,
243
+ },
244
+ },
245
+ )
246
+ if (cloned.code !== 0) {
247
+ throw new Error(scrubToken(cloned.stderr || cloned.stdout || 'git clone failed', token))
248
+ }
249
+ return { path, reused: false }
250
+ }
251
+
252
+ /**
253
+ * Answer one browser request.
254
+ * @param body - parsed request body.
255
+ * @param deps - injected GitHub and git implementations.
256
+ * @returns the JSON value the browser half renders.
257
+ */
258
+ export async function handleRequest(body, deps = {}) {
259
+ const action = body?.action
260
+ const token = await (deps.token ?? ghToken)()
261
+ if (action === 'status') {
262
+ if (token === undefined) {
263
+ return {
264
+ ok: true,
265
+ value: { signedIn: false, login: '', root: cloneRoot(deps.home), reason: 'The GitHub CLI is missing or not signed in: run `gh auth login` in a terminal.' },
266
+ }
267
+ }
268
+ const login = await (deps.login ?? fetchLogin)(token)
269
+ return { ok: true, value: { signedIn: true, login, root: cloneRoot(deps.home), reason: '' } }
270
+ }
271
+ if (token === undefined) {
272
+ return { ok: false, error: 'Sign in with the GitHub CLI first: run `gh auth login` in a terminal, then try again.' }
273
+ }
274
+ if (action === 'repos') {
275
+ const repos = await (deps.repos ?? fetchRepos)(token)
276
+ const marked = []
277
+ for (const repo of repos) {
278
+ const real = await existingRealpath(cloneDestination(repo.owner, repo.name, deps.home))
279
+ marked.push({ ...repo, cloned: real !== undefined })
280
+ }
281
+ return { ok: true, value: { root: cloneRoot(deps.home), repos: marked } }
282
+ }
283
+ if (action === 'clone') {
284
+ const owner = assertSegment(body.owner)
285
+ const name = assertSegment(body.name)
286
+ const result = await (deps.clone ?? cloneRepo)(owner, name, token, { home: deps.home })
287
+ return { ok: true, value: { path: result.path, reused: result.reused } }
288
+ }
289
+ return { ok: false, error: `unknown GitHub action: ${String(action)}` }
290
+ }
291
+
292
+ /**
293
+ * JSON response helper.
294
+ * @param value - payload to serialize.
295
+ * @param status - HTTP status.
296
+ * @returns a JSON response.
297
+ */
298
+ export function json(value, status = 200) {
299
+ return new Response(JSON.stringify(value), {
300
+ status,
301
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
302
+ })
303
+ }
304
+
305
+ /** Mount the route on a context that provides the `connection` service. */
306
+ export const githubFeature = {
307
+ name: 'polish-github',
308
+ inject: ['connection'],
309
+ apply(ctx, config = {}) {
310
+ ctx.effect(() => ctx.connection.fetch.register({
311
+ path: GITHUB_ROUTE,
312
+ // Without a method list the platform would only route GET, which is why the
313
+ // browser's POST would fall through to the static handler as a 404.
314
+ methods: ['POST'],
315
+ requestBody: 'buffered',
316
+ fetch: async (request) => {
317
+ if (request.method !== 'POST') return json({ ok: false, error: 'POST only' }, 405)
318
+ let body
319
+ try {
320
+ body = await request.json()
321
+ } catch {
322
+ return json({ ok: false, error: 'body must be JSON' }, 400)
323
+ }
324
+ try {
325
+ const deps = {
326
+ home: typeof config.home === 'string' && config.home !== '' ? config.home : undefined,
327
+ }
328
+ return json(await handleRequest(body, deps))
329
+ } catch (error) {
330
+ const message = error instanceof Error ? error.message : 'GitHub request failed'
331
+ return json({ ok: false, error: message })
332
+ }
333
+ },
334
+ }))
335
+ },
336
+ }
337
+
338
+ /**
339
+ * Register one child fiber carrying the GitHub route when a profile mounts the
340
+ * feature. Kept separate from the switch so turning the switch off removes the
341
+ * route together with every other surface.
342
+ * @param ctx - the row's context.
343
+ * @returns the mounted fiber.
344
+ */
345
+ export function mountGithub(ctx, config = {}) {
346
+ return ctx.plugin({ ...githubFeature, config })
347
+ }
@@ -24,6 +24,8 @@
24
24
  */
25
25
 
26
26
  import { mountCost } from './cost.js'
27
+ import { mountFolders } from './folders.js'
28
+ import { mountGithub } from './github.js'
27
29
 
28
30
  /**
29
31
  * Every feature this bundle ships. Spelling corrections are investigated and
@@ -52,6 +54,20 @@ export const features = [
52
54
  description: 'Add an "Open workspace folder" row to a Session\u2019s menu that opens its workspace in the file browser.',
53
55
  defaultOn: true,
54
56
  },
57
+ {
58
+ id: 'githubWorkspace',
59
+ label: 'Add a workspace from GitHub',
60
+ description: 'Sign in with the GitHub CLI, browse your repositories, and clone one as a workspace.',
61
+ defaultOn: true,
62
+ mount: mountGithub,
63
+ },
64
+ {
65
+ id: 'workspaceSources',
66
+ label: 'Add a workspace from this PC or GitHub',
67
+ description: 'Put an "On this PC" / "GitHub repository" source menu behind the Workspaces + button.',
68
+ defaultOn: true,
69
+ mount: mountFolders,
70
+ },
55
71
  ]
56
72
 
57
73
  /**